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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11204-a-maintenance-duty-registered-in-one-fil → main 5 commits fork 4 threads · 4 unresolved
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"; | ||
| 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/contract/tags.ts +1
| @@ -52,6 +52,7 @@ const ALIAS: Readonly<Record<string, string>> = { | ||
| 52 | 52 | heartbeat: "meta", |
| 53 | 53 | telemetry: "meta", |
| 54 | 54 | drills: "meta", |
| 55 | maintenance: "meta", | |
| 55 | 56 | devices: "meta", |
| 56 | 57 | thoughts: "feed", |
| 57 | 58 | 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"; | ||
| 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 { 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(); | |
| 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 "refused: a finding says nothing"; | |
outdated
Return text should start with |
||
| 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"; | ||
| 55 | 55 | import { entry as loopStalls } from "./loopstalls.ts"; |
| 56 | 56 | import { entry as slowPagesEntry } from "./slowpages.ts"; |
| 57 | 57 | import { entry as maintenance } from "./maintenance.ts"; |
| 58 | import { entry as maintainRuns } from "./maintainruns.ts"; | |
| 58 | 59 | import { entry as mirrorPacksEntry } from "./mirrorpacks.ts"; |
| 59 | 60 | import { entry as packReposEntry } from "./packrepos.ts"; |
| 60 | 61 | import { modelCeilingEntry, modelGrantEntry } from "./model.ts"; |
| @@ -115,6 +116,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 115 | 116 | telemetry, |
| 116 | 117 | resilience, |
| 117 | 118 | maintenance, |
| 119 | maintainRuns, | |
| 118 | 120 | mirrorPacksEntry, |
| 119 | 121 | packReposEntry, |
| 120 | 122 | 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"; | ||
| 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 +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", () => { | ||
| 117 | 117 | ["capacity", "Capacity", "concurrency and guards, live; e changes a settable one"], |
| 118 | 118 | ["resilience", "Resilience", "where snapshots go and the mirror; each protection green, red or untested"], |
| 119 | 119 | ["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"], | |
| 120 | 121 | ["crawlers", "Crawlers", "their share of answering time, and who was refused"], |
| 121 | 122 | ["scrapes", "Scrapes", "what was read by repository, and groups in a running capture"], |
| 122 | 123 | ["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", | ||
| 250 | 251 | ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"], |
| 251 | 252 | ["can", "What I can do", "your role and what it allows here"], |
| 252 | 253 | ["bugs", "Bugs", "open bugs and the bug clock"], |
| 254 | ["maintenance-runs", "Maintenance duty runs", "what each duty found, did and probed"], | |
| 253 | 255 | ["traffic-sources", "Traffic sources", "where people come from and where they go, for the forge"], |
| 254 | 256 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 255 | 257 | ["archived", "Archived repositories", "put away, not gone: open one or restore it"], |
Non-admin gets 404 while the contract declares
forbidden/member. Align them.