Public: anyone can read this; the crew can change it.
The restore drill's red card says, in minutes, what failed, how old the newest snapshot is, and the one next step #2817
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2812-the-restore-drill-s-red-card-says-in-min → main 1 commit fork 2 threads · 2 unresolved
3 files
changed +68
−5
src/snapshot/verdict.ts +31 −4
| @@ -35,13 +35,40 @@ export function lastVerdict(dataDir: string): DrillResult | undefined { | ||
| 35 | 35 | } |
| 36 | 36 | } |
| 37 | 37 | |
| 38 | /** The inbox card's sentence for a red drill; undefined for a green one. */ | |
| 39 | export function failedSentence(r: DrillResult | undefined): string | undefined { | |
| 38 | /** The durations the drill and the store write ("no answer in 596591.19 ms", "in 45 s") said the way a person would. */ | |
| 39 | export function plainDurations(text: string): string { | |
| 40 | return text.replace(/\b(no answer in|timed out in|after) (\d+(?:\.\d+)?) (ms|s)\b/g, (_, lead: string, n: string, unit: string) => { | |
| 41 | const ms = Number(n) * (unit === "s" ? 1000 : 1); | |
| 42 | if (ms < 1000) return `${lead} under a second`; | |
| 43 | if (ms < 90_000) return `${lead} about ${Math.round(ms / 1000)} seconds`; | |
| 44 | const min = Math.round(ms / 60_000); | |
| 45 | return `${lead} about ${min} minute${min === 1 ? "" : "s"}`; | |
| 46 | }); | |
| 47 | } | |
| 48 | ||
| 49 | /** How old a snapshot is, from its id (`2026-10-14T08-00-00Z`): "5 days old"; undefined when the id is no time. */ | |
| 50 | function snapshotAge(id: string | null, now: number): string | undefined { | |
| 51 | const at = Date.parse((id ?? "").replace(/T(\d\d)-(\d\d)-(\d\d)Z$/, "T$1:$2:$3Z")); | |
| 52 | if (!Number.isFinite(at)) return undefined; | |
| 53 | const hours = Math.max(0, Math.round((now - at) / 3_600_000)); | |
| 54 | if (hours < 48) return `${hours} hour${hours === 1 ? "" : "s"} old`; | |
| 55 | return `${Math.round(hours / 24)} days old`; | |
| 56 | } | |
| 57 | ||
| 58 | /** | |
| 59 | * The inbox card's sentence for a red drill; undefined for a green one. | |
| 60 | * Says what failed, how old the snapshot it tried is (when `now` is given) | |
| 61 | * and the one next step; no raw milliseconds. | |
| 62 | */ | |
| 63 | export function failedSentence(r: DrillResult | undefined, now?: number): string | undefined { | |
| 40 | 64 | if (r === undefined || r.verdict !== "red") return undefined; |
| 65 | const why = r.reason ? `: ${plainDurations(r.reason)}` : ""; | |
| 41 | 66 | const times = describeTimes(r.phases); |
| 67 | const age = now === undefined ? undefined : snapshotAge(r.snapshotId, now); | |
| 68 | const which = r.snapshotId === null ? "No snapshot was found." : `The newest snapshot is ${r.snapshotId}${age ? `, ${age}` : ""}.`; | |
| 42 | 69 | // An older drill kept no budget; it says none. |
| 43 | 70 | const budget = r.budgetMs > 0 ? ` It had ${formatMinutes(r.budgetMs)}.` : ""; |
| 44 | return `The restore drill failed at ${r.failedStep ?? "an unknown step"}${r.reason ? `: ${r.reason}` : ""}${times ? ` (${times})` : ""}.${budget} Snapshot ${r.snapshotId ?? "none"}; docs/RECOVERY.md says what to do.`; | |
| 71 | return `The restore drill failed at ${r.failedStep ?? "an unknown step"}${why}${times ? ` (${times})` : ""}.${budget} ${which} Next: restore it by hand as docs/RECOVERY.md Route 1 says; the card clears when a drill passes.`; | |
| 45 | 72 | } |
| 46 | 73 | |
| 47 | 74 | /** A green drill that used more than this share of its budget is a warning. */ |
| @@ -103,7 +130,7 @@ export function drillNeed( | ||
| 103 | 130 | now: number, |
| 104 | 131 | forgeSince?: () => string | undefined, |
| 105 | 132 | ): DrillNeed | undefined { |
| 106 | const red = failedSentence(r); | |
| 133 | const red = failedSentence(r, now); | |
| 107 | 134 | if (r !== undefined && red !== undefined) return { key: `red:${r.at}`, sentence: red, since: r.at }; |
| 108 | 135 | const days = Math.floor(DRILL_STALE_MS / 86_400_000); |
| 109 | 136 | if (r === undefined) { |
test/drill-card-words.test.ts added +36
| @@ -0,0 +1,36 @@ | ||
| 1 | /** | |
| 2 | * The red drill's card is for a person (#2812): minutes, never raw | |
The comment cites #2812 but this is pull #2817; check the issue number. |
||
| 3 | * milliseconds; how old the snapshot is; one next step. | |
| 4 | */ | |
| 5 | ||
| 6 | import { expect, test } from "bun:test"; | |
| 7 | import { failedSentence, plainDurations } from "../src/snapshot/verdict.ts"; | |
| 8 | import type { DrillResult } from "../src/snapshot/drill.ts"; | |
| 9 | ||
| 10 | const red = { | |
| 11 | at: "2026-10-18T08:00:00Z", | |
| 12 | snapshotId: "2026-10-14T08-00-00Z", | |
| 13 | ms: 600_000, | |
| 14 | counts: {}, | |
| 15 | health: "unreached", | |
| 16 | verdict: "red", | |
| 17 | failedStep: "restore", | |
| 18 | phases: [], | |
| 19 | budgetMs: 600_000, | |
| 20 | reason: "restore timed out: no answer in 596591.19 ms", | |
| 21 | } as unknown as DrillResult; | |
| 22 | ||
| 23 | test("the card says minutes, the snapshot's age and the next step", () => { | |
| 24 | const s = failedSentence(red, Date.parse("2026-10-19T08:00:00Z"))!; | |
| 25 | expect(s).toContain("about 10 minutes"); | |
| 26 | expect(s).not.toMatch(/\d\.\d+ ?ms|596591/); | |
| 27 | expect(s).toContain("5 days old"); | |
| 28 | expect(s).toContain("Next: restore it by hand as docs/RECOVERY.md Route 1 says"); | |
| 29 | }); | |
| 30 | ||
| 31 | test("plainDurations rounds sub-second, seconds and minutes", () => { | |
| 32 | expect(plainDurations("no answer in 340 ms")).toBe("no answer in under a second"); | |
| 33 | expect(plainDurations("no answer in 45 s")).toBe("no answer in about 45 seconds"); | |
| 34 | expect(plainDurations("no answer in 180 s")).toBe("no answer in about 3 minutes"); | |
| 35 | expect(plainDurations("table sizes 5 s and 7 ms")).toBe("table sizes 5 s and 7 ms"); | |
| 36 | }); |
test/drill-slow.test.ts +1 −1
| @@ -46,7 +46,7 @@ test("a drill with no budget says nothing", () => { | ||
| 46 | 46 | test("a red drill's card says its budget in minutes; an older one that kept none says nothing of it", () => { |
| 47 | 47 | const red = drill({ verdict: "red", failedStep: "restore", reason: "get snapshots/x failed after 5 attempts: no answer in 2 min", budgetMs: 1_000_000, phases: [] }); |
| 48 | 48 | expect(failedSentence(red)).toBe( |
| 49 | "The restore drill failed at restore: get snapshots/x failed after 5 attempts: no answer in 2 min. It had 16.7 min. Snapshot snap-1; docs/RECOVERY.md says what to do.", | |
| 49 | "The restore drill failed at restore: get snapshots/x failed after 5 attempts: no answer in 2 min. It had 16.7 min. The newest snapshot is snap-1. Next: restore it by hand as docs/RECOVERY.md Route 1 says; the card clears when a drill passes.", | |
| 50 | 50 | ); |
| 51 | 51 | expect(failedSentence({ ...red, budgetMs: 0 })).not.toContain("It had"); |
| 52 | 52 | }); |
new above
offline
Branch the Next text on r.snapshotId === null; a hand restore is not the step when there is no snapshot.