src/task-runs.ts
186 lines and 10 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| oneLine | 90 to 93 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| taskRunStatements | 96 to 109 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| record | 114 to 120 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| runTask | 129 to 152 | 7 | 100% | WatchCRAP 7 | No data: mutation testing has not run on this repo yet |
| readTaskRuns | 164 to 180 | 1 | 14% | OkCRAP 1.6 | No data: mutation testing has not run on this repo yet |
| <anonymous:166> | 166 to 169 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:173> | 173 to 178 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| <anonymous:176> | 176 to 176 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| taskStates | 187 to 198 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:188> | 188 to 197 | 9 | 100% | WatchCRAP 9 | No data: mutation testing has not run on this repo yet |
Imports
It imports src/env.ts, src/log.ts, src/ops-types/feed.ts, src/time.ts. Imported by src/index.ts, src/ops-feed.ts, src/site-repair.ts, src/tick.ts.
Source
import type { Env } from "./env";
import type { OpsTask, OpsTaskRun, OpsTaskRunOutcome } from "./ops-types/feed";
import { logEvent } from "./log";
import { MINUTE_MS, DAY_MS } from "./time";
// The run ledger (migrations/0029_task_runs.sql). Every scheduled task goes through runTask,
// which writes one task_runs row per run: start, finish, outcome and a one-line
// reason. The Portal reads the newest runs of each task (readTaskRuns) and flags a
// task that has failed or gone quiet (taskStates).
//
// Adapted from Foxhound's runCronJob (app/lib/cron/run.ts): one wrapper, one row per
// executed run, and the reason column, because "ok" alone cannot tell a sweep that
// found nothing from one that never looked.
export type TaskId =
| "backup"
| "skills-refresh"
| "tick"
| "auto-merge"
| "watcher"
| "outcome-sweep"
| "merge-resume"
| "maintenance"
| "site-repair"
| "public-build"
| "push"
| "digest"
| "merge-units"
| "merge-safety"
| "doc-index"
| "supersede";
// Each task, and how long it may go without a run before the Portal says it has
// gone quiet: twice its period, Foxhound's rule (app/lib/cron/alerts.server.ts),
// because one missed run is normal and a flag on it would teach the reader to
// ignore the flag. Null where runs come and go with the work, so a quiet spell
// means nothing: those are flagged only when they fail.
// tick */5 * * * *, which carries the five steps below it
// backup 0 9 * * *
// skills-refresh 30 9 * * *, doing its work on one UTC day a week
// watcher on the tick, every cadence_min minutes (src/watcher.ts); its
// period is read from the watcher's own snapshot
export const TASKS: ReadonlyArray<{ id: TaskId; label: string; period_ms: number | null }> = [
{ id: "tick", label: "Five-minute tick", period_ms: 5 * MINUTE_MS },
{ id: "watcher", label: "Watcher pass", period_ms: null },
{ id: "backup", label: "Backup", period_ms: DAY_MS },
{ id: "skills-refresh", label: "Skills refresh", period_ms: 7 * DAY_MS },
{ id: "auto-merge", label: "Auto-merge step", period_ms: null },
{ id: "outcome-sweep", label: "Merge-state sweep", period_ms: null },
{ id: "merge-resume", label: "Stale-job merge resume", period_ms: null },
{ id: "maintenance", label: "Daily maintenance pass", period_ms: DAY_MS },
// One row per call to a site's operator API (src/site-repair.ts), from the watcher or a
// person, so its runs come and go with the drift.
{ id: "site-repair", label: "Site repairs", period_ms: null },
// The public system model (src/public-data.ts), built once a UTC day on the tick.
{ id: "public-build", label: "Public model build", period_ms: DAY_MS },
// Phone alerts (src/push.ts), on the tick; recorded only when an alert was due.
{ id: "push", label: "Phone alerts", period_ms: null },
// The daily email digest (src/digest.ts), on the tick; off until Email Routing is on,
// so a quiet spell means nothing by itself and only a refusal is flagged.
{ id: "digest", label: "Daily email digest", period_ms: null },
// Merge unit promotion and demotion (src/merge-units.ts), on the tick; recorded only
// when its report or a suspension changed.
{ id: "merge-units", label: "Merge unit proposals", period_ms: null },
// The auto-merge safety meter and shutoff (src/merge-safety.ts), on the tick; recorded
// only when a revert was recorded, a namespace paused or the meter changed.
{ id: "merge-safety", label: "Auto-merge safety meter", period_ms: null },
// The meaning index (src/doc-index.ts), on the tick; recorded only when it sent or
// removed something, so a quiet spell means nothing changed.
{ id: "doc-index", label: "Search index", period_ms: null },
// The daily supersede pass (src/supersede.ts), with the backup; recorded only when it
// marked something.
{ id: "supersede", label: "Supersede pass", period_ms: null },
];
const STALE_PERIODS = 2;
// Rows older than this are pruned, PRUNE_BATCH at a time on each write.
export const RUN_RETENTION_DAYS = 14;
const PRUNE_BATCH = 50;
// How many of each task's newest runs the Portal shows.
export const RUNS_PER_TASK = 5;
// A reason is one line: what the run did or why it did not.
export const REASON_MAX = 300;
/** What a task's body reports about the run it made. Null means it was not due and
* made no run (the watcher between passes, the sweep before its day), so nothing is
* recorded. */
export type TaskResult = { outcome: Exclude<OpsTaskRunOutcome, "threw">; reason: string } | null;
function oneLine(text: string): string {
const line = text.replace(/\s+/g, " ").trim();
return line.length > REASON_MAX ? `${line.slice(0, REASON_MAX - 1)}…` : line;
}
/** The insert and a bounded prune, in one batch. */
export function taskRunStatements(db: D1Database, task: TaskId, started: Date, finished: Date, outcome: OpsTaskRunOutcome, reason: string): D1PreparedStatement[] {
const cutoff = new Date(finished.getTime() - RUN_RETENTION_DAYS * DAY_MS).toISOString();
return [
db
.prepare(`INSERT INTO task_runs (task, started_at, finished_at, outcome, reason) VALUES (?1, ?2, ?3, ?4, ?5)`)
.bind(task, started.toISOString(), finished.toISOString(), outcome, oneLine(reason)),
// The oldest PRUNE_BATCH rows by id, of which those past retention go: rows are
// appended in time order, so this stays a short index walk however large the
// table is (the same prune as session_events, src/ops-hooks.ts).
db
.prepare(`DELETE FROM task_runs WHERE id IN (SELECT id FROM task_runs ORDER BY id LIMIT ?1) AND finished_at < ?2`)
.bind(PRUNE_BATCH, cutoff),
];
}
/** Write one run. Never throws, so a failed ledger write cannot stop the task it
* records or the tasks after it. A write that fails is said in the log, by task, and
* the Portal then shows that task as quiet: the ledger's own failure is visible. */
async function record(env: Env, task: TaskId, started: Date, outcome: OpsTaskRunOutcome, reason: string): Promise<void> {
try {
await env.DB.batch(taskRunStatements(env.DB, task, started, new Date(), outcome, reason));
} catch (err) {
logEvent("error", "TASK_RUN_UNRECORDED", { message: `TASK_RUN_UNRECORDED ${task} ${outcome}: ${err instanceof Error ? err.message : String(err)}` });
}
}
/**
* Run one scheduled task and record it. A body that throws is recorded as `threw`
* with the error, logged with `tag`, and then rethrown when `rethrow` is set (a cron
* branch, so the invocation shows as failed) or swallowed into the record otherwise
* (a tick step, so one step's throw does not stop the steps after it; the record and
* the log line are where it is said).
*/
export async function runTask<T>(
env: Env,
task: TaskId,
body: () => Promise<T>,
judge: (value: T) => TaskResult,
opts: { tag: string; rethrow: boolean }
): Promise<T | null> {
const started = new Date();
let value: T;
try {
value = await body();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logEvent("error", opts.tag, {
message: `${opts.tag} ${err instanceof Error && err.stack ? `${message}\n${err.stack}` : message}`,
});
await record(env, task, started, "threw", message);
if (opts.rethrow) throw err;
return null;
}
const result = judge(value);
if (result) await record(env, task, started, result.outcome, result.reason);
return value;
}
interface TaskRunRow {
task: string;
started_at: string;
finished_at: string;
outcome: OpsTaskRunOutcome;
reason: string;
}
/** Each task's newest runs: one keyed read per task, through task_runs_task, in one
* batch. TASKS.length statements, counted in OPS_FEED_READS (src/ops-feed.ts). */
export async function readTaskRuns(db: D1Database): Promise<Map<TaskId, OpsTaskRun[]>> {
const results = await db.batch<TaskRunRow>(
TASKS.map((t) =>
db
.prepare(`SELECT task, started_at, finished_at, outcome, reason FROM task_runs WHERE task = ?1 ORDER BY id DESC LIMIT ?2`)
.bind(t.id, RUNS_PER_TASK)
)
);
const out = new Map<TaskId, OpsTaskRun[]>();
TASKS.forEach((t, i) => {
out.set(
t.id,
(results[i]?.results ?? []).map((r) => ({ started_at: r.started_at, finished_at: r.finished_at, outcome: r.outcome, reason: r.reason }))
);
});
return out;
}
/** Each task with its newest runs and its flag, decided at `now`.
* - `failing`: the newest run threw or was refused.
* - `quiet`: a periodic task whose newest run is more than STALE_PERIODS periods old.
* - `never`: no run in the ledger. A notice, not a fault: after the migration every
* task starts here, and the daily ones stay here until their hour. */
export function taskStates(runs: Map<TaskId, OpsTaskRun[]>, now: Date, watcherCadenceMin: number): OpsTask[] {
return TASKS.map((t) => {
const recent = runs.get(t.id) ?? [];
const last = recent[0] ?? null;
const period = t.id === "watcher" ? watcherCadenceMin * MINUTE_MS : t.period_ms;
let flag: OpsTask["flag"] = null;
if (last === null) flag = "never";
else if (last.outcome === "threw" || last.outcome === "refused") flag = "failing";
else if (period !== null && now.getTime() - Date.parse(last.finished_at) > STALE_PERIODS * period) flag = "quiet";
return { id: t.id, label: t.label, period_ms: period, flag, recent };
});
}