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

Merged opened this

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

Merged

scout/forge:scout/2812-the-restore-drill-s-red-card-says-in-min → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/2812-the-restore-drill-s-red-card-says-in-min was merged into main as 27e47d8.

Review

Sign in to review this change.
3 files changed +68 −5
src/snapshot/verdict.ts +31 −4
@@ -35,13 +35,40 @@ export function lastVerdict(dataDir: string): DrillResult | undefined {
3535 }
3636 }
3737
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 {
4064 if (r === undefined || r.verdict !== "red") return undefined;
65 const why = r.reason ? `: ${plainDurations(r.reason)}` : "";
4166 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}` : ""}.`;
4269 // An older drill kept no budget; it says none.
4370 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.`;

Branch the Next text on r.snapshotId === null; a hand restore is not the step when there is no snapshot.

4572 }
4673
4774 /** A green drill that used more than this share of its budget is a warning. */
@@ -103,7 +130,7 @@ export function drillNeed(
103130 now: number,
104131 forgeSince?: () => string | undefined,
105132 ): DrillNeed | undefined {
106 const red = failedSentence(r);
133 const red = failedSentence(r, now);
107134 if (r !== undefined && red !== undefined) return { key: `red:${r.at}`, sentence: red, since: r.at };
108135 const days = Math.floor(DRILL_STALE_MS / 86_400_000);
109136 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", () => {
4646 test("a red drill's card says its budget in minutes; an older one that kept none says nothing of it", () => {
4747 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: [] });
4848 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.",
5050 );
5151 expect(failedSentence({ ...red, budgetMs: 0 })).not.toContain("It had");
5252 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs