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

Maintenance from the forge: request a shutdown, keep a page up, and come back by itself #9639

Merged opened this in answer to

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

Merged

scout/forge:scout/9453-maintenance-from-the-forge-request-a-shu → main 5 commits fork 5 threads · 5 unresolved

  • Merged. scout/9453-maintenance-from-the-forge-request-a-shu was merged into main as a11b347.

Review

Sign in to review this change.
30 files changed +936 −2
docs/design/audit.md +1
@@ -99,6 +99,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
9999
100100 - `/system` — `pages/system.ts` · the machine as it is now: cores, load, memory, processes · `/flow`, `/` · none seen; updates in place over `/system/stream`, no reload script.
101101 - `/resilience` — `pages/resilience.ts` · each protection in `docs/THREATS.md` against the last gate's tests · `/settings/resilience` (similar name, different content) · none seen.
102 - `/maintenance` — `pages/maintenance.ts` · prepare the forge for a power-off and end it · `/resilience`, `/settings/backups` · none seen.
102103 - `/admin/limits` — `pages/admin-limits.ts` · every limit with value, default and reason · `/settings/capacity` (also a numbers-you-can-change page) · none seen.
103104 - `/settings/capacity` — `pages/capacity.ts` · how many runs and checks go at once · `/admin/limits`, `/settings/usage` · none seen.
104105 - `/settings/usage` — `pages/usage.ts` · route patterns by use, unused first · `/settings/telemetry`, `/board/returns` · none seen. Not about cost, despite the name.
docs/features/maintenance.md added +44
@@ -0,0 +1,44 @@
1 # Maintenance: shut down for a move and come back by itself (#9453)
2
3 **Prepare.** An admin opens `/maintenance` (linked from Resilience), the terminal's Maintenance command
4 (`src/tui/screens/maintenance.ts`: `p` prepare, `c` check again, `e` end; admins only), or calls
5 `POST /api/v1/admin/maintenance/prepare` (admin-scope token) or MCP
6 `maintenance {action: "prepare"}`. The forge then:
7
8 1. holds releases (an owner hold, `keeper/hold.ts`);
9 2. pauses the crew: the dispatcher starts nothing while `maintenance.json`
10 stands in the data directory; vitals reads the queue as held by "planned maintenance";
11 3. takes a fresh snapshot (`actions/backups_now.ts`);
12 4. reports the drain: `GET /api/v1/admin/maintenance` says `draining` while runs
13 or checks still go, `ready` when the backup is taken and nothing runs. Power
14 off on `ready`; a SIGTERM drains the rest as in a release.
15
16 The page says plainly what stops: releases, new crew runs, then the forge, its
17 apps and previews when the host powers off.
18
19 **Come back.** On boot `resumeAtBoot` acts only when the host has restarted since the prepare
20 (`/proc/sys/kernel/random/boot_id` differs from the one stored); a release roll of the process on
21 the same host boot keeps what the admin prepared. It then lifts the hold, unpauses the crew and
22 records a line (shown on `/maintenance`, in the status, and as the
23 `maintenance.ended` event) naming what was verified. An admin can also press
24 "End maintenance now". The tunnel, runner and nightly timers are the forge's
25 own boot: they start with the process, and the nightly jobs show on Resilience.
26 No Kubernetes is asked anything; the state is one file.
27
28 **While it is off.** `bun scripts/standby.ts 8080` serves the calm 503 page
29 (`src/maintenance/page.ts`) from the droplet; point the tunnel's or
30 Cloudflare's origin at it for the move, and back after. A Cloudflare Worker can
31 return the same HTML with status 503 and `Retry-After`.
32
33 **Size of a standby.** For reads or a whole forge: disk for the data (38 GB now,
34 apps 13 GB) plus one snapshot and headroom, so 64 GB; 4 GB of memory free for the
35 forge alone, 8 GB with apps running. The 16 GB droplet cannot hold it.
36
37 ## Not done
38 - No standby takeover: nothing replicates data to a second machine, so the
39 droplet can only show the page. That needs the snapshot mirror restored onto a
40 machine of the size above, and is a separate issue.
41 - The page is not pushed to Cloudflare automatically; `standby.ts` is started by hand.
42 - The admin is told on the page, event and status; no mail or push.
43 - Where the kernel gives no boot id, any restart ends maintenance.
44 - The return checks the database, hold, dispatcher, apps on record and nightly lines; it does not probe the tunnel or the runner.
scripts/standby.ts added +24
@@ -0,0 +1,24 @@
1 /**
2 * The maintenance page, served from somewhere that stays up (the droplet):
3 * `bun scripts/standby.ts [port]` answers every request with the calm page
4 * and a 503, so a tunnel or Cloudflare origin pointed here shows visitors
5 * "back soon", not an error. MAINTENANCE_REASON and MAINTENANCE_SINCE are
6 * optional. Zero dependencies; reads no forge data.
7 */
8 import { MAINTENANCE_HEADERS, maintenanceHtml } from "../src/maintenance/page.ts";
9
10 const port = Number(process.argv[2] ?? process.env["PORT"] ?? 8080);
11 const body = maintenanceHtml({
12 ...(process.env["MAINTENANCE_REASON"] ? { reason: process.env["MAINTENANCE_REASON"] } : {}),
13 ...(process.env["MAINTENANCE_SINCE"] ? { since: process.env["MAINTENANCE_SINCE"] } : {}),
14 });
15
16 Bun.serve({
17 port,
18 fetch(req) {
19 const path = new URL(req.url).pathname;
20 if (path === "/healthz") return new Response("standby", { status: 200 });
21 return new Response(req.method === "HEAD" ? null : body, { status: 503, headers: MAINTENANCE_HEADERS });
22 },
23 });
24 console.log(`standby maintenance page on :${port}`);
src/contract/openapi/routes/getApiV1AdminMaintenance.ts added +9
@@ -0,0 +1,9 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/admin/maintenance",
6 summary: "Where planned maintenance stands: { phase: idle | draining | ready, since?, reason?, backup?, working, last?, says }; ready means releases are held, the crew is paused, a fresh backup is taken and nothing runs",
7 auth: true,
8 refusals: { not_found: "only a forge administrator sees this route" },
9 };
src/contract/openapi/routes/index.ts +3
@@ -40,6 +40,7 @@ export { route as getApiV1AdminDrills } from "./getApiV1AdminDrills.ts";
4040 export { route as getApiV1AdminInvites } from "./getApiV1AdminInvites.ts";
4141 export { route as getApiV1AdminKpisProbe } from "./getApiV1AdminKpisProbe.ts";
4242 export { route as getApiV1AdminLimits } from "./getApiV1AdminLimits.ts";
43 export { route as getApiV1AdminMaintenance } from "./getApiV1AdminMaintenance.ts";
4344 export { route as getApiV1AdminPeople } from "./getApiV1AdminPeople.ts";
4445 export { route as getApiV1AdminPinned } from "./getApiV1AdminPinned.ts";
4546 export { route as getApiV1AdminProfile } from "./getApiV1AdminProfile.ts";
@@ -218,6 +219,8 @@ export { route as postApiV1AdminAccountsUsernameSuspend } from "./postApiV1Admin
218219 export { route as postApiV1AdminAccountsUsernameUnsuspend } from "./postApiV1AdminAccountsUsernameUnsuspend.ts";
219220 export { route as postApiV1AdminAccountsUsernameVerify } from "./postApiV1AdminAccountsUsernameVerify.ts";
220221 export { route as postApiV1AdminInvites } from "./postApiV1AdminInvites.ts";
222 export { route as postApiV1AdminMaintenancePrepare } from "./postApiV1AdminMaintenancePrepare.ts";
223 export { route as postApiV1AdminMaintenanceResume } from "./postApiV1AdminMaintenanceResume.ts";
221224 export { route as postApiV1AdminPinned } from "./postApiV1AdminPinned.ts";
222225 export { route as postApiV1AdminPinnedNameCheck } from "./postApiV1AdminPinnedNameCheck.ts";
223226 export { route as postApiV1AdminPinnedNameRollback } from "./postApiV1AdminPinnedNameRollback.ts";
src/contract/openapi/routes/postApiV1AdminMaintenancePrepare.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/admin/maintenance/prepare",
6 summary: "Prepare the forge for a power-off: hold releases, pause the crew, take a fresh backup, report the drain; needs an admin-scope token. Answers the status as GET does",
7 auth: true,
8 body: { reason: "string" },
9 refusals: { not_found: "only a forge administrator may", invalid: "the reason is at most 200 characters (422)" },
10 };
src/contract/openapi/routes/postApiV1AdminMaintenanceResume.ts added +9
@@ -0,0 +1,9 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/admin/maintenance/resume",
6 summary: "End maintenance now: lift the release hold and let the crew start; needs an admin-scope token. The forge does this by itself at boot",
7 auth: true,
8 refusals: { not_found: "only a forge administrator may", conflict: "the forge is not in maintenance (409)" },
9 };
src/core/event_audience.ts +2
@@ -192,6 +192,8 @@ export function classifiedAudience(kind: string): EventAudience | undefined {
192192 kind.startsWith("drill.") ||
193193 // A snapshot moving is the forge owner's too, and has no repository.
194194 kind.startsWith("backup.") ||
195 // Preparing for a power-off and ending it are the forge owner's (`maintenance/service.ts`).
196 kind.startsWith("maintenance.") ||
195197 // The weekly model review's changes and results are the owner's (`actions/model_review.ts`).
196198 kind.startsWith("crew.model_") ||
197199 // A child forge answering no one at its public address is the owner's to hear (`spawn/reach.ts`).
src/core/reserved.ts +1
@@ -96,6 +96,7 @@ export const SYSTEM_NAMES: readonly string[] = [
9696 "organizations",
9797 "now",
9898 "resilience",
99 "maintenance",
99100 "origins",
100101 "robots.txt",
101102 "favicon.ico",
src/crew/dispatch.ts +8
@@ -48,6 +48,7 @@ import { noteHeld, type HoldCause } from "../runs/held.ts";
4848 import { decideAndRecord } from "./ledger.ts";
4949 import { bugsFirst } from "../flow/bugs.ts";
5050 import { byDials } from "./team_share.ts";
51 import { inMaintenance } from "../maintenance/state.ts";
5152 import { gateQueue } from "../flow/valve_gate.ts";
5253 import { ARCHIVED_WHY, getRepoById, type Repo } from "../core/repos.ts";
5354 import { listReviews } from "../core/reviews.ts";
@@ -1527,6 +1528,13 @@ function pump(
15271528 const gated = startOrder(app);
15281529 // What this pass holds by policy, for vitals (`runs/held.ts`, #9403).
15291530 const held = new Map<string, HoldCause>();
1531 // Planned maintenance (`maintenance/service.ts`): nothing new starts, and
1532 // a queued run waits as it does behind a shut valve.
1533 if (inMaintenance(app.config.dataDir)) {
1534 for (const q of [...gated.order, ...gated.held]) held.set(q.id, "maintenance");
1535 noteHeld(app.db, held);
1536 return;
1537 }
15301538 for (const shut of gated.held) {
15311539 held.set(shut.id, "valve");
15321540 if (noted.has(shut.id)) continue;
src/http/api.ts +2
@@ -349,6 +349,7 @@ import { labelsRoutes } from "./api/labels.ts";
349349 import { visionRoutes } from "./api/vision.ts";
350350 import { tokenRoutes } from "./api/tokens.ts";
351351 import { accountAdmin, adminRoutes } from "./api/admin.ts";
352 import { maintenanceRoutes } from "./api/maintenance.ts";
352353 import { flagsRoutes } from "./api/flags.ts";
353354 import { issueRoutes, labelsHandler } from "./api/issues.ts";
354355 import { FORGE_NOT_CARRIED } from "../origins/words.ts";
@@ -1226,6 +1227,7 @@ export function apiRoutes(router: Router<Handler>): void {
12261227 userRoutes(router);
12271228 forgeOriginRoutes(router);
12281229 adminRoutes(router);
1230 maintenanceRoutes(router);
12291231 flagsRoutes(router);
12301232
12311233 // Open read hooks (#1789): the owner, or an agent holding `telemetry:read`.
src/http/api/maintenance.ts added +42
@@ -0,0 +1,42 @@
1 /** `/api/v1/admin/maintenance`: what the Maintenance page and MCP `maintenance` do, for a forge admin's admin-scope credential. */
2
3 import { prepare, resumeNow, status } from "../../maintenance/service.ts";
4 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
5 import { crossOrigin, type Handler } from "../context.ts";
6 import { apiError, boundedBody, forbidden, json, notFound, requireScope, unauthorized } from "../respond.ts";
7 import type { Router } from "../router.ts";
8
9 /** A reason is at most 200 characters; room for them escaped. */
10 const BODY_MAX = 4096;
11
12 export function maintenanceRoutes(router: Router<Handler>): void {
13 router.add("GET", "/api/v1/admin/maintenance", (ctx) => {
14 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
15 if (!ctx.auth.user) return unauthorized();
16 if (!ctx.auth.user.isAdmin) return notFound();
17 const denied = requireScope(ctx, "ro");
18 if (denied) return denied;
19 return json(status(ctx.app));
20 });
21 router.add("POST", "/api/v1/admin/maintenance/prepare", async (ctx) => {
22 if (!ctx.auth.user) return unauthorized();
23 if (!ctx.auth.user.isAdmin) return notFound();
24 const denied = requireScope(ctx, "admin");
outdated

High: the body is parsed with request.json() and no cap on bytes actually read. Use the repo's capped reader and refuse past the limit.

25 if (denied) return denied;
26 if (ctx.auth.via === "session" && crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden();
27 const input = await boundedBody(ctx.request, BODY_MAX);
28 if (input === "too_large") return apiError("too_large", 413, "The request is too large.");
29 const reason = typeof input["reason"] === "string" ? input["reason"] : "";
30 const r = await prepare(ctx.app, ctx.auth.user, reason);
31 return r.ok ? json(r.value) : apiError(r.error.code === "invalid" ? "invalid" : "not_found", r.error.code === "invalid" ? 422 : 404, r.error.says);
32 });
33 router.add("POST", "/api/v1/admin/maintenance/resume", (ctx) => {
34 if (!ctx.auth.user) return unauthorized();
35 if (!ctx.auth.user.isAdmin) return notFound();
36 const denied = requireScope(ctx, "admin");
37 if (denied) return denied;
38 if (ctx.auth.via === "session" && crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden();
39 const r = resumeNow(ctx.app, ctx.auth.user);
40 return r.ok ? json(r.value) : apiError(r.error.code, r.error.code === "conflict" ? 409 : 404, r.error.says);
41 });
42 }
src/http/mcp/tools/index.ts +1
@@ -86,6 +86,7 @@ export { tool as land_past_gate } from "./land_past_gate.ts";
8686 export { tool as lift } from "./lift.ts";
8787 export { tool as limb } from "./limb.ts";
8888 export { tool as limits } from "./limits.ts";
89 export { tool as maintenance } from "./maintenance.ts";
8990 export { tool as make_it_yours } from "./make_it_yours.ts";
9091 export { tool as map } from "./map.ts";
9192 export { tool as merge } from "./merge.ts";
src/http/mcp/tools/maintenance.ts added +28
@@ -0,0 +1,28 @@
1 import { prepare, resumeNow, status } from "../../../maintenance/service.ts";
2 import { scopeAllows } from "../../../core/tokens.ts";
3 import { good, bad, type Tool } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "maintenance",
7 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 }`",
8 description: "Prepare the forge for a power-off, check the drain, or end maintenance (admin).",
9 inputSchema: {
10 type: "object",
11 properties: { action: { type: "string", enum: ["status", "prepare", "resume"] }, reason: { type: "string", maxLength: 200 } },
12 },
13 writes: true,
14 async run(ctx, user, args) {
15 if (!user.isAdmin) return bad("not_found", "no such tool here");
16 const action = args["action"] ?? "status";
17 if (action === "status") return good(status(ctx.app));
18 if (action !== "prepare" && action !== "resume") return bad("invalid", "action is status, prepare or resume");
19 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) return bad("forbidden", "maintenance needs an admin-scope token");
20 if (action === "resume") {
21 const r = resumeNow(ctx.app, user);
22 return r.ok ? good(r.value) : bad(r.error.code, r.error.says);
23 }
24 const reason = typeof args["reason"] === "string" ? args["reason"] : "";
25 const r = await prepare(ctx.app, user, reason);
26 return r.ok ? good(r.value) : bad(r.error.code, r.error.says);
27 },
28 };
src/http/server.ts +4
@@ -26,6 +26,7 @@ import { prerenderComments } from "../web/prerender.ts";
2626 import { systemPage, systemStream } from "../web/pages/system.ts";
2727 import { contributePage } from "../web/pages/contribute.ts";
2828 import { pipelinePage } from "../web/pages/pipeline.ts";
29 import { maintenancePage, maintenancePrepare, maintenanceResume } from "../web/pages/maintenance.ts";
2930 import { nightlyOutputPage, resiliencePage } from "../web/pages/resilience.ts";
3031 import { limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts";
3132 import { measureSubmit } from "./measure.ts";
@@ -898,6 +899,9 @@ export function buildRouter(): Router<Handler> {
898899 pinnedRoutes(router);
899900 // Each THREATS.md protection against the last gate; literal, above /:owner.
900901 router.add("GET", "/resilience", resiliencePage);
902 router.add("GET", "/maintenance", maintenancePage);
903 router.add("POST", "/maintenance/prepare", maintenancePrepare);
904 router.add("POST", "/maintenance/resume", maintenanceResume);
901905 router.add("GET", "/resilience/nightly/:job", nightlyOutputPage);
902906 // A selection's spend and runs over time; literal, above /:owner.
903907 router.add("GET", "/fleet/graph", fleetGraphPage);
src/main.ts +3
@@ -72,6 +72,7 @@ import { peopleOnCrews } from "./core/agents.ts";
7272 import { startNotifier } from "./crew/notify.ts";
7373 import { expireCrewHolds } from "./actions/holds.ts";
7474 import { startClogTick } from "./flow/clog_tick.ts";
75 import { resumeAtBoot } from "./maintenance/service.ts";
7576 import { startKeeper } from "./flow/keeper.ts";
7677 import { sweepStaleAsks } from "./crew/stale_asks.ts";
7778 import { recordAskLedger } from "./web/studio/asks.ts";
@@ -673,6 +674,8 @@ const processTick = (): void => {
673674 const stopClogTick = startClogTick(app.db, app.bus, (error) => app.log.warn("clog tick failed", { error: String(error) }), 60_000);
674675 // The throughput keeper (#7645) names what binds landings each minute and lifts what is its to lift.
675676 const stopKeeper = startKeeper(app, () => sweepAssigned(app));
677 // A forge that went down prepared for maintenance comes back by itself.
678 resumeAtBoot(app);
676679 // An agent's hold that has stood 24 h is released each minute (#6918).
677680 const holdTimer = setInterval(() => {
678681 try {
src/maintenance/page.ts added +31
@@ -0,0 +1,31 @@
1 /**
2 * The page visitors see while the forge is off: calm, static, escaped, and
3 * answerable by anything (the droplet's `scripts/standby.ts`, or a Cloudflare
4 * Worker serving the same text). No script, no external asset.
5 */
6
7 const esc = (s: string): string =>
8 s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
9
10 export interface PageInput {
11 /** What the owner said, one line; may be empty. */
12 readonly reason?: string;
13 /** ISO time the forge went into maintenance, when known. */
14 readonly since?: string;
15 }
16
17 export function maintenanceHtml(input: PageInput = {}): string {
18 const reason = (input.reason ?? "").replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, 200);
19 const since = input.since !== undefined && /^[0-9T:.\-Z+]{10,30}$/.test(input.since) ? input.since : "";
20 return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="refresh" content="60"><title>Back soon</title><style>body{font:16px/1.5 system-ui,sans-serif;margin:0;display:grid;min-height:100vh;place-items:center;background:#fafafa;color:#222}main{max-width:32rem;padding:1.5rem}h1{font-size:1.5rem;margin:0 0 .5rem}p{margin:.5rem 0}.m{color:#666}@media(prefers-color-scheme:dark){body{background:#151515;color:#eee}.m{color:#aaa}}</style></head><body><main><h1>Back soon</h1><p>The forge is down for planned maintenance and will be back soon.</p>${reason === "" ? "" : `<p>${esc(reason)}</p>`}${since === "" ? "" : `<p class="m">Down since ${esc(since)}.</p>`}<p class="m">This page refreshes itself every minute.</p></main></body></html>`;
outdated

Principle 8 and truth: the text says the forge is 'being moved to new hardware', which is false for most maintenance. Say plainly: 'The forge is down for planned maintenance and will be back soon.'

21 }
22
23 /** The headers the maintenance page is always served with: 503 so nothing caches it as the real site. */
24 export const MAINTENANCE_HEADERS: Readonly<Record<string, string>> = {
25 "content-type": "text/html; charset=utf-8",
26 "retry-after": "300",
27 "cache-control": "no-store",
28 "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'",
29 "x-content-type-options": "nosniff",
30 "referrer-policy": "no-referrer",
31 };
src/maintenance/service.ts added +156
@@ -0,0 +1,156 @@
1 /**
2 * Planned maintenance: an admin asks the forge to get ready for a power-off
3 * (`prepare`), and on the next boot it comes back by itself (`resume`).
4 *
5 * prepare: hold releases (an owner hold, `keeper/hold.ts`), pause the crew
6 * (the dispatcher starts nothing while the state file stands), take a fresh
7 * snapshot, then report what still runs. The host may power off when
8 * `status().ready` is true; a SIGTERM drains the rest as it does for a roll.
9 * resume: lift the hold, let the crew start again, verify, and keep a line
10 * saying what is healthy. No Kubernetes is asked anything.
11 */
12
13 import { readFileSync } from "node:fs";
14 import type { App } from "../app.ts";
15 import { listApps } from "../core/apps.ts";
16 import { nightlyLines } from "../nightly/says.ts";
17 import { listActive } from "../core/runs.ts";
18 import type { User } from "../core/users.ts";
19 import { lift, MAX_REASON, setHold } from "../keeper/hold.ts";
20 import { err, ok, type Result } from "../kernel/result.ts";
21 import { running } from "../kernel/version.ts";
22 import { snapshotNow } from "../actions/backups_now.ts";
23 import { readState, writeState, type Maintenance, type Returned } from "./state.ts";
24
25 const isAdmin = (u: User | undefined): u is User => u?.isAdmin === true && u.kind !== "agent";
26
27 export interface Status {
28 readonly phase: "idle" | "draining" | "ready";
29 readonly since?: string;
30 readonly reason?: string;
31 readonly backup?: Maintenance["backup"];
32 /** Crew runs and checks still queued or running. */
33 readonly working: number;
34 readonly last?: Returned;
35 /** Plainly: what stops, or what is about to. */
36 readonly says: string;
37 }
38
39 /** What stops when maintenance is prepared, one row each; the page and the terminal screen say it the same. */
40 export const ABOUT_TO_STOP_ROWS: readonly { readonly what: string; readonly stops: string }[] = [
41 { what: "Releases", stops: "held now; none are cut." },
42 { what: "The crew", stops: "no run starts; running ones finish." },
43 { what: "The forge", stops: "stops with the host, with its apps and previews." },
44 ];
45
46 /** The host's boot id, or undefined where the kernel does not say: it changes only when the host restarts. */
47 export function hostBootId(): string | undefined {
48 try {
49 const id = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
50 return id === "" ? undefined : id;
51 } catch {
52 return undefined;
53 }
54 }
55
56 export function status(app: App): Status {
57 const { current, last } = readState(app.config.dataDir);
58 if (current === undefined)
59 return { phase: "idle", working: 0, ...(last ? { last } : {}), says: last ? last.says : "Not in maintenance." };
60 const working = listActive(app.db, "agent").length + listActive(app.db, "check").filter((r) => r.state === "running").length;
61 const ready = current.backup.ok && working === 0;
62 return {
63 phase: ready ? "ready" : "draining",
64 since: current.since,
65 reason: current.reason,
66 backup: current.backup,
67 working,
68 says: ready
69 ? "Ready: releases are held, the crew is paused, a fresh backup is taken and nothing is running. The host can power off."
70 : !current.backup.ok
71 ? `Not ready: ${current.backup.says}`
72 : `Draining: ${working} crew run${working === 1 ? "" : "s"} or check${working === 1 ? "" : "s"} still going; the host can power off when it reads 0.`,
73 };
74 }
75
76 export type Refusal = { readonly code: "not_found" | "invalid" | "conflict"; readonly says: string };
77
78 export async function prepare(app: App, actor: User | undefined, reasonIn: string): Promise<Result<Status, Refusal>> {
79 if (!isAdmin(actor)) return err({ code: "not_found", says: "Only a forge admin prepares maintenance." });
80 const reason = reasonIn.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
81 if (reason.length > 200) return err({ code: "invalid", says: "The reason is at most 200 characters." });
82 const dataDir = app.config.dataDir;
83 const prior = readState(dataDir);
84 if (prior.current !== undefined) {
85 // Asking again retakes the backup and reads the drain; the hold stands once.
86 const taken = await snapshotNow(app, actor);
87 writeState(dataDir, { ...prior, current: { ...prior.current, backup: { ok: taken.ok, says: taken.ok ? taken.value.says : taken.error.says } } });
88 return ok(status(app));
89 }
90 const hold = setHold(app.db, {
91 kind: "owner",
92 version: running().version,
93 reason: `maintenance${reason === "" ? "" : `: ${reason}`}`.slice(0, MAX_REASON),
94 at: Date.now(),
95 });
96 const now = new Date().toISOString();
97 // Written before the backup: the crew pauses at once, and a crash mid-backup still resumes at boot.
98 const boot = hostBootId();
99 const base: Maintenance = { since: now, by: actor.username, reason, holdId: hold.id, backup: { ok: false, says: "the backup has not finished" }, ...(boot === undefined ? {} : { bootId: boot }) };
100 writeState(dataDir, { ...(prior.last ? { last: prior.last } : {}), current: base });
101 app.bus.append({ kind: "maintenance.prepared", actorId: actor.id, payload: { hold: hold.id, reason } });
102 const taken = await snapshotNow(app, actor);
103 writeState(dataDir, {
104 ...(prior.last ? { last: prior.last } : {}),
105 current: { ...base, backup: { ok: taken.ok, says: taken.ok ? taken.value.says : taken.error.says } },
106 });
107 return ok(status(app));
108 }
109
110 /** Come back: lift our hold, start the crew, and record what was checked. `by` names who or what did it. */
111 export function resume(app: App, by: string): Result<Returned, Refusal> {
112 const dataDir = app.config.dataDir;
113 const { current } = readState(dataDir);
114 if (current === undefined) return err({ code: "conflict", says: "The forge is not in maintenance." });
115 const now = Date.now();
116 const lifted = lift(app.db, current.holdId, by, now);
117 const verified = [
118 `database open, version ${running().version}`,
119 lifted ? "release hold lifted" : "release hold was already lifted",
120 "crew dispatcher unpaused",
121 `${listApps(app.db).length} app(s) on record, started by the forge's own boot`,
122 `nightly jobs: ${nightlyLines(app.config.dataDir).join(" / ")}`,
123 `${listActive(app.db, "agent").length} crew run(s) and ${listActive(app.db, "check").length} check(s) waiting to start`,
124 ];
125 const back: Returned = {
126 at: new Date(now).toISOString(),
127 downSince: current.since,
128 says: `Back at ${new Date(now).toISOString()} after maintenance begun ${current.since}: ${verified.join("; ")}.`,
129 verified,
130 };
131 writeState(dataDir, { last: back });
132 app.bus.append({ kind: "maintenance.ended", payload: { by, hold: current.holdId, verified } });
133 return ok(back);
134 }
135
136 /**
137 * At boot: a forge that went down prepared comes back by itself, once the host
138 * has restarted. A restart of the process alone (a release roll) on the same
139 * host boot keeps what the admin prepared. Where the boot id is unknown, resume:
140 * a crew left paused is worse than a prepare that must be asked again.
141 */
142 export function resumeAtBoot(app: App, bootId: string | undefined = hostBootId()): void {
143 const { current } = readState(app.config.dataDir);
144 if (current === undefined) return;
145 if (bootId !== undefined && current.bootId === bootId) {
146 app.log.info("maintenance stands: the process restarted, the host did not");
147 return;
148 }
149 const r = resume(app, "boot");
150 if (r.ok) app.log.info("maintenance ended at boot", { says: r.value.says });
151 }
152
153 export function resumeNow(app: App, actor: User | undefined): Result<Returned, Refusal> {
154 if (!isAdmin(actor)) return err({ code: "not_found", says: "Only a forge admin ends maintenance." });
155 return resume(app, actor.username);
156 }
src/maintenance/state.ts added +70
@@ -0,0 +1,70 @@
1 /**
2 * The one file of maintenance state, `maintenance.json` under the data
3 * directory: it survives the power-off because the data does. Plain data in,
4 * plain data out; a missing or unreadable file reads as "not in maintenance",
5 * so a damaged file can never keep the crew paused.
6 */
7
8 import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9 import { join } from "node:path";
10
11 export interface Backup {
12 readonly ok: boolean;
13 readonly says: string;
14 }
15
16 export interface Maintenance {
17 readonly since: string;
18 readonly by: string;
19 /** One line the maintenance page shows, 0 to 200 characters. */
20 readonly reason: string;
21 /** The release hold this request placed (`keeper/hold.ts`); lifted when the forge returns. */
22 readonly holdId: number;
23 readonly backup: Backup;
24 /** The host's boot id when prepared: a different one at boot means the host was powered off. */
25 readonly bootId?: string;
26 }
27
28 export interface Returned {
29 readonly at: string;
30 readonly downSince: string;
31 readonly says: string;
32 readonly verified: readonly string[];
33 }
34
35 export interface Stored {
36 readonly current?: Maintenance;
37 /** The last return, kept so an admin is told when it is back and what is healthy. */
38 readonly last?: Returned;
39 }
40
41 const file = (dataDir: string): string => join(dataDir, "maintenance.json");
42
43 export function readState(dataDir: string): Stored {
44 try {
45 if (!existsSync(file(dataDir))) return {};
46 const raw: unknown = JSON.parse(readFileSync(file(dataDir), "utf8"));
47 if (typeof raw !== "object" || raw === null) return {};
48 const o = raw as Stored;
49 const cur = o.current;
50 const good =
51 cur !== undefined &&
52 typeof cur.since === "string" &&
53 typeof cur.holdId === "number" &&
54 typeof cur.backup?.ok === "boolean";
55 return { ...(good ? { current: cur } : {}), ...(o.last !== undefined && typeof o.last.at === "string" ? { last: o.last } : {}) };
56 } catch {
57 return {};
58 }
59 }
60
61 export function writeState(dataDir: string, state: Stored): void {
62 const tmp = `${file(dataDir)}.tmp`;
63 writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
64 renameSync(tmp, file(dataDir));
65 }
outdated

clearState has no caller. Remove it (dead code).

66
67 /** Is the forge in maintenance? The dispatcher asks on every pass. */
68 export function inMaintenance(dataDir: string): boolean {
69 return readState(dataDir).current !== undefined;
70 }
src/runs/held.ts +2 −1
@@ -17,13 +17,14 @@ import type { Database } from "bun:sqlite";
1717 export const HELD_FRESH_MS = 2 * 60_000;
1818
1919 /** Why the dispatcher held a run. */
20 export type HoldCause = "valve" | "pace" | "limit";
20 export type HoldCause = "valve" | "pace" | "limit" | "maintenance";
2121
2222 /** How vitals names each cause: "2 by a shut valve". */
2323 export const HOLD_WORDS: Readonly<Record<HoldCause, string>> = {
2424 valve: "a shut valve",
2525 pace: "the week's pace",
2626 limit: "an account limit",
27 maintenance: "planned maintenance",
2728 };
2829
2930 interface Pass {
src/tui/screens/entries.ts +2
@@ -33,6 +33,7 @@ import { entry as homePins } from "./home_pins.ts";
3333 import { entry as invites } from "./invites.ts";
3434 import { entry as limits } from "./limits.ts";
3535 import { entry as loopStalls } from "./loopstalls.ts";
36 import { entry as maintenance } from "./maintenance.ts";
3637 import { modelCeilingEntry, modelGrantEntry } from "./model.ts";
3738 import { entry as orgGrants } from "./orggrants.ts";
3839 import { entry as orgTeams } from "./orgteams.ts";
@@ -82,6 +83,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
8283 loopStalls,
8384 telemetry,
8485 resilience,
86 maintenance,
8587 valves,
8688 access,
8789 edgeOnly,
src/tui/screens/maintenance.ts added +140
@@ -0,0 +1,140 @@
1 /**
2 * Maintenance, a forge admin's, in the terminal (#9453): the /maintenance
3 * page's facts and actions through the same service (`maintenance/service.ts`).
4 * `p` asks for a reason shown to visitors, then confirms, then prepares;
5 * `c` checks again and retakes the backup; `e` ends maintenance after a
6 * confirm. Anyone else is offered nothing and, were the screen reached, is
7 * refused by the service. Enter, Esc or q closes it.
8 */
9
10 import { ABOUT_TO_STOP_ROWS, prepare, resumeNow, status } from "../../maintenance/service.ts";
11 import type { Ctx, Screen } from "../app/app.ts";
12 import { binding } from "../app/keys.ts";
13 import type { Session } from "../session.ts";
14 import { wrap } from "../term/width.ts";
15 import { dialog } from "../ui/widgets.ts";
16 import { confirmScreen, later, promptScreen, runAsync } from "./common.ts";
17 import type { PaletteEntry } from "./entries.ts";
18
19 const WIDTH = 72;
20 const INNER = WIDTH - 6;
21
22 export function maintenanceLines(f: Session): string[] {
23 const s = status(f.app);
24 const facts =
25 s.phase === "idle"
26 ? ABOUT_TO_STOP_ROWS.flatMap((r) => wrap(`${r.what}: ${r.stops}`, INNER))
27 : [
28 ...wrap(`Since ${s.since ?? ""}${s.reason ? `: ${s.reason}` : ""}`, INNER),
29 ...wrap(`Backup: ${s.backup?.says ?? ""}`, INNER),
30 `Still working: ${s.working}`,
31 ];
32 return [...wrap(s.says, INNER), "", ...facts];
33 }
34
35 export function maintenanceScreen(f: Session, ctx: Ctx): Screen {
36 const mayAct = (): boolean => f.user?.isAdmin === true && f.user.kind !== "agent";
37 let busy = false;
38 let note = "";
39 const say = (text: string): void => {
40 note = text;
41 ctx.invalidate();
42 };
43
44 const run = (what: () => Promise<string> | string): void => {
45 busy = true;
46 say("Working…");
47 runAsync(ctx, async () => {
48 try {
49 say(await what());
50 } finally {
51 busy = false;
52 ctx.invalidate();
53 }
54 });
55 };
56
57 const doPrepare = (reason: string): void =>
58 run(async () => {
59 const r = await prepare(f.app, f.user, reason);
60 return r.ok ? r.value.says : r.error.says;
61 });
62
63 const askPrepare = (): Screen =>
64 promptScreen(ctx, screen, {
65 title: "Prepare for maintenance",
66 label: "Shown to visitors (optional, at most 200 characters)",
67 hint: "Enter goes on to the confirm.",
68 onSubmit: (reason) => {
69 if (reason.length > 200) return "At most 200 characters.";
70 return later(ctx, () =>
71 confirmScreen(ctx, screen, {
72 title: "Prepare for maintenance?",
73 body: ["The crew and releases stop now; the forge,", "its apps and previews stop at power-off."],
74 actions: [{ label: "Cancel", variant: "ghost" }, { label: "Prepare", variant: "danger" }],
75 onChoose: (i) => {
76 if (i === 1) doPrepare(reason);
77 },
78 }),
79 );
80 },
81 });
82
83 const askEnd = (): Screen =>
84 confirmScreen(ctx, screen, {
85 title: "End maintenance now?",
86 body: ["Releases are let go and the crew starts again."],
87 actions: [{ label: "Cancel", variant: "ghost" }, { label: "End maintenance" }],
88 onChoose: (i) => {
89 if (i !== 1) return;
90 const r = resumeNow(f.app, f.user);
91 say(r.ok ? r.value.says : r.error.says);
92 },
93 });
94
95 const screen: Screen = {
96 title: "Maintenance",
97 get keys() {
98 const idle = status(f.app).phase === "idle";
99 return [
100 ...(!mayAct() || busy
101 ? []
102 : idle
103 ? [binding(["p"], "prepare for maintenance")]
104 : [binding(["c"], "check again, retake the backup"), binding(["e"], "end maintenance")]),
105 binding(["enter", "esc", "q"], "close"),
106 ];
107 },
108 render(area, s) {
109 const buttons = dialog(s, area, ctx.theme.c, {
110 title: "Maintenance",
111 body: [...(note === "" ? [] : [...wrap(note, INNER), ""]), ...maintenanceLines(f)],
112 actions: [{ label: "Close" }],
113 selected: 0,
114 width: WIDTH,
115 });
116 buttons.forEach((rect) => ctx.hit(rect, () => ctx.pop()));
117 },
118 key(e) {
119 if (e.ctrl || e.alt) return true;
120 const idle = status(f.app).phase === "idle";
121 if (e.key === "enter" || e.key === "esc" || e.key === "q") ctx.pop();
122 else if (!mayAct() || busy) return true;
123 else if (e.key === "p" && idle) ctx.push(askPrepare());
124 else if (e.key === "c" && !idle) doPrepare("");
125 else if (e.key === "e" && !idle) ctx.push(askEnd());
126 return true;
127 },
128 };
129 return screen;
130 }
131
132 /** Prepare the forge for a power-off, check the drain, or end it: a site admin's, as /maintenance is. */
133 export const entry: PaletteEntry = {
134 id: "maintenance",
135 title: "Maintenance",
136 hint: "prepare the forge for a power-off, check the drain, or end maintenance",
137 after: ["resilience", "capacity", "flow", "sentinel"],
138 offered: (session) => session.user?.isAdmin === true && session.user.kind !== "agent",
139 run: (session, ctx) => ctx.push(maintenanceScreen(session, ctx)),
140 };
src/web/pages/maintenance.ts added +74
@@ -0,0 +1,74 @@
1 /**
2 * /maintenance: an admin's page to prepare the forge for a power-off and to
3 * end it. It says plainly what stops, what the drain still waits for, and,
4 * once the forge is back, when and what was verified.
5 */
6
7 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
8 import { ABOUT_TO_STOP_ROWS, prepare, resumeNow, status } from "../../maintenance/service.ts";
9 import type { Ctx } from "../../http/context.ts";
10 import { notFoundPage, signInFirst } from "../chrome.ts";
11 import { field as formField, readForm } from "../form.ts";
12 import { html, type Html } from "../html.ts";
13 import { flashTo, page } from "../layout.ts";
14 import { btn, field } from "../ui.ts";
15
16 function admin(ctx: Ctx): Response | undefined {
17 if (!ctx.auth.user) return signInFirst(ctx);
18 if (!ctx.auth.user.isAdmin) return notFoundPage(ctx);
19 return undefined;
20 }
21
22 /** One fact of the page: what it is and how it stands, as a `dl` row. */
23 const fact = (label: string, value: Html | string): Html => html`<div class="status-row"><dt>${label}</dt><dd>${value}</dd></div>`;
24
25 export function maintenancePage(ctx: Ctx): Response {
26 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
27 const denied = admin(ctx);
28 if (denied) return denied;
29 const rendering = performance.now();
30 const s = status(ctx.app);
31 const body = html`<div class="page-head"><h1>Maintenance</h1></div>
outdated

Principles 6 and 7: a card boxes the form. Make it rows (a dl for the facts: since, backup, working) with a hairline where it ends. Use the named form components, not a bare label and input.

32 <p id="maintenance-says">${s.says}</p>
outdated

Principle 1: ABOUT_TO_STOP repeats what the status line above (says) and the docs already say. One sentence on what the page is for; the stops can be a short list of rows. Also the heading has no sentence saying what the page is for.

33 ${s.phase === "idle"
34 ? html`<p class="muted">Get the forge ready for a planned power-off. What stops:</p>
35 <dl class="status-rows">${ABOUT_TO_STOP_ROWS.map((r) => fact(r.what, r.stops))}</dl>
36 <form method="post" action="/maintenance/prepare" class="stack">
37 ${field({ name: "reason", label: "Shown to visitors (optional)", maxlength: 200, autocomplete: "off" })}
38 ${field({ name: "confirm", type: "checkbox", value: "yes", required: true, label: "I understand the crew and releases stop now" })}
39 <div class="form-actions">${btn("Prepare for maintenance", { variant: "danger" })}</div>
40 </form>
41 ${s.last ? html`<p id="maintenance-last" class="muted">${s.last.says}</p>` : null}`
42 : html`<dl class="status-rows">
43 ${fact("Since", s.since ?? "")} ${s.reason ? fact("Reason", s.reason) : null}
44 ${fact("Backup", s.backup?.says ?? "")} ${fact("Still working", String(s.working))}
45 </dl>
46 <div class="form-actions">
47 <form method="post" action="/maintenance/prepare">${btn("Check again and retake the backup", { variant: "outline" })}</form>
48 <form method="post" action="/maintenance/resume">${btn("End maintenance now")}</form>
49 </div>`}`;
50 const response = page({ ctx, title: "Maintenance", body });
51 notePhase(ctx.request, "render", performance.now() - rendering);
52 return response;
53 }
54
55 export async function maintenancePrepare(ctx: Ctx): Promise<Response> {
56 const denied = admin(ctx);
57 if (denied) return denied;
58 const form = await readForm(ctx, "admin");
59 if (form instanceof Response) return form;
60 // Idle -> prepared stops the crew: asked first. A re-check while prepared has already been agreed.
61 if (status(ctx.app).phase === "idle" && formField(form, "confirm") !== "yes")
62 return flashTo(ctx, "/maintenance", { tone: "danger", message: "Tick the box to confirm: the crew and releases stop." });
63 const r = await prepare(ctx.app, ctx.auth.user, formField(form, "reason"));
64 return flashTo(ctx, "/maintenance", r.ok ? { message: r.value.says } : { tone: "danger", message: r.error.says });
65 }
66
67 export async function maintenanceResume(ctx: Ctx): Promise<Response> {
68 const denied = admin(ctx);
69 if (denied) return denied;
70 const form = await readForm(ctx, "admin");
71 if (form instanceof Response) return form;
72 const r = resumeNow(ctx.app, ctx.auth.user);
73 return flashTo(ctx, "/maintenance", r.ok ? { message: r.value.says } : { tone: "danger", message: r.error.says });
74 }
src/web/pages/resilience.ts +1
@@ -108,6 +108,7 @@ export function resiliencePage(ctx: Ctx): Response {
108108 </p>
109109 </div>
110110 ${backupsLineHtml(ctx)}
111 <p><a href="/maintenance">Maintenance</a> prepares the forge for a planned power-off.</p>
111112 ${nightlyHtml(ctx)}
112113 <p id="tally">
113114 ${String(count("green"))} green · ${String(count("red"))} red ·
test/conversation-confirm.test.ts +1 −1
@@ -123,7 +123,7 @@ describe("the one list of what asks first", () => {
123123 "access", "account_verify", "app_members", "app_notes", "asks_act", "asks_decide", "asks_proposal", "asks_undecide",
124124 "assign", "attach_look", "bet", "board_pins", "board_write", "branches", "capacity", "valves", "cloud_accounts", "close", "confirm_wait",
125125 "crew", "decisions_by", "detach", "edit", "email_verify", "feed_chips", "feed_dismiss", "flag_set", "flag_winner",
126 "follow_mode", "fork", "front_page", "hold", "issue_teams", "keep_plan", "labels", "lift", "limb", "make_it_yours", "map", "merge", "new_app",
126 "follow_mode", "fork", "front_page", "hold", "issue_teams", "keep_plan", "labels", "lift", "limb", "maintenance", "make_it_yours", "map", "merge", "new_app",
127127 "next_up", "week", "notify", "open_pull", "org_create", "origin_apps", "pins", "plan", "profile", "propose_to_parent_forge", "repo_labels", "reports",
128128 "restore_drill", "review", "say", "signup", "snapshot_now", "spend", "steer", "sync", "take_back", "take_updates",
129129 "teams", "tell", "threads", "trail", "upstream", "variant", "wait_for", "watch", "whoami_can", "write_file",
test/forge-looks.test.ts +1
@@ -37,6 +37,7 @@ const NOT_A_PAGE: Record<string, string> = {
3737 "/opensearch.xml": "the browser search description, XML",
3838 "/sw.js": "the service worker script",
3939 "/audit.csv": "a CSV download of /audit",
40 "/maintenance": "no picture: the cap of 24 looks is spent; an admin's page, covered by test/maintenance.test.ts",
4041 "/claim": "answers 404 to an admin: it exists only for a link-holder",
4142 "/first-admin": "the setup form for a forge with no admin yet",
4243 "/signin": "a sign-in form, not a page of the forge",
test/maintenance-crew.test.ts added +49
@@ -0,0 +1,49 @@
1 /** While the forge is in maintenance the real dispatcher starts nothing and says why. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { chmodSync, writeFileSync } from "node:fs";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { createRun, getRun } from "../src/core/runs.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { startCrew } from "../src/crew/dispatch.ts";
11 import { serve } from "../src/http/server.ts";
12 import { writeState } from "../src/maintenance/state.ts";
13 import { heldRuns } from "../src/runs/held.ts";
14
15 let dataDir: string;
16 let app: App;
17 let repoId = "";
18
19 beforeAll(async () => {
20 dataDir = await mkdtemp(join(tmpdir(), "forge-maint-crew-"));
21 app = createApp({ dataDir, port: 0, disk: () => ({ freeBytes: 100, totalBytes: 100 }) });
22 const owner = await createUser(app.db, { origin: "seed", trusted: true, username: "holder", email: "[email protected]", password: "a-good-passphrase" });
23 if (!owner.ok) throw new Error(String(owner.error));
24 repoId = crypto.randomUUID();
25 app.db.query("INSERT INTO repo (id, owner_id, name, visibility, created_at) VALUES (?1, ?2, 'm', 'private', ?3)").run(repoId, owner.value.id, new Date().toISOString());
26 });
27 afterAll(async () => {
28 app.close();
29 await rm(dataDir, { recursive: true, force: true });
30 });
31
32 test("queued runs stay queued, held by maintenance", async () => {
33 writeState(dataDir, { current: { since: new Date().toISOString(), by: "a", reason: "", holdId: 1, backup: { ok: true, says: "x" } } });
34 const run = createRun(app.db, { repoId, kind: "agent" });
35 const servers = await serve(app);
36 const fake = join(dataDir, "fake-claude");
37 writeFileSync(fake, `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi\nexit 1\n`);
38 chmodSync(fake, 0o755);
39 const crew = await startCrew(app, { forgeUrl: servers.url, localUrl: servers.url, binary: fake, concurrency: 2, budgetUsd: 1, runTimeoutMs: 60_000, tickMs: 20 });
40 try {
41 const deadline = Date.now() + 10_000;
42 while (heldRuns(app.db).get(run.id) !== "maintenance" && Date.now() < deadline) await Bun.sleep(20);
43 expect(heldRuns(app.db).get(run.id)).toBe("maintenance");
44 expect(getRun(app.db, run.id)?.state).toBe("queued");
45 } finally {
46 await crew.stop();
47 servers.stop();
48 }
49 });
test/maintenance.test.ts added +216
@@ -0,0 +1,216 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp, type App } from "../src/app.ts";
6 import { createToken } from "../src/core/tokens.ts";
7 import { createUser, type User } from "../src/core/users.ts";
8 import { serve, type Servers } from "../src/http/server.ts";
9 import { standingHolds } from "../src/keeper/hold.ts";
10 import { maintenanceHtml, MAINTENANCE_HEADERS } from "../src/maintenance/page.ts";
11 import { prepare, resume, resumeAtBoot, status } from "../src/maintenance/service.ts";
12 import { inMaintenance, readState, writeState } from "../src/maintenance/state.ts";
13 import { HOLD_WORDS } from "../src/runs/held.ts";
14 import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts";
15 import { dirStore } from "../src/snapshot/store.ts";
16 import { entry, maintenanceScreen } from "../src/tui/screens/maintenance.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18
19 quietLogs();
20
21 test("state: missing or damaged file is not maintenance", async () => {
22 const dir = await mkdtemp(join(tmpdir(), "maint-"));
23 expect(inMaintenance(dir)).toBe(false);
24 await Bun.write(join(dir, "maintenance.json"), "{nope");
25 expect(inMaintenance(dir)).toBe(false);
26 writeState(dir, { current: { since: "2026-11-08T00:00:00Z", by: "a", reason: "", holdId: 1, backup: { ok: true, says: "x" } } });
27 expect(inMaintenance(dir)).toBe(true);
28 expect(readState(dir).current?.holdId).toBe(1);
29 await rm(dir, { recursive: true, force: true });
30 });
31
32 test("page escapes the reason, refreshes, and says it is planned maintenance", () => {
33 const h = maintenanceHtml({ reason: "<script>alert(1)</script>", since: "2026-11-08T00:00:00Z" });
34 expect(h).not.toContain("<script>");
35 expect(h).toContain("&lt;script&gt;");
36 expect(h).toContain('http-equiv="refresh"');
37 expect(h).toContain("down for planned maintenance and will be back soon");
38 expect(h).not.toContain("new hardware");
39 expect(maintenanceHtml({ since: '"><b>' })).not.toContain("<b>");
40 });
41
42 test("vitals names maintenance as a hold", () => {
43 expect(HOLD_WORDS.maintenance).toBe("planned maintenance");
44 });
45
46 describe("prepare and come back", () => {
47 let dataDir: string;
48 let app: App;
49 let servers: Servers;
50 let svc: SnapshotsHandle | undefined;
51 let admin: User;
52 let bob: User;
53 let adminRo: string;
54 let adminAdmin: string;
55 let bobAdmin: string;
56
57 const login = async (username: string): Promise<string> => {
58 const res = await fetch(`${servers.url}/signin`, {
59 method: "POST",
60 redirect: "manual",
61 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
62 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
63 });
64 return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
65 };
66 const post = (path: string, headers: Record<string, string>, body?: string): Promise<Response> =>
67 fetch(`${servers.url}${path}`, {
68 method: "POST",
69 redirect: "manual",
70 headers: { "content-type": "application/json", origin: servers.url, ...headers },
71 body: body ?? "{}",
72 });
73 const form = (path: string, cookie: string, fields: Record<string, string>): Promise<Response> =>
74 fetch(`${servers.url}${path}`, {
75 method: "POST",
76 redirect: "manual",
77 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url, cookie },
78 body: new URLSearchParams(fields),
79 });
80
81 beforeAll(async () => {
82 dataDir = await mkdtemp(join(tmpdir(), "forge-maint-"));
83 app = createApp({ dataDir, port: 0 });
84 servers = await serve(app);
85 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
86 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
87 if (!a.ok || !b.ok) throw new Error("user");
88 admin = a.value;
89 bob = b.value;
90 adminRo = createToken(app.db, admin.id, "t", "ro").token;
91 adminAdmin = createToken(app.db, admin.id, "t", "admin").token;
92 bobAdmin = createToken(app.db, bob.id, "t", "admin").token;
93 });
94 afterAll(async () => {
95 await svc?.stop();
96 servers.stop();
97 app.close();
98 await rm(dataDir, { recursive: true, force: true });
99 });
100
101 test("a non-admin cannot prepare", async () => {
102 const r = await prepare(app, bob, "");
103 expect(r.ok).toBe(false);
104 expect(inMaintenance(dataDir)).toBe(false);
105 });
106
107 test("API: a non-admin is refused, a non-admin-scope token 403, a cross-origin session 403", async () => {
108 // The /api/v1/admin prefix refuses a non-admin before the handler (403); the handler's own 404 is the second lock.
109 expect([403, 404]).toContain((await post("/api/v1/admin/maintenance/prepare", { cookie: await login("bob") })).status);
110 expect([403, 404]).toContain((await post("/api/v1/admin/maintenance/prepare", { authorization: `Bearer ${bobAdmin}` })).status);
111 expect((await post("/api/v1/admin/maintenance/prepare", { authorization: `Bearer ${adminRo}` })).status).toBe(403);
112 const cookie = await login("alice");
113 expect((await post("/api/v1/admin/maintenance/prepare", { cookie, origin: "https://evil.example" })).status).toBe(403);
114 expect(inMaintenance(dataDir)).toBe(false);
115 });
116
117 test("API: a long reason is 422 and an oversized body is 413; neither prepares", async () => {
118 const auth = { authorization: `Bearer ${adminAdmin}` };
119 expect((await post("/api/v1/admin/maintenance/prepare", auth, JSON.stringify({ reason: "x".repeat(201) }))).status).toBe(422);
120 expect((await post("/api/v1/admin/maintenance/prepare", auth, JSON.stringify({ reason: "x".repeat(10_000) }))).status).toBe(413);
121 expect(inMaintenance(dataDir)).toBe(false);
122 });
123
124 test("API: resume while idle is 409", async () => {
125 expect((await post("/api/v1/admin/maintenance/resume", { authorization: `Bearer ${adminAdmin}` })).status).toBe(409);
126 });
127
128 test("MCP: a non-admin is refused, a read-only token cannot prepare", async () => {
129 const call = (token: string, args: Record<string, unknown>): Promise<Response> =>
130 post("/mcp", { authorization: `Bearer ${token}`, accept: "application/json, text/event-stream" },
131 JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "maintenance", arguments: args } }));
132 const ro = await (await call(adminRo, { action: "prepare" })).text();
133 expect(ro).toContain("forbidden");
134 const nope = await (await call(bobAdmin, { action: "status" })).text();
135 expect(nope).toContain("no such tool");
136 expect(inMaintenance(dataDir)).toBe(false);
137 });
138
139 test("page: guest is sent to sign in, a non-admin gets not-found", async () => {
140 const guest = await fetch(`${servers.url}/maintenance`, { redirect: "manual" });
141 expect([302, 303, 401]).toContain(guest.status);
142 const b = await fetch(`${servers.url}/maintenance`, { headers: { cookie: await login("bob") } });
143 expect(b.status).toBe(404);
144 expect((await form("/maintenance/prepare", await login("bob"), { confirm: "yes" })).status).not.toBe(200);
145 expect(inMaintenance(dataDir)).toBe(false);
146 });
147
148 test("page: the confirm box is required to prepare", async () => {
149 const cookie = await login("alice");
150 const idle = await (await fetch(`${servers.url}/maintenance`, { headers: { cookie } })).text();
151 expect(idle).toContain("status-rows");
152 expect(idle).toContain('name="confirm"');
153 await form("/maintenance/prepare", cookie, { reason: "no tick" });
154 expect(inMaintenance(dataDir)).toBe(false);
155 });
156
157 test("without a backup place it holds and pauses but is not ready; the page shows facts", async () => {
158 const cookie = await login("alice");
159 await form("/maintenance/prepare", cookie, { reason: "moving the rack", confirm: "yes" });
160 expect(inMaintenance(dataDir)).toBe(true);
161 expect(standingHolds(app.db)).toHaveLength(1);
162 expect(status(app).phase).toBe("draining");
163 expect(status(app).says).toContain("nowhere to put a snapshot");
164 const html = await (await fetch(`${servers.url}/maintenance`, { headers: { cookie } })).text();
165 expect(html).toContain("moving the rack");
166 expect(html).toContain("End maintenance now");
167 });
168
169 test("the terminal door: offered to admins only, and its lines say the state", () => {
170 const session = (user: User) => ({ user, app, db: app.db }) as never;
171 expect(entry.offered(session(admin))).toBe(true);
172 expect(entry.offered(session(bob))).toBe(false);
173 const screen = maintenanceScreen(session(admin), {} as never);
174 expect(screen.title).toBe("Maintenance");
175 expect(screen.keys.map((k) => k.help).join(" ")).toContain("end maintenance");
176 });
177
178 test("a restart of the process on the same host boot keeps what was prepared", () => {
179 const boot = readState(dataDir).current?.bootId;
180 resumeAtBoot(app, boot ?? "same");
181 if (boot !== undefined) expect(inMaintenance(dataDir)).toBe(true);
182 resumeAtBoot(app, "another-boot-id");
183 expect(inMaintenance(dataDir)).toBe(false);
184 expect(standingHolds(app.db)).toHaveLength(0);
185 expect(status(app).last?.verified.length).toBeGreaterThan(0);
186 expect(resume(app, "x").ok).toBe(false);
187 });
188
189 test("with a store it is ready once the backup is taken and nothing runs", async () => {
190 svc = startSnapshots(app, dirStore(join(dataDir, "store")), { version: "t", nightly: false });
191 const r = await prepare(app, admin, "");
192 expect(r.ok && r.value.phase).toBe("ready");
193 expect(await svc.list()).toHaveLength(1);
194 resume(app, "alice");
195 });
196 });
197
198 describe("standby", () => {
199 test("answers 503 with Retry-After, and /healthz is 200", async () => {
200 const port = 20000 + Math.floor(Math.random() * 20000);
201 const proc = Bun.spawn(["bun", "scripts/standby.ts", String(port)], { stdout: "pipe", stderr: "pipe", env: { ...process.env, MAINTENANCE_REASON: "<b>x</b>" } });
202 try {
203 let res: Response | undefined;
204 for (let i = 0; i < 50 && res === undefined; i++) {
205 res = await fetch(`http://127.0.0.1:${port}/anything`).catch(() => undefined);
206 if (res === undefined) await Bun.sleep(100);
207 }
208 expect(res?.status).toBe(503);
209 expect(res?.headers.get("retry-after")).toBe(MAINTENANCE_HEADERS["retry-after"]!);
210 expect(await res?.text()).toContain("&lt;b&gt;x&lt;/b&gt;");
211 expect((await fetch(`http://127.0.0.1:${port}/healthz`)).status).toBe(200);
212 } finally {
213 proc.kill();
214 }
215 });
216 });
test/mcp.test.ts +1
@@ -318,6 +318,7 @@ describe("the forge over MCP", () => {
318318 "feed",
319319 "feed_chips",
320320 "feed_dismiss",
321 "maintenance",
321322 "make_it_yours",
322323 "origin_apps",
323324 "front_page",
test/palette-roster.test.ts +1
@@ -99,6 +99,7 @@ test("a site-admin person is offered the whole roster", () => {
9999 ["telemetry", "Telemetry", "which agents may read flow and system; enter flips the grant"],
100100 ["capacity", "Capacity", "concurrency and guards, live; e changes a settable one"],
101101 ["resilience", "Resilience", "where snapshots go and the mirror; each protection green, red or untested"],
102 ["maintenance", "Maintenance", "prepare the forge for a power-off, check the drain, or end maintenance"],
102103 ["crawlers", "Crawlers", "their share of answering time, and who was refused"],
103104 ["scrapes", "Scrapes", "what was read by repository, and groups in a running capture"],
104105 ["api", "API", "the forge's endpoints; run a GET"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs