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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9453-maintenance-from-the-forge-request-a-shu → main 5 commits fork 5 threads · 5 unresolved
docs/design/audit.md +1
| @@ -99,6 +99,7 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 99 | 99 | |
| 100 | 100 | - `/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. |
| 101 | 101 | - `/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. | |
| 102 | 103 | - `/admin/limits` — `pages/admin-limits.ts` · every limit with value, default and reason · `/settings/capacity` (also a numbers-you-can-change page) · none seen. |
| 103 | 104 | - `/settings/capacity` — `pages/capacity.ts` · how many runs and checks go at once · `/admin/limits`, `/settings/usage` · none seen. |
| 104 | 105 | - `/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"; | ||
| 40 | 40 | export { route as getApiV1AdminInvites } from "./getApiV1AdminInvites.ts"; |
| 41 | 41 | export { route as getApiV1AdminKpisProbe } from "./getApiV1AdminKpisProbe.ts"; |
| 42 | 42 | export { route as getApiV1AdminLimits } from "./getApiV1AdminLimits.ts"; |
| 43 | export { route as getApiV1AdminMaintenance } from "./getApiV1AdminMaintenance.ts"; | |
| 43 | 44 | export { route as getApiV1AdminPeople } from "./getApiV1AdminPeople.ts"; |
| 44 | 45 | export { route as getApiV1AdminPinned } from "./getApiV1AdminPinned.ts"; |
| 45 | 46 | export { route as getApiV1AdminProfile } from "./getApiV1AdminProfile.ts"; |
| @@ -218,6 +219,8 @@ export { route as postApiV1AdminAccountsUsernameSuspend } from "./postApiV1Admin | ||
| 218 | 219 | export { route as postApiV1AdminAccountsUsernameUnsuspend } from "./postApiV1AdminAccountsUsernameUnsuspend.ts"; |
| 219 | 220 | export { route as postApiV1AdminAccountsUsernameVerify } from "./postApiV1AdminAccountsUsernameVerify.ts"; |
| 220 | 221 | export { route as postApiV1AdminInvites } from "./postApiV1AdminInvites.ts"; |
| 222 | export { route as postApiV1AdminMaintenancePrepare } from "./postApiV1AdminMaintenancePrepare.ts"; | |
| 223 | export { route as postApiV1AdminMaintenanceResume } from "./postApiV1AdminMaintenanceResume.ts"; | |
| 221 | 224 | export { route as postApiV1AdminPinned } from "./postApiV1AdminPinned.ts"; |
| 222 | 225 | export { route as postApiV1AdminPinnedNameCheck } from "./postApiV1AdminPinnedNameCheck.ts"; |
| 223 | 226 | 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 { | ||
| 192 | 192 | kind.startsWith("drill.") || |
| 193 | 193 | // A snapshot moving is the forge owner's too, and has no repository. |
| 194 | 194 | kind.startsWith("backup.") || |
| 195 | // Preparing for a power-off and ending it are the forge owner's (`maintenance/service.ts`). | |
| 196 | kind.startsWith("maintenance.") || | |
| 195 | 197 | // The weekly model review's changes and results are the owner's (`actions/model_review.ts`). |
| 196 | 198 | kind.startsWith("crew.model_") || |
| 197 | 199 | // 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[] = [ | ||
| 96 | 96 | "organizations", |
| 97 | 97 | "now", |
| 98 | 98 | "resilience", |
| 99 | "maintenance", | |
| 99 | 100 | "origins", |
| 100 | 101 | "robots.txt", |
| 101 | 102 | "favicon.ico", |
src/crew/dispatch.ts +8
| @@ -48,6 +48,7 @@ import { noteHeld, type HoldCause } from "../runs/held.ts"; | ||
| 48 | 48 | import { decideAndRecord } from "./ledger.ts"; |
| 49 | 49 | import { bugsFirst } from "../flow/bugs.ts"; |
| 50 | 50 | import { byDials } from "./team_share.ts"; |
| 51 | import { inMaintenance } from "../maintenance/state.ts"; | |
| 51 | 52 | import { gateQueue } from "../flow/valve_gate.ts"; |
| 52 | 53 | import { ARCHIVED_WHY, getRepoById, type Repo } from "../core/repos.ts"; |
| 53 | 54 | import { listReviews } from "../core/reviews.ts"; |
| @@ -1527,6 +1528,13 @@ function pump( | ||
| 1527 | 1528 | const gated = startOrder(app); |
| 1528 | 1529 | // What this pass holds by policy, for vitals (`runs/held.ts`, #9403). |
| 1529 | 1530 | 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 | } | |
| 1530 | 1538 | for (const shut of gated.held) { |
| 1531 | 1539 | held.set(shut.id, "valve"); |
| 1532 | 1540 | if (noted.has(shut.id)) continue; |
src/http/api.ts +2
| @@ -349,6 +349,7 @@ import { labelsRoutes } from "./api/labels.ts"; | ||
| 349 | 349 | import { visionRoutes } from "./api/vision.ts"; |
| 350 | 350 | import { tokenRoutes } from "./api/tokens.ts"; |
| 351 | 351 | import { accountAdmin, adminRoutes } from "./api/admin.ts"; |
| 352 | import { maintenanceRoutes } from "./api/maintenance.ts"; | |
| 352 | 353 | import { flagsRoutes } from "./api/flags.ts"; |
| 353 | 354 | import { issueRoutes, labelsHandler } from "./api/issues.ts"; |
| 354 | 355 | import { FORGE_NOT_CARRIED } from "../origins/words.ts"; |
| @@ -1226,6 +1227,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1226 | 1227 | userRoutes(router); |
| 1227 | 1228 | forgeOriginRoutes(router); |
| 1228 | 1229 | adminRoutes(router); |
| 1230 | maintenanceRoutes(router); | |
| 1229 | 1231 | flagsRoutes(router); |
| 1230 | 1232 | |
| 1231 | 1233 | // 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"); | |
| 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"; | ||
| 86 | 86 | export { tool as lift } from "./lift.ts"; |
| 87 | 87 | export { tool as limb } from "./limb.ts"; |
| 88 | 88 | export { tool as limits } from "./limits.ts"; |
| 89 | export { tool as maintenance } from "./maintenance.ts"; | |
| 89 | 90 | export { tool as make_it_yours } from "./make_it_yours.ts"; |
| 90 | 91 | export { tool as map } from "./map.ts"; |
| 91 | 92 | 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"; | ||
| 26 | 26 | import { systemPage, systemStream } from "../web/pages/system.ts"; |
| 27 | 27 | import { contributePage } from "../web/pages/contribute.ts"; |
| 28 | 28 | import { pipelinePage } from "../web/pages/pipeline.ts"; |
| 29 | import { maintenancePage, maintenancePrepare, maintenanceResume } from "../web/pages/maintenance.ts"; | |
| 29 | 30 | import { nightlyOutputPage, resiliencePage } from "../web/pages/resilience.ts"; |
| 30 | 31 | import { limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts"; |
| 31 | 32 | import { measureSubmit } from "./measure.ts"; |
| @@ -898,6 +899,9 @@ export function buildRouter(): Router<Handler> { | ||
| 898 | 899 | pinnedRoutes(router); |
| 899 | 900 | // Each THREATS.md protection against the last gate; literal, above /:owner. |
| 900 | 901 | 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); | |
| 901 | 905 | router.add("GET", "/resilience/nightly/:job", nightlyOutputPage); |
| 902 | 906 | // A selection's spend and runs over time; literal, above /:owner. |
| 903 | 907 | router.add("GET", "/fleet/graph", fleetGraphPage); |
src/main.ts +3
| @@ -72,6 +72,7 @@ import { peopleOnCrews } from "./core/agents.ts"; | ||
| 72 | 72 | import { startNotifier } from "./crew/notify.ts"; |
| 73 | 73 | import { expireCrewHolds } from "./actions/holds.ts"; |
| 74 | 74 | import { startClogTick } from "./flow/clog_tick.ts"; |
| 75 | import { resumeAtBoot } from "./maintenance/service.ts"; | |
| 75 | 76 | import { startKeeper } from "./flow/keeper.ts"; |
| 76 | 77 | import { sweepStaleAsks } from "./crew/stale_asks.ts"; |
| 77 | 78 | import { recordAskLedger } from "./web/studio/asks.ts"; |
| @@ -673,6 +674,8 @@ const processTick = (): void => { | ||
| 673 | 674 | const stopClogTick = startClogTick(app.db, app.bus, (error) => app.log.warn("clog tick failed", { error: String(error) }), 60_000); |
| 674 | 675 | // The throughput keeper (#7645) names what binds landings each minute and lifts what is its to lift. |
| 675 | 676 | const stopKeeper = startKeeper(app, () => sweepAssigned(app)); |
| 677 | // A forge that went down prepared for maintenance comes back by itself. | |
| 678 | resumeAtBoot(app); | |
| 676 | 679 | // An agent's hold that has stood 24 h is released each minute (#6918). |
| 677 | 680 | const holdTimer = setInterval(() => { |
| 678 | 681 | 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "&9;"); | |
| 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
|
||
| 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"; | ||
| 17 | 17 | export const HELD_FRESH_MS = 2 * 60_000; |
| 18 | 18 | |
| 19 | 19 | /** Why the dispatcher held a run. */ |
| 20 | export type HoldCause = "valve" | "pace" | "limit"; | |
| 20 | export type HoldCause = "valve" | "pace" | "limit" | "maintenance"; | |
| 21 | 21 | |
| 22 | 22 | /** How vitals names each cause: "2 by a shut valve". */ |
| 23 | 23 | export const HOLD_WORDS: Readonly<Record<HoldCause, string>> = { |
| 24 | 24 | valve: "a shut valve", |
| 25 | 25 | pace: "the week's pace", |
| 26 | 26 | limit: "an account limit", |
| 27 | maintenance: "planned maintenance", | |
| 27 | 28 | }; |
| 28 | 29 | |
| 29 | 30 | interface Pass { |
src/tui/screens/entries.ts +2
| @@ -33,6 +33,7 @@ import { entry as homePins } from "./home_pins.ts"; | ||
| 33 | 33 | import { entry as invites } from "./invites.ts"; |
| 34 | 34 | import { entry as limits } from "./limits.ts"; |
| 35 | 35 | import { entry as loopStalls } from "./loopstalls.ts"; |
| 36 | import { entry as maintenance } from "./maintenance.ts"; | |
| 36 | 37 | import { modelCeilingEntry, modelGrantEntry } from "./model.ts"; |
| 37 | 38 | import { entry as orgGrants } from "./orggrants.ts"; |
| 38 | 39 | import { entry as orgTeams } from "./orgteams.ts"; |
| @@ -82,6 +83,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 82 | 83 | loopStalls, |
| 83 | 84 | telemetry, |
| 84 | 85 | resilience, |
| 86 | maintenance, | |
| 85 | 87 | valves, |
| 86 | 88 | access, |
| 87 | 89 | 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 |
||
| 32 | <p id="maintenance-says">${s.says}</p> | |
outdated
Principle 1: |
||
| 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 { | ||
| 108 | 108 | </p> |
| 109 | 109 | </div> |
| 110 | 110 | ${backupsLineHtml(ctx)} |
| 111 | <p><a href="/maintenance">Maintenance</a> prepares the forge for a planned power-off.</p> | |
| 111 | 112 | ${nightlyHtml(ctx)} |
| 112 | 113 | <p id="tally"> |
| 113 | 114 | ${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", () => { | ||
| 123 | 123 | "access", "account_verify", "app_members", "app_notes", "asks_act", "asks_decide", "asks_proposal", "asks_undecide", |
| 124 | 124 | "assign", "attach_look", "bet", "board_pins", "board_write", "branches", "capacity", "valves", "cloud_accounts", "close", "confirm_wait", |
| 125 | 125 | "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", | |
| 127 | 127 | "next_up", "week", "notify", "open_pull", "org_create", "origin_apps", "pins", "plan", "profile", "propose_to_parent_forge", "repo_labels", "reports", |
| 128 | 128 | "restore_drill", "review", "say", "signup", "snapshot_now", "spend", "steer", "sync", "take_back", "take_updates", |
| 129 | 129 | "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> = { | ||
| 37 | 37 | "/opensearch.xml": "the browser search description, XML", |
| 38 | 38 | "/sw.js": "the service worker script", |
| 39 | 39 | "/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", | |
| 40 | 41 | "/claim": "answers 404 to an admin: it exists only for a link-holder", |
| 41 | 42 | "/first-admin": "the setup form for a forge with no admin yet", |
| 42 | 43 | "/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("<script>"); | |
| 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("<b>x</b>"); | |
| 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", () => { | ||
| 318 | 318 | "feed", |
| 319 | 319 | "feed_chips", |
| 320 | 320 | "feed_dismiss", |
| 321 | "maintenance", | |
| 321 | 322 | "make_it_yours", |
| 322 | 323 | "origin_apps", |
| 323 | 324 | "front_page", |
test/palette-roster.test.ts +1
| @@ -99,6 +99,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 99 | 99 | ["telemetry", "Telemetry", "which agents may read flow and system; enter flips the grant"], |
| 100 | 100 | ["capacity", "Capacity", "concurrency and guards, live; e changes a settable one"], |
| 101 | 101 | ["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"], | |
| 102 | 103 | ["crawlers", "Crawlers", "their share of answering time, and who was refused"], |
| 103 | 104 | ["scrapes", "Scrapes", "what was read by repository, and groups in a running capture"], |
| 104 | 105 | ["api", "API", "the forge's endpoints; run a GET"], |
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.