Public: anyone can read this; the crew can change it.
A nightly off-box backup older than 26 hours is a card on /flow with the one action that retries it #10933
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10924-a-nightly-off-box-backup-older-than-26-h → main 7 commits fork 3 threads · 3 unresolved
26 files
changed +399
−31
docs/features/backup-overdue.md added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | # An overdue off-box backup (#10924) | |
| 2 | ||
| 3 | When the newest copy of the forge that left the machine is more than 26 hours old (a nightly plus two hours), `flow/alarm_backup.ts` fires the `backup` flow alarm. It reads two small files under the data directory (the snapshot outcome and the mirror's record), never the network, and says one sentence: how old the newest off-box backup is, or that none has landed, and the last failure's cause. A forge that has never sent a copy off the box and never failed to is not alarmed. | |
| 4 | ||
| 5 | - **On the web.** `/flow` (a forge admin) leads with one row: that sentence and **Retry the backup now** (`POST /flow/backup/retry`). Home and /boards draw it as a line like the other flow alarms; their cache is keyed on the backup's state, so a cleared backup leaves Home at once. | |
| 6 | - **In the terminal.** The palette's **Valves** (a forge admin who is a person) shows the same sentence on its first line, with `b` to retry. The Flow dialog and the dashboard carry it in their alarm lines. | |
| 7 | - **One retry.** Both doors call `actions/backup_retry.ts`, which takes one snapshot through `snapshotNow` (the service's own queue, so two never run at once). | |
| 8 | - **Honest about where it landed.** The alarm clears only when a copy is off the machine. A snapshot that landed in a local directory is said to have "stayed on this machine, so it is not an off-box backup"; the card stays while an older off-box copy is the newest. | |
| 9 | - **Callers.** `firedAlarms` takes `dataDir` as a required option (`null` for a unit over a bare db), so a door cannot silently lose the alarm. | |
| 10 | ||
| 11 | Tests: `test/alarm-backup.test.ts`, `test/backup-retry.test.ts` (a real snapshot into a directory; the Valves screen). |
src/actions/backup_retry.ts added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | /** | |
| 2 | * The retry the off-box backup alarm offers (#10924), for both doors: one | |
| 3 | * snapshot now through `snapshotNow`, then the truth about the alarm. A | |
| 4 | * snapshot that landed in a directory on this machine does not count as off-box, | |
| 5 | * and the answer says so instead of reporting a success the card contradicts. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { App } from "../app.ts"; | |
| 9 | import type { User } from "../core/users.ts"; | |
| 10 | import { isOffBox } from "../snapshot/off_box.ts"; | |
| 11 | import { snapshotsOf } from "../snapshot/service.ts"; | |
| 12 | import { snapshotNow } from "./backups_now.ts"; | |
| 13 | ||
| 14 | export interface BackupRetry { | |
| 15 | /** False when the card will still stand: the snapshot failed, or it did not leave the machine. */ | |
| 16 | readonly cleared: boolean; | |
| 17 | /** The refusal code, when the actor may not; otherwise absent. */ | |
| 18 | readonly refused?: "not_found"; | |
| 19 | readonly says: string; | |
| 20 | } | |
| 21 | ||
| 22 | export async function retryBackup(app: App, actor: User | undefined): Promise<BackupRetry> { | |
| 23 | const taken = await snapshotNow(app, actor); | |
| 24 | if (!taken.ok) return { cleared: false, ...(taken.error.code === "not_found" ? { refused: "not_found" as const } : {}), says: taken.error.says }; | |
| 25 | const store = snapshotsOf(app)?.store; | |
| 26 | if (store !== undefined && !isOffBox(store)) | |
| 27 | return { cleared: false, says: `${taken.value.says} It stayed on this machine, so it is not an off-box backup; the card clears when the mirror or a bucket destination holds a new one.` }; | |
| 28 | return { cleared: true, says: taken.value.says }; | |
| 29 | } |
src/crew/notify.ts +1 −1
| @@ -299,7 +299,7 @@ export function alarmLines( | ||
| 299 | 299 | fired?: readonly FiredAlarm[], |
| 300 | 300 | ): { id: string; line: string; items: readonly string[]; link: string }[] { |
| 301 | 301 | if (!user.isAdmin || user.kind !== "user") return []; |
| 302 | const alarms = fired ?? [...firedAlarms(app.db, new Date(now), { ceilingUsd: forgeCeiling(app.config) }), ...reachAlarms(app)]; | |
| 302 | const alarms = fired ?? [...firedAlarms(app.db, new Date(now), { ceilingUsd: forgeCeiling(app.config), dataDir: app.config.dataDir }), ...reachAlarms(app)]; | |
| 303 | 303 | return alarms.map((a) => { |
| 304 | 304 | const id = `alarm:${a.key}@${a.since}`; |
| 305 | 305 | return { id, line: a.says, items: [id], link: a.href }; |
src/flow/alarm_backup.ts added +56
| @@ -0,0 +1,56 @@ | ||
| 1 | /** | |
| 2 | * The "off-box backup" flow alarm (#10924): the newest copy of the forge to | |
| 3 | * have left the machine is more than 26 hours old, so last night's nightly | |
| 4 | * did not land. Reads what Home reads (`snapshot/off_box.ts`) and the | |
| 5 | * failure the outcome files kept; no network. Pure of the clock: `now` is | |
| 6 | * passed in. A forge that has never sent a copy off the box and has never | |
| 7 | * failed to is not alarmed: it has no off-box backup to be late. | |
| 8 | */ | |
| 9 | ||
| 10 | import { lastMirror } from "../snapshot/mirror.ts"; | |
| 11 | import { lastOffBox } from "../snapshot/off_box.ts"; | |
| 12 | import { lastSnapshotOutcome } from "../snapshot/outcome.ts"; | |
| 13 | ||
| 14 | /** A nightly plus two hours of grace. */ | |
| 15 | export const BACKUP_ALARM_MS = 26 * 3_600_000; | |
| 16 | ||
| 17 | export interface BackupAlarm { | |
| 18 | readonly fired: boolean; | |
| 19 | /** When the backup became overdue; only when fired. */ | |
| 20 | readonly since?: string; | |
| 21 | /** The last failure's cause as one line; only when fired and one is known. */ | |
| 22 | readonly cause?: string; | |
| 23 | readonly says: string; | |
| 24 | } | |
| 25 | ||
| 26 | /** One line, bounded: the first line of an error, 160 characters at most. */ | |
| 27 | function oneLine(text: string): string { | |
| 28 | const line = (text.split("\n")[0] ?? "").replace(/\s+/g, " ").trim(); | |
| 29 | return line.length > 160 ? `${line.slice(0, 159)}…` : line; | |
| 30 | } | |
| 31 | ||
| 32 | /** The cause of the latest failure, from the snapshot attempt or the mirror pass, whichever failed last. */ | |
| 33 | export function backupCause(dataDir: string): string | undefined { | |
| 34 | const snap = lastSnapshotOutcome(dataDir); | |
| 35 | const mirror = lastMirror(dataDir); | |
| 36 | const found: { at: number; text: string }[] = []; | |
| 37 | if (snap?.failingSince !== undefined && snap.error) found.push({ at: Date.parse(snap.at), text: `The snapshot failed: ${snap.error}` }); | |
| 38 | const mirrorWhy = mirror?.sourceError ?? mirror?.error; | |
| 39 | if (mirror !== undefined && (mirror.failingSince !== undefined || mirror.sourceError !== undefined) && mirrorWhy) | |
| 40 | found.push({ at: Date.parse(mirror.attemptAt), text: `The mirror failed: ${mirrorWhy}` }); | |
| 41 | if (found.length === 0) return undefined; | |
| 42 | found.sort((a, b) => (Number.isNaN(b.at) ? 0 : b.at) - (Number.isNaN(a.at) ? 0 : a.at)); | |
| 43 | return oneLine(found[0]!.text); | |
| 44 | } | |
| 45 | ||
| 46 | export function alarmBackup(dataDir: string, now: Date): BackupAlarm { | |
| 47 | const newest = lastOffBox(dataDir); | |
| 48 | const failing = lastSnapshotOutcome(dataDir)?.failingSince ?? lastMirror(dataDir)?.failingSince; | |
| 49 | if (newest === null && failing === undefined) return { fired: false, says: "No off-box backup has been taken or failed." }; | |
| 50 | const age = newest === null ? Number.POSITIVE_INFINITY : now.getTime() - Date.parse(newest); | |
| 51 | if (age <= BACKUP_ALARM_MS) return { fired: false, says: "The newest off-box backup is under 26 hours old." }; | |
| 52 | const since = newest === null ? failing! : new Date(Date.parse(newest) + BACKUP_ALARM_MS).toISOString(); | |
| 53 | const cause = backupCause(dataDir); | |
| 54 | const held = newest === null ? "No off-box backup has landed" : `The newest off-box backup is ${String(Math.floor(age / 3_600_000))} h old`; | |
| 55 | return { fired: true, since, ...(cause === undefined ? {} : { cause }), says: `${held}.${cause === undefined ? "" : ` ${cause}`}` }; | |
| 56 | } |
src/flow/alarms.ts +6 −2
| @@ -12,6 +12,7 @@ import { redHref, redMain } from "../checks/red_main.ts"; | ||
| 12 | 12 | import { getRepo } from "../core/repos.ts"; |
| 13 | 13 | import { ownRepoPath } from "../kernel/own_repo.ts"; |
| 14 | 14 | import { bugClockAlarm } from "./bugs.ts"; |
| 15 | import { alarmBackup } from "./alarm_backup.ts"; | |
| 15 | 16 | import { alarmFromApp } from "./alarm_from_app.ts"; |
| 16 | 17 | import { alarmIdleCrew } from "./alarm_idle_crew.ts"; |
| 17 | 18 | import { noReleaseAlarm } from "./alarm_no_release.ts"; |
| @@ -21,7 +22,7 @@ import { alarmSilent } from "./alarm_silent.ts"; | ||
| 21 | 22 | import { alarmStuck } from "./alarm_stuck.ts"; |
| 22 | 23 | |
| 23 | 24 | export interface FiredAlarm { |
| 24 | readonly key: "landing_stopped" | "red_main" | "stuck" | "idle_crew" | "no_release" | "bug_clock" | "silent" | "repeat" | "from_app" | "unreachable"; | |
| 25 | readonly key: "landing_stopped" | "red_main" | "stuck" | "idle_crew" | "no_release" | "bug_clock" | "silent" | "repeat" | "from_app" | "unreachable" | "backup"; | |
| 25 | 26 | readonly says: string; |
| 26 | 27 | readonly since: string; |
| 27 | 28 | /** A same-origin path to the item the alarm names. */ |
| @@ -38,7 +39,8 @@ function redMainHref(db: Database): string { | ||
| 38 | 39 | export function firedAlarms( |
| 39 | 40 | db: Database, |
| 40 | 41 | now: Date, |
| 41 | opts: { readonly publicOnly?: boolean; readonly ceilingUsd?: number | null } = {}, | |
| 42 | /** `dataDir` is required so a caller cannot forget it and lose the backup alarm; `null` says there is none to read (a unit over a bare db). */ | |
| 43 | opts: { readonly dataDir: string | null; readonly publicOnly?: boolean; readonly ceilingUsd?: number | null }, | |
| 42 | 44 | ): FiredAlarm[] { |
| 43 | 45 | const out: FiredAlarm[] = []; |
| 44 | 46 | const [owner, name] = ownRepoPath().split("/"); |
| @@ -58,6 +60,8 @@ export function firedAlarms( | ||
| 58 | 60 | if (repeat.fired) out.push({ key: "repeat", says: repeat.says, since: repeat.since!, href: `/${repeat.repo}/issues/${repeat.number}` }); |
| 59 | 61 | const fromApp = alarmFromApp(db, now); |
| 60 | 62 | if (fromApp.fired) out.push({ key: "from_app", says: fromApp.says, since: fromApp.since!, href: `/${fromApp.repo}/issues/${fromApp.number}` }); |
| 63 | const backup = opts.dataDir === null ? undefined : alarmBackup(opts.dataDir, now); | |
| 64 | if (backup?.fired) out.push({ key: "backup", says: backup.says, since: backup.since!, href: "/flow" }); | |
| 61 | 65 | const idle = alarmIdleCrew(db, now, { ceilingUsd: opts.ceilingUsd ?? null }); |
| 62 | 66 | if (idle.fired) out.push({ key: "idle_crew", says: idle.says, since: idle.since!, href: "/boards" }); |
| 63 | 67 | } |
src/http/server.ts +2 −1
| @@ -310,7 +310,7 @@ import { appNowFragment } from "../web/studio/now.ts"; | ||
| 310 | 310 | import { appStateFragment } from "../web/studio/previews.ts"; |
| 311 | 311 | import { changesList, tellsList } from "../web/studio/lists.ts"; |
| 312 | 312 | import { dependencySubmit } from "../web/pages/thread.ts"; |
| 313 | import { flowPage, suggestionTake, valveSet } from "../web/pages/flow.ts"; | |
| 313 | import { backupRetry, flowPage, suggestionTake, valveSet } from "../web/pages/flow.ts"; | |
| 314 | 314 | import { heldApply, heldPage, liftHoldSubmit } from "../web/pages/gate.ts"; |
| 315 | 315 | import { |
| 316 | 316 | assigneeSubmit, |
| @@ -937,6 +937,7 @@ export function buildRouter(): Router<Handler> { | ||
| 937 | 937 | // /flow is the week's valves (#8698); a query string is an old pipeline drill, which went to /boards. |
| 938 | 938 | router.add("GET", "/flow", flowPage); |
| 939 | 939 | router.add("POST", "/flow/valve", valveSet); |
| 940 | router.add("POST", "/flow/backup/retry", backupRetry); | |
| 940 | 941 | router.add("POST", "/flow/suggestion", suggestionTake); |
| 941 | 942 | router.add("GET", "/system", systemPage); |
| 942 | 943 | router.add("GET", "/system/stream", systemStream); |
src/snapshot/off_box.ts +3 −3
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { lastMirror } from "./mirror.ts"; |
| 14 | import { lastSnapshotOutcome } from "./outcome.ts"; | |
| 14 | import { lastSnapshotOutcome, offBoxAtOf } from "./outcome.ts"; | |
| 15 | 15 | import type { Store } from "./store.ts"; |
| 16 | 16 | |
| 17 | 17 | /** The host of a bucket's `where` (`host[:port]/bucket`), lower-cased, without its port or brackets. */ |
| @@ -40,8 +40,8 @@ export function lastOffBox(dataDir: string): string | null { | ||
| 40 | 40 | const mirrored = lastMirror(dataDir)?.newestAt; |
| 41 | 41 | if (mirrored !== undefined && Number.isFinite(Date.parse(mirrored))) times.push(mirrored); |
| 42 | 42 | const kept = lastSnapshotOutcome(dataDir); |
| 43 | if (kept?.offBox === true && kept.landedAt !== undefined && Number.isFinite(Date.parse(kept.landedAt))) | |
| 44 | times.push(kept.landedAt); | |
| 43 | const landed = offBoxAtOf(kept); | |
| 44 | if (landed !== undefined && Number.isFinite(Date.parse(landed))) times.push(landed); | |
| 45 | 45 | if (times.length === 0) return null; |
| 46 | 46 | return times.reduce((a, b) => (Date.parse(b) > Date.parse(a) ? b : a)); |
| 47 | 47 | } |
src/snapshot/outcome.ts +20 −1
| @@ -19,6 +19,11 @@ export interface SnapshotOutcome { | ||
| 19 | 19 | readonly landedAt?: string; |
| 20 | 20 | /** Whether the store it landed in is off the machine (a bucket, not a directory); absent when not known. */ |
| 21 | 21 | readonly offBox?: boolean; |
| 22 | /** | |
| 23 | * When a snapshot last landed off the machine; kept across landings in a local directory, | |
| 24 | * so moving the destination home does not erase the age of the last real off-box copy (#10924). | |
| 25 | */ | |
| 26 | readonly offBoxAt?: string; | |
| 22 | 27 | /** Set while attempts are failing: since when, and the last one's reason. */ |
| 23 | 28 | readonly failingSince?: string; |
| 24 | 29 | readonly error?: string; |
| @@ -37,6 +42,13 @@ export function lastSnapshotOutcome(dataDir: string): SnapshotOutcome | undefine | ||
| 37 | 42 | } |
| 38 | 43 | } |
| 39 | 44 | |
| 45 | /** When this outcome's last off-box landing was: the kept time, or the landing itself when it was off the machine. */ | |
| 46 | export function offBoxAtOf(s: SnapshotOutcome | undefined): string | undefined { | |
| 47 | if (s === undefined) return undefined; | |
| 48 | if (s.offBoxAt !== undefined) return s.offBoxAt; | |
| 49 | return s.offBox === true ? s.landedAt : undefined; | |
| 50 | } | |
| 51 | ||
| 40 | 52 | /** Keep an attempt's end: a name that landed, or the error it failed with. Returns what was kept. */ |
| 41 | 53 | export function recordSnapshotOutcome( |
| 42 | 54 | dataDir: string, |
| @@ -48,10 +60,17 @@ export function recordSnapshotOutcome( | ||
| 48 | 60 | ...(prev?.landed === undefined ? {} : { landed: prev.landed }), |
| 49 | 61 | ...(prev?.landedAt === undefined ? {} : { landedAt: prev.landedAt }), |
| 50 | 62 | ...(prev?.offBox === undefined ? {} : { offBox: prev.offBox }), |
| 63 | ...(offBoxAtOf(prev) === undefined ? {} : { offBoxAt: offBoxAtOf(prev)! }), | |
| 51 | 64 | }; |
| 52 | 65 | const next: SnapshotOutcome = |
| 53 | 66 | "landed" in result |
| 54 | ? { at, landed: result.landed, landedAt: at, ...(result.offBox === undefined ? {} : { offBox: result.offBox }) } | |
| 67 | ? { | |
| 68 | at, | |
| 69 | landed: result.landed, | |
| 70 | landedAt: at, | |
| 71 | ...(result.offBox === undefined ? {} : { offBox: result.offBox }), | |
| 72 | ...(result.offBox === true ? { offBoxAt: at } : offBoxAtOf(prev) === undefined ? {} : { offBoxAt: offBoxAtOf(prev)! }), | |
| 73 | } | |
| 55 | 74 | : { at, ...kept, failingSince: prev?.failingSince ?? at, error: result.error }; |
| 56 | 75 | write(dataDir, next); |
| 57 | 76 | return next; |
src/system/telemetry.ts +1 −1
| @@ -117,7 +117,7 @@ function flowBody(x: { | ||
| 117 | 117 | // Declined build runs of the last 24 h by reason, each with the runs behind it. |
| 118 | 118 | declines: readDeclines(app.db, now, reader.kind === "agent"), |
| 119 | 119 | // The four alarms (#5930) that are firing now; empty when none is. |
| 120 | alarms: firedAlarms(app.db, now, { publicOnly: reader.kind === "agent" }), | |
| 120 | alarms: firedAlarms(app.db, now, { publicOnly: reader.kind === "agent", dataDir: app.config.dataDir }), | |
| 121 | 121 | // Review runs in flight per reviewer, cut to public repositories for an agent (#4733). |
| 122 | 122 | reviewing: { says: reviewingSays(busy), reviewers: busy }, |
| 123 | 123 | conflicted: { says: conflictedSays(conflicted, now.getTime()), pulls: conflicted }, |
src/tui/forge.ts +1
| @@ -465,6 +465,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 465 | 465 | forgeCeiling(session.app.config), |
| 466 | 466 | readVerified(session.app, flowReader.kind === "owner"), |
| 467 | 467 | keeper, |
| 468 | session.app.config.dataDir, | |
| 468 | 469 | ), keeper.ask && user ? { ask: keeper.ask, answer: () => answerKeeper(session.app, keeper.ask!.id, user.id) } : undefined)); |
| 469 | 470 | }, |
| 470 | 471 | }); |
src/tui/screens/dashboard.ts +1 −1
| @@ -406,7 +406,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 406 | 406 | ...weekBlock(f.app, now), |
| 407 | 407 | ...kpiLines(readKpis(f.db, now, { ...kpiForge(f.app), idle: () => idleReason(f.app, now) }), commit === null ? null : { sha: commit, since: ago(BOOTED) }), |
| 408 | 408 | vitalsLine(readVitals(f.db, now)), |
| 409 | ...firedAlarms(f.db, now, { ceilingUsd: forgeCeiling(f.app.config) }).map((a) => a.says), | |
| 409 | ...firedAlarms(f.db, now, { ceilingUsd: forgeCeiling(f.app.config), dataDir: f.app.config.dataDir }).map((a) => a.says), | |
| 410 | 410 | ...(cleared === undefined ? [] : [cleared]), |
| 411 | 411 | ]; |
| 412 | 412 | // "Yours" is ownership plus the orgs you are on a team of; typing in the |
src/tui/screens/flow.ts +3 −1
| @@ -74,6 +74,8 @@ export function flowLines( | ||
| 74 | 74 | verified?: readonly VerifiedRow[], |
| 75 | 75 | /** The keeper's cause and last act (`readKeeper`, already cut to what the reader may see). */ |
| 76 | 76 | keeper?: KeeperRead, |
| 77 | /** Where the backup outcome files are kept; without it the off-box backup alarm cannot be read (the terminal passes the app's). */ | |
| 78 | dataDir: string | null = null, | |
| 77 | 79 | ): string[] { |
| 78 | 80 | const stages = readPipeline(db, now); |
| 79 | 81 | const flow = readThroughput(db, stages.map((s) => s.key), now); |
| @@ -100,7 +102,7 @@ export function flowLines( | ||
| 100 | 102 | "", |
| 101 | 103 | ...wrap(vitalsLine(vitalsFor(readVitals(db, now, agent !== undefined), agent === undefined)), inner), |
| 102 | 104 | ...(agent === undefined ? clogLines(readClog(db, now), now.getTime()).flatMap((l) => wrap(l, inner)) : []), |
| 103 | ...firedAlarms(db, now, { publicOnly: agent !== undefined, ceilingUsd }).flatMap((a) => wrap(a.says, inner)), | |
| 105 | ...firedAlarms(db, now, { publicOnly: agent !== undefined, ceilingUsd, dataDir }).flatMap((a) => wrap(a.says, inner)), | |
| 104 | 106 | "", |
| 105 | 107 | ...wrap(parkedSays(fig.parked), inner), |
| 106 | 108 | "", |
src/tui/screens/valves.ts +30 −3
| @@ -5,8 +5,13 @@ | ||
| 5 | 5 | * `x` shuts it, `o` opens it, `t` takes the suggestion beside it. A turn goes |
| 6 | 6 | * through `actions/valves.ts`, so it is kept, announced as `valve.turned` and |
| 7 | 7 | * reaches every open page and screen; the screen refreshes on that event. |
| 8 | * An overdue off-box backup (#10924, `flow/alarm_backup.ts`) is the first | |
| 9 | * line, as /flow draws it, and `b` retries it through `actions/backup_retry.ts`, | |
| 10 | * the same action as the page's button. | |
| 8 | 11 | */ |
| 9 | 12 | |
| 13 | import { retryBackup } from "../../actions/backup_retry.ts"; | |
| 14 | import { alarmBackup } from "../../flow/alarm_backup.ts"; | |
| 10 | 15 | import { acceptSuggestion, turnValveFor } from "../../actions/valves.ts"; |
| 11 | 16 | import { readPipes, type Pipes, type ValveRow } from "../../flow/pipes.ts"; |
| 12 | 17 | import { levelWords } from "../../flow/valve.ts"; |
| @@ -17,7 +22,7 @@ import { style } from "../term/style.ts"; | ||
| 17 | 22 | import { truncate } from "../term/width.ts"; |
| 18 | 23 | import { fill, len, split } from "../ui/layout.ts"; |
| 19 | 24 | import type { PaletteEntry } from "./entries.ts"; |
| 20 | import { ListState, drawList, live, type ListRow } from "./common.ts"; | |
| 25 | import { ListState, drawList, live, runAsync, type ListRow } from "./common.ts"; | |
| 21 | 26 | import { mayCapacity } from "./capacity.ts"; |
| 22 | 27 | |
| 23 | 28 | const STEP = 10; |
| @@ -39,6 +44,12 @@ export function valvesScreen(f: Session, ctx: Ctx): Screen { | ||
| 39 | 44 | pipes = mayCapacity(f) && f.user !== undefined ? readPipes(f.db, f.user.id) : undefined; |
| 40 | 45 | ctx.invalidate(); |
| 41 | 46 | }; |
| 47 | let retrying = false; | |
| 48 | /** The overdue off-box backup's sentence, read fresh from the outcome files; empty when none is overdue. */ | |
| 49 | const overdue = (): string => { | |
| 50 | const a = alarmBackup(f.app.config.dataDir, new Date()); | |
| 51 | return a.fired ? `${a.says} b retries it now.` : ""; | |
| 52 | }; | |
| 42 | 53 | const valves = (): readonly ValveRow[] => pipes?.valves ?? []; |
| 43 | 54 | const here = (): ValveRow | undefined => valves()[state.index]; |
| 44 | 55 | const turn = (to: (v: ValveRow) => number): void => { |
| @@ -56,6 +67,7 @@ export function valvesScreen(f: Session, ctx: Ctx): Screen { | ||
| 56 | 67 | binding(["x"], "shut"), |
| 57 | 68 | binding(["o"], "open"), |
| 58 | 69 | binding(["t"], "take the suggestion"), |
| 70 | binding(["b"], "retry an overdue off-box backup"), | |
| 59 | 71 | ], |
| 60 | 72 | load(signal) { |
| 61 | 73 | live(f, signal, { kinds: ["valve.turned"] }, reload); |
| @@ -64,7 +76,9 @@ export function valvesScreen(f: Session, ctx: Ctx): Screen { | ||
| 64 | 76 | }, |
| 65 | 77 | render(area, s) { |
| 66 | 78 | const c = ctx.theme.c; |
| 67 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 79 | const late = overdue(); | |
| 80 | const [warn, head, list] = split(area, "v", [len(late === "" ? 0 : 1), len(1), fill(1)]); | |
| 81 | if (late !== "") s.text(warn!.x, warn!.y, truncate(late, warn!.w), style(c.fg, c.bg), warn!.x + warn!.w); | |
| 68 | 82 | const spent = pipes === undefined ? "" : `$${pipes.usd.toFixed(2)} spent in 7 days, ${String(pipes.shipped)} shipped; `; |
| 69 | 83 | s.text(head!.x, head!.y, truncate(`${spent}the crew does what the valves say, from the next tick`, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 70 | 84 | drawList(s, list!, c, state, valves().map(rowOf)); |
| @@ -74,7 +88,20 @@ export function valvesScreen(f: Session, ctx: Ctx): Screen { | ||
| 74 | 88 | else if (e.key === "l") turn((v) => Math.min(100, v.level + STEP)); |
| 75 | 89 | else if (e.key === "x") turn(() => 0); |
| 76 | 90 | else if (e.key === "o") turn(() => 100); |
| 77 | else if (e.key === "t") { | |
| 91 | else if (e.key === "b") { | |
| 92 | if (retrying || overdue() === "") return true; | |
| 93 | retrying = true; | |
| 94 | ctx.toast("Taking a snapshot…"); | |
| 95 | runAsync(ctx, async () => { | |
| 96 | try { | |
| 97 | const r = await retryBackup(f.app, f.user); | |
| 98 | ctx.toast(r.says, r.cleared ? undefined : "attn"); | |
| 99 | } finally { | |
| 100 | retrying = false; | |
| 101 | ctx.invalidate(); | |
| 102 | } | |
| 103 | }); | |
| 104 | } else if (e.key === "t") { | |
| 78 | 105 | const v = here(); |
| 79 | 106 | if (v === undefined) return true; |
| 80 | 107 | if (v.suggestion === undefined) ctx.toast("no suggestion here", "attn"); |
src/web/pages/boards-view.ts +1 −1
| @@ -123,7 +123,7 @@ export async function boardsList(ctx: Ctx): Promise<Response> { | ||
| 123 | 123 | body: html`<div class="page-head"><h1>Boards</h1> |
| 124 | 124 | ${btn("Flow", { href: "/flow", variant: "ghost" })} |
| 125 | 125 | ${btn("Explore the data", { href: "/boards/data", variant: "ghost" })}</div> |
| 126 | ${alarmLines(cachedAlarms(ctx.app.db, new Date(), forgeCeiling(ctx.app.config)))} | |
| 126 | ${alarmLines(cachedAlarms(ctx.app.db, new Date(), forgeCeiling(ctx.app.config), ctx.app.config.dataDir))} | |
| 127 | 127 | ${body} ${make} ${drift} ${flow} ${boardsHoldsCard(ctx.app.db, ctx.auth.user, true)}`, |
| 128 | 128 | }); |
| 129 | 129 | notePhase(ctx.request, "render", performance.now() - rendering); |
src/web/pages/flow.ts +23
| @@ -7,6 +7,8 @@ | ||
| 7 | 7 | * `tui/screens/valves.ts`, on the same `readPipes` and the same actions. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { retryBackup } from "../../actions/backup_retry.ts"; | |
| 11 | import { alarmBackup, type BackupAlarm } from "../../flow/alarm_backup.ts"; | |
| 10 | 12 | import { acceptSuggestion, turnValveFor, type ValveError } from "../../actions/valves.ts"; |
| 11 | 13 | import type { Ctx } from "../../http/context.ts"; |
| 12 | 14 | import { readPipes, type ValveRow } from "../../flow/pipes.ts"; |
| @@ -74,6 +76,16 @@ function valveRow(v: ValveRow) { | ||
| 74 | 76 | }); |
| 75 | 77 | } |
| 76 | 78 | |
| 79 | /** The overdue off-box backup as one row: the sentence (with the last failure's cause) and the one action that retries. Nothing when none is overdue. */ | |
| 80 | export function backupRow(alarm: BackupAlarm) { | |
| 81 | if (!alarm.fired) return html``; | |
| 82 | return html`<ul class="list mb">${row({ | |
outdated
Principle 7: a card box for a single fact. Use a row. Principle 1: heading, says and 'Overdue since' repeat each other; one sentence and the button. |
||
| 83 | lead: icon("alert"), | |
| 84 | title: html`${alarm.says}`, | |
| 85 | meta: html`<form method="post" action="/flow/backup/retry">${btn("Retry the backup now", { type: "submit", variant: "primary", size: "sm" })}</form>`, | |
| 86 | })}</ul>`; | |
| 87 | } | |
| 88 | ||
| 77 | 89 | // GET /flow |
| 78 | 90 | export function flowPage(ctx: Ctx): Response { |
| 79 | 91 | if (ctx.url.search !== "") return new Response(null, { status: 301, headers: { location: `/boards${ctx.url.search}` } }); |
| @@ -84,6 +96,7 @@ export function flowPage(ctx: Ctx): Response { | ||
| 84 | 96 | const p = readPipes(ctx.app.db, user.id); |
| 85 | 97 | const body = html`<div class="page-head"><h1>Flow</h1>${btn("Boards", { href: "/boards", variant: "ghost" })}</div> |
| 86 | 98 | <p class="mb">Money flows from the left to shipped on the right, as wide as this week's real spend. Every repository has a valve: drag it to give the repository a share of the week, all the way down to shut. The crew does exactly what the valves say, from the next tick, and nothing but you turns one.</p> |
| 99 | ${backupRow(alarmBackup(ctx.app.config.dataDir, new Date()))} | |
| 87 | 100 | ${pipesSvg(p)} |
| 88 | 101 | <p class="muted small">${money(p.usd)} spent in the last 7 days; ${plural(p.shipped, "pull")} shipped.</p> |
| 89 | 102 | ${p.valves.length === 0 |
| @@ -121,3 +134,13 @@ export async function suggestionTake(ctx: Ctx): Promise<Response> { | ||
| 121 | 134 | if (form instanceof Response) return form; |
| 122 | 135 | return done(ctx, acceptSuggestion(ctx.app, ctx.auth.user, field(form, "repo"))); |
| 123 | 136 | } |
| 137 | ||
| 138 | // POST /flow/backup/retry: one snapshot now, through the service's own queue, so two never run at once. | |
| 139 | export async function backupRetry(ctx: Ctx): Promise<Response> { | |
| 140 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 141 | const form = await readForm(ctx, "admin"); | |
outdated
Retry exists only here. Add the same action as a TUI screen entry and docs/features/<name>.md. Also test a real snapshot, and handle a local landing that leaves the card standing. |
||
| 142 | if (form instanceof Response) return form; | |
| 143 | const retried = await retryBackup(ctx.app, ctx.auth.user); | |
| 144 | if (retried.refused) return notFoundPage(ctx); | |
| 145 | return flashTo(ctx, "/flow", retried.cleared ? { message: retried.says } : { tone: "danger", message: retried.says }); | |
| 146 | } |
src/web/studio/alarm_lines.ts +7 −4
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * checked, because the words name private repositories. |
| 6 | 6 | */ |
| 7 | 7 | import type { Database } from "bun:sqlite"; |
| 8 | import { alarmBackup } from "../../flow/alarm_backup.ts"; | |
| 8 | 9 | import { firedAlarms, type FiredAlarm } from "../../flow/alarms.ts"; |
| 9 | 10 | import { writeStamp } from "../../core/write_clock.ts"; |
| 10 | 11 | import { Stamped } from "../../kernel/stamped.ts"; |
| @@ -14,15 +15,17 @@ import { HOME_MAX_AGE_MS } from "./home_cache.ts"; | ||
| 14 | 15 | const caches = new WeakMap<Database, Stamped<FiredAlarm[]>>(); |
| 15 | 16 | |
| 16 | 17 | /** Valid like the vitals: while the write clock holds, 30 seconds at most. */ |
| 17 | export function cachedAlarms(db: Database, now: Date, ceilingUsd: number | null): FiredAlarm[] { | |
| 18 | export function cachedAlarms(db: Database, now: Date, ceilingUsd: number | null, dataDir: string): FiredAlarm[] { | |
| 18 | 19 | const stamp = writeStamp(db); |
| 19 | if (stamp === undefined) return firedAlarms(db, now, { ceilingUsd }); | |
| 20 | if (stamp === undefined) return firedAlarms(db, now, { ceilingUsd, dataDir }); | |
| 20 | 21 | let cache = caches.get(db); |
| 21 | 22 | if (cache === undefined) { |
| 22 | cache = new Stamped<FiredAlarm[]>({ maxEntries: 2, maxAgeMs: HOME_MAX_AGE_MS }); | |
| 23 | cache = new Stamped<FiredAlarm[]>({ maxEntries: 8, maxAgeMs: HOME_MAX_AGE_MS }); | |
| 23 | 24 | caches.set(db, cache); |
| 24 | 25 | } |
| 25 | return cache.get("alarms", stamp, () => firedAlarms(db, now, { ceilingUsd })); | |
| 26 | // The backup alarm reads files, not the db the stamp watches: its state is part of the key, so a cleared backup leaves Home at once. | |
| 27 | const backup = alarmBackup(dataDir, now); | |
| 28 | return cache.get(`alarms:${backup.fired ? backup.since : "-"}:${backup.cause ?? ""}`, stamp, () => firedAlarms(db, now, { ceilingUsd, dataDir })); | |
| 26 | 29 | } |
| 27 | 30 | |
| 28 | 31 | export function alarmLines(alarms: readonly FiredAlarm[]): Html { |
src/web/studio/home.ts +1 −1
| @@ -344,7 +344,7 @@ export async function studioHome( | ||
| 344 | 344 | const waiting = await homeRegion(ctx.app.db, ctx.request, user.id, "waiting", homeVariant(ctx.url), access, draws.waiting); |
| 345 | 345 | const held = timePhase(ctx.request, "held", () => heldLine(ctx, user)); |
| 346 | 346 | const alarms = user.isAdmin |
| 347 | ? alarmLines(cachedAlarms(ctx.app.db, new Date(), forgeCeiling(ctx.app.config))) | |
| 347 | ? alarmLines(cachedAlarms(ctx.app.db, new Date(), forgeCeiling(ctx.app.config), ctx.app.config.dataDir)) | |
| 348 | 348 | : null; |
| 349 | 349 | const visitedAt = Date.now(); |
| 350 | 350 | const before = timePhase(ctx.request, "visit", () => |
test/alarm-backup.test.ts added +106
| @@ -0,0 +1,106 @@ | ||
| 1 | /** The off-box backup alarm (#10924): raises at 26 h, names the last failure's cause, clears when a backup lands; /flow draws the card with one retry. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createUser } from "../src/core/users.ts"; | |
| 8 | import { alarmBackup } from "../src/flow/alarm_backup.ts"; | |
| 9 | import { firedAlarms } from "../src/flow/alarms.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { recordSnapshotOutcome } from "../src/snapshot/outcome.ts"; | |
| 12 | ||
| 13 | const NOW = new Date("2026-11-10T12:00:00.000Z"); | |
| 14 | const hoursAgo = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString(); | |
| 15 | ||
| 16 | let dir: string; | |
| 17 | let app: App; | |
| 18 | let servers: Servers; | |
| 19 | beforeAll(async () => { | |
| 20 | dir = await mkdtemp(join(tmpdir(), "forge-alarm-backup-")); | |
| 21 | app = createApp({ dataDir: dir, port: 0 }); | |
| 22 | servers = await serve(app); | |
| 23 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 24 | const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst }); | |
| 25 | if (!u.ok) throw new Error(u.error); | |
| 26 | } | |
| 27 | }); | |
| 28 | afterAll(async () => { | |
| 29 | servers.stop(); | |
| 30 | await rm(dir, { recursive: true, force: true }); | |
| 31 | }); | |
| 32 | ||
| 33 | const fresh = () => mkdtemp(join(tmpdir(), "forge-ab-")); | |
| 34 | ||
| 35 | describe("alarmBackup", () => { | |
| 36 | test("says nothing for a forge that has neither sent a copy off the box nor failed to", async () => { | |
| 37 | expect(alarmBackup(await fresh(), NOW).fired).toBe(false); | |
| 38 | }); | |
| 39 | ||
| 40 | test("raises past 26 h with the cause line, and not at 25 h", async () => { | |
| 41 | const d = await fresh(); | |
| 42 | recordSnapshotOutcome(d, { landed: "s1", offBox: true }, hoursAgo(25)); | |
| 43 | expect(alarmBackup(d, NOW).fired).toBe(false); | |
| 44 | recordSnapshotOutcome(d, { error: "S3 PUT refused: 403 AccessDenied\nstack…" }, hoursAgo(1)); | |
| 45 | const late = new Date(NOW.getTime() + 2 * 3_600_000); | |
| 46 | const a = alarmBackup(d, late); | |
| 47 | expect(a.fired).toBe(true); | |
| 48 | expect(a.since).toBe(new Date(Date.parse(hoursAgo(25)) + 26 * 3_600_000).toISOString()); | |
| 49 | expect(a.cause).toBe("The snapshot failed: S3 PUT refused: 403 AccessDenied"); | |
| 50 | expect(a.says).toBe("The newest off-box backup is 27 h old. The snapshot failed: S3 PUT refused: 403 AccessDenied"); | |
| 51 | }); | |
| 52 | ||
| 53 | test("a failure with nothing ever off the box raises from the failure", async () => { | |
| 54 | const d = await fresh(); | |
| 55 | recordSnapshotOutcome(d, { error: "no route to bucket" }, hoursAgo(3)); | |
| 56 | const a = alarmBackup(d, NOW); | |
| 57 | expect(a.fired).toBe(true); | |
| 58 | expect(a.since).toBe(hoursAgo(3)); | |
| 59 | expect(a.says).toBe("No off-box backup has landed. The snapshot failed: no route to bucket"); | |
| 60 | }); | |
| 61 | ||
| 62 | test("clears when a backup lands off the box", async () => { | |
| 63 | const d = await fresh(); | |
| 64 | recordSnapshotOutcome(d, { landed: "s1", offBox: true }, hoursAgo(40)); | |
| 65 | recordSnapshotOutcome(d, { error: "boom" }, hoursAgo(10)); | |
| 66 | expect(alarmBackup(d, NOW).fired).toBe(true); | |
| 67 | recordSnapshotOutcome(d, { landed: "s2", offBox: true }, hoursAgo(0.5)); | |
| 68 | expect(alarmBackup(d, NOW).fired).toBe(false); | |
| 69 | }); | |
| 70 | ||
| 71 | test("firedAlarms carries it once given the data directory", async () => { | |
| 72 | const d = await fresh(); | |
| 73 | recordSnapshotOutcome(d, { error: "boom" }, hoursAgo(3)); | |
| 74 | expect(firedAlarms(app.db, NOW, { dataDir: d }).some((a) => a.key === "backup")).toBe(true); | |
| 75 | expect(firedAlarms(app.db, NOW, { dataDir: d, publicOnly: true }).some((a) => a.key === "backup")).toBe(false); | |
| 76 | }); | |
| 77 | }); | |
| 78 | ||
| 79 | describe("/flow backup card", () => { | |
| 80 | const login = async (username: string) => { | |
| 81 | const res = await fetch(`${servers.url}/signin`, { method: "POST", redirect: "manual", headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url }, body: new URLSearchParams({ username, password: "a-good-passphrase" }) }); | |
| 82 | return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 83 | }; | |
| 84 | const retry = (cookie: string, origin = servers.url) => | |
| 85 | fetch(`${servers.url}/flow/backup/retry`, { method: "POST", redirect: "manual", headers: { cookie, origin, "content-type": "application/x-www-form-urlencoded" }, body: "" }); | |
| 86 | ||
| 87 | test("draws the cause and one retry while overdue, and nothing once cleared", async () => { | |
| 88 | const cookie = await login("alice"); | |
| 89 | recordSnapshotOutcome(dir, { error: "bucket unreachable" }, hoursAgo(3)); | |
| 90 | const text = await (await fetch(`${servers.url}/flow`, { headers: { cookie } })).text(); | |
| 91 | expect(text).toContain("No off-box backup has landed"); | |
| 92 | expect(text).toContain("The snapshot failed: bucket unreachable"); | |
| 93 | expect(text.match(/action="\/flow\/backup\/retry"/g)?.length).toBe(1); | |
| 94 | recordSnapshotOutcome(dir, { landed: "s9", offBox: true }, new Date().toISOString()); | |
| 95 | const after = await (await fetch(`${servers.url}/flow`, { headers: { cookie } })).text(); | |
| 96 | expect(after).not.toContain("/flow/backup/retry"); | |
| 97 | }); | |
| 98 | ||
| 99 | test("retry answers an admin with a flash back to /flow, and refuses a stranger and a foreign origin", async () => { | |
| 100 | const res = await retry(await login("alice")); | |
| 101 | expect(res.status).toBe(303); | |
| 102 | expect(res.headers.get("location")).toBe("/flow"); | |
| 103 | expect((await retry(await login("bob"))).status).toBe(404); | |
| 104 | expect((await retry(await login("alice"), "https://evil.example")).status).toBeGreaterThanOrEqual(400); | |
| 105 | }); | |
| 106 | }); |
test/alarm_from_app.test.ts +1 −1
| @@ -93,7 +93,7 @@ describe("a person's note from an app", () => { | ||
| 93 | 93 | expect(unassigned.fired).toBe(true); |
| 94 | 94 | expect(unassigned.says).toContain(`alice/shop#${n}`); |
| 95 | 95 | expect(unassigned.says).toContain("unassigned"); |
| 96 | expect(firedAlarms(app.db, new Date(at + 16 * MIN)).map((a) => a.key)).toContain("from_app"); | |
| 96 | expect(firedAlarms(app.db, new Date(at + 16 * MIN), { dataDir: null }).map((a) => a.key)).toContain("from_app"); | |
| 97 | 97 | // Assigned: quiet until the hour. |
| 98 | 98 | app.db.query("UPDATE issue SET assignee_id = ?3 WHERE repo_id = ?1 AND number = ?2").run(repo.id, n, builder.id); |
| 99 | 99 | expect(alarmFromApp(app.db, new Date(at + 59 * MIN)).fired).toBe(false); |
test/backup-retry.test.ts added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | /** #10924 round two: the retry is honest when the store is a local directory, the terminal has the same retry, and Home drops a cleared backup at once. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { retryBackup } from "../src/actions/backup_retry.ts"; | |
| 8 | import { createUser, type User } from "../src/core/users.ts"; | |
| 9 | import { alarmBackup } from "../src/flow/alarm_backup.ts"; | |
| 10 | import { recordSnapshotOutcome } from "../src/snapshot/outcome.ts"; | |
| 11 | import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts"; | |
| 12 | import { dirStore } from "../src/snapshot/store.ts"; | |
| 13 | import { cachedAlarms } from "../src/web/studio/alarm_lines.ts"; | |
| 14 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 15 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | import { settleApp, settleUntil } from "./helpers/tui.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | let dir: string; | |
| 22 | let app: App; | |
| 23 | let svc: SnapshotsHandle; | |
| 24 | let alice: User; | |
| 25 | let bob: User; | |
| 26 | const identity = (user: User) => ({ sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }); | |
| 27 | ||
| 28 | beforeAll(async () => { | |
| 29 | dir = await mkdtemp(join(tmpdir(), "forge-backup-retry-")); | |
| 30 | app = createApp({ dataDir: dir, port: 0 }); | |
| 31 | const mk = async (username: string, adminIfFirst: boolean) => { | |
| 32 | const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst }); | |
| 33 | if (!r.ok) throw new Error(r.error); | |
| 34 | return r.value; | |
| 35 | }; | |
| 36 | alice = await mk("alice", true); | |
| 37 | bob = await mk("bob", false); | |
| 38 | svc = startSnapshots(app, dirStore(join(dir, "store")), { version: "t", nightly: false }); | |
| 39 | }, 30_000); | |
| 40 | ||
| 41 | afterAll(async () => { | |
| 42 | await svc.stop(); | |
| 43 | app.close(); | |
| 44 | await rm(dir, { recursive: true, force: true }); | |
| 45 | }); | |
| 46 | ||
| 47 | test("a real snapshot into a local directory does not clear the alarm, and the retry says so", async () => { | |
| 48 | recordSnapshotOutcome(dir, { landed: "old", offBox: true }, new Date(Date.now() - 40 * 3_600_000).toISOString()); | |
| 49 | recordSnapshotOutcome(dir, { error: "bucket unreachable" }, new Date(Date.now() - 3 * 3_600_000).toISOString()); | |
| 50 | expect(alarmBackup(dir, new Date()).fired).toBe(true); | |
| 51 | const r = await retryBackup(app, alice); | |
| 52 | expect(r.cleared).toBe(false); | |
| 53 | expect(r.says).toContain("It stayed on this machine, so it is not an off-box backup"); | |
| 54 | expect(alarmBackup(dir, new Date()).fired).toBe(true); | |
| 55 | }); | |
| 56 | ||
| 57 | test("a stranger is refused", async () => { | |
| 58 | expect((await retryBackup(app, bob)).refused).toBe("not_found"); | |
| 59 | }); | |
| 60 | ||
| 61 | test("Home's cached alarms drop a backup that has cleared, inside the 30 s", () => { | |
| 62 | const d = dir; | |
| 63 | recordSnapshotOutcome(d, { error: "boom" }, new Date(Date.now() - 3 * 3_600_000).toISOString()); | |
| 64 | expect(cachedAlarms(app.db, new Date(), null, d).some((a) => a.key === "backup")).toBe(true); | |
| 65 | recordSnapshotOutcome(d, { landed: "s", offBox: true }, new Date().toISOString()); | |
| 66 | expect(cachedAlarms(app.db, new Date(), null, d).some((a) => a.key === "backup")).toBe(false); | |
| 67 | }); | |
| 68 | ||
| 69 | test("the Valves screen shows the overdue backup and b retries it", async () => { | |
| 70 | recordSnapshotOutcome(dir, { landed: "old", offBox: true }, new Date(Date.now() - 40 * 3_600_000).toISOString()); | |
| 71 | recordSnapshotOutcome(dir, { error: "bucket unreachable" }, new Date(Date.now() - 3 * 3_600_000).toISOString()); | |
| 72 | const { app: tui } = createForgeApp(app, identity(alice), { terminal: headlessTerminal(160, 24), exitHooks: false }); | |
| 73 | tui.start(); | |
| 74 | tui.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false }); | |
| 75 | for (const ch of "Valves") tui.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false }); | |
| 76 | await settleApp(tui); | |
| 77 | tui.dispatch({ type: "key", key: "enter", ctrl: false, alt: false, shift: false }); | |
| 78 | await settleUntil(tui, "b retries it now", { timeoutMs: 4_000 }); | |
| 79 | tui.dispatch({ type: "key", key: "b", ctrl: false, alt: false, shift: false }); | |
| 80 | await settleUntil(tui, "stayed on this machine", { timeoutMs: 8_000 }); | |
| 81 | tui.stop?.(); | |
| 82 | }); |
test/flow-alarm-lines.test.ts +1 −1
| @@ -67,7 +67,7 @@ describe("alarm lines", () => { | ||
| 67 | 67 | expect(flowFor(app, { kind: "owner" }).alarms.map((a) => a.key)).toEqual(["no_release"]); |
| 68 | 68 | const none = await seed(); |
| 69 | 69 | expect(flowFor(none.app, { kind: "owner" }).alarms).toEqual([]); |
| 70 | expect(firedAlarms(none.app.db, new Date())).toEqual([]); | |
| 70 | expect(firedAlarms(none.app.db, new Date(), { dataDir: null })).toEqual([]); | |
| 71 | 71 | }); |
| 72 | 72 | |
| 73 | 73 | test("the owner is told once when an alarm starts firing, not again while it stands", async () => { |
test/flow-bugs.test.ts +1 −1
| @@ -58,7 +58,7 @@ describe("bug clock", () => { | ||
| 58 | 58 | expect(a.fired).toBe(true); |
| 59 | 59 | expect(a.says).toContain("and 1 more"); |
| 60 | 60 | expect(countBugs(app.db, NOW)).toEqual({ open: 4, overdue: 2 }); |
| 61 | const fired = firedAlarms(app.db, NOW).filter((x) => x.key === "bug_clock"); | |
| 61 | const fired = firedAlarms(app.db, NOW, { dataDir: null }).filter((x) => x.key === "bug_clock"); | |
| 62 | 62 | expect(fired).toHaveLength(1); |
| 63 | 63 | expect(fired[0]!.href).toMatch(/^\/alice\/r\/issues\/\d+$/); |
| 64 | 64 | }); |
test/gate-bypass.test.ts +4 −4
| @@ -160,15 +160,15 @@ describe("landing past a broken gate", () => { | ||
| 160 | 160 | expect(landingStopped(app.db, repo.id)).toBeUndefined(); |
| 161 | 161 | gate(t + 2, "red", ["the gate > is broken", "other"]); |
| 162 | 162 | expect(landingStopped(app.db, repo.id)?.gates).toBe(3); |
| 163 | const alarm = firedAlarms(app.db, new Date()).find((a) => a.key === "landing_stopped"); | |
| 163 | const alarm = firedAlarms(app.db, new Date(), { dataDir: null }).find((a) => a.key === "landing_stopped"); | |
| 164 | 164 | expect(alarm?.says).toStartWith("Landing has stopped:"); |
| 165 | 165 | expect(alarm?.says.split(". ").length).toBe(1); |
| 166 | 166 | }); |
| 167 | 167 | |
| 168 | 168 | test("the alarm is the owner's: a public-only reader never sees it", () => { |
| 169 | 169 | stop(); |
| 170 | expect(firedAlarms(app.db, new Date()).some((a) => a.key === "landing_stopped")).toBe(true); | |
| 171 | expect(firedAlarms(app.db, new Date(), { publicOnly: true }).some((a) => a.key === "landing_stopped")).toBe(false); | |
| 170 | expect(firedAlarms(app.db, new Date(), { dataDir: null }).some((a) => a.key === "landing_stopped")).toBe(true); | |
| 171 | expect(firedAlarms(app.db, new Date(), { dataDir: null, publicOnly: true }).some((a) => a.key === "landing_stopped")).toBe(false); | |
| 172 | 172 | }); |
| 173 | 173 | |
| 174 | 174 | test("three red gates of different causes, or mixed with a green, are not a stop", () => { |
| @@ -191,7 +191,7 @@ describe("landing past a broken gate", () => { | ||
| 191 | 191 | await green(next); |
| 192 | 192 | expect((await mergePull(app, owner, repo, next, "squash")).ok).toBe(true); |
| 193 | 193 | expect(landingStopped(app.db, repo.id)).toBeUndefined(); |
| 194 | expect(firedAlarms(app.db, new Date()).some((a) => a.key === "landing_stopped")).toBe(false); | |
| 194 | expect(firedAlarms(app.db, new Date(), { dataDir: null }).some((a) => a.key === "landing_stopped")).toBe(false); | |
| 195 | 195 | }); |
| 196 | 196 | |
| 197 | 197 | test("core: refused while landing is running, for an outsider, an agent, a short reason; then it lands and records", async () => { |
test/mcp-system-stalls.test.ts +4 −2
| @@ -34,8 +34,10 @@ afterAll(async () => { | ||
| 34 | 34 | }); |
| 35 | 35 | |
| 36 | 36 | test("a stall under a named job is read through system, at most 10, most ms first", async () => { |
| 37 | // Let every request other tests in this process finished fall outside the 300 ms window, so only the job ran across it. | |
| 38 | await Bun.sleep(320); | |
| 37 | 39 | during("test-job-10907", () => 1); |
| 38 | loopWatch.stalled(5_000, performance.now()); | |
| 40 | loopWatch.stalled(300, performance.now()); | |
| 39 | 41 | const res = await fetch(`${servers.url}/mcp`, { |
| 40 | 42 | method: "POST", |
| 41 | 43 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, |
| @@ -44,7 +46,7 @@ test("a stall under a named job is read through system, at most 10, most ms firs | ||
| 44 | 46 | const body = (await res.json()) as any; |
| 45 | 47 | const out = JSON.parse(body.result.content[0].text); |
| 46 | 48 | expect(out.stalls.length).toBeLessThanOrEqual(10); |
| 47 | expect(out.stalls).toContainEqual({ cause: "job:test-job-10907", ms: 5000, count: 1 }); | |
| 49 | expect(out.stalls).toContainEqual({ cause: "job:test-job-10907", ms: 300, count: 1 }); | |
| 48 | 50 | const ms = out.stalls.map((s: any) => s.ms); |
| 49 | 51 | expect(ms).toEqual([...ms].sort((a, b) => b - a)); |
| 50 | 52 | }); |
test/mcp-write-scope.test.ts +2
| @@ -99,6 +99,8 @@ describe("a token that may only read cannot write through any tool", () => { | ||
| 99 | 99 | /** Minimal arguments that make the call a real write, not a plan or a list. */ |
| 100 | 100 | const writing = (t: ReturnType<typeof toolRegistry>[number]) => { |
| 101 | 101 | if (t.name in ADMIN_ARGS) return ADMIN_ARGS[t.name]!; |
| 102 | // `flow` reads unless it is asked to update a drifting pull (#10959). | |
| 103 | if (t.name === "flow") return { action: "update", kind: "drift", repo: "alice/game", pull: 1 }; | |
| 102 | 104 | const args: Record<string, unknown> = { dryRun: false, action: "x" }; |
| 103 | 105 | if (t.readsWithout !== undefined) args[[t.readsWithout].flat()[0]!] = "x"; |
| 104 | 106 | return args; |
test/run-state.test.ts +2 −2
| @@ -115,7 +115,7 @@ describe("silence raises one alarm", () => { | ||
| 115 | 115 | test("two silent runs are one alarm, naming the one silent longest; the shown state is on the API row", async () => { |
| 116 | 116 | const older = runOn(held("g"), "running", 3 * SILENT_MS); |
| 117 | 117 | runOn(held("h"), "running", 2 * SILENT_MS); |
| 118 | const silent = firedAlarms(app.db, new Date()).filter((a) => a.key === "silent"); | |
| 118 | const silent = firedAlarms(app.db, new Date(), { dataDir: null }).filter((a) => a.key === "silent"); | |
| 119 | 119 | expect(silent).toHaveLength(1); |
| 120 | 120 | expect(silent[0]!.href).toEndWith(`/runs/${older.id}`); |
| 121 | 121 | const token = createToken(app.db, alice.id, "t", "ro").token; |
| @@ -137,7 +137,7 @@ describe("the same issue repeating runs is stopped", () => { | ||
| 137 | 137 | expect(queueAgentRun(app, fresh(), issue, bot, "assigned", alice.id)).toBeUndefined(); |
| 138 | 138 | expect(listComments(app.db, issue.id).filter((c) => c.body === REPEAT_NOTE)).toHaveLength(1); |
| 139 | 139 | expect(getIssueById(app.db, issue.id)!.assigneeId).toBeNull(); |
| 140 | expect(firedAlarms(app.db, new Date()).filter((a) => a.key === "repeat")).toHaveLength(1); | |
| 140 | expect(firedAlarms(app.db, new Date(), { dataDir: null }).filter((a) => a.key === "repeat")).toHaveLength(1); | |
| 141 | 141 | }); |
| 142 | 142 | test("runs spread beyond ten minutes do not trip it; a reader's word does not lift it, a writer's does", async () => { |
| 143 | 143 | const slow = held("Slow"); |
Optional dataDir lets callers drop the alarm silently (TUI flow already does). Make it required.