Skip to the content

Capsid menu

workers/watchdog.ts

633 lines and 41 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.

Functions

The functions of watchdog.ts
FunctionLinesComplexityCoveredCRAPMutation
readState48 to 602No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
writeState63 to 782No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
readHealth87 to 1002No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:92>92 to 921No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
repair103 to 1489No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:120>120 to 1201No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
isCitationRefreshWindow155 to 1583No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
refreshCitationsWeekly166 to 1929No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:181>181 to 1811No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:187>187 to 1871No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
isZeroResultPurgeWindow200 to 2032No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
purgeZeroResultsDaily211 to 2387No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:226>226 to 2261No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
isD1BackupWindow245 to 2482No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
backupD1Daily257 to 2827No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:272>272 to 2721No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
isPublicationCheckWindow288 to 2912No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
checkPublicationsDaily304 to 35716No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:319>319 to 3191No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
fresh335 to 3351No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:335>335 to 3351No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
line341 to 3413No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
alert359 to 3772No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
readErrorRate384 to 45510No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:422>422 to 4221No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:447>447 to 4471No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
renderBody457 to 4634No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
runRepairs473 to 54612No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:495>495 to 4951No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
reread501 to 5011No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
sleep502 to 5021No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:502>502 to 5021No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:525>525 to 5251No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
mergeErrorRate549 to 5624No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
mailTransition565 to 6133No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
send589 to 6065No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
write607 to 6071No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
scheduled617 to 6787No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:627>627 to 6271No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:629>629 to 6291No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:666>666 to 6661No data: no coverage report in this mapNo data: no coverage report in this mapNo 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>;