diff --git a/scripts/tools/daily-checkin-data.mjs b/scripts/tools/daily-checkin-data.mjs index 79cae767..1501ca1d 100644 --- a/scripts/tools/daily-checkin-data.mjs +++ b/scripts/tools/daily-checkin-data.mjs @@ -7,6 +7,7 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { approximateDeployedSha, + buildHealthEmailQuery, buildWorkersQuery, collectWorkflowRuns, deriveBaselineFlag, @@ -344,7 +345,7 @@ const d1 = probe(() => { ? d1Query(`SELECT queue, job_type, substr(exception, 1, 160) exception, COUNT(*) count, MIN(failed_at) first_failed_at, MAX(failed_at) last_failed_at FROM failed_jobs WHERE failed_at >= ${sinceSec} GROUP BY queue, job_type, substr(exception, 1, 160) ORDER BY count DESC LIMIT 10`) : null const healthEmail = has('daily_health_checks') - ? parseHealthEmailRows(d1Query(`SELECT report_date, health_status, delivery_status, recipient, sent_at, error, summary_json FROM daily_health_checks ORDER BY report_date DESC LIMIT 2`)) + ? parseHealthEmailRows(d1Query(buildHealthEmailQuery())) : null const recentJobBatches = has('job_batches') ? d1Query(`SELECT id, name, total_jobs, pending_jobs, failed_jobs, created_at, updated_at, finished_at FROM job_batches ORDER BY updated_at DESC LIMIT 10`) diff --git a/scripts/tools/daily-checkin-observability.mjs b/scripts/tools/daily-checkin-observability.mjs index 3e83b222..c18d51dc 100644 --- a/scripts/tools/daily-checkin-observability.mjs +++ b/scripts/tools/daily-checkin-observability.mjs @@ -211,6 +211,17 @@ function stringArray(value, field) { return value } +// The gate is one sent operator report per Melbourne date, so the read has to +// show enough days to verify continuity. The old two-row page could only ever +// verify the two newest dates, which is how a gap three days back stayed +// invisible. Melbourne runs up to one day ahead of UTC, so eight UTC days is +// the smallest bound that always covers seven Melbourne dates. `report_date` +// is the table's primary key, so a row cap equal to the window can never hide +// an in-window date. +export function buildHealthEmailQuery() { + return `SELECT report_date, health_status, delivery_status, recipient, sent_at, error, summary_json FROM daily_health_checks WHERE report_date >= date('now', '-8 days') ORDER BY report_date DESC LIMIT 8` +} + export function parseHealthEmailRows(rows) { return rows.map((row) => { const summary = (() => { diff --git a/test/unit/daily-checkin-observability.test.ts b/test/unit/daily-checkin-observability.test.ts index 682cfc87..2f638fac 100644 --- a/test/unit/daily-checkin-observability.test.ts +++ b/test/unit/daily-checkin-observability.test.ts @@ -3,6 +3,7 @@ import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { approximateDeployedSha, + buildHealthEmailQuery, buildWorkersQuery, collectWorkflowRuns, deriveBaselineFlag, @@ -107,6 +108,24 @@ describe('daily check-in observability', () => { )).toContain('filter: {scriptName: "skilld-dev"') }) + // The one-sent-report-per-Melbourne-date gate needs more than the two newest + // days: a two-row page cannot show continuity, so a gap three days back was + // invisible. report_date is the table's primary key, so the row cap can equal + // the window without ever hiding an in-window date. + it('bounds the health email read to a window that covers a week of Melbourne dates', () => { + const query = buildHealthEmailQuery() + + const windowDays = Number(query.match(/report_date >= date\('now', '-(\d+) days'\)/)?.[1]) + // Melbourne runs up to one day ahead of UTC, so eight UTC days is the + // smallest bound that always covers seven Melbourne dates. + expect(windowDays).toBeGreaterThanOrEqual(8) + + const limit = Number(query.match(/LIMIT (\d+)\s*$/)?.[1]) + expect(limit).toBeGreaterThanOrEqual(windowDays) + + expect(query).toContain('ORDER BY report_date DESC') + }) + // The exact shape from issue #195: the routine ran on 2026-09-09 and not // again until the 21:40Z slot six days later, so every "overnight" rate in // the archive covered six days while reading as an ordinary window.