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

Parked opened this
Closed without merging

scout/forge:scout/11204-a-maintenance-duty-registered-in-one-fil → main 3 commits fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
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";
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/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";
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 { 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";
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);
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";
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 +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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs