Skip to the content

Capsid menu

src/inbox.ts

187 lines and 22 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. It is in an import cycle. This file on GitHub.

Functions

The functions of inbox.ts
FunctionLinesComplexityCoveredCRAPMutation
severityOf42 to 453100%OkCRAP 3No data: mutation testing has not run on this repo yet
<anonymous:43>43 to 431100%OkCRAP 1No data: mutation testing has not run on this repo yet
prHoldTitle61 to 644100%OkCRAP 4No data: mutation testing has not run on this repo yet
migrationsNamed78 to 801100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:79>79 to 791100%OkCRAP 1No data: mutation testing has not run on this repo yet
blockSettled82 to 9510100%WatchCRAP 10No data: mutation testing has not run on this repo yet
<anonymous:94>94 to 941100%OkCRAP 1No data: mutation testing has not run on this repo yet
appliedMigrations98 to 105371%OkCRAP 3.2No data: mutation testing has not run on this repo yet
<anonymous:101>101 to 1011100%OkCRAP 1No data: mutation testing has not run on this repo yet
blockedJobItem109 to 1134100%OkCRAP 4No data: mutation testing has not run on this repo yet
gatherInbox119 to 2002465%CriticalCRAP 48.3No data: mutation testing has not run on this repo yet
keep121 to 1212100%OkCRAP 2No data: mutation testing has not run on this repo yet
add123 to 128433%WatchCRAP 8.8No data: mutation testing has not run on this repo yet
<anonymous:138>138 to 1381100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:139>139 to 1391100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:160>160 to 1601100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:183>183 to 1831100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:184>184 to 1841100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:198>198 to 1981100%OkCRAP 1No data: mutation testing has not run on this repo yet
restrictInbox204 to 2081100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:205>205 to 2051100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:206>206 to 2061100%OkCRAP 1No data: mutation testing has not run on this repo yet

Imports

It imports src/auto-merge-tick.ts, src/check-words.ts, src/env.ts, src/inbox-report.ts, src/manual-steps.ts, src/merge-class.ts, src/merge-units.ts, src/ops-sites.ts, src/ops-snapshot.ts, src/ops-types/feed.ts, src/outcome-prs.ts, src/stale-jobs.ts. Imported by src/inbox-report.ts, src/ops-feed.ts, src/routes.ts, src/status.ts, src/whats-next.ts.

Source

import type { Env } from "./env";
import { AWAITING_SEAT_KEY, type AwaitingSeat } from "./auto-merge-tick";
import { readSiteConfig } from "./ops-sites";
import { readSnapshot } from "./ops-snapshot";
import { prUrlsFromJob } from "./outcome-prs";
import { readReports } from "./inbox-report";
import { unitInboxItems } from "./merge-units";
import type { MergeClass } from "./merge-class";
import { checkWords } from "./check-words";
import { prsSettled, readStaleCache, type StalePrCache } from "./stale-jobs";
import { readStepCache, stepsSeen, type StaleStepCache } from "./manual-steps";

// "WHAT NEEDS DUSTIN, PER APP" (job_84e901d8eb71, Dustin 2026-10-06). The shared admin
// shell shows a badge per app and a combined inbox count; Capsid answers it once, so no
// app queries another. Everything here is read from what the queue, auto-merge and the
// watcher already keep; this module only gathers and counts. It writes nothing.
//
// The app list is configuration (the ops_sites rows the Portal edits), never a list in
// code. A namespace that has something waiting but no site row (Carrel, Capsomer) still
// appears, under its namespace name, because a job waiting on Dustin there is just as
// real.
//
// Severity, per app: needs-you when anything is waiting on a person (a job blocked for
// the seat, a question, a pull request auto-merge declined), failing when only a machine
// fault is open (the latest CI run on the repo failed, the site's health probe is down),
// none otherwise. "Needs you" wins, because it is the thing only Dustin can clear.
//
// What an app reports about itself (Carrel's drafts waiting) arrives through
// POST /ops/inbox/report (src/inbox-report.ts) and is read here as kind "report". A
// reported item is always needs-you: an app may say a person must act, never that its
// badge should be quieter.

// The shapes are in the feed contract (src/ops-types/), which the Portal also reads.
export type { Inbox, InboxApp, InboxItem, InboxSeverity } from "./ops-types/feed";
import type { Inbox, InboxApp, InboxItem, InboxKind, InboxSeverity } from "./ops-types/feed";

const MAX_BLOCKED = 200;
const MAX_ITEMS_PER_APP = 50;
const WAITING_ON_PERSON: ReadonlySet<InboxKind> = new Set(["blocked-job", "question", "pr", "report", "proposal"]);

/** The worst severity of a set of items. */
export function severityOf(items: readonly Pick<InboxItem, "kind">[]): InboxSeverity {
  if (items.some((i) => WAITING_ON_PERSON.has(i.kind))) return "needs-you";
  return items.length > 0 ? "failing" : "none";
}


// What answering it takes, by the class the tick gave it (src/merge-class.ts).
const CLASS_WORDS: Record<MergeClass, string> = {
  auto: "merges on its own",
  approve: "one tap to approve",
  typed: "a typed confirmation",
  seat: "a step outside a merge",
  wait: "nothing yet, CI is running",
};

/** An auto-merge hold as an inbox title, in plain words: what held it, then what it takes.
 *  A visual pull request one tap would merge says it waits for his look, whichever check
 *  stopped it first: a session's visual PR usually names no job, and "names no job" hid
 *  that there was something to look at (job_0368576da167). */
export function prHoldTitle(a: Pick<AwaitingSeat, "number" | "failed" | "class" | "visual">): string {
  const why = checkWords(a.visual && a.class === "approve" ? "visual_awaits_look" : a.failed);
  return `PR #${a.number} waits for you: ${why}${a.class ? ` (${CLASS_WORDS[a.class]})` : ""}`;
}

// A blocked job whose block is already answered is not waiting on anyone (Dustin's review of
// 2026-10-11: the inbox kept a migration the seat had applied, and a job whose pull requests
// had merged). Two facts the Worker already holds settle it: every pull request it names is
// merged or closed, as the merge-resume step last read them after the job last changed
// (src/stale-jobs.ts, the same test as the stale view's prs-settled), or every migration
// file its block names (the command is in its summary, src/jobs-holder.ts) is in d1_migrations,
// or every manual step its "check:" lines name was seen by the watcher's last pass after the
// job last changed (src/manual-steps.ts, the stale view's steps-seen). Such a job stays
// blocked and shows in the stale view, where it is closed; the inbox leaves it out.
const MIGRATION_FILE = /\b(\d{4}_[a-z0-9_]+)(?:\.sql)?\b/gi;

/** The migration files a blocked job's summary names, without ".sql". */
export function migrationsNamed(text: string): string[] {
  return [...new Set([...text.matchAll(MIGRATION_FILE)].map((m) => m[1]!.toLowerCase()))];
}

export function blockSettled(
  job: { id: string; result_summary: string | null; updated_at: string },
  stale: StalePrCache,
  applied: ReadonlySet<string> | null,
  steps: StaleStepCache = {}
): boolean {
  const entry = stale[job.id];
  if (entry && entry.at >= job.updated_at && prsSettled(entry)) return true;
  const step = steps[job.id];
  if (step && step.at >= job.updated_at && stepsSeen(step)) return true;
  if (!applied) return false;
  const named = migrationsNamed(job.result_summary ?? "");
  return named.length > 0 && named.every((n) => applied.has(n));
}

/** The applied migrations by file name without ".sql", or null when the table cannot be read. */
export async function appliedMigrations(db: D1Database): Promise<Set<string> | null> {
  try {
    const rows = await db.prepare("SELECT name FROM d1_migrations").all<{ name: string }>();
    return new Set((rows.results ?? []).map((r) => r.name.toLowerCase().replace(/\.sql$/, "")));
  } catch {
    return null;
  }
}

/** A blocked job as an inbox item. A block that asks a question is marked in its summary
 *  (src/jobs-holder.ts), and it is the kind that most needs a person's words. */
export function blockedJobItem(job: { id: string; title: string; result_ref: string | null; result_summary: string | null; updated_at: string }): InboxItem {
  const question = (job.result_summary ?? "").startsWith("QUESTION:");
  const [pr] = prUrlsFromJob({ result_ref: job.result_ref, result_summary: job.result_summary });
  return { title: job.title, kind: question ? "question" : "blocked-job", link: pr ?? null, since: job.updated_at, job: job.id };
}

/**
 * Gather the inbox for `namespaces` (the ones the caller may read; null means all).
 * `now` stamps the answer.
 */
export async function gatherInbox(env: Env, now: Date, namespaces: readonly string[] | null): Promise<Inbox> {
  const allowed = namespaces === null ? null : new Set(namespaces);
  const keep = (namespace: string) => allowed === null || allowed.has(namespace);
  const byNamespace = new Map<string, InboxItem[]>();
  const add = (namespace: string, item: InboxItem) => {
    if (!keep(namespace)) return;
    const list = byNamespace.get(namespace) ?? [];
    if (list.length < MAX_ITEMS_PER_APP) list.push(item);
    byNamespace.set(namespace, list);
  };

  const blocked = await env.DB.prepare(
    "SELECT id, namespace, title, result_ref, result_summary, updated_at FROM jobs WHERE status = 'blocked' ORDER BY updated_at LIMIT ?1"
  )
    .bind(MAX_BLOCKED)
    .all<{ id: string; namespace: string; title: string; result_ref: string | null; result_summary: string | null; updated_at: string }>();
  // An unreadable cache or migrations table settles nothing: the job shows, as before.
  // All three are read whether or not a job is blocked, so the feed's stated read count
  // (src/ops-feed.ts, OPS_FEED_READS) does not move with the data.
  const stale = (await readStaleCache(env.APP_KV).catch(() => ({ cache: {} }))).cache;
  const steps = (await readStepCache(env.APP_KV).catch(() => ({ cache: {} }))).cache;
  const applied = await appliedMigrations(env.DB);
  for (const job of blocked.results ?? []) {
    if (!blockSettled(job, stale, applied, steps)) add(job.namespace, blockedJobItem(job));
  }

  // The pull requests auto-merge declined, rewritten whole by its tick. An unreadable
  // list reads as none, not as a failure of the inbox.
  try {
    const raw = await env.APP_KV.get(AWAITING_SEAT_KEY);
    const parsed: unknown = raw ? JSON.parse(raw) : [];
    if (Array.isArray(parsed)) {
      for (const a of parsed as AwaitingSeat[]) {
        add(a.namespace, { title: prHoldTitle(a), kind: "pr", link: `https://github.com/${a.repo}/pull/${a.number}`, since: a.at });
      }
    }
  } catch {
    // none
  }

  // The watcher's last pass: the newest CI run on each repo and each site's probe.
  const snapshot = await readSnapshot(env).catch(() => null);
  for (const ci of snapshot?.ci ?? []) {
    if (ci.latest?.conclusion === "failure") {
      add(ci.namespace, { title: `CI is failing on ${ci.latest.head_sha.slice(0, 7)}`, kind: "ci", link: ci.latest.url, since: ci.latest.created_at });
    }
  }
  for (const site of snapshot?.sites ?? []) {
    if (site.state === "down") {
      add(site.namespace, { title: `${site.name} is down${site.error ? ` (${site.error})` : ""}`, kind: "site-down", link: site.origin, since: site.checked_at });
    }
  }

  // The merge units' proposals and suspensions (src/merge-units.ts): a widening or a
  // narrowing only Dustin signs.
  for (const { namespace, item } of await unitInboxItems(env.APP_KV)) add(namespace, item);

  // What each app last reported about itself, while it is under 6 hours old.
  for (const [namespace, items] of await readReports(env.APP_KV, now)) {
    for (const item of items) add(namespace, item);
  }

  // Every configured app appears, with a count of zero when nothing waits, so the shell
  // can draw a quiet badge; a namespace with items but no site row is added after.
  const config = await readSiteConfig(env.DB).catch(() => []);
  const names = new Map<string, string>(config.map((c) => [c.namespace, c.name]));
  const apps: InboxApp[] = [];
  const seen = new Set<string>();
  for (const [namespace, name] of names) {
    if (!keep(namespace)) continue;
    seen.add(namespace);
    const items = byNamespace.get(namespace) ?? [];
    apps.push({ namespace, name, count: items.length, severity: severityOf(items), items });
  }
  for (const [namespace, items] of byNamespace) {
    if (seen.has(namespace)) continue;
    apps.push({ namespace, name: namespace, count: items.length, severity: severityOf(items), items });
  }

  const all = apps.flatMap((a) => a.items);
  return { generated: now.toISOString(), count: all.length, severity: severityOf(all), apps };
}

/** The inbox limited to the apps `readable` admits, with the totals recomputed, so a
 *  scoped caller sees its own apps and a count that matches them. */
export function restrictInbox(inbox: Inbox, readable: (namespace: string) => boolean): Inbox {
  const apps = inbox.apps.filter((a) => readable(a.namespace));
  const all = apps.flatMap((a) => a.items);
  return { generated: inbox.generated, count: all.length, severity: severityOf(all), apps };
}