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

A maintenance duty pauses and resumes from the board and over the API, and pauses itself after three errors in a row, saying why #11270

Merged opened this

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

Merged

scout/forge:scout/11205-a-maintenance-duty-pauses-and-resumes-fr → main 6 commits fork 4 threads · 4 unresolved

  • Merged. scout/11205-a-maintenance-duty-pauses-and-resumes-fr was merged into main as 6c52325.

Review

Sign in to review this change.
17 files changed +435 −8
docs/MCP.md +1 −1
@@ -134,7 +134,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
134134 | `lift` | — | Lift an issue out from behind the Later gate | 214 |
135135 | `limb` | `defer`, `resume`, `close`, `raise`, `lower`, `replan`, `takeback`, `undo` | Act on a limb by `action` | 532 |
136136 | `limits` | — | Every limit the owner can hit, value and bounds | 977 |
137 | `maintenance` | `status`, `prepare`, `resume` | Prepare the forge for a power-off, check the drain, or end maintenance (admin) | 256 |
137 | `maintenance` | `status`, `prepare`, `resume`, `pause-duty`, `resume-duty` | Prepare the forge for a power-off, check the drain, or end maintenance (admin) | 308 |
138138 | `make_it_yours` | — | Fork an app you can see: code and history, following the original | 337 |
139139 | `map` | — | An app's plan: ready or waiting, critical path, crew, parkedTwice; `done` adds closed; `first: N` ranks N first | 375 |
140140 | `merge` | — | Land a pull, as its page's Merge button does; a person's only | 319 |
docs/features/maintenance-duties.md added +14
@@ -0,0 +1,14 @@
1 # Pause and resume a maintenance duty (#11205)
2
3 Each duty the forge's maintenance runner runs (`src/maintain/registry.ts`) has an on/off switch, kept in `maintenance_duty_state`. A paused duty is skipped by every tick. After three errored runs in a row (it threw, timed out or had a finding refused) the runner pauses the duty itself and says why: "paused after 3 errors in a row; last error: …". Resuming starts the count again, so one more error does not pause it at once.
4
5 All doors are for a forge admin and call one function, `switchDuty` in `src/maintain/pause.ts`, so they say the same words.
6
7 - **On the web.** `/maintenance` lists each duty once; the ones the runner runs carry their state ("on", or "paused since … ; why") and one **Pause** / **Resume** button (`POST /maintenance/duties/:name`, same-origin only, form body bounded, `paused` is `yes` or `no`).
8 - **In the terminal.** Ctrl-K, **Pause or resume a maintenance duty** (an admin who is a person): `j`/`k` choose, Enter or Space flips, Esc closes. It writes the same row, so a web viewer sees it on its next load.
9 - **Over the API.** `GET /api/v1/maintenance/duties` lists the states; `POST /api/v1/maintenance/duties/:name` with `{"paused": true|false}` needs an `admin`-scope token.
10 - **In the MCP.** The `maintenance` tool: `pause-duty` and `resume-duty` with `duty`.
11
12 An unknown duty name is 404 (a flash on the page); a `paused` that is not a boolean is 422.
13
14 Tests: `test/maintain-pause.test.ts` (the three errors, resume from the API, the page and the terminal, the gates).
src/contract/openapi/routes/getApiV1MaintenanceDuties.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/duties",
6 summary:
7 "Each maintenance duty with whether it is paused, and why and by whom (a forge admin's token); answers { duties }",
8 auth: true,
9 role: "admin",
10 refusals: { forbidden: "the token is not a forge admin" },
11 };
src/contract/openapi/routes/index.ts +2
@@ -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 getApiV1MaintenanceDuties } from "./getApiV1MaintenanceDuties.ts";
105106 export { route as getApiV1MaintenanceRuns } from "./getApiV1MaintenanceRuns.ts";
106107 export { route as getApiV1Map } from "./getApiV1Map.ts";
107108 export { route as getApiV1MigrateJob } from "./getApiV1MigrateJob.ts";
@@ -286,6 +287,7 @@ export { route as postApiV1FleetOpsIdUndo } from "./postApiV1FleetOpsIdUndo.ts";
286287 export { route as postApiV1Forges } from "./postApiV1Forges.ts";
287288 export { route as postApiV1ForgesNameClaimLink } from "./postApiV1ForgesNameClaimLink.ts";
288289 export { route as postApiV1Heartbeat } from "./postApiV1Heartbeat.ts";
290 export { route as postApiV1MaintenanceDutiesName } from "./postApiV1MaintenanceDutiesName.ts";
289291 export { route as postApiV1Migrate } from "./postApiV1Migrate.ts";
290292 export { route as postApiV1Orgs } from "./postApiV1Orgs.ts";
291293 export { route as postApiV1OrgsOrgTeams } from "./postApiV1OrgsOrgTeams.ts";
src/contract/openapi/routes/postApiV1MaintenanceDutiesName.ts added +11
@@ -0,0 +1,11 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/maintenance/duties/{name}",
6 summary:
7 "Pause or resume one maintenance duty with { paused: boolean } (a forge admin's admin-scope token); answers { says }",
8 auth: true,
9 role: "admin",
10 refusals: { forbidden: "the token is not a forge admin", not_found: "no duty has that name", invalid: "paused is not a boolean" },
11 };
src/core/migration-pins/20261111120000-maintenance-duty-state.pin added +1
@@ -0,0 +1,1 @@
1 391de19c4b728caa065a215f40be9cf48eda5929d63d10876598d85b728932eb
src/core/migrations/20261111120000-maintenance-duty-state.ts added +12
@@ -0,0 +1,12 @@
1 // A maintenance duty's on/off switch (#11205): paused by a person or by three errors in a row,
2 // with why and when. `from_run` is the newest run at the last resume: only later runs count toward three.
3 export default `
4 CREATE TABLE maintenance_duty_state (
5 duty TEXT PRIMARY KEY,
6 paused INTEGER NOT NULL DEFAULT 0,
7 reason TEXT NOT NULL DEFAULT '',
8 by TEXT NOT NULL DEFAULT '',
9 since TEXT NOT NULL DEFAULT '',
10 from_run INTEGER NOT NULL DEFAULT 0
11 );
12 `;
src/http/api/maintenance_runs.ts +22 −1
@@ -1,9 +1,11 @@
11 /** `GET /api/v1/maintenance/runs`: the latest maintenance duty runs, for a forge admin. */
22
3 import { switchDuty, dutyStates } from "../../maintain/pause.ts";
34 import { latest } from "../../maintain/runs.ts";
45 import type { Handler } from "../context.ts";
56 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
6 import { forbidden, json, requireScope, unauthorized } from "../respond.ts";
7 import { crossOrigin } from "../context.ts";
8 import { apiError, boundedBody, forbidden, json, notFound, requireScope, unauthorized } from "../respond.ts";
79 import type { Router } from "../router.ts";
810
911 export function maintenanceRunsRoutes(router: Router<Handler>): void {
@@ -17,4 +19,23 @@ export function maintenanceRunsRoutes(router: Router<Handler>): void {
1719 const limit = Number.isInteger(asked) && asked > 0 ? asked : 20;
1820 return json({ runs: latest(ctx.app.db, limit) });
1921 });
22 router.add("GET", "/api/v1/maintenance/duties", (ctx) => {
23 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
24 if (!ctx.auth.user) return unauthorized();
25 if (!ctx.auth.user.isAdmin) return forbidden();
26 const denied = requireScope(ctx, "ro");
27 if (denied) return denied;
28 return json({ duties: dutyStates(ctx.app.db) });
29 });
30 router.add("POST", "/api/v1/maintenance/duties/:name", async (ctx) => {
31 if (!ctx.auth.user) return unauthorized();
32 if (!ctx.auth.user.isAdmin) return forbidden();
33 const denied = requireScope(ctx, "admin");
34 if (denied) return denied;
35 if (ctx.auth.via === "session" && crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden();
36 const input = await boundedBody(ctx.request, 1024);
37 if (input === "too_large") return apiError("too_large", 413, "The request is too large.");
38 const r = switchDuty(ctx.app.db, ctx.params["name"], input["paused"], ctx.auth.user.username);
39 return r.ok ? json({ says: r.says }) : r.code === "not_found" ? notFound() : apiError("invalid", 422, r.says);
40 });
2041 }
src/http/mcp/tools/maintenance.ts +10 −4
@@ -2,22 +2,28 @@ import { prepare, resumeNow, status } from "../../../maintenance/service.ts";
22 import { duties } from "../../../maintenance/duties.ts";
33 import { statusWithStandby } from "../../../maintenance/standby.ts";
44 import { scopeAllows } from "../../../core/tokens.ts";
5 import { dutyStates, switchDuty } from "../../../maintain/pause.ts";
56 import { good, bad, type Tool } from "../shared.ts";
67
78 export const tool: Tool = {
89 name: "maintenance",
9 doc: "`action` is `status` (default), `prepare` or `resume`; `reason` (prepare only, at most 200 characters) is shown to visitors; a forge admin, and prepare and resume need an admin-scope token; answers the status `{ phase, since?, reason?, backup?, working, says, standby: { state, says }, duties }`",
10 doc: "`action` is `status` (default), `prepare`, `resume`, `pause-duty` or `resume-duty` (with `duty`, a name from `status`); `reason` (prepare only, at most 200 characters) is shown to visitors; a forge admin, and prepare and resume need an admin-scope token; answers the status `{ phase, since?, reason?, backup?, working, says, standby: { state, says }, duties }`",
1011 description: "Prepare the forge for a power-off, check the drain, or end maintenance (admin).",
1112 inputSchema: {
1213 type: "object",
13 properties: { action: { type: "string", enum: ["status", "prepare", "resume"] }, reason: { type: "string", maxLength: 200 } },
14 properties: { action: { type: "string", enum: ["status", "prepare", "resume", "pause-duty", "resume-duty"] }, duty: { type: "string", maxLength: 64 }, reason: { type: "string", maxLength: 200 } },
1415 },
1516 writes: true,
1617 async run(ctx, user, args) {
1718 if (!user.isAdmin) return bad("not_found", "no such tool here");
1819 const action = args["action"] ?? "status";
19 if (action === "status") return good({ ...(await statusWithStandby(status(ctx.app))), duties: duties(ctx.app.config.dataDir).map((d) => d.says) });
20 if (action !== "prepare" && action !== "resume") return bad("invalid", "action is status, prepare or resume");
20 if (action === "status") return good({ ...(await statusWithStandby(status(ctx.app))), duties: [...duties(ctx.app.config.dataDir).map((d) => d.says), ...dutyStates(ctx.app.db).map((d) => d.says)] });
21 if (action === "pause-duty" || action === "resume-duty") {
22 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) return bad("forbidden", "maintenance needs an admin-scope token");
23 const r = switchDuty(ctx.app.db, args["duty"], action === "pause-duty", user.username);
24 return r.ok ? good({ says: r.says }) : bad(r.code, r.says);
25 }
26 if (action !== "prepare" && action !== "resume") return bad("invalid", "action is status, prepare, resume, pause-duty or resume-duty");
2127 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) return bad("forbidden", "maintenance needs an admin-scope token");
2228 if (action === "resume") {
2329 const r = resumeNow(ctx.app, user);
src/http/server.ts +2 −1
@@ -30,7 +30,7 @@ import { contributePage } from "../web/pages/contribute.ts";
3030 import { pipelinePage } from "../web/pages/pipeline.ts";
3131 import { adminDataPage, appDataPage, dataPage } from "../web/pages/data.ts";
3232 import { fleetAct, fleetReceipt, fleetUndo } from "../web/pages/fleet-ops.ts";
33 import { maintenancePage, maintenancePrepare, maintenanceResume } from "../web/pages/maintenance.ts";
33 import { maintenanceDuty, maintenancePage, maintenancePrepare, maintenanceResume } from "../web/pages/maintenance.ts";
3434 import { nightlyOutputPage, resiliencePage } from "../web/pages/resilience.ts";
3535 import { agentStartSet, billingLinksSet, limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts";
3636 import { plansSet } from "../web/settings/plans.ts";
@@ -986,6 +986,7 @@ export function buildRouter(): Router<Handler> {
986986 router.add("GET", "/maintenance", maintenancePage);
987987 router.add("POST", "/maintenance/prepare", maintenancePrepare);
988988 router.add("POST", "/maintenance/resume", maintenanceResume);
989 router.add("POST", "/maintenance/duties/:name", maintenanceDuty);
989990 router.add("GET", "/resilience/nightly/:job", nightlyOutputPage);
990991 // A selection's spend and runs over time; literal, above /:owner.
991992 router.add("GET", "/fleet", fleetPage);
src/maintain/pause.ts added +70
@@ -0,0 +1,70 @@
1 /** A maintenance duty's on/off switch (#11205): stored, set by a person or by three errors in a row; plain data in and out. */
2
3 import type { Database } from "bun:sqlite";
4 import { clip } from "./duty.ts";
5 import { duties as registered } from "./registry.ts";
6
7 /** Errored runs in a row that pause a duty. */
8 export const ERRORS_TO_PAUSE = 3;
9
10 export interface DutyState {
11 readonly name: string;
12 readonly paused: boolean;
13 /** The row in words: the page, the API and the MCP say this. */
14 readonly says: string;
15 /** `says` without the name: what a row already headed by the name adds. */
16 readonly detail: string;
17 }
18
19 interface Row { paused: number; reason: string; by: string; since: string; from_run: number }
20
21 const stateRow = (db: Database, name: string): Row | null =>
22 db.query("SELECT paused, reason, by, since, from_run FROM maintenance_duty_state WHERE duty = ?1").get(name) as Row | null;
23
24 export const isPaused = (db: Database, name: string): boolean => stateRow(db, name)?.paused === 1;
25
26 /** A run that did not come out `ok`: it threw, timed out or had a finding refused. */
27 export const errored = (outcome: string): boolean => outcome !== "ok";
28
29 function put(db: Database, name: string, paused: boolean, reason: string, by: string, now: Date): void {
30 const from = (db.query("SELECT COALESCE(MAX(id), 0) AS id FROM maintenance_run WHERE duty = ?1").get(name) as { id: number }).id;
31 db.query(
32 `INSERT INTO maintenance_duty_state (duty, paused, reason, by, since, from_run) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
33 ON CONFLICT(duty) DO UPDATE SET paused = ?2, reason = ?3, by = ?4, since = ?5, from_run = CASE WHEN ?2 = 0 THEN ?6 ELSE from_run END`,
34 ).run(name, paused ? 1 : 0, clip(reason), by, now.toISOString(), from);
35 }
36
37 export const setPaused = (db: Database, name: string, paused: boolean, by: string, now = new Date()): void =>
38 put(db, name, paused, paused ? `paused by ${by}` : "", by, now);
39
40 /** After a run: three errored runs in a row (since the last resume) pause the duty, with the last error as the reason. Returns whether it paused. */
41 export function pauseOnErrors(db: Database, name: string, now = new Date()): boolean {
42 const from = stateRow(db, name)?.from_run ?? 0;
43 const rows = db.query("SELECT outcome FROM maintenance_run WHERE duty = ?1 AND id > ?2 ORDER BY id DESC LIMIT ?3").all(name, from, ERRORS_TO_PAUSE) as { outcome: string }[];
44 if (rows.length < ERRORS_TO_PAUSE || !rows.every((r) => errored(r.outcome))) return false;
45 put(db, name, true, `paused after ${ERRORS_TO_PAUSE} errors in a row; last error: ${rows[0]!.outcome}`, "the runner", now);
46 return true;
47 }
48
49 export function dutyState(db: Database, name: string): DutyState {
50 const s = stateRow(db, name);
51 if (s?.paused === 1) {
52 const detail = `paused since ${s.since.slice(0, 16).replace("T", " ")} UTC; ${s.reason}`;
53 return { name, paused: true, says: `${name}: ${detail}.`, detail };
54 }
55 const last = db.query("SELECT outcome FROM maintenance_run WHERE duty = ?1 ORDER BY id DESC LIMIT 1").get(name) as { outcome: string } | null;
56 const detail = `on${last && errored(last.outcome) ? `; last run errored: ${last.outcome}` : ""}`;
57 return { name, paused: false, says: `${name}: ${detail}.`, detail };
58 }
59
60 export const dutyStates = (db: Database): DutyState[] => registered.map((d) => dutyState(db, d.name));
61
62 export const knownDuty = (name: string): boolean => registered.some((d) => d.name === name);
63
64 /** One switch for page, API and MCP: the same words. */
65 export function switchDuty(db: Database, name: unknown, paused: unknown, by: string): { ok: true; says: string } | { ok: false; code: "invalid" | "not_found"; says: string } {
66 if (typeof name !== "string" || !knownDuty(name)) return { ok: false, code: "not_found", says: "No such maintenance duty." };
67 if (typeof paused !== "boolean") return { ok: false, code: "invalid", says: "paused is true or false." };
68 setPaused(db, name, paused, by);
69 return { ok: true, says: paused ? `${name} is paused.` : `${name} is on again.` };
70 }
src/maintain/runner.ts +3 −1
@@ -8,6 +8,7 @@ import type { App } from "../app.ts";
88 import { during } from "../perf/jobs.ts";
99 import { clip, refusal, validName, type Duty } from "./duty.ts";
1010 import { duties as registered } from "./registry.ts";
11 import { isPaused, pauseOnErrors } from "./pause.ts";
1112 import { lastStarted, prune, record } from "./runs.ts";
1213
1314 export const TICK_MS = 60_000;
@@ -62,6 +63,7 @@ export function startMaintain(app: App, options: { duties?: readonly Duty[]; eve
6263 try {
6364 record(app.db, { duty: duty.name, started, finished: new Date(), findings, actions, outcome });
6465 prune(app.db, duty.name, options.keep ?? KEEP_PER_DUTY);
66 if (pauseOnErrors(app.db, duty.name)) app.log.error("maintenance duty paused after errors", { duty: duty.name });
6567 } catch (error) {
6668 app.log.error("maintenance run not recorded", { duty: duty.name, error: String(error) });
6769 }
@@ -71,7 +73,7 @@ export function startMaintain(app: App, options: { duties?: readonly Duty[]; eve
7173 for (const duty of list) {
7274 const last = lastStarted(app.db, duty.name);
7375 if (last !== undefined && now.getTime() - last < duty.intervalMs) continue;
74 if (running.has(duty.name)) continue;
76 if (running.has(duty.name) || isPaused(app.db, duty.name)) continue;
7577 await runOne(duty, now);
7678 // Duties take turns: the loop turns between one and the next.
7779 await new Promise((resolve) => setImmediate(resolve));
src/tui/screens/dutyswitch.ts added +66
@@ -0,0 +1,66 @@
1 /**
2 * Pause and resume a maintenance duty in the terminal (#11205): the switches
3 * of the /maintenance page, through the same `switchDuty`, behind the same
4 * gate (a forge admin who is a person). j/k choose a duty, Enter or Space
5 * flips it, Esc or q closes. The write is to the forge's own database, so a
6 * web viewer sees it on its next load.
7 */
8
9 import { dutyStates, switchDuty } from "../../maintain/pause.ts";
10 import type { Ctx, Screen } from "../app/app.ts";
11 import { binding } from "../app/keys.ts";
12 import type { Session } from "../session.ts";
13 import { style } from "../term/style.ts";
14 import { drawLines, Scroll } from "./common.ts";
15 import type { PaletteEntry } from "./entries.ts";
16
17 const mayAct = (session: Session): boolean => session.user?.isAdmin === true && session.user.kind !== "agent";
18
19 /** One duty a line, the chosen one marked; empty for anyone the gate refuses. */
20 export function dutyLines(session: Session, chosen: number): string[] {
21 if (!mayAct(session)) return [];
22 return dutyStates(session.db).map((d, i) => `${i === chosen ? ">" : " "} ${d.says}`);
23 }
24
25 /** Flip the chosen duty; what it says, or nothing for anyone the gate refuses. */
26 export function flipDuty(session: Session, chosen: number): string {
27 if (!mayAct(session)) return "";
28 const d = dutyStates(session.db)[chosen];
29 if (d === undefined) return "";
30 const r = switchDuty(session.db, d.name, !d.paused, session.user!.username);
31 return r.says;
32 }
33
34 export function dutySwitchScreen(session: Session, ctx: Ctx): Screen {
35 let chosen = 0;
36 let note = "";
37 const count = (): number => dutyStates(session.db).length;
38 return {
39 title: "Maintenance duties",
40 keys: [binding(["j", "k"], "choose"), binding(["enter", "space"], "pause or resume"), binding(["esc", "q"], "back")],
41 render(area, s) {
42 const c = ctx.theme.c;
43 const lines = [...dutyLines(session, chosen), ...(note === "" ? [] : ["", note])];
44 drawLines(s, area, c, new Scroll(), lines.map((text) => [{ text, style: style(c.fg, c.bg) }]));
45 },
46 key(e) {
47 if (e.ctrl || e.alt) return true;
48 if (e.key === "esc" || e.key === "q") ctx.pop();
49 else if (e.key === "j" || e.key === "down") chosen = Math.min(chosen + 1, count() - 1);
50 else if (e.key === "k" || e.key === "up") chosen = Math.max(chosen - 1, 0);
51 else if (e.key === "enter" || e.key === " " || e.key === "space") note = flipDuty(session, chosen);
52 else return false;
53 ctx.invalidate();
54 return true;
55 },
56 };
57 }
58
59 export const entry: PaletteEntry = {
60 id: "maintenance-duties",
61 title: "Pause or resume a maintenance duty",
62 hint: "switch a duty off, or on again after it paused itself",
63 after: ["maintenance-runs", "maintenance"],
64 offered: mayAct,
65 run: (session, ctx) => ctx.push(dutySwitchScreen(session, ctx)),
66 };
src/tui/screens/entries.ts +2
@@ -57,6 +57,7 @@ import { entry as loopStalls } from "./loopstalls.ts";
5757 import { entry as slowPagesEntry } from "./slowpages.ts";
5858 import { entry as maintenance } from "./maintenance.ts";
5959 import { entry as maintainRuns } from "./maintainruns.ts";
60 import { entry as dutySwitch } from "./dutyswitch.ts";
6061 import { entry as mirrorPacksEntry } from "./mirrorpacks.ts";
6162 import { entry as packReposEntry } from "./packrepos.ts";
6263 import { modelCeilingEntry, modelGrantEntry } from "./model.ts";
@@ -118,6 +119,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
118119 resilience,
119120 maintenance,
120121 maintainRuns,
122 dutySwitch,
121123 mirrorPacksEntry,
122124 packReposEntry,
123125 valves,
src/web/pages/maintenance.ts +18
@@ -5,6 +5,7 @@
55 */
66
77 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
8 import { dutyStates, switchDuty } from "../../maintain/pause.ts";
89 import { duties } from "../../maintenance/duties.ts";
910 import { statusWithStandby } from "../../maintenance/standby.ts";
1011 import { ABOUT_TO_STOP_ROWS, prepare, resumeNow, status } from "../../maintenance/service.ts";
@@ -34,6 +35,12 @@ export async function maintenancePage(ctx: Ctx): Promise<Response> {
3435 <p id="maintenance-says">${s.says}</p>
3536 <dl id="maintenance-duties" class="status-rows">
3637 ${duties(ctx.app.config.dataDir).map((d) => fact(d.name, html`${d.detail}${d.overdue ? html` ${badge("overdue", { tone: "danger" })}` : null}`))}
38 ${dutyStates(ctx.app.db).map((d) =>
39 fact(d.name, html`${d.detail}
outdated

Principles 1 and 7: every duty is listed twice, in maintenance-duties and again here, so the page says the same thing twice. Fold the Pause/Resume form and the pause state into the existing duty row, one row per duty.

40 <form method="post" action="/maintenance/duties/${d.name}" class="inline">
41 <input type="hidden" name="paused" value="${d.paused ? "no" : "yes"}">${btn(d.paused ? "Resume" : "Pause", { variant: "outline" })}
42 </form>`),
outdated

The state sentence and a button share one dd. Put the sentence in the row and keep the button as the single interaction, per DESIGN.md rows-over-boxes. Also formField(...) === "yes" treats any other value as resume; fine but keep the values explicit.

43 )}
3744 </dl>
3845 ${s.phase === "idle" ? html`<p id="maintenance-standby" class="muted">${s.standby.says}</p>` : null}
3946 ${s.phase === "idle"
@@ -88,3 +95,14 @@ export async function maintenanceResume(ctx: Ctx): Promise<Response> {
8895 const r = resumeNow(ctx.app, ctx.auth.user);
8996 return flashTo(ctx, "/maintenance", r.ok ? { message: r.value.says } : { tone: "danger", message: r.error.says });
9097 }
98
99 export async function maintenanceDuty(ctx: Ctx): Promise<Response> {
100 const denied = admin(ctx);
101 if (denied) return denied;
outdated

maintenanceDuty has no test: nothing posts the form, nothing checks that a non-admin or cross-origin post is refused. Add them.

102 const form = await readForm(ctx, "admin");
103 if (form instanceof Response) return form;
104 const want = formField(form, "paused");
105 if (want !== "yes" && want !== "no") return flashTo(ctx, "/maintenance", { tone: "danger", message: "paused is yes or no." });
106 const r = switchDuty(ctx.app.db, ctx.params["name"], want === "yes", ctx.auth.user!.username);
107 return flashTo(ctx, "/maintenance", r.ok ? { message: r.says } : { tone: "danger", message: r.says });
108 }
test/maintain-pause.test.ts added +189
@@ -0,0 +1,189 @@
1 /** A maintenance duty's switch (#11205): the API, the MCP, the page, and three errors in a row. */
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 type { Duty } from "../src/maintain/duty.ts";
11 import { dutyState, isPaused, pauseOnErrors, setPaused } from "../src/maintain/pause.ts";
12 import { duties } from "../src/maintain/registry.ts";
13 import { startMaintain } from "../src/maintain/runner.ts";
14 import { dutySwitchScreen, entry as switchEntry, flipDuty } from "../src/tui/screens/dutyswitch.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16
17 quietLogs();
18 let root = "";
19 let app: App;
20 let servers: Servers;
21 let admin = "";
22 let ro = "";
23 const name = duties[0]!.name;
24
25 beforeAll(async () => {
26 root = mkdtempSync(join(tmpdir(), "forge-pause-"));
27 app = createApp({ dataDir: root, port: 0 });
28 servers = await serve(app);
29 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
30 if (!a.ok) throw new Error("user");
31 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
32 if (!b.ok) throw new Error("user");
33 admin = createToken(app.db, a.value.id, "a", "admin").token;
34 ro = createToken(app.db, a.value.id, "r", "ro").token;
35 });
36 afterAll(() => {
37 servers.stop?.();
38 rmSync(root, { recursive: true, force: true });
39 });
40
41 const post = (token: string, n: string, body: unknown) =>
42 fetch(`${servers.url}/api/v1/maintenance/duties/${n}`, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify(body) });
43 const mcp = async (token: string, args: unknown) => {
44 const res = await fetch(`${servers.url}/mcp`, {
45 method: "POST",
46 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
47 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "maintenance", arguments: args } }),
48 });
49 const b = (await res.json()) as any;
50 return { error: b.result.isError === true, text: b.result.content[0].text as string };
51 };
52
53 test("the API pauses and resumes a duty, admin scope only, unknown names and bad bodies refused", async () => {
54 expect((await post(ro, name, { paused: true })).status).toBe(403);
55 expect((await post(admin, "nope", { paused: true })).status).toBe(404);
56 expect((await post(admin, name, { paused: "yes" })).status).toBe(422);
57 expect((await post(admin, name, { paused: true })).status).toBe(200);
58 expect(isPaused(app.db, name)).toBe(true);
59 const list = (await (await fetch(`${servers.url}/api/v1/maintenance/duties`, { headers: { authorization: `Bearer ${ro}` } })).json()) as any;
60 expect(list.duties.find((d: any) => d.name === name).says).toContain("paused");
61 expect((await post(admin, name, { paused: false })).status).toBe(200);
62 expect(isPaused(app.db, name)).toBe(false);
63 });
64
65 test("the MCP maintenance tool pauses and resumes with the same words", async () => {
66 const p = await mcp(admin, { action: "pause-duty", duty: name });
67 expect(p.error).toBe(false);
68 expect(p.text).toContain(`${name} is paused.`);
69 expect((await mcp(admin, { action: "status" })).text).toContain(`${name}: paused`);
70 expect((await mcp(ro, { action: "pause-duty", duty: name })).error).toBe(true);
71 expect((await mcp(admin, { action: "resume-duty", duty: "nope" })).error).toBe(true);
outdated

This test is named "its form flips it" but only checks that the form renders. Post it as an admin and assert isPaused flips, and that a non-admin is refused. Also resume in the auto-pause test goes through raw SQL, so the real resume code path (from_run reset) is not covered.

72 expect((await mcp(admin, { action: "resume-duty", duty: name })).text).toContain(`${name} is on again.`);
73 });
74
75 const login = async (username: string): Promise<string> => {
76 const res = await fetch(`${servers.url}/signin`, {
77 method: "POST",
78 redirect: "manual",
79 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
80 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
81 });
82 return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
83 };
84 const form = (cookie: string, n: string, fields: Record<string, string>, origin = servers.url) =>
85 fetch(`${servers.url}/maintenance/duties/${n}`, {
86 method: "POST",
87 redirect: "manual",
88 headers: { "content-type": "application/x-www-form-urlencoded", origin, cookie },
89 body: new URLSearchParams(fields),
90 });
91
92 test("the page lists each duty once, its switch in the row", async () => {
93 const html = await (await fetch(`${servers.url}/maintenance`, { headers: { authorization: `Bearer ${admin}` } })).text();
94 expect(html).toContain(`action="/maintenance/duties/${name}"`);
95 expect(html.split(`<dt>${name}</dt>`).length - 1).toBe(1);
96 expect(html.split('id="maintenance-duties"').length - 1).toBe(1);
97 expect(html).not.toContain('id="maintenance-switches"');
98 });
99
100 test("the page form flips a duty for an admin; a non-admin, a cross-origin post and a stray value change nothing", async () => {
101 app.db.exec("DELETE FROM maintenance_duty_state");
102 const alice = await login("alice");
103 const bob = await login("bob");
104 expect((await form(bob, name, { paused: "yes" })).status).toBeGreaterThanOrEqual(400);
105 expect(isPaused(app.db, name)).toBe(false);
106 expect((await form(alice, name, { paused: "yes" }, "https://evil.example")).status).toBe(403);
107 expect(isPaused(app.db, name)).toBe(false);
108 expect((await form(alice, name, { paused: "maybe" })).status).toBe(303);
109 expect(isPaused(app.db, name)).toBe(false);
110 expect((await form(alice, "nope", { paused: "yes" })).status).toBe(303);
111 expect((await form(alice, name, { paused: "yes" })).status).toBe(303);
112 expect(isPaused(app.db, name)).toBe(true);
113 expect(await (await fetch(`${servers.url}/maintenance`, { headers: { cookie: alice } })).text()).toContain("Resume");
114 expect((await form(alice, name, { paused: "no" })).status).toBe(303);
115 expect(isPaused(app.db, name)).toBe(false);
116 });
117
118 test("the terminal switch is offered to an admin person only and flips the duty through the same code", () => {
119 app.db.exec("DELETE FROM maintenance_duty_state");
120 const session = (user: unknown) => ({ user, app, db: app.db }) as never;
121 const adminUser = { id: "a", username: "alice", isAdmin: true, kind: "person" };
122 expect(switchEntry.offered(session(adminUser))).toBe(true);
123 expect(switchEntry.offered(session({ ...adminUser, kind: "agent" }))).toBe(false);
124 expect(switchEntry.offered(session({ ...adminUser, isAdmin: false }))).toBe(false);
125 expect(flipDuty(session({ ...adminUser, isAdmin: false }), 0)).toBe("");
126 expect(isPaused(app.db, name)).toBe(false);
127 expect(flipDuty(session(adminUser), 0)).toBe(`${name} is paused.`);
128 expect(isPaused(app.db, name)).toBe(true);
129 const screen = dutySwitchScreen(session(adminUser), { invalidate: () => undefined } as never);
130 screen.key({ key: "enter" } as never);
131 expect(isPaused(app.db, name)).toBe(false);
132 });
133
134 test("a paused duty does not run; three errors in a row pause it saying the last error; resume starts the count again", async () => {
135 app.db.exec("DELETE FROM maintenance_run; DELETE FROM maintenance_duty_state");
136 let calls = 0;
137 const bad: Duty = { name: "t-bad", intervalMs: 1, run: () => { calls++; throw new Error(`boom ${calls}`); } };
138 const m = startMaintain(app, { duties: [bad], everyMs: 1e9 });
139 m.stop();
140 const t = (i: number) => new Date(Date.UTC(2026, 10, 11, 0, 0, i));
141 await m.tick(t(1));
142 await m.tick(t(2));
143 expect(isPaused(app.db, "t-bad")).toBe(false);
144 await m.tick(t(3));
145 expect(isPaused(app.db, "t-bad")).toBe(true);
146 expect(dutyState(app.db, "t-bad").says).toContain("last error: failed: boom 3");
147 await m.tick(t(4));
148 expect(calls).toBe(3);
149 // Resumed by hand: it runs again, and one more error does not re-pause it at once.
150 setPaused(app.db, "t-bad", false, "alice");
151 await m.tick(t(5));
152 expect(calls).toBe(4);
153 expect(isPaused(app.db, "t-bad")).toBe(false);
154 await m.tick(t(6));
155 expect(isPaused(app.db, "t-bad")).toBe(false);
156 await m.tick(t(7));
157 expect(calls).toBe(6);
158 expect(isPaused(app.db, "t-bad")).toBe(true);
159 });
160
161 test("an ok run in between keeps the count from reaching three", async () => {
162 app.db.exec("DELETE FROM maintenance_run; DELETE FROM maintenance_duty_state");
163 let n = 0;
164 const flaky: Duty = { name: "t-flaky", intervalMs: 1, run: () => { if (++n === 3) return { findings: [], actions: [] }; throw new Error("x"); } };
165 const m = startMaintain(app, { duties: [flaky], everyMs: 1e9 });
166 m.stop();
167 for (let i = 1; i <= 5; i++) await m.tick(new Date(Date.UTC(2026, 10, 11, 0, 0, i)));
168 expect(isPaused(app.db, "t-flaky")).toBe(false);
169 });
170
171 test("resuming through the API and through the page starts the count again", async () => {
172 const fail = () => app.db.run("INSERT INTO maintenance_run (duty, started, finished, outcome) VALUES (?1, ?2, ?2, 'failed: x')", [name, new Date().toISOString()]);
173 app.db.exec("DELETE FROM maintenance_run; DELETE FROM maintenance_duty_state");
174 const viaApi = async () => {
175 for (let i = 0; i < 3; i++) fail();
176 expect(pauseOnErrors(app.db, name)).toBe(true);
177 };
178 await viaApi();
179 expect((await post(admin, name, { paused: false })).status).toBe(200);
180 fail();
181 expect(pauseOnErrors(app.db, name)).toBe(false);
182 fail();
183 fail();
184 expect(pauseOnErrors(app.db, name)).toBe(true);
185 expect((await form(await login("alice"), name, { paused: "no" })).status).toBe(303);
186 fail();
187 expect(pauseOnErrors(app.db, name)).toBe(false);
188 expect(isPaused(app.db, name)).toBe(false);
189 });
test/palette-roster.test.ts +1
@@ -118,6 +118,7 @@ test("a site-admin person is offered the whole roster", () => {
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"],
120120 ["maintenance-runs", "Maintenance duty runs", "what each duty found, did and probed"],
121 ["maintenance-duties", "Pause or resume a maintenance duty", "switch a duty off, or on again after it paused itself"],
121122 ["crawlers", "Crawlers", "their share of answering time, and who was refused"],
122123 ["scrapes", "Scrapes", "what was read by repository, and groups in a running capture"],
123124 ["traffic", "Traffic", "who is using the forge's answering time, and the top readers"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs