workers/watchdog.ts
633 lines and 41 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| readState | 48 to 60 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| writeState | 63 to 78 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| readHealth | 87 to 100 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:92> | 92 to 92 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| repair | 103 to 148 | 9 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:120> | 120 to 120 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| isCitationRefreshWindow | 155 to 158 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| refreshCitationsWeekly | 166 to 192 | 9 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:181> | 181 to 181 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:187> | 187 to 187 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| isZeroResultPurgeWindow | 200 to 203 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| purgeZeroResultsDaily | 211 to 238 | 7 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:226> | 226 to 226 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| isD1BackupWindow | 245 to 248 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| backupD1Daily | 257 to 282 | 7 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:272> | 272 to 272 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| isPublicationCheckWindow | 288 to 291 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| checkPublicationsDaily | 304 to 357 | 16 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:319> | 319 to 319 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| fresh | 335 to 335 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:335> | 335 to 335 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| line | 341 to 341 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| alert | 359 to 377 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| readErrorRate | 384 to 455 | 10 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:422> | 422 to 422 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:447> | 447 to 447 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| renderBody | 457 to 463 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| runRepairs | 473 to 546 | 12 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:495> | 495 to 495 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| reread | 501 to 501 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| sleep | 502 to 502 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:502> | 502 to 502 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:525> | 525 to 525 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| mergeErrorRate | 549 to 562 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| mailTransition | 565 to 613 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| send | 589 to 606 | 5 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| write | 607 to 607 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| scheduled | 617 to 678 | 7 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:627> | 627 to 627 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:629> | 629 to 629 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:666> | 666 to 666 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
Imports
It imports app/lib/error-message.mjs, app/lib/health/alert-state.mjs, app/lib/health/error-rate.mjs, app/lib/health/repair.mjs, app/lib/seo.ts. No module of this repo imports it.
Source
// Relative imports on purpose: this Worker's esbuild bundle does not read the `~/` tsconfig mapping,
// so `~/` here would typecheck and then fail at deploy.
import {
failingCheckNames,
refusalMiss,
settleAskRecheck,
watchdogActions,
watchdogOutcome,
} from "../app/lib/health/repair.mjs";
import {
alertTransition,
deliverTransition,
formatDuration,
} from "../app/lib/health/alert-state.mjs";
import {
ERROR_WINDOW_MINUTES,
errorRateQuery,
errorRateVerdict,
} from "../app/lib/health/error-rate.mjs";
import { SITE_ORIGIN } from "../app/lib/seo";
import { errorMessage } from "../app/lib/error-message.mjs";
// A script name, not a hostname, so not derived from SITE_ORIGIN. Must equal the site config's `name`.
const SITE_SCRIPT_NAME = "dustinedwards";
/**
* A separate Worker because a watcher inside the site dies with it. It reaches the site only through
* the `SITE` service binding (error 1042 refuses same-zone Worker fetch), so it cannot prove the site
* is reachable from outside. It must never write the health snapshot, whose age is its liveness.
*/
// `ALERT_EMAIL` is a var, not a secret: an inbox address grants nothing.
interface WatchdogEnv {
SITE: Fetcher;
EMAIL: SendEmail;
ALERT_EMAIL: string;
OPERATOR_TOKEN?: string;
APP_KV: KVNamespace;
// Optional, and absence is reported by name so losing sight of errors never looks like seeing none.
CLOUDFLARE_ACCOUNT_ID?: string;
CLOUDFLARE_API_TOKEN?: string;
}
// Namespaced because this KV is shared with the Ask cache and the site's other KV state.
const STATE_KEY = "watchdog:alert-state";
// Absent and unreadable differ: unreadable means the dedupe is blind, and it must not stay quiet.
async function readState(env: WatchdogEnv): Promise<{ readable: boolean; stored: unknown }> {
try {
return { readable: true, stored: await env.APP_KV.get(STATE_KEY, "json") };
} catch (error) {
console.error(
JSON.stringify({
watchdog: "state-read-failed",
error: errorMessage(error),
}),
);
return { readable: false, stored: null };
}
}
// Never throws: a failed write costs at worst one duplicate mail, a throw would cost this alert.
async function writeState(
env: WatchdogEnv,
state: { red: boolean; since: string; checks: string[] },
): Promise<void> {
try {
await env.APP_KV.put(STATE_KEY, JSON.stringify(state));
} catch (error) {
console.error(
JSON.stringify({
alert: "watchdog-state-write-failed",
watchdog: "state-write-failed",
error: errorMessage(error),
}),
);
}
}
// Not derived from SITE_ORIGIN: that is `*.workers.dev`, which Email Sending would refuse as a sender.
const ALERT_FROM = { email: "watchdog@dustinedwards.info", name: "dustinedwards.info watchdog" };
type Reading = { status: number; body: unknown; error?: string };
// Never throws, and keeps the cause: a timeout is a site problem, a 1042 is a misdirected binding.
// Exported only so the worker test can drive the catch.
export async function readHealth(env: WatchdogEnv): Promise<Reading> {
try {
const response = await env.SITE.fetch(`${SITE_ORIGIN}/api/health`, {
headers: { "user-agent": "watchdog", "cache-control": "no-cache" },
});
return { status: response.status, body: await response.json().catch(() => null) };
} catch (error) {
return {
status: 0,
body: null,
error: errorMessage(error),
};
}
}
// The verdict is read, never inferred from a 200. The token is never logged or put in the mail.
async function repair(
env: WatchdogEnv,
token: string,
tool: string,
): Promise<{ miss: string; unrepairable: boolean }> {
let response: Response;
let payload: unknown;
try {
response = await env.SITE.fetch(`${SITE_ORIGIN}/api/operator`, {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
"user-agent": "watchdog",
},
body: JSON.stringify({ tool, args: {} }),
});
payload = await response.json().catch(() => null);
} catch (error) {
return {
miss: `${tool} did not complete: ${errorMessage(error)}`,
unrepairable: false,
};
}
if (!response.ok) return refusalMiss(tool, response.status, payload);
const report = (
payload && typeof payload === "object"
? ((payload as { data?: unknown }).data ?? payload)
: null
) as { converged?: unknown; expected?: unknown; present?: unknown } | null;
if (!report || typeof report.converged !== "boolean") {
return {
miss: `${tool} answered without a converged verdict, so nothing was proven`,
unrepairable: false,
};
}
if (report.converged !== true) {
return {
miss: `${tool} ran and did not converge (${report.expected} expected, ${report.present} present)`,
unrepairable: false,
};
}
return { miss: "", unrepairable: false };
}
/**
* The one firing a week that refreshes the citation counts: Sunday, 03:00 to 03:14 UTC. The cron fires every
* fifteen minutes, so exactly one firing falls in that quarter hour, and the main Worker needs no cron of its
* own (wrangler.jsonc.example keeps `crons: []` on purpose). Exported only so the worker test can drive it.
*/
export function isCitationRefreshWindow(scheduledTime: number): boolean {
const at = new Date(scheduledTime);
return at.getUTCDay() === 0 && at.getUTCHours() === 3 && at.getUTCMinutes() < 15;
}
/**
* Calls the operator API's refresh_citations, which replaces each paper's count in D1 and keeps the old one
* where OpenAlex fails. Returns the problem, or null when nothing was wrong or it was not this firing's turn;
* the caller throws it after the health mail, so the invocation is recorded as failed and the cause is named.
* Exported only so the worker test can drive it.
*/
export async function refreshCitationsWeekly(
env: WatchdogEnv,
token: string,
scheduledTime: number,
): Promise<string | null> {
if (!isCitationRefreshWindow(scheduledTime)) return null;
if (!token) return "the weekly citation refresh did not run: OPERATOR_TOKEN is not set on this Worker";
let response: Response;
let payload: unknown;
try {
response = await env.SITE.fetch(`${SITE_ORIGIN}/api/operator`, {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json", "user-agent": "watchdog" },
body: JSON.stringify({ tool: "refresh_citations", args: {} }),
});
payload = await response.json().catch(() => null);
} catch (error) {
return `the weekly citation refresh did not complete: ${errorMessage(error)}`;
}
if (!response.ok) {
const body = payload as { error?: string; detail?: { failures?: Array<{ doi: string; stage: string }> } } | null;
const failed = (body?.detail?.failures ?? []).map((f) => `${f.doi} (${f.stage})`).join(", ");
return `the weekly citation refresh answered ${response.status}: ${body?.error ?? "(no error was given)"}${failed ? ` Not refreshed: ${failed}.` : ""}`;
}
console.log(JSON.stringify({ watchdog: "citations-refreshed", data: (payload as { data?: unknown } | null)?.data ?? null }));
return null;
}
/**
* The one firing a day that purges expired zero-result search queries: 04:00 to 04:14 UTC, a different
* quarter hour than the weekly citation refresh above so the two never share a firing. Replaces the admin
* Tools page's manual "remove expired queries" button, which this Worker has no cron of its own to drive
* (wrangler.jsonc.example keeps `crons: []` on purpose). Exported only so the worker test can drive it.
*/
export function isZeroResultPurgeWindow(scheduledTime: number): boolean {
const at = new Date(scheduledTime);
return at.getUTCHours() === 4 && at.getUTCMinutes() < 15;
}
/**
* Calls the operator API's purge_zero_results, which removes every zero-result query row past its
* retention cutoff (the same cutoff the removed button enforced). Returns the problem, or null when
* nothing was wrong or it was not this firing's turn; the caller throws it, so the invocation is
* recorded as failed and the cause is named. Exported only so the worker test can drive it.
*/
export async function purgeZeroResultsDaily(
env: WatchdogEnv,
token: string,
scheduledTime: number,
): Promise<string | null> {
if (!isZeroResultPurgeWindow(scheduledTime)) return null;
if (!token) return "the daily zero-result purge did not run: OPERATOR_TOKEN is not set on this Worker";
let response: Response;
let payload: unknown;
try {
response = await env.SITE.fetch(`${SITE_ORIGIN}/api/operator`, {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json", "user-agent": "watchdog" },
body: JSON.stringify({ tool: "purge_zero_results", args: {} }),
});
payload = await response.json().catch(() => null);
} catch (error) {
return `the daily zero-result purge did not complete: ${errorMessage(error)}`;
}
if (!response.ok) {
const body = payload as { error?: string } | null;
return `the daily zero-result purge answered ${response.status}: ${body?.error ?? "(no error was given)"}`;
}
console.log(
JSON.stringify({ watchdog: "zero-results-purged", data: (payload as { data?: unknown } | null)?.data ?? null }),
);
return null;
}
/**
* The one firing a day that takes the D1 dump: 05:00 to 05:14 UTC, its own quarter hour so it never
* shares a firing with the citation refresh (Sunday 03:00) or the zero-result purge (04:00).
* Exported only so the worker test can drive it.
*/
export function isD1BackupWindow(scheduledTime: number): boolean {
const at = new Date(scheduledTime);
return at.getUTCHours() === 5 && at.getUTCMinutes() < 15;
}
/**
* Calls the operator API's backup_d1, which dumps D1 to the BACKUPS bucket, checks the dump and prunes
* by retention. Returns the problem, or null when nothing was wrong or it was not this firing's turn;
* the caller throws it, so the invocation is recorded as failed and the cause is named. A failed run
* is retried by nothing until tomorrow, which is why the failure must be loud. Exported only so the
* worker test can drive it.
*/
export async function backupD1Daily(
env: WatchdogEnv,
token: string,
scheduledTime: number,
): Promise<string | null> {
if (!isD1BackupWindow(scheduledTime)) return null;
if (!token) return "the daily D1 backup did not run: OPERATOR_TOKEN is not set on this Worker";
let response: Response;
let payload: unknown;
try {
response = await env.SITE.fetch(`${SITE_ORIGIN}/api/operator`, {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json", "user-agent": "watchdog" },
body: JSON.stringify({ tool: "backup_d1", args: {} }),
});
payload = await response.json().catch(() => null);
} catch (error) {
return `the daily D1 backup did not complete: ${errorMessage(error)}`;
}
if (!response.ok) {
const body = payload as { error?: string } | null;
return `the daily D1 backup answered ${response.status}: ${body?.error ?? "(no error was given)"}`;
}
console.log(JSON.stringify({ watchdog: "d1-backed-up", data: (payload as { data?: unknown } | null)?.data ?? null }));
return null;
}
/**
* The one firing a day that checks the publications list against ORCID and Crossref: 06:00 to 06:14 UTC,
* its own quarter hour. Exported only so the worker test can drive it.
*/
export function isPublicationCheckWindow(scheduledTime: number): boolean {
const at = new Date(scheduledTime);
return at.getUTCHours() === 6 && at.getUTCMinutes() < 15;
}
// The DOIs already mailed about, so a paper waiting to be added through Carrel is mailed once, not daily.
const PUBLICATIONS_MAILED_KEY = "watchdog:publications-mailed";
type FoundWork = { doi: string; title: string; year: number | null };
/**
* Calls the operator API's check_publications and mails each DOI it finds that no file carries, once.
* Returns the problem, or null when nothing was wrong or it was not this firing's turn; the caller throws
* it, so the invocation is recorded as failed and the cause is named. Exported only so the worker test
* can drive it.
*/
export async function checkPublicationsDaily(
env: WatchdogEnv,
token: string,
scheduledTime: number,
): Promise<string | null> {
if (!isPublicationCheckWindow(scheduledTime)) return null;
if (!token) return "the daily publication check did not run: OPERATOR_TOKEN is not set on this Worker";
let response: Response;
let payload: unknown;
try {
response = await env.SITE.fetch(`${SITE_ORIGIN}/api/operator`, {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json", "user-agent": "watchdog" },
body: JSON.stringify({ tool: "check_publications", args: {} }),
});
payload = await response.json().catch(() => null);
} catch (error) {
return `the daily publication check did not complete: ${errorMessage(error)}`;
}
if (!response.ok) {
const body = payload as { error?: string } | null;
return `the daily publication check answered ${response.status}: ${body?.error ?? "(no error was given)"}`;
}
const data = (payload as { data?: { notOnSite?: FoundWork[]; nameMatches?: FoundWork[] } } | null)?.data;
let mailed: Set<string>;
try {
mailed = new Set(((await env.APP_KV.get(PUBLICATIONS_MAILED_KEY, "json")) as string[] | null) ?? []);
} catch (error) {
// Unreadable is not empty: mailing every DOI again daily would bury the one that is new.
return `the daily publication check could not read which DOIs it already mailed: ${errorMessage(error)}`;
}
const fresh = (works: FoundWork[] = []) => works.filter((w) => !mailed.has(w.doi.toLowerCase()));
const tied = fresh(data?.notOnSite);
const byName = fresh(data?.nameMatches);
console.log(JSON.stringify({ watchdog: "publications-checked", new: tied.length + byName.length }));
if (tied.length + byName.length === 0) return null;
const line = (w: FoundWork) => `${w.title || "(no title)"}${w.year ? ` (${w.year})` : ""}: https://doi.org/${w.doi}`;
const sent = await alert(env, "New publication found for dustinedwards.info", [
...(tied.length > 0 ? ["ORCID or Crossref lists these under your ORCID, and the site does not:", ...tied.map(line)] : []),
...(tied.length > 0 && byName.length > 0 ? [""] : []),
...(byName.length > 0 ? ["Crossref lists these under your name, without your ORCID. Check they are yours:", ...byName.map(line)] : []),
"",
"Add a paper through Carrel. Each DOI is mailed once.",
]);
if (!sent) return "the daily publication check found new DOIs and the mail did not send; it will try again tomorrow";
for (const w of [...tied, ...byName]) mailed.add(w.doi.toLowerCase());
try {
await env.APP_KV.put(PUBLICATIONS_MAILED_KEY, JSON.stringify([...mailed]));
} catch (error) {
return `the daily publication check mailed its DOIs and could not record them, so tomorrow mails them again: ${errorMessage(error)}`;
}
return null;
}
async function alert(env: WatchdogEnv, subject: string, lines: string[]): Promise<boolean> {
try {
await env.EMAIL.send({
to: env.ALERT_EMAIL,
from: ALERT_FROM,
subject,
text: lines.join("\n"),
});
return true;
} catch (error) {
console.error(
JSON.stringify({
alert: "watchdog-notify-failed",
error: errorMessage(error),
}),
);
return false;
}
}
/**
* Not a health check: `ship` refuses unless `/api/health` is ok, so an error spike there would block
* deploying the fix. Never throws, and any failure to read the rate is reported as not ok.
* Exported only so the worker test can drive it against a stubbed analytics API.
*/
export async function readErrorRate(
env: WatchdogEnv,
): Promise<{ ok: boolean; detail: string; configured: boolean }> {
const account = env.CLOUDFLARE_ACCOUNT_ID ?? "";
const token = env.CLOUDFLARE_API_TOKEN ?? "";
if (!account || !token) {
return {
ok: true,
configured: false,
detail:
"error rate NOT CHECKED: " +
`${!account ? "CLOUDFLARE_ACCOUNT_ID" : "CLOUDFLARE_API_TOKEN"} is not set on this ` +
"Worker. Set it with `wrangler secret put CLOUDFLARE_API_TOKEN -c " +
"wrangler.watchdog.jsonc` (Account / Account Analytics / Read).",
};
}
const until = new Date();
const since = new Date(until.getTime() - ERROR_WINDOW_MINUTES * 60 * 1000);
const body = errorRateQuery({
accountId: account,
scriptName: SITE_SCRIPT_NAME,
since: since.toISOString(),
until: until.toISOString(),
});
/** The GraphQL answer as far as it is read; every level is optional-chained below. */
type InvocationsAnswer = {
errors?: Array<{ message?: string } | null>;
data?: { viewer?: { accounts?: Array<{ workersInvocationsAdaptive?: Parameters<typeof errorRateVerdict>[0] }> } };
} | null;
let payload: InvocationsAnswer = null;
try {
const response = await fetch("https://api.cloudflare.com/client/v4/graphql", {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify(body),
});
payload = (await response.json().catch(() => null)) as InvocationsAnswer;
if (!response.ok) {
return {
ok: false,
configured: true,
detail: `the invocations query answered HTTP ${response.status}, so the error rate is unknown.`,
};
}
} catch (error) {
return {
ok: false,
configured: true,
detail:
"the invocations query did not complete, so the error rate is unknown: " +
`${errorMessage(error)}`,
};
}
// A GraphQL 200 can carry errors; an absent `data` would otherwise sum to zero errors.
if (Array.isArray(payload?.errors) && payload.errors.length > 0) {
return {
ok: false,
configured: true,
detail:
"the invocations query returned GraphQL errors, so the error rate is unknown: " +
`${payload.errors.map((e) => e?.message).join("; ").slice(0, 200)}`,
};
}
const verdict = errorRateVerdict(
payload?.data?.viewer?.accounts?.[0]?.workersInvocationsAdaptive,
);
return { ok: verdict.ok, configured: true, detail: verdict.detail };
}
const renderBody = (reading: Reading | null): string =>
reading === null
? "(no reading was taken)"
: reading.status === 0
? // No body reached us, so print the cause rather than "null".
`HTTP 0 (the request did not complete)\n${reading.error ?? "(no cause was recorded)"}`
: `HTTP ${reading.status}\n${JSON.stringify(reading.body, null, 2)}`;
type Action = ReturnType<typeof watchdogActions>[number];
// Repairs run every firing and only the mail is deduplicated, so `alerting` is where it ended.
type AlertDraft = { alerting: boolean; subject: string; lines: string[]; failing: string[] };
const QUIET: AlertDraft = { alerting: false, subject: "", lines: [], failing: [] };
// Called only when every action is a repair or a recheck; a notify action never reaches here.
async function runRepairs(
env: WatchdogEnv,
token: string,
reading: Reading,
actions: Action[],
): Promise<AlertDraft> {
const attempted: string[] = [];
const misses: string[] = [];
// A refusal abandons the rest: the Ask corpus is read from what content repair writes.
let refused = false;
for (const action of actions) {
if (action.type !== "repair") continue;
attempted.push(action.tool);
const outcome = await repair(env, token, action.tool);
if (!outcome.miss) continue;
misses.push(outcome.miss);
if (outcome.unrepairable) {
refused = true;
break;
}
}
const first = actions.some((a) => a.type === "recheck") ? await readHealth(env) : null;
// Its own sync_ask can leave the Ask index listing short for about a minute; a cron run may wait
// up to 15 minutes of wall time, and waiting spends no CPU, so the re-check settles here.
const { reading: recheck, polls: settlePolls } = await settleAskRecheck({
recheck: first,
attempted,
reread: () => readHealth(env),
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
});
const outcome = watchdogOutcome({ misses, recheck });
console.log(
JSON.stringify({
watchdog: "repaired",
attempted,
misses,
recheckStatus: recheck?.status ?? null,
settlePolls,
alerting: outcome.length > 0,
}),
);
if (outcome.length === 0) return QUIET;
return {
alerting: true,
failing: failingCheckNames((recheck ?? reading).body),
subject: refused
? "dustinedwards.info: self-repair was refused, a deploy is the repair"
: "dustinedwards.info: self-repair did not settle it",
lines: [
...outcome.map((a) => (a as { reason: string }).reason),
...(refused
? [
"",
"The operator API REFUSED the content itself (422), so repeating this " +
"cannot converge. That is what a repository ahead of the deployed " +
"build looks like: the content is valid and the renderer in " +
"production is older than it. Deploy (npm run ship), then let the " +
"next poll converge it.",
]
: []),
"",
`Attempted: ${attempted.join(", ") || "(nothing)"}`,
"",
"Health BEFORE the repair:",
renderBody(reading),
"",
"Health AFTER the repair:",
renderBody(recheck),
],
};
}
// A throwing Worker alerts even when every health check is green.
function mergeErrorRate(
draft: AlertDraft,
errorRate: { ok: boolean; detail: string },
): AlertDraft {
if (errorRate.ok) return draft;
return {
alerting: true,
subject: draft.alerting ? draft.subject : "dustinedwards.info is throwing",
lines: draft.alerting
? [...draft.lines, "", `Error rate: ${errorRate.detail}`]
: [errorRate.detail],
failing: [...draft.failing, "error-rate"],
};
}
// Mails only on a change of state; throws when that mail did not send, so the next firing retries.
async function mailTransition(
env: WatchdogEnv,
draft: AlertDraft,
reading: Reading,
errorRateNote: string[],
): Promise<void> {
const { alerting, failing, subject, lines } = draft;
const { readable, stored } = await readState(env);
const now = new Date().toISOString();
const transition = alertTransition({ stored, storedReadable: readable, alerting, failing, now });
console.log(
JSON.stringify({
watchdog: "alert-decision",
alerting,
failing,
storedReadable: readable,
email: transition.email?.kind ?? "none",
reason: transition.reason,
}),
);
const email = transition.email;
const delivered = await deliverTransition(transition, {
send: () =>
email?.kind === "opened"
? alert(env, subject, [
`Changed to unhealthy. Failing now: ${email.checks.join(", ") || "(the endpoint named none)"}.`,
"",
"You will not be mailed again about this until it recovers.",
"",
...lines,
...errorRateNote,
])
: alert(env, "dustinedwards.info recovered", [
`Health is green again after ${formatDuration(email?.durationMs ?? null)}.`,
"",
`Was failing: ${email?.checks.join(", ") || "(the endpoint named none)"}.`,
"",
renderBody(reading),
...errorRateNote,
]),
write: () => writeState(env, transition.state),
});
// Thrown so the firing is recorded as failed; the unchanged state makes the next one retry.
if (!delivered) {
throw new Error(`the ${email?.kind} mail did not send, so the alert state was left unchanged`);
}
}
export default {
// Re-raised: a throwing Cron Trigger is recorded as a failed invocation, the last signal left.
async scheduled(controller: ScheduledController, env: WatchdogEnv): Promise<void> {
try {
const token = env.OPERATOR_TOKEN ?? "";
const reading = await readHealth(env);
const actions = watchdogActions(reading, { hasToken: token.length > 0 });
let draft = QUIET;
if (actions.length === 0) {
console.log(JSON.stringify({ watchdog: "healthy", status: reading.status }));
} else {
const notify = actions.filter((a) => a.type === "notify");
if (notify.length > 0) {
const reasons = notify.map((a) => (a as { reason: string }).reason);
console.error(JSON.stringify({ watchdog: "alerting", reasons }));
draft = {
alerting: true,
failing: failingCheckNames(reading.body),
subject: "dustinedwards.info is unhealthy",
lines: [...reasons, "", "Nothing was repaired.", "", renderBody(reading)],
};
} else {
draft = await runRepairs(env, token, reading, actions);
}
}
// Read every firing, whatever health said: checks can all be green while the Worker throws.
// Never repaired; a rebuild aimed at a throwing Worker treats an unclassified symptom.
const errorRate = await readErrorRate(env);
console.log(
JSON.stringify({
watchdog: "error-rate",
ok: errorRate.ok,
configured: errorRate.configured,
detail: errorRate.detail,
}),
);
draft = mergeErrorRate(draft, errorRate);
// An unconfigured rate reads ok, so every mail says it was not checked rather than implying it was.
const errorRateNote = errorRate.configured ? [] : ["", errorRate.detail];
await mailTransition(env, draft, reading, errorRateNote);
// Each runs independently, and both are raised after the health mail, so neither a bad week of
// counts nor a missed purge hides a bad site, and one firing's two unrelated jobs cannot mask
// each other (they fall in different quarter hours, so this is belt and suspenders).
const refreshProblem = await refreshCitationsWeekly(env, token, controller.scheduledTime);
const purgeProblem = await purgeZeroResultsDaily(env, token, controller.scheduledTime);
const backupProblem = await backupD1Daily(env, token, controller.scheduledTime);
const publicationProblem = await checkPublicationsDaily(env, token, controller.scheduledTime);
const scheduledProblems = [refreshProblem, purgeProblem, backupProblem, publicationProblem].filter((p): p is string => p !== null);
if (scheduledProblems.length > 0) throw new Error(scheduledProblems.join(" "));
} catch (error) {
const message = errorMessage(error);
console.error(JSON.stringify({ watchdog: "threw", error: message }));
await alert(env, "dustinedwards.info watchdog THREW", [
"The watchdog itself failed, so this firing proved nothing about the site.",
"",
message,
]);
throw error;
}
},
} satisfies ExportedHandler<WatchdogEnv>;