Add persistent automatic scheduling toggle to admin panel

This commit is contained in:
Matt Isenhower
2026-09-13 10:06:31 -07:00
parent 5deac8c8cb
commit 6555ff709b
6 changed files with 193 additions and 9 deletions

View File

@@ -392,3 +392,42 @@ describe('Worker routing', () => {
expect((await worker.fetch(request(headers), fakeEnv, createExecutionContext())).status).toBe(200);
});
});
it('keeps manual runs available with scheduling off and does not replay missed hours', async () => {
network();
let scheduler = stub();
await scheduler.ensureArmed();
await scheduler.setAutomaticScheduling(false);
expect(await scheduler.ensureArmed()).toEqual({ armed: false, automaticSchedulingEnabled: false });
expect(await scheduler.status()).toMatchObject({
automaticSchedulingEnabled: false,
alarmAt: null,
hourlyAt: null,
retryAt: null,
});
let manual = await scheduler.startManual('data');
expect(manual.ok).toBe(true);
expect(await scheduler.setAutomaticScheduling(true)).toMatchObject({ busy: true });
let status = await runAlarmUntil(scheduler, s => s.lastManualRun !== null);
expect(status.lastManualRun.ok).toBe(true);
expect(status.lastRun).toBeNull();
expect(status.alarmAt).toBeNull();
expect(status.automaticSchedulingEnabled).toBe(false);
// A stale platform alarm must not run automatic work after disabling it.
await runInDurableObject(scheduler, instance => instance.alarm());
expect((await scheduler.status()).lastRun).toBeNull();
expect((await scheduler.run({ only: ['Schedules'] })).ok).toBe(true);
expect((await scheduler.status()).alarmAt).toBeNull();
await scheduler.setAutomaticScheduling(true);
status = await scheduler.status();
expect(status.hourlyAt).toBe(nextRunAt());
expect(status.alarmAt).toBe(status.hourlyAt);
expect(status.lastRun).toBeNull();
});

View File

@@ -63,3 +63,27 @@ it.each(['/', '/admin/'])(
expect(await response.text()).not.toContain('__NONCE__');
},
);
it('protects scheduling changes with Access, same-origin checks and boolean validation', async () => {
const request = (enabled, origin = url) => new Request(url + '/admin/api/scheduling', {
method: 'POST',
headers: { Origin: origin, 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
});
const setAutomaticScheduling = vi.fn(async enabled => ({ ok: true, automaticSchedulingEnabled: enabled }));
const scheduler = () => ({ setAutomaticScheduling });
verifyAccess.mockResolvedValue(null);
expect((await adminRequest(request(false), {}, scheduler)).status).toBe(401);
verifyAccess.mockResolvedValue({ email: 'admin@example.test' });
expect((await adminRequest(request(false, 'https://other.test'), {}, scheduler)).status).toBe(403);
expect((await adminRequest(request('false'), {}, scheduler)).status).toBe(400);
expect(setAutomaticScheduling).not.toHaveBeenCalled();
expect((await adminRequest(request(false), {}, scheduler)).status).toBe(200);
expect(setAutomaticScheduling).toHaveBeenCalledWith(false);
setAutomaticScheduling.mockResolvedValue({ ok: false, busy: true });
expect((await adminRequest(request(true), {}, scheduler)).status).toBe(409);
});

View File

@@ -9,6 +9,7 @@ const state = {
preview: true,
user: { email: 'Local preview' },
paused: false,
automaticSchedulingEnabled: true,
busy: false,
hourlyAt: hour + 3600000 + 10000,
retryAt: null,
@@ -68,7 +69,7 @@ createServer(async (request, response) => {
if (request.method === 'GET' && url.pathname === '/admin/api/status')
return json(state);
if (request.method === 'POST' && url.pathname === '/admin/api/run') {
if (request.method === 'POST' && ['/admin/api/run', '/admin/api/scheduling'].includes(url.pathname)) {
if (request.headers.origin !== `http://${request.headers.host}`)
return json({ error: 'Invalid origin.' }, 403);
@@ -84,7 +85,19 @@ createServer(async (request, response) => {
return json({ error: 'Request too large.' }, 413);
}
const { mode } = JSON.parse(body);
const input = JSON.parse(body);
if (url.pathname === '/admin/api/scheduling') {
if (typeof input?.enabled !== 'boolean')
return json({ error: 'Enabled must be a boolean.' }, 400);
state.automaticSchedulingEnabled = input.enabled;
state.hourlyAt = input.enabled ? Math.floor(Date.now() / 3600000) * 3600000 + 3610000 : null;
return json({ ok: true });
}
const { mode } = input;
if (!['data', 'social', 'both'].includes(mode))
return json({ error: 'Unknown mode.' }, 400);

View File

@@ -26,6 +26,8 @@ export class Scheduler extends DurableObject {
// becomes one immediate full run, then the generic queue is retired.
return {
paused: saved.paused ?? false,
automaticSchedulingEnabled: saved.automaticSchedulingEnabled
?? (this.env.AUTOMATIC_SCHEDULING_ENABLED !== 'false'),
hourlyAt: saved.hourlyAt ?? null,
retryAt: saved.retryAt ?? (saved.pending?.length ? Date.now() : null),
retries: saved.retries ?? 0,
@@ -48,16 +50,70 @@ export class Scheduler extends DurableObject {
let armed = (await this.ctx.storage.getAlarm()) === null;
state.hourlyAt ??= nextRunAt();
let manual = await this.#pendingManual();
if (!state.automaticSchedulingEnabled && !manual) {
await this.ctx.storage.deleteAlarm();
return { armed: false, automaticSchedulingEnabled: false };
}
if (state.automaticSchedulingEnabled)
state.hourlyAt ??= nextRunAt();
await this.ctx.storage.put('state', state);
let alarmAt = (await this.#pendingManual()) ? Date.now() : (state.retryAt ?? state.hourlyAt);
let alarmAt = manual ? Date.now() : (state.retryAt ?? state.hourlyAt);
await this.ctx.storage.setAlarm(alarmAt);
return { armed, hourlyAt: state.hourlyAt, alarmAt };
}
async setAutomaticScheduling(enabled) {
if (typeof enabled !== 'boolean')
return { ok: false, error: 'Enabled must be a boolean.' };
if (this.#running)
return { ok: false, busy: true, error: 'Wait for the current run to finish.' };
this.#running = true;
try {
if (await this.#pendingManual())
return { ok: false, busy: true, error: 'Wait for the queued run to finish.' };
let state = await this.#state();
if (state.paused)
return { ok: false, paused: true, error: 'The Worker is paused for maintenance.' };
if (state.automaticSchedulingEnabled !== enabled) {
state.hourlyAt = enabled ? nextRunAt() : null;
state.retryAt = null;
state.retries = 0;
}
state.automaticSchedulingEnabled = enabled;
if (enabled)
state.hourlyAt ??= nextRunAt();
await this.ctx.storage.transaction(async txn => {
await txn.put('state', state);
if (enabled) {
await txn.setAlarm(state.retryAt ?? state.hourlyAt);
} else {
await txn.deleteAlarm();
}
});
return { ok: true, automaticSchedulingEnabled: enabled };
} finally {
this.#running = false;
}
}
async pause() {
if (this.#running || (await this.#pendingManual()))
return { ok: false, busy: true, error: 'Wait for the current run to finish before pausing.' };
@@ -254,6 +310,12 @@ export class Scheduler extends DurableObject {
});
}
if (!state.automaticSchedulingEnabled) {
await this.ctx.storage.deleteAlarm();
return;
}
state.hourlyAt ??= nextRunAt();
let scheduledFor = state.retryAt ?? state.hourlyAt;

View File

@@ -445,7 +445,10 @@
</div>
<div class="sub" id="state-detail">Checking the updater</div>
</div>
<div class="next"><small>Next scheduled run</small><strong id="next">—</strong></div>
<div class="next">
<small>Next scheduled run</small><strong id="next">—</strong>
<button class="refresh" id="scheduling" role="switch" aria-checked="false" disabled>Automatic scheduling</button>
</div>
</section>
<div class="section-label">
<h2>Start a run</h2>
@@ -526,6 +529,8 @@
}
function updateControls() {
element('scheduling').disabled = sending || !state || state.busy || state.paused;
for (const button of buttons)
button.disabled = sending || !state || state.busy || state.paused;
}
@@ -638,6 +643,10 @@
element('state').textContent = 'Scheduler paused';
element('dot').className = 'dot warning';
element('state-detail').textContent = 'Manual runs are disabled while paused.';
} else if (!state.automaticSchedulingEnabled) {
element('state').textContent = 'Automatic scheduling off';
element('dot').className = 'dot neutral';
element('state-detail').textContent = 'Manual runs are available.';
} else {
element('state').textContent = 'Ready when you are';
element('dot').className = 'dot';
@@ -650,7 +659,10 @@
element('state-detail').textContent = `${name} · ${progress}`;
}
element('next').textContent = state.paused ? 'Paused' : formatDate(state.retryAt || state.hourlyAt);
element('next').textContent = state.paused ? 'Paused'
: state.automaticSchedulingEnabled ? formatDate(state.retryAt || state.hourlyAt) : 'Off';
element('scheduling').setAttribute('aria-checked', String(state.automaticSchedulingEnabled));
element('scheduling').textContent = `Automatic scheduling: ${state.automaticSchedulingEnabled ? 'On' : 'Off'}`;
element('live-run').hidden = !state.activeRun;
if (state.activeRun) {
@@ -772,6 +784,29 @@
}
});
element('scheduling').addEventListener('click', async () => {
const enabled = !state.automaticSchedulingEnabled;
sending = true;
updateControls();
try {
await requestAdmin('scheduling', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
});
element('notice').textContent = enabled
? 'Automatic scheduling enabled. The next run starts after the next hour.'
: 'Automatic scheduling disabled. You can still run updates manually.';
} catch (error) {
element('notice').textContent = error.message;
} finally {
sending = false;
await refreshStatus();
updateControls();
}
});
element('refresh').addEventListener('click', refreshStatus);
document.addEventListener('visibilitychange', () => {
if (document.hidden)

View File

@@ -29,7 +29,7 @@ export async function adminRequest(request, env, getScheduler) {
if (request.method === 'GET' && url.pathname === '/admin/api/status')
return json({ ...(await getScheduler().status()), user, preview: false });
if (request.method === 'POST' && url.pathname === '/admin/api/run') {
if (request.method === 'POST' && ['/admin/api/run', '/admin/api/scheduling'].includes(url.pathname)) {
// Access cookies authenticate the user; require a same-origin JSON request as well.
if (
request.headers.get('Origin') !== url.origin ||
@@ -37,14 +37,25 @@ export async function adminRequest(request, env, getScheduler) {
)
return json({ error: 'A same-origin JSON request is required.' }, 403);
let mode;
let body;
try {
({ mode } = await request.json());
body = await request.json();
} catch {
return json({ error: 'Invalid request.' }, 400);
}
if (url.pathname === '/admin/api/scheduling') {
if (typeof body?.enabled !== 'boolean')
return json({ error: 'Enabled must be a boolean.' }, 400);
let result = await getScheduler().setAutomaticScheduling(body.enabled);
return json(result, result.ok ? 200 : result.busy || result.paused ? 409 : 400);
}
let mode = body?.mode;
if (!MANUAL_MODES.includes(mode))
return json({ error: 'Unknown run mode.' }, 400);