Public: anyone can read this; the crew can change it.
The terminal's failure domains screen runs a restore drill on the newest snapshot with `d` and a confirm, as MCP `restore_drill` does #7315
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7298-the-terminal-s-failure-domains-screen-ru → main 2 commits fork 4 threads · 4 unresolved
docs/SNAPSHOT.md +1
| @@ -196,6 +196,7 @@ last kept drill (when, green or red with its reason, seconds until back) or | ||
| 196 | 196 | the sentence on why it is not drilled. In the terminal it is the **Failure domains** |
| 197 | 197 | command (`ctrl+k`; the **Resilience** command is the THREATS.md one), shown to an admin only and read through the same |
| 198 | 198 | `listResilience` action. Only the latest drill per domain is shown. |
| 199 | An admin presses `d` on Failure domains to run the restore drill, behind a confirm, with the same words as the Backups button and MCP `restore_drill`; it keeps the snapshot drill record only and leaves the domain drills as they are. | |
| 199 | 200 | The **Owner's needs** command (admin only, `ownerView`) is the owner's one screen: the forge's needs as the board strip says them, where snapshots go (and any mirror) with the newest snapshot, and every domain with what the owner does. |
| 200 | 201 | |
| 201 | 202 | A red drill, or none in eight days, is one line for the forge's admin on the |
src/tui/screens/domains.ts +74 −8
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * empty screen that says nothing exists. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { snapshotsOf } from "../../snapshot/service.ts"; | |
| 8 | 9 | import { listResilience, type ResilienceRow } from "../../actions/resilience.ts"; |
| 9 | 10 | import type { Ctx, Screen } from "../app/app.ts"; |
| 10 | 11 | import { binding } from "../app/keys.ts"; |
| @@ -12,7 +13,7 @@ import { fill, len, split } from "../ui/layout.ts"; | ||
| 12 | 13 | import { style } from "../term/style.ts"; |
| 13 | 14 | import { truncate } from "../term/width.ts"; |
| 14 | 15 | import type { Session } from "../session.ts"; |
| 15 | import { ListState, drawList, type ListRow } from "./common.ts"; | |
| 16 | import { ListState, confirmScreen, drawList, type ListRow } from "./common.ts"; | |
| 16 | 17 | |
| 17 | 18 | function drillLine(d: ResilienceRow): string { |
| 18 | 19 | if (d.drill.name === null) return `Not drilled: ${d.drill.why}`; |
| @@ -25,6 +26,9 @@ export function domainsScreen(f: Session, ctx: Ctx): Screen { | ||
| 25 | 26 | const state = new ListState(); |
| 26 | 27 | let domains: ResilienceRow[] = []; |
| 27 | 28 | let loaded = false; |
| 29 | let admin = false; | |
| 30 | let drilling = false; | |
| 31 | let verdict = ""; | |
| 28 | 32 | |
| 29 | 33 | const rows = (): ListRow[] => |
| 30 | 34 | domains.map((d) => ({ |
| @@ -33,15 +37,69 @@ export function domainsScreen(f: Session, ctx: Ctx): Screen { | ||
| 33 | 37 | meta: `${d.breaks} · ${drillLine(d)}`, |
| 34 | 38 | })); |
| 35 | 39 | |
| 40 | const refresh = (): void => { | |
| 41 | const listed = listResilience(f.app, f.user); | |
| 42 | domains = listed.ok ? listed.value : []; | |
| 43 | admin = listed.ok; | |
| 44 | loaded = true; | |
| 45 | state.clamp(domains.length); | |
| 46 | ctx.invalidate(); | |
| 47 | }; | |
| 48 | ||
| 49 | /** Restore the newest snapshot as a drill, worded as MCP `restore_drill` and the Backups button. */ | |
| 50 | const drill = async (): Promise<void> => { | |
| 51 | if (drilling) return; | |
| 52 | // The write asks again who is pressing, as the Backups POST and MCP do on every call. | |
| 53 | if (!listResilience(f.app, f.user).ok || f.user?.kind === "agent") { | |
Non-blocking: |
||
| 54 | admin = false; | |
| 55 | verdict = "Only a forge admin can run a restore drill."; | |
| 56 | ctx.toast(verdict, "danger"); | |
| 57 | ctx.invalidate(); | |
| 58 | return; | |
| 59 | } | |
| 60 | drilling = true; | |
| 61 | ctx.invalidate(); | |
| 62 | let says: string; | |
| 63 | let red = true; | |
| 64 | try { | |
| 65 | const svc = snapshotsOf(f.app); | |
outdated
(high) This writes a
That row feeds |
||
| 66 | if (svc === undefined || svc.store === undefined) says = "This forge has no snapshots to drill."; | |
| 67 | else { | |
| 68 | const d = await svc.drill(); | |
| 69 | red = d.verdict !== "green"; | |
| 70 | says = red ? `The drill failed at ${d.failedStep}.` : "The drill restored green."; | |
| 71 | } | |
| 72 | } catch (error) { | |
| 73 | says = `The drill could not run: ${String(error)}`; | |
| 74 | } | |
| 75 | drilling = false; | |
| 76 | verdict = says; | |
| 77 | ctx.toast(says, red ? "danger" : undefined); | |
| 78 | refresh(); | |
| 79 | }; | |
| 80 | ||
| 81 | const ask = (): void => { | |
| 82 | if (!admin || drilling) return; | |
| 83 | ctx.push( | |
| 84 | confirmScreen(ctx, screen, { | |
| 85 | title: "Restore drill", | |
| 86 | body: ["Restore the newest snapshot as a drill?"], | |
| 87 | actions: [{ label: "Run drill" }, { label: "Cancel" }], | |
| 88 | selected: 1, | |
| 89 | onChoose: (choice) => { | |
| 90 | if (choice === 0 && !drilling) void drill(); | |
| 91 | }, | |
| 92 | }), | |
| 93 | ); | |
| 94 | }; | |
| 95 | ||
| 36 | 96 | const screen: Screen = { |
| 37 | 97 | title: "Failure domains", |
| 38 | keys: [binding(["j", "k"], "move cursor")], | |
| 98 | get keys() { | |
| 99 | return [binding(["j", "k"], "move cursor"), ...(admin && !drilling ? [binding(["d"], "restore drill")] : [])]; | |
| 100 | }, | |
| 39 | 101 | load() { |
| 40 | const listed = listResilience(f.app, f.user); | |
| 41 | domains = listed.ok ? listed.value : []; | |
| 42 | loaded = true; | |
| 43 | state.clamp(domains.length); | |
| 44 | ctx.invalidate(); | |
| 102 | refresh(); | |
| 45 | 103 | return Promise.resolve(); |
| 46 | 104 | }, |
| 47 | 105 | render(area, s) { |
| @@ -51,11 +109,19 @@ export function domainsScreen(f: Session, ctx: Ctx): Screen { | ||
| 51 | 109 | ? "Loading…" |
| 52 | 110 | : domains.length === 0 |
| 53 | 111 | ? "Nothing here." |
| 54 | : `${domains.length} failure domains, and each one's last drill`; | |
| 112 | : drilling | |
| 113 | ? "Restore drill running…" | |
| 114 | : verdict !== "" | |
| 115 | ? verdict | |
| 116 | : `${domains.length} failure domains, and each one's last drill`; | |
| 55 | 117 | s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 56 | 118 | drawList(s, list!, c, state, rows()); |
| 57 | 119 | }, |
| 58 | 120 | key(e) { |
| 121 | if (e.key === "d") { | |
| 122 | ask(); | |
| 123 | return true; | |
| 124 | } | |
| 59 | 125 | return state.key(e, domains.length); |
| 60 | 126 | }, |
| 61 | 127 | }; |
test/tui-drill.test.ts added +144
| @@ -0,0 +1,144 @@ | ||
| 1 | /** The terminal's failure domains screen runs a restore drill with `d` and a confirm (#7298). */ | |
| 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 as Forge } from "../src/app.ts"; | |
| 7 | import { createUser, type User } from "../src/core/users.ts"; | |
| 8 | import type { DrillResult } from "../src/snapshot/drill.ts"; | |
| 9 | import { startSnapshots } from "../src/snapshot/service.ts"; | |
| 10 | import { dirStore } from "../src/snapshot/store.ts"; | |
| 11 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 12 | import { domainsScreen } from "../src/tui/screens/domains.ts"; | |
| 13 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 14 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | import { settleApp } from "./helpers/tui.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | let dir: string; | |
| 21 | let forge: Forge; | |
| 22 | let alice: User; | |
| 23 | let bob: User; | |
| 24 | let runs = 0; | |
| 25 | let handle: ReturnType<typeof startSnapshots> | undefined; | |
| 26 | ||
| 27 | const result = (verdict: "green" | "red"): DrillResult => ({ | |
| 28 | at: new Date().toISOString(), | |
| 29 | snapshotId: "s1", | |
| 30 | ms: 5, | |
| 31 | counts: { repositories: 0, issues: 0, users: 0, pulls: 0, apps: 0 }, | |
| 32 | health: verdict === "green" ? "ok" : "unreached", | |
| 33 | verdict, | |
| 34 | failedStep: verdict === "green" ? null : "fetch", | |
| 35 | phases: [], | |
| 36 | budgetMs: 1000, | |
| 37 | reason: null, | |
| 38 | }) as DrillResult; | |
| 39 | ||
| 40 | beforeAll(async () => { | |
| 41 | dir = await mkdtemp(join(tmpdir(), "forge-tui-drill-")); | |
| 42 | forge = createApp({ dataDir: dir, port: 0 }); | |
| 43 | const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 44 | if (!a.ok) throw new Error("signup failed"); | |
| 45 | alice = a.value; | |
| 46 | const b = await createUser(forge.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-long-passphrase" }); | |
| 47 | if (!b.ok) throw new Error("bob failed"); | |
| 48 | bob = b.value; | |
| 49 | }); | |
| 50 | afterAll(async () => { | |
| 51 | await handle?.stop(); | |
| 52 | forge.close?.(); | |
| 53 | await rm(dir, { recursive: true, force: true }); | |
| 54 | }); | |
| 55 | ||
| 56 | async function open(user: User) { | |
| 57 | const { app, session } = createForgeApp( | |
| 58 | forge, | |
| 59 | { sshUsername: user.username, remoteAddress: "127.0.0.1", user }, | |
| 60 | { terminal: headlessTerminal(160, 30), exitHooks: false }, | |
| 61 | ); | |
| 62 | app.start(); | |
| 63 | await settleApp(app); | |
| 64 | app.push(domainsScreen(session, app.ctx)); | |
| 65 | await settleApp(app); | |
| 66 | const press = async (key: string): Promise<string> => { | |
| 67 | app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 68 | await settleApp(app); | |
| 69 | return app.screen.toText(); | |
| 70 | }; | |
| 71 | return { app, press, text: () => app.screen.toText() }; | |
| 72 | } | |
| 73 | ||
| 74 | test("a non-admin is not offered d and a press asks nothing", async () => { | |
| 75 | const h = await open(bob); | |
| 76 | expect(h.text()).not.toMatch(/d +restore drill/); | |
| 77 | expect(await h.press("d")).not.toContain("Restore the newest snapshot"); | |
| 78 | h.app.stop(); | |
| 79 | }); | |
| 80 | ||
| 81 | test("no store says so in words", async () => { | |
| 82 | const h = await open(alice); | |
| 83 | expect(h.text()).toMatch(/d +restore drill/); | |
| 84 | expect(await h.press("d")).toContain("Restore the newest snapshot"); | |
| 85 | await h.press("left"); | |
| 86 | const text = await h.press("enter"); | |
| 87 | expect(text).toContain("This forge has no snapshots to drill."); | |
| 88 | h.app.stop(); | |
| 89 | }); | |
| 90 | ||
outdated
(medium) No store exists here, so this passes even if Cancel ran the drill. Run it with the stub store and assert |
||
| 91 | test("a restore drill says its outcome in words and leaves the domain row alone", async () => { | |
| 92 | let next: "green" | "red" = "green"; | |
| 93 | handle = startSnapshots(forge, dirStore(join(dir, "store")), { | |
| 94 | version: "0", | |
| 95 | nightly: false, | |
| 96 | drill: async () => { | |
| 97 | runs++; | |
| 98 | return result(next); | |
| 99 | }, | |
| 100 | }); | |
| 101 | const h = await open(alice); | |
| 102 | expect(h.text()).toMatch(/The snapshot store +never run/); | |
| 103 | await h.press("d"); | |
| 104 | await h.press("left"); | |
| 105 | let text = await h.press("enter"); | |
| 106 | expect(runs).toBe(1); | |
| 107 | expect(text).toContain("The drill restored green."); | |
| 108 | expect(text).toMatch(/The snapshot store +never run/); // the domain row is the fault drill's, not this one's | |
| 109 | ||
| 110 | next = "red"; | |
| 111 | await h.press("d"); | |
| 112 | await h.press("left"); | |
| 113 | text = await h.press("enter"); | |
| 114 | expect(runs).toBe(2); | |
| 115 | expect(text).toContain("The drill failed at fetch."); | |
| 116 | expect(text).toMatch(/The snapshot store +never run/); | |
| 117 | h.app.stop(); | |
| 118 | }); | |
| 119 | ||
| 120 | test("declining runs nothing", async () => { | |
| 121 | const h = await open(alice); | |
| 122 | const before = runs; | |
| 123 | await h.press("d"); | |
| 124 | const text = await h.press("enter"); // defaults to Cancel | |
| 125 | expect(text).not.toContain("restored green"); | |
| 126 | expect(runs).toBe(before); | |
| 127 | h.app.stop(); | |
| 128 | }); | |
| 129 | ||
| 130 | test("an admin demoted after load runs nothing", async () => { | |
| 131 | const carol = await createUser(forge.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-long-passphrase" }); | |
| 132 | if (!carol.ok) throw new Error("carol failed"); | |
| 133 | const user = { ...carol.value, isAdmin: true } as User; | |
| 134 | const h = await open(user); | |
| 135 | expect(h.text()).toMatch(/d +restore drill/); | |
| 136 | const before = runs; | |
| 137 | await h.press("d"); | |
| 138 | (user as { isAdmin: boolean }).isAdmin = false; | |
| 139 | await h.press("left"); | |
| 140 | const text = await h.press("enter"); | |
| 141 | expect(runs).toBe(before); | |
| 142 | expect(text).not.toContain("restored green"); | |
| 143 | h.app.stop(); | |
| 144 | }); |
(medium) Before
svc.drill(), check again thatf.useris a non-agent admin, as the POST and MCP do on every call. Theadminflag cached at load is not a per-write check.