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 #11212
scout/forge:scout/11204-a-maintenance-duty-registered-in-one-fil → main 3 commits fork
15 files
changed +432
−0
docs/features/maintain.md added +9
| @@ -0,0 +1,9 @@ | ||
| 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. |
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: "member", | |
| 10 | refusals: { forbidden: "the token is not a person who administers the forge" }, | |
| 11 | }; |
src/contract/openapi/routes/index.ts +1
| @@ -102,6 +102,7 @@ export { route as getApiV1Front } from "./getApiV1Front.ts"; | ||
| 102 | 102 | export { route as getApiV1Health } from "./getApiV1Health.ts"; |
| 103 | 103 | export { route as getApiV1Here } from "./getApiV1Here.ts"; |
| 104 | 104 | export { route as getApiV1Keeper } from "./getApiV1Keeper.ts"; |
| 105 | export { route as getApiV1MaintenanceRuns } from "./getApiV1MaintenanceRuns.ts"; | |
| 105 | 106 | export { route as getApiV1Map } from "./getApiV1Map.ts"; |
| 106 | 107 | export { route as getApiV1MigrateJob } from "./getApiV1MigrateJob.ts"; |
| 107 | 108 | export { route as getApiV1MigrateJobStream } from "./getApiV1MigrateJobStream.ts"; |
src/core/migration-pins/20261111100000-maintenance-run.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | c7266eda3263e87bc4570f7dd1403080b279857caf3f4fb5a21414509bf13fa5 |
src/core/migrations/20261111100000-maintenance-run.ts added +18
| @@ -0,0 +1,18 @@ | ||
| 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 | // `maintenance_run_duty` serves "when did this duty last start" and | |
| 4 | // the primary key the latest-first list. The example duty adds no index to | |
| 5 | // `issue`: a new one there changes the planner's choice for the cold reads. | |
| 6 | export default ` | |
| 7 | CREATE TABLE maintenance_run ( | |
| 8 | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 9 | duty TEXT NOT NULL, | |
| 10 | started TEXT NOT NULL, | |
| 11 | finished TEXT NOT NULL, | |
| 12 | findings TEXT NOT NULL DEFAULT '[]', | |
| 13 | actions TEXT NOT NULL DEFAULT '[]', | |
| 14 | probes TEXT NOT NULL DEFAULT '[]', | |
| 15 | outcome TEXT NOT NULL | |
| 16 | ); | |
| 17 | CREATE INDEX maintenance_run_duty ON maintenance_run (duty, id); | |
| 18 | `; |
src/http/api.ts +2
| @@ -369,6 +369,7 @@ import { businessPlanRoutes } from "./api/business_plan.ts"; | ||
| 369 | 369 | import { tokenRoutes } from "./api/tokens.ts"; |
| 370 | 370 | import { accountAdmin, adminRoutes } from "./api/admin.ts"; |
| 371 | 371 | import { maintenanceRoutes } from "./api/maintenance.ts"; |
| 372 | import { maintenanceRunsRoutes } from "./api/maintenance_runs.ts"; | |
| 372 | 373 | import { diskReadingRoutes } from "./api/disk_readings.ts"; |
| 373 | 374 | import { TEMPLATE_ERRORS } from "../apps/template_tip.ts"; |
| 374 | 375 | import { flagsRoutes } from "./api/flags.ts"; |
| @@ -1260,6 +1261,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1260 | 1261 | forgeOriginRoutes(router); |
| 1261 | 1262 | adminRoutes(router); |
| 1262 | 1263 | maintenanceRoutes(router); |
| 1264 | maintenanceRunsRoutes(router); | |
| 1263 | 1265 | diskReadingRoutes(router); |
| 1264 | 1266 | flagsRoutes(router); |
| 1265 | 1267 | 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 { json, notFound, 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 notFound(); | |
| 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"; | ||
| 59 | 59 | import { RED_HOLD_MS } from "./checks/red_main.ts"; |
| 60 | 60 | import { stopQueue } from "./checks/queue.ts"; |
| 61 | 61 | import { startLooks } from "./looks/service.ts"; |
| 62 | import { startMaintain } from "./maintain/runner.ts"; | |
| 62 | 63 | import { startMirrorOut } from "./origins/mirror-out.ts"; |
| 63 | 64 | import { startMirrorPulls } from "./origins/mirror-pulls.ts"; |
| 64 | 65 | import { startProbes } from "./contract/probes.ts"; |
| @@ -501,6 +502,8 @@ const mirrorPulls = startMirrorPulls(app); | ||
| 501 | 502 | // The look: a picture of every environment that comes up green, taken |
| 502 | 503 | // through this forge's own listener. |
| 503 | 504 | 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); | |
| 504 | 507 | // Is it so: the forge knocks on every door of an app with a contract after |
| 505 | 508 | // each production deploy and once a night, and compares with the table. |
| 506 | 509 | const probes = apps === undefined ? undefined : startProbes(app, apps); |
| @@ -953,6 +956,7 @@ function drain(signal: NodeJS.Signals): Promise<unknown> { | ||
| 953 | 956 | mirrorOut(), |
| 954 | 957 | mirrorPulls(), |
| 955 | 958 | looks?.stop(), |
| 959 | maintain.stop(), | |
| 956 | 960 | probes?.stop(), |
| 957 | 961 | nightly?.stop(), |
| 958 | 962 | 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); | |
| 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 "a finding says nothing"; | |
| 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 +75
| @@ -0,0 +1,75 @@ | ||
| 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, type Duty } from "./duty.ts"; | |
| 10 | import { duties as registered } from "./registry.ts"; | |
| 11 | import { lastStarted, record } from "./runs.ts"; | |
| 12 | ||
| 13 | export const TICK_MS = 60_000; | |
| 14 | const FIRST_MS = 15_000; | |
| 15 | ||
| 16 | export interface Maintain { | |
| 17 | /** One pass: every due duty runs; resolves when they have. */ | |
| 18 | tick(now?: Date): Promise<void>; | |
| 19 | stop(): void; | |
| 20 | } | |
| 21 | ||
| 22 | export function startMaintain(app: App, options: { duties?: readonly Duty[]; everyMs?: number } = {}): Maintain { | |
| 23 | const list = options.duties ?? registered; | |
| 24 | const running = new Set<string>(); | |
| 25 | ||
| 26 | async function runOne(duty: Duty, now: Date): Promise<void> { | |
| 27 | if (running.has(duty.name)) return; | |
| 28 | running.add(duty.name); | |
| 29 | const started = now; | |
| 30 | let findings: Awaited<ReturnType<Duty["run"]>>["findings"] = []; | |
| 31 | let actions: readonly string[] = []; | |
| 32 | let outcome = "ok"; | |
| 33 | try { | |
| 34 | const result = await duty.run({ db: app.db, now }); | |
| 35 | const refused = refusal(result.findings); | |
| 36 | if (refused !== undefined) outcome = refused; | |
| 37 | else { | |
| 38 | findings = result.findings; | |
| 39 | actions = result.actions.map(clip); | |
| 40 | } | |
| 41 | } catch (error) { | |
| 42 | outcome = clip(`failed: ${error instanceof Error ? error.message : String(error)}`); | |
| 43 | } finally { | |
| 44 | running.delete(duty.name); | |
| 45 | } | |
| 46 | try { | |
| 47 | record(app.db, { duty: duty.name, started, finished: new Date(), findings, actions, outcome }); | |
| 48 | } catch (error) { | |
| 49 | app.log.error("maintenance run not recorded", { duty: duty.name, error: String(error) }); | |
| 50 | } | |
| 51 | } | |
| 52 | ||
| 53 | async function tick(now = new Date()): Promise<void> { | |
| 54 | for (const duty of list) { | |
| 55 | const last = lastStarted(app.db, duty.name); | |
| 56 | if (last !== undefined && now.getTime() - last < duty.intervalMs) continue; | |
| 57 | if (running.has(duty.name)) continue; | |
| 58 | await runOne(duty, now); | |
| 59 | // Duties take turns: the loop turns between one and the next. | |
| 60 | await new Promise((resolve) => setImmediate(resolve)); | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | const first = setTimeout(() => void during("maintain", () => tick()), FIRST_MS); | |
| 65 | const every = setInterval(() => void during("maintain", () => tick()), options.everyMs ?? TICK_MS); | |
| 66 | first.unref?.(); | |
| 67 | every.unref?.(); | |
| 68 | return { | |
| 69 | tick, | |
| 70 | stop() { | |
| 71 | clearTimeout(first); | |
| 72 | clearInterval(every); | |
| 73 | }, | |
| 74 | }; | |
| 75 | } |
src/maintain/runs.ts added +53
| @@ -0,0 +1,53 @@ | ||
| 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 | export function lastStarted(db: Database, duty: string): number | undefined { | |
| 35 | const row = db.query("SELECT started FROM maintenance_run WHERE duty = ?1 ORDER BY id DESC LIMIT 1").get(duty) as | |
| 36 | | { started: string } | |
| 37 | | null; | |
| 38 | return row ? Date.parse(row.started) : undefined; | |
| 39 | } | |
| 40 | ||
| 41 | export const LIST_MAX = 100; | |
| 42 | ||
| 43 | export function latest(db: Database, limit: number): RunRow[] { | |
| 44 | const rows = db.query("SELECT * FROM maintenance_run ORDER BY id DESC LIMIT ?1").all(Math.min(Math.max(1, limit), LIST_MAX)) as { | |
| 45 | id: number; duty: string; started: string; finished: string; findings: string; actions: string; probes: string; outcome: string; | |
| 46 | }[]; | |
| 47 | return rows.map((r) => ({ | |
| 48 | ...r, | |
| 49 | findings: JSON.parse(r.findings) as unknown[], | |
| 50 | actions: JSON.parse(r.actions) as string[], | |
| 51 | probes: JSON.parse(r.probes) as unknown[], | |
| 52 | })); | |
| 53 | } |
test/job-slices.test.ts +4
| @@ -64,6 +64,7 @@ import { nightlyDue } from "../src/checks/scan_store.ts"; | ||
| 64 | 64 | import { startNotifier } from "../src/crew/notify.ts"; |
| 65 | 65 | import { fireDue } from "../src/apps/feature_schedule.ts"; |
| 66 | 66 | import { pullDue } from "../src/apps/pull_schedule.ts"; |
| 67 | import { startMaintain } from "../src/maintain/runner.ts"; | |
| 67 | 68 | |
| 68 | 69 | /** Thousands of issues and pulls, as the issue asks; runs, comments and events thinned so the file runs in seconds (#1242). */ |
| 69 | 70 | 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> { | ||
| 134 | 135 | const memory = startMemorySampler(app, 1e9); |
| 135 | 136 | sampler.stop(); |
| 136 | 137 | memory.stop(); |
| 138 | const maintainer = startMaintain(app, { everyMs: 1e9 }); | |
| 139 | maintainer.stop(); | |
| 137 | 140 | return { |
| 138 | 141 | sampler: () => sampler.tick(), |
| 139 | 142 | "memory-sampler": () => memory.tick(), |
| @@ -221,6 +224,7 @@ function jobs(): Record<string, () => unknown> { | ||
| 221 | 224 | "nightly-scan": () => nightlyDue(app.db, new Date(Date.now() - 24 * 3_600_000).toISOString(), 25), |
| 222 | 225 | "feature-schedule": () => fireDue(app), |
| 223 | 226 | "pull-schedule": () => pullDue(app), |
| 227 | maintain: () => maintainer.tick(new Date(Date.now() + 1e9)), | |
| 224 | 228 | }; |
| 225 | 229 | } |
| 226 | 230 |
test/maintain.test.ts added +140
| @@ -0,0 +1,140 @@ | ||
| 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(404); | |
| 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 | }); |