Public: anyone can read this; the crew can change it.

A maintenance duty registered in one file runs on its interval and leaves a maintenance_run row with its findings and the probe behind each #11228

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11204-a-maintenance-duty-registered-in-one-fil → main 5 commits fork 4 threads · 4 unresolved

  • Merged. scout/11204-a-maintenance-duty-registered-in-one-fil was merged into main as 4c33ea7.

Review

Sign in to review this change.
19 files changed +580 −0
docs/features/maintain.md added +13
@@ -0,0 +1,13 @@
1 # Maintenance duties (#11204)
2
3 A duty is one file in `src/maintain/duties/`: a `name`, an `intervalMs` and a `run` that returns `findings` and `actions`. Register it with one line in `src/maintain/registry.ts`.
4
5 Every minute the runner (`src/maintain/runner.ts`, inside `during("maintain")`) runs each duty whose interval has passed since it last started, never two runs of one duty at once, and writes a `maintenance_run` row: duty, started, finished, findings, actions, probes, outcome.
6
7 A finding must carry a probe: `{ kind: "query" | "measurement", what, number, at }`. A finding without one is refused: the run keeps no findings or actions and its outcome reads `refused: finding "…" has no probe`. A duty that throws reads `failed: <message>`.
8
9 `GET /api/v1/maintenance/runs?limit=20` (forge admin, `ro` scope) lists the latest rows. The shipped duty, `pulls-waiting`, counts open pulls older than 24 hours.
10
11 The terminal door is the palette entry "Maintenance duty runs" (`src/tui/screens/maintainruns.ts`): the same rows through the same read, offered only to a forge admin; anyone else is offered nothing and sees no rows.
12
13 Each run is bounded: a duty that has not answered in 30 seconds reads `failed: timed out` and the next duty runs (the late duty keeps its slot until it settles, so it never overlaps itself). A duty name must be lowercase words and dashes and unique, or the runner refuses to start. Each duty keeps its newest 200 rows; older ones are deleted after each run. A finding that says nothing reads `refused: a finding says nothing`. A non-admin's token reads 403 `forbidden`.
src/contract/openapi/routes/getApiV1MaintenanceRuns.ts added +11
@@ -0,0 +1,11 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/maintenance/runs",
6 summary:
7 "The latest maintenance duty runs, newest first, each with its findings, actions and probes (a forge admin's token; ?limit= up to 100); answers { runs }",
8 auth: true,
9 role: "admin",
10 refusals: { forbidden: "the token is not a forge admin" },
11 };
src/contract/openapi/routes/index.ts +1
@@ -102,6 +102,7 @@ export { route as getApiV1Front } from "./getApiV1Front.ts";
102102 export { route as getApiV1Health } from "./getApiV1Health.ts";
103103 export { route as getApiV1Here } from "./getApiV1Here.ts";
104104 export { route as getApiV1Keeper } from "./getApiV1Keeper.ts";
105 export { route as getApiV1MaintenanceRuns } from "./getApiV1MaintenanceRuns.ts";
105106 export { route as getApiV1Map } from "./getApiV1Map.ts";
106107 export { route as getApiV1MigrateJob } from "./getApiV1MigrateJob.ts";
107108 export { route as getApiV1MigrateJobStream } from "./getApiV1MigrateJobStream.ts";
src/contract/tags.ts +1
@@ -52,6 +52,7 @@ const ALIAS: Readonly<Record<string, string>> = {
5252 heartbeat: "meta",
5353 telemetry: "meta",
5454 drills: "meta",
55 maintenance: "meta",
5556 devices: "meta",
5657 thoughts: "feed",
5758 share: "feed",
src/core/migration-pins/20261111100000-maintenance-run.pin added +1
@@ -0,0 +1,1 @@
1 c7266eda3263e87bc4570f7dd1403080b279857caf3f4fb5a21414509bf13fa5
src/core/migrations/20261111100000-maintenance-run.ts added +19
@@ -0,0 +1,19 @@
1 // Maintenance runs (#11204): one row per run of a maintenance duty, with what
2 // it found, what it did and the probe behind each finding. Additive.
3 // The index `maintenance_run_duty` serves "when did this duty last start" and
4 // the pruning of a duty's old rows; the primary key serves the latest-first
5 // list. The example duty adds no index to `issue`: a new one there changes
6 // the planner's choice for the cold reads.
7 export default `
8 CREATE TABLE maintenance_run (
9 id INTEGER PRIMARY KEY AUTOINCREMENT,
10 duty TEXT NOT NULL,
11 started TEXT NOT NULL,
12 finished TEXT NOT NULL,
13 findings TEXT NOT NULL DEFAULT '[]',
14 actions TEXT NOT NULL DEFAULT '[]',
15 probes TEXT NOT NULL DEFAULT '[]',
16 outcome TEXT NOT NULL
17 );
18 CREATE INDEX maintenance_run_duty ON maintenance_run (duty, id);
19 `;
src/http/api.ts +2
@@ -369,6 +369,7 @@ import { businessPlanRoutes } from "./api/business_plan.ts";
369369 import { tokenRoutes } from "./api/tokens.ts";
370370 import { accountAdmin, adminRoutes } from "./api/admin.ts";
371371 import { maintenanceRoutes } from "./api/maintenance.ts";
372 import { maintenanceRunsRoutes } from "./api/maintenance_runs.ts";
372373 import { diskReadingRoutes } from "./api/disk_readings.ts";
373374 import { TEMPLATE_ERRORS } from "../apps/template_tip.ts";
374375 import { flagsRoutes } from "./api/flags.ts";
@@ -1260,6 +1261,7 @@ export function apiRoutes(router: Router<Handler>): void {
12601261 forgeOriginRoutes(router);
12611262 adminRoutes(router);
12621263 maintenanceRoutes(router);
1264 maintenanceRunsRoutes(router);
12631265 diskReadingRoutes(router);
12641266 flagsRoutes(router);
12651267 appTemplateRoutes(router);
src/http/api/maintenance_runs.ts added +20
@@ -0,0 +1,20 @@
1 /** `GET /api/v1/maintenance/runs`: the latest maintenance duty runs, for a forge admin. */
2
3 import { latest } from "../../maintain/runs.ts";
4 import type { Handler } from "../context.ts";
5 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
6 import { forbidden, json, requireScope, unauthorized } from "../respond.ts";
7 import type { Router } from "../router.ts";
8
9 export function maintenanceRunsRoutes(router: Router<Handler>): void {
10 router.add("GET", "/api/v1/maintenance/runs", (ctx) => {
11 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
12 if (!ctx.auth.user) return unauthorized();
13 if (!ctx.auth.user.isAdmin) return forbidden();
outdated

Non-admin gets 404 while the contract declares forbidden/member. Align them.

14 const denied = requireScope(ctx, "ro");
15 if (denied) return denied;
16 const asked = Number(ctx.url.searchParams.get("limit") ?? 20);
17 const limit = Number.isInteger(asked) && asked > 0 ? asked : 20;
18 return json({ runs: latest(ctx.app.db, limit) });
19 });
20 }
src/main.ts +4
@@ -59,6 +59,7 @@ import { VERIFY_EVERY_MS } from "./checks/verify.ts";
5959 import { RED_HOLD_MS } from "./checks/red_main.ts";
6060 import { stopQueue } from "./checks/queue.ts";
6161 import { startLooks } from "./looks/service.ts";
62 import { startMaintain } from "./maintain/runner.ts";
6263 import { startMirrorOut } from "./origins/mirror-out.ts";
6364 import { startMirrorPulls } from "./origins/mirror-pulls.ts";
6465 import { startProbes } from "./contract/probes.ts";
@@ -501,6 +502,8 @@ const mirrorPulls = startMirrorPulls(app);
501502 // The look: a picture of every environment that comes up green, taken
502503 // through this forge's own listener.
503504 const looks = appsOn ? startLooks(app, { localUrl: servers.url }) : undefined;
505 // Maintenance duties (src/maintain): each runs on its interval and leaves a row.
506 const maintain = startMaintain(app);
504507 // Is it so: the forge knocks on every door of an app with a contract after
505508 // each production deploy and once a night, and compares with the table.
506509 const probes = apps === undefined ? undefined : startProbes(app, apps);
@@ -953,6 +956,7 @@ function drain(signal: NodeJS.Signals): Promise<unknown> {
953956 mirrorOut(),
954957 mirrorPulls(),
955958 looks?.stop(),
959 maintain.stop(),
956960 probes?.stop(),
957961 nightly?.stop(),
958962 snapshots.stop(),
src/maintain/duties/stale_pulls.ts added +25
@@ -0,0 +1,25 @@
1 /** Example duty (#11204): how many open pulls have waited over 24 hours. Reads only. */
2
3 import type { Duty } from "../duty.ts";
4
5 const DAY_MS = 24 * 3_600_000;
6 const SQL = "SELECT COUNT(*) AS n FROM issue WHERE kind = 'pull' AND state = 'open' AND parked = 0 AND created_at < ?1";
7
8 export const duty: Duty = {
9 name: "pulls-waiting",
10 intervalMs: 3_600_000,
11 run({ db, now }) {
12 const cutoff = new Date(now.getTime() - DAY_MS).toISOString();
13 const n = (db.query(SQL).get(cutoff) as { n: number }).n;
14 if (n === 0) return { findings: [], actions: [] };
15 return {
16 findings: [
17 {
18 summary: `${n} open pull${n === 1 ? "" : "s"} waiting over 24 hours`,
19 probe: { kind: "query", what: SQL.replace("?1", `'${cutoff}'`), number: n, at: now.toISOString() },
20 },
21 ],
22 actions: [],
23 };
24 },
25 };
src/maintain/duty.ts added +63
@@ -0,0 +1,63 @@
1 /** What a maintenance duty is (#11204): a name, an interval and a handler; plain data in, plain data out. */
2
3 import type { Database } from "bun:sqlite";
4
5 /** The number a finding rests on and when it was read: a query's result or a measurement. */
6 export interface Probe {
7 readonly kind: "query" | "measurement";
8 /** The SQL, or what was measured. */
9 readonly what: string;
10 readonly number: number;
11 /** ISO time the number was read. */
12 readonly at: string;
13 }
14
15 export interface Finding {
16 readonly summary: string;
17 readonly probe?: Probe | undefined;
18 }
19
20 export interface DutyResult {
21 readonly findings: readonly Finding[];
22 /** What the duty did about it, one line each. */
23 readonly actions: readonly string[];
24 }
25
26 export interface DutyContext {
27 readonly db: Database;
28 readonly now: Date;
29 }
30
31 export interface Duty {
32 /** Lowercase words and dashes; the row's `duty`. */
33 readonly name: string;
34 readonly intervalMs: number;
35 readonly run: (ctx: DutyContext) => DutyResult | Promise<DutyResult>;
36 }
37
38 const NAME = /^[a-z][a-z0-9-]{0,63}$/;
39 const MAX_TEXT = 500;
40
41 export const validName = (name: string): boolean => NAME.test(name);

validName is unused: dead code unless enforced.

42
43 /** Why a probe is no probe, or undefined when it is one. */
44 export function probeFault(p: Probe | undefined): string | undefined {
45 if (p === undefined || p === null) return "no probe";
46 if (p.kind !== "query" && p.kind !== "measurement") return "probe kind is neither query nor measurement";
47 if (typeof p.what !== "string" || p.what.trim() === "") return "probe says nothing of what was read";
48 if (typeof p.number !== "number" || !Number.isFinite(p.number)) return "probe has no number";
49 if (typeof p.at !== "string" || Number.isNaN(Date.parse(p.at))) return "probe has no time";
50 return undefined;
51 }
52
53 /** The first finding refused, as a sentence; undefined when every finding stands on a probe. */
54 export function refusal(findings: readonly Finding[]): string | undefined {
55 for (const f of findings) {
56 if (typeof f?.summary !== "string" || f.summary.trim() === "") return "refused: a finding says nothing";
outdated

Return text should start with refused: like the probe case.

57 const fault = probeFault(f.probe);
58 if (fault !== undefined) return `refused: finding "${f.summary.slice(0, 80)}" has ${fault}`;
59 }
60 return undefined;
61 }
62
63 export const clip = (text: string): string => (text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}…` : text);
src/maintain/registry.ts added +6
@@ -0,0 +1,6 @@
1 /** The duties the forge runs: one line per duty file in `duties/`. */
2
3 import type { Duty } from "./duty.ts";
4 import { duty as pullsWaiting } from "./duties/stale_pulls.ts";
5
6 export const duties: readonly Duty[] = [pullsWaiting];
src/maintain/runner.ts added +92
@@ -0,0 +1,92 @@
1 /**
2 * The duty runner (#11204): on the clock, each duty whose interval has passed since it last
3 * started runs once, never twice at the same time, and leaves a `maintenance_run` row. A
4 * finding without a probe is refused: the run's outcome says so and no finding is kept.
5 */
6
7 import type { App } from "../app.ts";
8 import { during } from "../perf/jobs.ts";
9 import { clip, refusal, validName, type Duty } from "./duty.ts";
10 import { duties as registered } from "./registry.ts";
11 import { lastStarted, prune, record } from "./runs.ts";
12
13 export const TICK_MS = 60_000;
14 const FIRST_MS = 15_000;
15 /** A duty that has not answered in this long is recorded as timed out and the next duty runs. */
16 export const DUTY_TIMEOUT_MS = 30_000;
17 /** Rows kept per duty; older ones are deleted after each run. */
18 export const KEEP_PER_DUTY = 200;
19
20 export interface Maintain {
21 /** One pass: every due duty runs; resolves when they have. */
22 tick(now?: Date): Promise<void>;
23 stop(): void;
24 }
25
26 export function startMaintain(app: App, options: { duties?: readonly Duty[]; everyMs?: number; timeoutMs?: number; keep?: number } = {}): Maintain {
27 const list = options.duties ?? registered;
28 const seen = new Set<string>();
29 for (const d of list) {
30 if (!validName(d.name)) throw new Error(`maintenance duty name "${d.name}" is not lowercase words and dashes`);
31 if (seen.has(d.name)) throw new Error(`maintenance duty "${d.name}" is registered twice`);
32 seen.add(d.name);
33 }
34 const timeoutMs = options.timeoutMs ?? DUTY_TIMEOUT_MS;
outdated

No deadline on duty.run: a hung duty stalls every other duty. Add a timeout and record it as failed.

35 const running = new Set<string>();
36
37 async function runOne(duty: Duty, now: Date): Promise<void> {
38 if (running.has(duty.name)) return;
39 running.add(duty.name);
40 const started = now;
41 let findings: Awaited<ReturnType<Duty["run"]>>["findings"] = [];
42 let actions: readonly string[] = [];
43 let outcome = "ok";
44 try {
45 // The duty keeps its `running` slot until it really settles, so a timed-out duty never overlaps itself.
46 const work = Promise.resolve().then(() => duty.run({ db: app.db, now }));
47 work.then(() => running.delete(duty.name), () => running.delete(duty.name));
48 let timer: ReturnType<typeof setTimeout> | undefined;
49 const deadline = new Promise<never>((_, reject) => {
50 timer = setTimeout(() => reject(new Error("timed out")), timeoutMs);
51 });
52 const result = await Promise.race([work, deadline]).finally(() => clearTimeout(timer));
53 const refused = refusal(result.findings);
54 if (refused !== undefined) outcome = refused;
55 else {
56 findings = result.findings;
57 actions = result.actions.map(clip);
58 }
59 } catch (error) {
60 outcome = clip(`failed: ${error instanceof Error ? error.message : String(error)}`);
61 }
62 try {
63 record(app.db, { duty: duty.name, started, finished: new Date(), findings, actions, outcome });
64 prune(app.db, duty.name, options.keep ?? KEEP_PER_DUTY);
65 } catch (error) {
66 app.log.error("maintenance run not recorded", { duty: duty.name, error: String(error) });
67 }
68 }
69
70 async function tick(now = new Date()): Promise<void> {
71 for (const duty of list) {
72 const last = lastStarted(app.db, duty.name);
73 if (last !== undefined && now.getTime() - last < duty.intervalMs) continue;
74 if (running.has(duty.name)) continue;
75 await runOne(duty, now);
76 // Duties take turns: the loop turns between one and the next.
77 await new Promise((resolve) => setImmediate(resolve));
78 }
79 }
80
81 const first = setTimeout(() => void during("maintain", () => tick()), FIRST_MS);
82 const every = setInterval(() => void during("maintain", () => tick()), options.everyMs ?? TICK_MS);
83 first.unref?.();
84 every.unref?.();
85 return {
86 tick,
87 stop() {
88 clearTimeout(first);
89 clearInterval(every);
90 },
91 };
92 }
src/maintain/runs.ts added +58
@@ -0,0 +1,58 @@
1 /** `maintenance_run`: the record of each duty's runs. */
2
3 import type { Database } from "bun:sqlite";
4 import type { Finding } from "./duty.ts";
5
6 export interface RunRow {
7 readonly id: number;
8 readonly duty: string;
9 readonly started: string;
10 readonly finished: string;
11 readonly findings: readonly unknown[];
12 readonly actions: readonly string[];
13 readonly probes: readonly unknown[];
14 readonly outcome: string;
15 }
16
17 export function record(
18 db: Database,
19 r: { duty: string; started: Date; finished: Date; findings: readonly Finding[]; actions: readonly string[]; outcome: string },
20 ): void {
21 db.query(
22 "INSERT INTO maintenance_run (duty, started, finished, findings, actions, probes, outcome) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
23 ).run(
24 r.duty,
25 r.started.toISOString(),
26 r.finished.toISOString(),
27 JSON.stringify(r.findings.map((f) => f.summary)),
28 JSON.stringify(r.actions),
29 JSON.stringify(r.findings.flatMap((f) => (f.probe ? [f.probe] : []))),
30 r.outcome,
31 );
32 }
33
34 /** Keep a duty's newest `keep` rows; the rest go. */
35 export function prune(db: Database, duty: string, keep: number): void {
36 db.query("DELETE FROM maintenance_run WHERE duty = ?1 AND id NOT IN (SELECT id FROM maintenance_run WHERE duty = ?1 ORDER BY id DESC LIMIT ?2)").run(duty, keep);
37 }
38
39 export function lastStarted(db: Database, duty: string): number | undefined {
40 const row = db.query("SELECT started FROM maintenance_run WHERE duty = ?1 ORDER BY id DESC LIMIT 1").get(duty) as
41 | { started: string }
42 | null;
43 return row ? Date.parse(row.started) : undefined;
44 }
45
46 export const LIST_MAX = 100;
47
48 export function latest(db: Database, limit: number): RunRow[] {
49 const rows = db.query("SELECT * FROM maintenance_run ORDER BY id DESC LIMIT ?1").all(Math.min(Math.max(1, limit), LIST_MAX)) as {
50 id: number; duty: string; started: string; finished: string; findings: string; actions: string; probes: string; outcome: string;
51 }[];
52 return rows.map((r) => ({
53 ...r,
54 findings: JSON.parse(r.findings) as unknown[],
55 actions: JSON.parse(r.actions) as string[],
56 probes: JSON.parse(r.probes) as unknown[],
57 }));
58 }
src/tui/screens/entries.ts +2
@@ -55,6 +55,7 @@ import { entry as money } from "./money.ts";
5555 import { entry as loopStalls } from "./loopstalls.ts";
5656 import { entry as slowPagesEntry } from "./slowpages.ts";
5757 import { entry as maintenance } from "./maintenance.ts";
58 import { entry as maintainRuns } from "./maintainruns.ts";
5859 import { entry as mirrorPacksEntry } from "./mirrorpacks.ts";
5960 import { entry as packReposEntry } from "./packrepos.ts";
6061 import { modelCeilingEntry, modelGrantEntry } from "./model.ts";
@@ -115,6 +116,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
115116 telemetry,
116117 resilience,
117118 maintenance,
119 maintainRuns,
118120 mirrorPacksEntry,
119121 packReposEntry,
120122 valves,
src/tui/screens/maintainruns.ts added +60
@@ -0,0 +1,60 @@
1 /**
2 * The latest maintenance duty runs in the terminal (#11204): what
3 * `GET /api/v1/maintenance/runs` lists, through the same read (`latest`) and
4 * the same gate, a forge admin; anyone else is offered nothing and sees no
5 * rows. Read-only; j/k scroll, Esc or q closes it.
6 */
7
8 import { latest } from "../../maintain/runs.ts";
9 import type { Ctx, Screen } from "../app/app.ts";
10 import { binding } from "../app/keys.ts";
11 import type { Session } from "../session.ts";
12 import { style } from "../term/style.ts";
13 import { pad } from "../term/width.ts";
14 import { Scroll, drawLines, hitScroll } from "./common.ts";
15 import type { PaletteEntry } from "./entries.ts";
16
17 const mayRead = (session: Session): boolean => session.user?.isAdmin === true;
18
19 /** A header, then a run a row: duty, started, findings, probes, outcome. Empty for anyone the gate refuses. */
20 export function runLines(session: Session, limit = 50): string[] {
21 if (!mayRead(session)) return [];
22 const head = `${pad("Duty", 24)}${pad("Started (UTC)", 21)}${pad("Found", 7)}${pad("Probes", 8)}Outcome`;
23 const rows = latest(session.db, limit).map(
24 (r) =>
25 `${pad(r.duty, 24)}${pad(r.started.slice(0, 19).replace("T", " "), 21)}${pad(String(r.findings.length), 7)}${pad(String(r.probes.length), 8)}${r.outcome}`,
26 );
27 return [head, ...rows];
28 }
29
30 export function maintainRunsScreen(session: Session, ctx: Ctx): Screen {
31 const scroll = new Scroll();
32 return {
33 title: "Maintenance duty runs",
34 keys: [binding(["j", "k"], "scroll"), binding(["esc", "q"], "back")],
35 render(area, s) {
36 const c = ctx.theme.c;
37 const lines = runLines(session);
38 const spans = lines.map((text, i) => [{ text, style: style(i === 0 ? c.fg : c.mutedFg, c.bg) }]);
39 drawLines(s, area, c, scroll, spans);
40 hitScroll(ctx, area, scroll, lines.length);
41 },
42 key(e) {
43 if (scroll.key(e, runLines(session).length)) return true;
44 if (e.key === "esc" || e.key === "q") {
45 ctx.pop();
46 return true;
47 }
48 return false;
49 },
50 };
51 }
52
53 export const entry: PaletteEntry = {
54 id: "maintenance-runs",
55 title: "Maintenance duty runs",
56 hint: "what each duty found, did and probed",
57 after: ["maintenance"],
58 offered: mayRead,
59 run: (session, ctx) => ctx.push(maintainRunsScreen(session, ctx)),
60 };
test/job-slices.test.ts +4
@@ -64,6 +64,7 @@ import { nightlyDue } from "../src/checks/scan_store.ts";
6464 import { startNotifier } from "../src/crew/notify.ts";
6565 import { fireDue } from "../src/apps/feature_schedule.ts";
6666 import { pullDue } from "../src/apps/pull_schedule.ts";
67 import { startMaintain } from "../src/maintain/runner.ts";
6768
6869 /** Thousands of issues and pulls, as the issue asks; runs, comments and events thinned so the file runs in seconds (#1242). */
6970 const BUSY = { issues: 2000, pulls: 2000, requested: 100, runs: 5000, comments: 5000, events: 10000, reviewComments: 500 };
@@ -134,6 +135,8 @@ function jobs(): Record<string, () => unknown> {
134135 const memory = startMemorySampler(app, 1e9);
135136 sampler.stop();
136137 memory.stop();
138 const maintainer = startMaintain(app, { everyMs: 1e9 });
139 maintainer.stop();
137140 return {
138141 sampler: () => sampler.tick(),
139142 "memory-sampler": () => memory.tick(),
@@ -221,6 +224,7 @@ function jobs(): Record<string, () => unknown> {
221224 "nightly-scan": () => nightlyDue(app.db, new Date(Date.now() - 24 * 3_600_000).toISOString(), 25),
222225 "feature-schedule": () => fireDue(app),
223226 "pull-schedule": () => pullDue(app),
227 maintain: () => maintainer.tick(new Date(Date.now() + 1e9)),
224228 };
225229 }
226230
test/maintain.test.ts added +196
@@ -0,0 +1,196 @@
1 /** The duty runner (#11204): schedule, record, refusal of a finding without a probe, the example duty, the admin list. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtempSync, rmSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createToken } from "../src/core/tokens.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10 import { duties } from "../src/maintain/registry.ts";
11 import { startMaintain } from "../src/maintain/runner.ts";
12 import { latest } from "../src/maintain/runs.ts";
13 import type { Duty } from "../src/maintain/duty.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15
16 quietLogs();
17
18 let root = "";
19 let app: App;
20 let servers: Servers;
21 let admin = "";
22 let bob = "";
23
24 beforeAll(async () => {
25 root = mkdtempSync(join(tmpdir(), "forge-maintain-"));
26 app = createApp({ dataDir: root, port: 0 });
27 servers = await serve(app);
28 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
29 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
30 if (!a.ok || !b.ok) throw new Error("user");
31 admin = createToken(app.db, a.value.id, "ro", "ro").token;
32 bob = createToken(app.db, b.value.id, "ro", "ro").token;
33 });
34 afterAll(() => {
35 servers.stop?.();
36 rmSync(root, { recursive: true, force: true });
37 });
38
39 const probe = { kind: "measurement", what: "queue depth", number: 3, at: "2026-11-11T00:00:00.000Z" } as const;
40 const clear = () => app.db.exec("DELETE FROM maintenance_run");
41
42 test("a duty runs when due, not before its interval, and leaves a row with findings and probes", async () => {
43 clear();
44 let calls = 0;
45 const duty: Duty = { name: "t-sched", intervalMs: 1000, run: () => (calls++, { findings: [{ summary: "deep queue", probe }], actions: ["noted"] }) };
46 const m = startMaintain(app, { duties: [duty], everyMs: 1e9 });
47 m.stop();
48 const t0 = new Date("2026-11-11T00:00:00Z");
49 await m.tick(t0);
50 await m.tick(new Date(t0.getTime() + 500));
51 expect(calls).toBe(1);
52 await m.tick(new Date(t0.getTime() + 1500));
53 expect(calls).toBe(2);
54 const [row] = latest(app.db, 1);
55 expect(row).toMatchObject({ duty: "t-sched", outcome: "ok", findings: ["deep queue"], actions: ["noted"], probes: [probe] });
56 expect(row!.started).toBe("2026-11-11T00:00:01.500Z");
57 expect(Date.parse(row!.finished)).toBeGreaterThan(0);
58 });
59
60 test("a duty never runs twice at once", async () => {
61 clear();
62 let live = 0;
63 let worst = 0;
64 const duty: Duty = {
65 name: "t-once", intervalMs: 0,
66 run: async () => { live++; worst = Math.max(worst, live); await new Promise((r) => setTimeout(r, 20)); live--; return { findings: [], actions: [] }; },
67 };
68 const m = startMaintain(app, { duties: [duty], everyMs: 1e9 });
69 m.stop();
70 await Promise.all([m.tick(), m.tick()]);
71 expect(worst).toBe(1);
72 });
73
74 test("a finding without a probe is refused and the outcome says so", async () => {
75 clear();
76 const bad: Duty = { name: "t-bad", intervalMs: 1, run: () => ({ findings: [{ summary: "it is slow" }], actions: ["did a thing"] }) };
77 const nan: Duty = { name: "t-nan", intervalMs: 1, run: () => ({ findings: [{ summary: "x", probe: { ...probe, number: NaN } }], actions: [] }) };
78 const m = startMaintain(app, { duties: [bad, nan], everyMs: 1e9 });
79 m.stop();
80 await m.tick();
81 const rows = latest(app.db, 5);
82 const byName = Object.fromEntries(rows.map((r) => [r.duty, r]));
83 expect(byName["t-bad"]!.outcome).toContain("refused");
84 expect(byName["t-bad"]!.outcome).toContain("no probe");
85 expect(byName["t-bad"]!.findings).toEqual([]);
86 expect(byName["t-bad"]!.actions).toEqual([]);
87 expect(byName["t-nan"]!.outcome).toContain("probe has no number");
88 });
89
90 test("a duty that throws is recorded as failed and the next still runs", async () => {
91 clear();
92 const boom: Duty = { name: "t-boom", intervalMs: 1, run: () => { throw new Error("no"); } };
93 const fine: Duty = { name: "t-fine", intervalMs: 1, run: () => ({ findings: [], actions: [] }) };
94 const m = startMaintain(app, { duties: [boom, fine], everyMs: 1e9 });
95 m.stop();
96 await m.tick();
97 const rows = latest(app.db, 5);
98 expect(rows.find((r) => r.duty === "t-boom")!.outcome).toBe("failed: no");
99 expect(rows.find((r) => r.duty === "t-fine")!.outcome).toBe("ok");
100 });
101
102 test("the example duty is registered and counts open pulls waiting over 24 hours, with its probe", async () => {
103 clear();
104 expect(duties.map((d) => d.name)).toContain("pulls-waiting");
105 const now = new Date("2026-11-11T12:00:00Z");
106 app.db.exec("PRAGMA foreign_keys = OFF");
107 const ins = app.db.prepare("INSERT INTO issue (id, repo_id, number, kind, title, author_id, state, created_at) VALUES (?1, 'r', ?2, 'pull', 't', 'u', ?3, ?4)");
108 ins.run("p1", 1, "open", "2026-11-09T00:00:00.000Z");
109 ins.run("p2", 2, "open", "2026-11-11T11:00:00.000Z");
110 ins.run("p3", 3, "closed", "2026-11-01T00:00:00.000Z");
111 const m = startMaintain(app, { duties: duties.filter((d) => d.name === "pulls-waiting"), everyMs: 1e9 });
112 m.stop();
113 await m.tick(now);
114 const [row] = latest(app.db, 1);
115 expect(row!.outcome).toBe("ok");
116 expect(row!.findings).toEqual(["1 open pull waiting over 24 hours"]);
117 expect(row!.probes[0]).toMatchObject({ kind: "query", number: 1, at: now.toISOString() });
118 app.db.exec("DELETE FROM issue WHERE id IN ('p1','p2','p3')");
119 });
120
121 test("the example duty's count searches an existing index, not the table", () => {
122 const plan = (app.db.query("EXPLAIN QUERY PLAN SELECT COUNT(*) AS n FROM issue WHERE kind = 'pull' AND state = 'open' AND parked = 0 AND created_at < ?1").all("x") as { detail: string }[]).map((r) => r.detail).join("\n");
123 expect(plan).toMatch(/USING (COVERING )?INDEX/);
124 expect(plan).not.toMatch(/SCAN|TEMP B-TREE/);
125 });
126
127 test("GET /api/v1/maintenance/runs lists the latest rows for an admin only", async () => {
128 clear();
129 const m = startMaintain(app, { duties: [{ name: "t-list", intervalMs: 1, run: () => ({ findings: [], actions: [] }) }], everyMs: 1e9 });
130 m.stop();
131 await m.tick();
132 const get = (token?: string, q = "") => fetch(`${servers.url}/api/v1/maintenance/runs${q}`, { headers: token ? { authorization: `Bearer ${token}` } : {} });
133 expect((await get()).status).toBe(401);
134 expect((await get(bob)).status).toBe(403);
135 const res = await get(admin, "?limit=5");
136 expect(res.status).toBe(200);
137 const body = (await res.json()) as { runs: { duty: string; outcome: string }[] };
138 expect(body.runs[0]).toMatchObject({ duty: "t-list", outcome: "ok" });
139 expect((await get(admin, "?limit=abc")).status).toBe(200);
140 });
141
142 test("a hung duty is recorded as timed out and the next duty still runs, without overlapping itself", async () => {
143 clear();
144 let release = () => {};
145 let starts = 0;
146 const hung: Duty = { name: "t-hung", intervalMs: 1, run: () => { starts++; return new Promise((r) => { release = () => r({ findings: [], actions: [] }); }); } };
147 const fine: Duty = { name: "t-after", intervalMs: 1, run: () => ({ findings: [], actions: [] }) };
148 const m = startMaintain(app, { duties: [hung, fine], everyMs: 1e9, timeoutMs: 20 });
149 m.stop();
150 await m.tick();
151 const rows = latest(app.db, 5);
152 expect(rows.find((r) => r.duty === "t-hung")!.outcome).toBe("failed: timed out");
153 expect(rows.find((r) => r.duty === "t-after")!.outcome).toBe("ok");
154 await m.tick(new Date(Date.now() + 10));
155 expect(starts).toBe(1);
156 release();
157 });
158
159 test("an empty finding reads refused:", async () => {
160 clear();
161 const d: Duty = { name: "t-empty", intervalMs: 1, run: () => ({ findings: [{ summary: " ", probe }], actions: [] }) };
162 const m = startMaintain(app, { duties: [d], everyMs: 1e9 });
163 m.stop();
164 await m.tick();
165 expect(latest(app.db, 1)[0]!.outcome).toBe("refused: a finding says nothing");
166 });
167
168 test("old rows are pruned to a cap per duty, and other duties keep theirs", async () => {
169 clear();
170 const a: Duty = { name: "t-a", intervalMs: 1, run: () => ({ findings: [], actions: [] }) };
171 const b: Duty = { name: "t-b", intervalMs: 1, run: () => ({ findings: [], actions: [] }) };
172 const m = startMaintain(app, { duties: [a, b], everyMs: 1e9, keep: 3 });
173 m.stop();
174 for (let i = 0; i < 6; i++) await m.tick(new Date(Date.now() + i * 10));
175 const rows = latest(app.db, 100);
176 expect(rows.filter((r) => r.duty === "t-a")).toHaveLength(3);
177 expect(rows.filter((r) => r.duty === "t-b")).toHaveLength(3);
178 });
179
180 test("a duty name that is not lowercase words and dashes, or is registered twice, is refused at start", () => {
181 const run = () => ({ findings: [], actions: [] });
182 expect(() => startMaintain(app, { duties: [{ name: "Bad Name", intervalMs: 1, run }], everyMs: 1e9 })).toThrow("not lowercase");
183 expect(() => startMaintain(app, { duties: [{ name: "dup", intervalMs: 1, run }, { name: "dup", intervalMs: 1, run }], everyMs: 1e9 })).toThrow("twice");
184 });
185
186 test("the terminal screen lists runs for an admin and nothing for anyone else", async () => {
187 clear();
188 const m = startMaintain(app, { duties: [{ name: "t-tui", intervalMs: 1, run: () => ({ findings: [], actions: [] }) }], everyMs: 1e9 });
189 m.stop();
190 await m.tick();
191 const { runLines } = await import("../src/tui/screens/maintainruns.ts");
192 const as = (isAdmin: boolean) => ({ user: { isAdmin }, db: app.db }) as never;
193 expect(runLines(as(true)).some((l) => l.startsWith("t-tui"))).toBe(true);
194 expect(runLines(as(false))).toEqual([]);
195 expect(runLines({ user: undefined, db: app.db } as never)).toEqual([]);
196 });
test/palette-roster.test.ts +2
@@ -117,6 +117,7 @@ test("a site-admin person is offered the whole roster", () => {
117117 ["capacity", "Capacity", "concurrency and guards, live; e changes a settable one"],
118118 ["resilience", "Resilience", "where snapshots go and the mirror; each protection green, red or untested"],
119119 ["maintenance", "Maintenance", "prepare the forge for a power-off, check the drain, or end maintenance"],
120 ["maintenance-runs", "Maintenance duty runs", "what each duty found, did and probed"],
120121 ["crawlers", "Crawlers", "their share of answering time, and who was refused"],
121122 ["scrapes", "Scrapes", "what was read by repository, and groups in a running capture"],
122123 ["traffic", "Traffic", "who is using the forge's answering time, and the top readers"],
@@ -250,6 +251,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
250251 ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"],
251252 ["can", "What I can do", "your role and what it allows here"],
252253 ["bugs", "Bugs", "open bugs and the bug clock"],
254 ["maintenance-runs", "Maintenance duty runs", "what each duty found, did and probed"],
253255 ["traffic-sources", "Traffic sources", "where people come from and where they go, for the forge"],
254256 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
255257 ["archived", "Archived repositories", "put away, not gone: open one or restore it"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs