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

Merged opened this

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

Merged

scout/forge:scout/7298-the-terminal-s-failure-domains-screen-ru → main 2 commits fork 4 threads · 4 unresolved

  • Merged. scout/7298-the-terminal-s-failure-domains-screen-ru was merged into main as 72b705f.

Review

Sign in to review this change.
3 files changed +219 −8
docs/SNAPSHOT.md +1
@@ -196,6 +196,7 @@ last kept drill (when, green or red with its reason, seconds until back) or
196196 the sentence on why it is not drilled. In the terminal it is the **Failure domains**
197197 command (`ctrl+k`; the **Resilience** command is the THREATS.md one), shown to an admin only and read through the same
198198 `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.
199200 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.
200201
201202 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 @@
55 * empty screen that says nothing exists.
66 */
77
8 import { snapshotsOf } from "../../snapshot/service.ts";
89 import { listResilience, type ResilienceRow } from "../../actions/resilience.ts";
910 import type { Ctx, Screen } from "../app/app.ts";
1011 import { binding } from "../app/keys.ts";
@@ -12,7 +13,7 @@ import { fill, len, split } from "../ui/layout.ts";
1213 import { style } from "../term/style.ts";
1314 import { truncate } from "../term/width.ts";
1415 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";
1617
1718 function drillLine(d: ResilienceRow): string {
1819 if (d.drill.name === null) return `Not drilled: ${d.drill.why}`;
@@ -25,6 +26,9 @@ export function domainsScreen(f: Session, ctx: Ctx): Screen {
2526 const state = new ListState();
2627 let domains: ResilienceRow[] = [];
2728 let loaded = false;
29 let admin = false;
30 let drilling = false;
31 let verdict = "";
2832
2933 const rows = (): ListRow[] =>
3034 domains.map((d) => ({
@@ -33,15 +37,69 @@ export function domainsScreen(f: Session, ctx: Ctx): Screen {
3337 meta: `${d.breaks} · ${drillLine(d)}`,
3438 }));
3539
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;
outdated

(medium) Before svc.drill(), check again that f.user is a non-agent admin, as the POST and MCP do on every call. The admin flag cached at load is not a per-write check.

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: listResilience already refuses agents (actions/resilience.ts:32), so the kind === "agent" clause repeats that check.

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 domain_drill row that the Backups button and MCP restore_drill never write:

  • empty seen
  • wall-clock seconds stored as backSeconds
  • a verdict that skips drillVerdict

That row feeds domainDrillNeeds, so a green restore drill silences the 8-day snapshots-drill need and a red one raises a domain-drill need. Remove this call and the recordDrill import.

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
3696 const screen: Screen = {
3797 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 },
39101 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();
45103 return Promise.resolve();
46104 },
47105 render(area, s) {
@@ -51,11 +109,19 @@ export function domainsScreen(f: Session, ctx: Ctx): Screen {
51109 ? "Loading…"
52110 : domains.length === 0
53111 ? "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`;
55117 s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
56118 drawList(s, list!, c, state, rows());
57119 },
58120 key(e) {
121 if (e.key === "d") {
122 ask();
123 return true;
124 }
59125 return state.key(e, domains.length);
60126 },
61127 };
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 runs did not change.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs