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
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| severityOf | 42 to 45 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| <anonymous:43> | 43 to 43 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| prHoldTitle | 61 to 64 | 4 | 100% | OkCRAP 4 | No data: mutation testing has not run on this repo yet |
| migrationsNamed | 78 to 80 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:79> | 79 to 79 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| blockSettled | 82 to 95 | 10 | 100% | WatchCRAP 10 | No data: mutation testing has not run on this repo yet |
| <anonymous:94> | 94 to 94 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| appliedMigrations | 98 to 105 | 3 | 71% | OkCRAP 3.2 | No data: mutation testing has not run on this repo yet |
| <anonymous:101> | 101 to 101 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| blockedJobItem | 109 to 113 | 4 | 100% | OkCRAP 4 | No data: mutation testing has not run on this repo yet |
| gatherInbox | 119 to 200 | 24 | 65% | CriticalCRAP 48.3 | No data: mutation testing has not run on this repo yet |
| keep | 121 to 121 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| add | 123 to 128 | 4 | 33% | WatchCRAP 8.8 | No data: mutation testing has not run on this repo yet |
| <anonymous:138> | 138 to 138 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:139> | 139 to 139 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:160> | 160 to 160 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:183> | 183 to 183 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:184> | 184 to 184 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:198> | 198 to 198 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| restrictInbox | 204 to 208 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:205> | 205 to 205 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:206> | 206 to 206 | 1 | 100% | OkCRAP 1 | No 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 };
}