src/site-repair.ts
117 lines and 8 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| limitKey | 33 to 33 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| readLimit | 35 to 47 | 5 | 46% | WatchCRAP 8.9 | No data: mutation testing has not run on this repo yet |
| limitRefusal | 50 to 60 | 6 | 100% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| <anonymous:56> | 56 to 56 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| limitRefusalFor | 63 to 65 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| writeLimit | 67 to 71 | 3 | 0% | WatchCRAP 12 | No data: mutation testing has not run on this repo yet |
| <anonymous:68> | 68 to 68 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| runSiteRepair | 79 to 127 | 11 | 2% | CriticalCRAP 124.9 | No data: mutation testing has not run on this repo yet |
Imports
It imports src/env.ts, src/log.ts, src/ops-sites.ts, src/ops-types/sites.ts, src/site-operator.ts, src/store-guards.ts, src/task-runs.ts, src/time.ts. Imported by src/controls-perform.ts, src/controls-plan.ts, src/site-convergence.ts, src/site-watch.ts.
Source
import type { Env } from "./env";
import { logEvent } from "./log";
import { readSiteRow } from "./ops-sites";
import type { SiteOperator } from "./ops-types/sites";
import { callLine, callOperator, toolRefusal, type FetchLike, type OperatorCall } from "./site-operator";
import { auditStatement } from "./store-guards";
import { taskRunStatements } from "./task-runs";
import { MINUTE_MS, DAY_MS } from "./time";
// ONE REPAIR, from the Portal, capsid_admin_run_controls or the watcher (job_09e5f6cbf782). The
// site_repair control and the watcher's repair step both come here, so the allowlist, the
// rate limit and the record are one implementation: an audit_log row (site-repair) and a
// task_runs row (site-repair) for every call that left the Worker, and none for one
// refused before it.
//
// The rate limit is per site and tool, in APP_KV, so a stuck check cannot loop:
// - any call waits MIN_GAP_MS after the last one, whoever made it (a double click, or the
// watcher and a person at once);
// - the watcher makes at most AUTO_PER_DAY calls per site and tool in a rolling day, and
// after that only a person can run it. The finding says so.
export const REPAIR_AUDIT_ACTION = "site-repair";
const LIMIT_PREFIX = "site-repair-limit:";
const MIN_GAP_MS = 2 * MINUTE_MS;
const AUTO_GAP_MS = 30 * MINUTE_MS;
export const AUTO_PER_DAY = 4;
interface LimitRecord {
last: string;
auto: string[];
}
const limitKey = (namespace: string, tool: string) => `${LIMIT_PREFIX}${namespace}:${tool}`;
async function readLimit(env: Pick<Env, "APP_KV">, namespace: string, tool: string): Promise<LimitRecord | null> {
const raw = await env.APP_KV.get(limitKey(namespace, tool));
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as LimitRecord;
return typeof parsed.last === "string" && Array.isArray(parsed.auto) ? parsed : null;
} catch {
// A record that does not parse is replaced by the next call's; it limits nothing until
// then, and that is logged so it is not silent.
logEvent("warn", "SITE_REPAIR_LIMIT_UNREADABLE", { message: `SITE_REPAIR_LIMIT_UNREADABLE ${namespace} ${tool}` });
return null;
}
}
/** Why this call must wait, or null. `auto` is the watcher. */
export function limitRefusal(record: LimitRecord | null, now: Date, auto: boolean): string | null {
if (!record) return null;
const since = now.getTime() - Date.parse(record.last);
const gap = auto ? AUTO_GAP_MS : MIN_GAP_MS;
if (since < gap) return `it ran ${Math.round(since / MINUTE_MS)} minute(s) ago; the next call may go after ${Math.round(gap / MINUTE_MS)} minutes.`;
if (auto) {
const today = record.auto.filter((at) => now.getTime() - Date.parse(at) < DAY_MS);
if (today.length >= AUTO_PER_DAY) return `the watcher has run it ${today.length} times in the last day, its limit; a person can still run it from the Portal.`;
}
return null;
}
/** The rate limit for a person's call, read now: what a preview shows. */
export async function limitRefusalFor(env: Pick<Env, "APP_KV">, namespace: string, tool: string, now: Date): Promise<string | null> {
return limitRefusal(await readLimit(env, namespace, tool), now, false);
}
async function writeLimit(env: Pick<Env, "APP_KV">, namespace: string, tool: string, before: LimitRecord | null, now: Date, auto: boolean): Promise<void> {
const kept = (before?.auto ?? []).filter((at) => now.getTime() - Date.parse(at) < DAY_MS);
const next: LimitRecord = { last: now.toISOString(), auto: auto ? [...kept, now.toISOString()] : kept };
await env.APP_KV.put(limitKey(namespace, tool), JSON.stringify(next), { expirationTtl: 2 * 86_400 });
}
export type RepairOutcome =
| { ran: false; refusal: string }
| { ran: true; call: OperatorCall; line: string; recorded: boolean };
/** Run one repair on one site. Refused, with nothing sent, when the site has no usable
* operator configuration, the tool is not allowed, or the rate limit holds. */
export async function runSiteRepair(
env: Env,
args: { namespace: string; tool: string; actor: string; auto: boolean; via: string },
now: Date,
fetchImpl: FetchLike = fetch,
site?: { origin: string; operator: SiteOperator }
): Promise<RepairOutcome> {
let target = site;
if (!target) {
const row = await readSiteRow(env.DB, args.namespace);
if (!row?.origin) return { ran: false, refusal: `${args.namespace} is not a configured site.` };
if (!row.operator) return { ran: false, refusal: row.operator_problem ?? `${args.namespace} has no operator API configured, so Capsid may call nothing on it.` };
target = { origin: row.origin, operator: row.operator };
}
const refusal = toolRefusal(target.operator, args.tool);
if (refusal) return { ran: false, refusal };
const before = await readLimit(env, args.namespace, args.tool);
const wait = limitRefusal(before, now, args.auto);
if (wait) return { ran: false, refusal: `${args.tool} on ${args.namespace} waits: ${wait}` };
// The limit is written before the call, which narrows a race between two callers but does
// not close it: KV has no compare-and-set, so two that read the same record both go. The
// operator API's own limits are the hard bound (Grok audit 2026-10-21, CA-7).
await writeLimit(env, args.namespace, args.tool, before, now, args.auto);
const started = new Date();
const call = await callOperator(env, target, args.tool, fetchImpl);
const line = callLine(args.tool, call);
const finished = new Date();
// The record of a call that left the Worker. A failed write is logged and said, and the
// call's own result still goes back: it happened.
let recorded = true;
try {
await env.DB.batch([
auditStatement(env.DB, args.actor, REPAIR_AUDIT_ACTION, args.namespace, null, {
tool: args.tool,
via: args.via,
status: call.status,
converged: call.converged,
expected: call.expected,
present: call.present,
error: call.error,
}),
...taskRunStatements(env.DB, "site-repair", started, finished, call.ok && call.converged !== false ? "ok" : "refused", `${args.namespace}: ${line} (${args.via})`),
]);
} catch (err) {
recorded = false;
logEvent("error", "SITE_REPAIR_NOT_RECORDED", { message: `SITE_REPAIR_NOT_RECORDED ${args.namespace} ${args.tool}: ${err instanceof Error ? err.message : String(err)}` });
}
return { ran: true, call, line, recorded };
}