diff --git a/workers/updater/README.md b/workers/updater/README.md index dc57395..49b0246 100644 --- a/workers/updater/README.md +++ b/workers/updater/README.md @@ -196,6 +196,8 @@ reported as failed rather than automatically replaying an uncertain social send. The hourly schedule is preserved. Paused scheduling also blocks manual runs. The panel polls live application log lines every two seconds during a run and +retains the latest 50 manual and scheduled runs in one history, including failed +attempts. Older deployments contribute their two existing results. Each run stores up to the latest 200 lines (500 characters each, 32 KB total) with its final summary. Known secret values are redacted from captured lines. This includes updater, diff --git a/workers/updater/Scheduler.spec.mjs b/workers/updater/Scheduler.spec.mjs index c5ff7a7..b085008 100644 --- a/workers/updater/Scheduler.spec.mjs +++ b/workers/updater/Scheduler.spec.mjs @@ -159,6 +159,8 @@ describe('Scheduler', () => { vi.setSystemTime(status.retryAt); status = await runAlarmUntil(scheduler, s => s.retryAt === null); + expect(status.runHistory).toHaveLength(4); + expect(status.runHistory.every(run => run.trigger === 'scheduled' && !run.ok)).toBe(true); expect(status.retries).toBe(0); expect(status.alarmAt).toBe(hourlyAt + HOUR_MS); }); @@ -342,8 +344,93 @@ describe('Background manual runs', () => { expect(status.lastManualRun).toMatchObject({ id: 'due', ok: true, mode: 'data' }); expect(status.lastRun).toMatchObject({ ok: true, mode: 'both', social: { ok: true } }); + expect(status.runHistory.map(run => run.trigger)).toEqual(['scheduled', 'manual']); expect(status.hourlyAt).toBe(hourlyAt + HOUR_MS); }); + + it('preserves older results when starting a combined run history', async () => { + const scheduler = stub(); + const now = Date.now(); + + await runInDurableObject(scheduler, async (_instance, ctx) => { + await ctx.storage.put('state', { + automaticSchedulingEnabled: false, + lastRun: { + startedAt: now - 2000, + ok: false, + mode: 'both', + }, + }); + await ctx.storage.put('lastManualRun', { + startedAt: now - 1000, + ok: true, + mode: 'data', + }); + await ctx.storage.put('pendingManual', { + id: 'interrupted', + status: 'running', + mode: 'social', + startedAt: now, + }); + }); + + await scheduler.ensureArmed(); + + expect((await scheduler.status()).runHistory.map(run => run.trigger)) + .toEqual(['manual', 'scheduled']); + + const status = await runAlarmUntil(scheduler, s => s.lastManualRun?.id === 'interrupted'); + + expect(status.runHistory.map(run => [run.trigger, run.ok])).toEqual([ + ['manual', false], + ['manual', true], + ['scheduled', false], + ]); + expect((await scheduler.status()).runHistory).toEqual(status.runHistory); + }); + + it('retains only the newest 50 runs, including their logs', async () => { + const scheduler = stub(); + const now = Date.now(); + const logs = { lines: [{ text: 'x'.repeat(30000) }], omitted: 0 }; + + await runInDurableObject(scheduler, async (_instance, ctx) => { + await ctx.storage.put('state', { automaticSchedulingEnabled: false }); + + for (let i = 0; i < 50; i++) { + const startedAt = now - 1000 + i; + + await ctx.storage.put(`run:${String(startedAt).padStart(15, '0')}:${i}`, { + id: String(i), + startedAt, + trigger: 'scheduled', + ok: true, + logs, + }); + } + + await ctx.storage.put('pendingManual', { + id: 'latest', + status: 'running', + mode: 'data', + startedAt: now, + }); + }); + + await scheduler.ensureArmed(); + + const status = await runAlarmUntil(scheduler, s => s.lastManualRun?.id === 'latest'); + + expect(status.runHistory).toHaveLength(50); + expect(status.runHistory[0].id).toBe('latest'); + expect(status.runHistory.at(-1).id).toBe('1'); + expect(status.runHistory[1].logs).toEqual(logs); + + await runInDurableObject(scheduler, async (_instance, ctx) => { + expect((await ctx.storage.list({ prefix: 'run:' })).size).toBe(50); + }); + }); + it('rejects invalid modes and paused scheduling', async () => { const scheduler = stub(); diff --git a/workers/updater/preview/server.mjs b/workers/updater/preview/server.mjs index b49667f..2d65d3f 100644 --- a/workers/updater/preview/server.mjs +++ b/workers/updater/preview/server.mjs @@ -44,6 +44,11 @@ const state = { }, }; +state.runHistory = [ + { ...state.lastRun, trigger: 'scheduled' }, + { ...state.lastManualRun, trigger: 'manual' }, +]; + createServer(async (request, response) => { response.setHeader('Cache-Control', 'no-store'); @@ -171,6 +176,8 @@ createServer(async (request, response) => { ? { ok: true, skipped: true } : { ok: true, posts: [{ name: 'Schedule', ok: true, simulated: true }] }, }; + state.runHistory.unshift({ ...state.lastManualRun, trigger: 'manual' }); + state.runHistory = state.runHistory.slice(0, 50); state.busy = false; state.pendingManual = null; state.activeRun = null; diff --git a/workers/updater/src/Scheduler.mjs b/workers/updater/src/Scheduler.mjs index 8bd5423..e5d9072 100644 --- a/workers/updater/src/Scheduler.mjs +++ b/workers/updater/src/Scheduler.mjs @@ -10,6 +10,7 @@ import { createLogger, describeError } from './log.mjs'; const RETRY_DELAY_MS = 60 * 1000; const MAX_RETRIES = 3; +const MAX_STORED_RUNS = 50; export const MANUAL_MODES = ['data', 'social', 'both']; export class Scheduler extends DurableObject { @@ -39,6 +40,41 @@ export class Scheduler extends DurableObject { return (await this.ctx.storage.get('pendingManual')) ?? null; } + async #runHistory(storage = this.ctx.storage) { + const records = await storage.list({ prefix: 'run:', reverse: true }); + + if (records.size) + return [...records.values()]; + + // Keep the two results retained by older deployments when history starts. + const state = await storage.get('state'); + const manual = await storage.get('lastManualRun'); + + return [ + state?.lastRun && { ...state.lastRun, trigger: 'scheduled' }, + manual && { ...manual, trigger: 'manual' }, + ].filter(Boolean).sort((a, b) => b.startedAt - a.startedAt); + } + + async #recordRun(storage, result, trigger) { + const records = await storage.list({ prefix: 'run:', reverse: true }); + const previous = records.size ? [] : await this.#runHistory(storage); + const run = { ...result, trigger, id: result.id ?? crypto.randomUUID() }; + let sequence = Number(records.keys().next().value?.split(':')[1] ?? 0); + + // Separate records keep each run's logs out of one growing storage value. + for (const entry of [...previous.reverse(), run]) { + const key = `run:${String(++sequence).padStart(15, '0')}`; + await storage.put(key, entry); + records.set(key, entry); + } + + const expired = [...records.keys()].sort().reverse().slice(MAX_STORED_RUNS); + + if (expired.length) + await storage.delete(expired); + } + async ensureArmed() { if (this.#running) return { armed: false, busy: true }; @@ -144,6 +180,7 @@ export class Scheduler extends DurableObject { ...(await this.#state()), alarmAt: await this.ctx.storage.getAlarm(), lastManualRun: (await this.ctx.storage.get('lastManualRun')) ?? null, + runHistory: await this.#runHistory(), pendingManual, activeRun: this.#activeRun, busy: this.#running || !!pendingManual, @@ -197,7 +234,10 @@ export class Scheduler extends DurableObject { let result = await this.#execute(only); - await this.ctx.storage.put('lastManualRun', result); + await this.ctx.storage.transaction(async txn => { + await this.#recordRun(txn, result, 'manual'); + await txn.put('lastManualRun', result); + }); return result; } finally { @@ -300,12 +340,15 @@ export class Scheduler extends DurableObject { } await this.ctx.storage.transaction(async txn => { - await txn.put('lastManualRun', { + const completed = { ...manual, ...result, status: result.ok ? 'succeeded' : 'failed', finishedAt: Date.now(), - }); + }; + + await this.#recordRun(txn, completed, 'manual'); + await txn.put('lastManualRun', completed); await txn.delete('pendingManual'); }); } @@ -320,10 +363,12 @@ export class Scheduler extends DurableObject { let scheduledFor = state.retryAt ?? state.hourlyAt; + let scheduledRun; + if (Date.now() >= scheduledFor) { let result = await this.#execute(); - state.lastRun = { + scheduledRun = { ...result, scheduledFor, driftMs: result.startedAt - scheduledFor, @@ -331,6 +376,8 @@ export class Scheduler extends DurableObject { retryCount: alarmInfo?.retryCount ?? 0, }; + state.lastRun = scheduledRun; + if (result.ok || state.retries >= MAX_RETRIES) { state.hourlyAt = nextRunAt(); state.retryAt = null; @@ -343,8 +390,13 @@ export class Scheduler extends DurableObject { // Storage failures escape so the platform retries. Schedule and state are saved // together without external I/O in between. - await this.ctx.storage.put('state', state); - await this.ctx.storage.setAlarm(state.retryAt ?? state.hourlyAt); + await this.ctx.storage.transaction(async txn => { + if (scheduledRun) + await this.#recordRun(txn, scheduledRun, 'scheduled'); + + await txn.put('state', state); + await txn.setAlarm(state.retryAt ?? state.hourlyAt); + }); } finally { this.#running = false; } diff --git a/workers/updater/src/admin/page.html b/workers/updater/src/admin/page.html index 71960b5..931aec0 100644 --- a/workers/updater/src/admin/page.html +++ b/workers/updater/src/admin/page.html @@ -720,12 +720,15 @@ } // Keep expanded details open during polling when the records have not changed. - const signature = JSON.stringify([state.lastManualRun, state.lastRun]); + const signature = JSON.stringify(state.runHistory); if (element('history').dataset.signature !== signature) { element('history').replaceChildren( - renderRunRecord('Last manual run', state.lastManualRun), - renderRunRecord('Last scheduled run', state.lastRun), + ...(state.runHistory?.length + ? state.runHistory.map(run => renderRunRecord( + run.trigger === 'scheduled' ? 'Scheduled run' : 'Manual run', run, + )) + : [renderRunRecord('Run history', null)]), ); element('history').dataset.signature = signature; }