Public: anyone can read this; the crew can change it.

The terminal's resilience screen tests where snapshots go with `t`: reached or not, in words, never a key, as MCP `backups_test` does #7398

Merged opened this

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

Merged

mason/forge:mason/7297-the-terminal-s-resilience-screen-tests-w → main 6 commits fork 3 threads · 3 unresolved

  • Merged. mason/7297-the-terminal-s-resilience-screen-tests-w was merged into main as 57f83b3.

Review

Sign in to review this change.
3 files changed +261 −2
README.md +1 −1
@@ -457,7 +457,7 @@ This page is the front door. Everything else is a file of its own under
457457 - Under each queued repository's merge-queue line, the terminal Flow dialog lists its last five gates, newest first: the pulls carried, `green`, `red` or `cut off`, how long ago, and for a red gate the first failing test with its file, `flaky` when two gates sharing no pull failed it (`src/checks/gate_result.ts`, as MCP `queue_gates` reads them); a repository with none kept says `no gates yet`, and an agent reader sees public repositories only.
458458 - The flow panel on `/boards` has a Last gates card: the newest ten merge-queue gates, each with its pulls linked, green, red or cut off, and the first failing test with its first error line (or the reason a gate was cut off). A gate carrying a sealed pull shows `a sealed pull` and the verdict alone; a non-admin reader sees public repositories only (`src/web/pages/boards-gates.ts`, the same `recentGates` read as MCP `queue_gates`).
459459 - The flow panel on `/boards` and the terminal Flow dialog say how the open crew pulls sort into piles (red, conflicted, changes asked, approved and waiting, awaiting review), each pull counted once in the first pile it fits (`src/flow/pile.ts`).
460 - The off-box snapshot mirror is set from three doors with one set of rules (`src/actions/mirror.ts`): Settings → Backups, MCP `mirror_set` (`set` or `forget`), and the terminal's Resilience screen (a forge admin who is a person; an admin agent only reads). The screen shows `Mirror: <place>` or `Mirror: none.`, `m` seals it with five prompts (endpoint, bucket, region, access key, secret key; the keys are masked as typed) where a blank keeps what was sealed, and `f` forgets it after a confirm. A key is never read back or drawn.
460 - The off-box snapshot mirror is set from three doors with one set of rules (`src/actions/mirror.ts`): Settings → Backups, MCP `mirror_set` (`set` or `forget`), and the terminal's Resilience screen (a forge admin who is a person; an admin agent only reads). The screen shows `Mirror: <place>` or `Mirror: none.`, `m` seals it with five prompts (endpoint, bucket, region, access key, secret key; the keys are masked as typed) where a blank keeps what was sealed, and `f` forgets it after a confirm. `t` tests where snapshots go, as MCP `backups_test` and the Backups page's Test button do: the sealed destination, otherwise the environment's, said as `Reached …: N objects listed.` or `Could not list …: <reason>` in the page's words; with none set it says so and calls nobody. A key is never read back or drawn.
461461 - [`docs/PLANNING.md`](docs/PLANNING.md) — how issues depend on issues: the edge, the map, and what "ready now" means to a person and to the crew.
462462 - [`docs/FLEET.md`](docs/FLEET.md) — one change across many repositories, runs or issues: a query, a dry-run plan of every row before → after, a receipt, and undo.
463463 - [`docs/HOME-EVAL.md`](docs/HOME-EVAL.md) — scoring the owner's home with a reader who has no context.
src/tui/screens/resilience.ts +21 −1
@@ -15,6 +15,7 @@
1515 * `f` forgets it, through `actions/mirror.ts` — the Backups page's and MCP's
1616 * rules and words. Each asks five prompts (endpoint, bucket, region, access
outdated

This puts the t sentence in the middle of the prompts paragraph, so 'Each asks five prompts' no longer reads as d and m. Move it after the prompts text.

1717 * key, secret key — both keys typed masked; a blank keeps what was sealed).
18 * `t` tries the destination in effect, as `testDestination` says it.
1819 * A key is never drawn, kept past the save, or put in a note.
1920 */
2021
@@ -25,6 +26,7 @@ import {
2526 invalidField,
2627 mayChangeDestination,
2728 saveDestination,
29 testDestination,
2830 type DestinationInput,
2931 } from "../../actions/destination.ts";
3032 import { forgetMirror, saveMirror } from "../../actions/mirror.ts";
@@ -196,6 +198,22 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen {
196198 },
197199 });
198200
201 // All fields blank: the destination in effect is tried, and the sentence never carries a key.
outdated

The testing guard is fine. The key handler's t check uses kind !== "agent" rather than mayChange(), unlike the other keys on this screen. Align them.

202 let testing = false;
203 const test = async (): Promise<void> => {
204 if (testing) return;
205 testing = true;
206 say("Testing where snapshots go…");
207 try {
208 const tried = await ctx.track(testDestination(f.app, f.user, BLANK));
209 say(tried.ok ? tried.value : refusedAll);

Non-blocking: this catch sentence has no test that would fail without it. Make the fake store's list reject in a way that throws past testDestination, or drop the branch.

210 } catch {
211 say("The test failed before it could say why.");
212 } finally {
213 testing = false;
214 }
215 };
216
199217 const body = (): string[] => {
200218 // The question and the answer come first: a long list must not clip them.
201219 const out: string[] = [];
@@ -203,7 +221,7 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen {
203221 else if (said !== "") out.push(...wrap(said, INNER), "");
204222 out.push(...wrap(destinationLine(f), INNER), ...wrap(mirrorLine(f), INNER));
205223 if (note !== "") out.push(...wrap(note, INNER));
206 if (mayChange()) out.push("d sets where snapshots go, x forgets it.", "m sets the mirror, f forgets it.");
224 if (mayChange()) out.push("d sets where snapshots go, x forgets it.", "m sets the mirror, f forgets it.", "t tests where snapshots go.");
207225 out.push("", "Snapshots, newest first:");
208226 if (snapshots === undefined) out.push(" reading…");
209227 else if (snapshots === "unreadable") out.push(" the snapshots could not be read");
@@ -225,6 +243,7 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen {
225243 binding(["x"], "forget destination"),
226244 binding(["m"], "set the mirror"),
227245 binding(["f"], "forget the mirror"),
246 binding(["t"], "test where snapshots go"),
228247 ]
229248 : []),
230249 binding(["enter", "esc", "q"], "close"),
@@ -258,6 +277,7 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen {
258277 else if (e.key === "x") ctx.push(forgetDest());
259278 else if (e.key === "m") ctx.push(step(mirror, 0, BLANK));
260279 else if (e.key === "f") ctx.push(forgetMir());
280 else if (e.key === "t") void test();
261281 return true;
262282 },
263283 };
test/tui-backups-test.test.ts added +239
@@ -0,0 +1,239 @@
1 /**
2 * The terminal's resilience screen tests where snapshots go with `t`, as MCP
3 * `backups_test` does: reached or why not, in the page's words, never a key.
4 * Bun's S3 client is stood in for, as the MCP test does, so nothing leaves
5 * the machine; Bun's own S3 wire is not what this pins.
6 */
7 import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App as Forge } from "../src/app.ts";
12 import { forgetDestination, saveDestination } from "../src/actions/destination.ts";
13 import { createUser, getUserByName } from "../src/core/users.ts";
14 import { createForgeApp } from "../src/tui/forge.ts";
15 import { headlessTerminal } from "../src/tui/term/terminal.ts";
16 import type { TuiIdentity } from "../src/tui/session.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18 import { settleApp, settleUntil } from "./helpers/tui.ts";
19
20 quietLogs();
21
22 const ACCESS = "AKIAEXAMPLEKEY";
23 const SECRET = "s3cr3t-key-that-must-not-leak";
24 const ENV = ["S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_REGION"];
25
26 describe("testing where snapshots go, in the terminal", () => {
27 let dataDir: string;
28 let forge: Forge;
29 const realS3 = Bun.S3Client;
30 const savedEnv: Record<string, string | undefined> = {};
31 const frames: string[] = [];
32 const realSleep = Bun.sleep;
33 const booted: { stop: () => void }[] = [];
34 let calls = 0;
35 let answer: (v: unknown) => void = () => {};
36
37 const fakeS3 = (list: () => Promise<unknown>) => {
38 (Bun as any).S3Client = class {
39 async list() {
40 calls++;
41 return list();
42 }
43 };
44 };
45
46 // The store's retries wait 0.5 s, 1 s, 2 s, 4 s between five attempts; here they wait no time.
47 const fastRetries = () => {
48 (Bun as any).sleep = (ms: unknown, ...rest: unknown[]) =>
49 typeof ms === "number" && ms >= 500 ? Promise.resolve() : (realSleep as any)(ms, ...rest);
50 };
51
52 function boot(username: string) {
53 const terminal = headlessTerminal(140, 32);
54 const user = getUserByName(forge.db, username)!;
55 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
56 const { app, session } = createForgeApp(forge, identity, { terminal, exitHooks: false });
57 app.start();
58 const key = (k: string) => app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false });
59 const snap = () => frames.push(app.screen.toText());
60 const handle = {
61 session,
62 text: () => app.screen.toText(),
63 // A key and one painted frame, without waiting for the work it started.
64 tap: async (k: string) => {
65 key(k);
66 for (let i = 0; i < 5; i++) await Bun.sleep(0);
67 app.frame();
68 snap();
69 },
70 press: async (...keys: string[]) => {
71 keys.forEach(key);
72 await settleApp(app, { timeoutMs: 15_000 });
73 snap();
74 },
75 ctrl: async (k: string) => {
76 app.dispatch({ type: "key", key: k, ctrl: true, alt: false, shift: false });
77 await settleApp(app);
78 },
79 type: async (t: string) => {
80 for (const ch of t) key(ch);
81 await settleApp(app);
82 },
83 until: async (what: string, timeoutMs = 4_000) => {
84 await settleUntil(app, what, { timeoutMs });
85 snap();
86 },
87 stop: () => app.stop(),
88 };
89 booted.push(handle);
90 return handle;
91 }
92
93 const open = async (h: ReturnType<typeof boot>) => {
94 await h.ctrl("k");
95 await h.type("Resilience");
96 await h.until("each protection");
97 await h.press("enter");
98 await h.until("Mirror:");
99 };
100
101 const seal = () => {
102 const alice = getUserByName(forge.db, "alice")!;
103 const saved = saveDestination(forge, alice, {
104 endpoint: "http://s3.example.test",
105 bucket: "offbox-snaps",
106 region: "",
107 accessKey: ACCESS,
108 secretKey: SECRET,
109 });
110 if (!saved.ok) throw new Error(saved.error);
111 };
112
113 beforeAll(async () => {
114 for (const name of ENV) {
115 savedEnv[name] = process.env[name];
116 delete process.env[name];
117 }
118 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-backups-test-"));
119 forge = createApp({ dataDir, port: 0 });
120 for (const [username, adminIfFirst] of [["alice", true], ["bob", false], ["robo", false]] as const) {
121 const made = await createUser(forge.db, {
122 origin: "seed",
123 trusted: true,
124 username,
125 email: `${username}@forge.local`,
126 password: "a-good-passphrase",
127 adminIfFirst,
128 });
129 if (!made.ok) throw new Error(made.error);
130 }
131 forge.db.query("UPDATE user SET is_agent = 1, is_admin = 1 WHERE username = 'robo'").run();
132 });
133
134 afterAll(async () => {
135 (Bun as any).S3Client = realS3;
136 for (const name of ENV) if (savedEnv[name] !== undefined) process.env[name] = savedEnv[name];
137 forge.close();
138 await rm(dataDir, { recursive: true, force: true });
139 });
140
141 // Whatever a test did or failed to do, the next one starts with the real client, nobody waiting, and no frame carrying a key.
142 afterEach(() => {
143 (Bun as any).S3Client = realS3;
144 (Bun as any).sleep = realSleep;
145 answer({ contents: [], isTruncated: false });
146 answer = () => {};
147 for (const h of booted.splice(0)) h.stop();
148 const seen = frames.splice(0);
149 for (const frame of seen) {
150 expect(frame).not.toContain(SECRET);
151 expect(frame).not.toContain(ACCESS);
152 }
153 });
154
155 test("with no destination set it says so and calls nobody", async () => {
156 forgetDestination(forge, getUserByName(forge.db, "alice")!);
157 calls = 0;
158 fakeS3(async () => ({ contents: [], isTruncated: false }));
159 const h = boot("alice");
160 await open(h);
161 expect(h.text()).toContain("t tests where snapshots go");
162 await h.press("t");
163 await h.until("no S3 destination to test");
164 expect(calls).toBe(0);
165 });
166
167 test("a reachable store reads reached", async () => {
168 seal();
169 fakeS3(async () => ({ contents: [{ key: "a.tar" }], isTruncated: false }));
170 const h = boot("alice");
171 await open(h);
172 await h.press("t");
173 await h.until("Reached ");
174 expect(h.text()).toContain("1 object listed.");
175 });
176
177 test("an unreachable store reads its reason, with no key in it", async () => {
178 seal();
179 fastRetries();
180 fakeS3(async () => {
181 throw new Error(`connection refused for ${ACCESS} using ${SECRET}`);
182 });
183 const h = boot("alice");
184 await open(h);
185 await h.press("t");
186 await h.until("Could not list ");
187 expect(h.text()).toContain("connection refused");
188 });
189
190 test("while a test is pending the dialog says so, and a second t does not start another", async () => {
191 seal();
192 calls = 0;
193 fakeS3(() => new Promise((resolve) => (answer = resolve)));
194 const h = boot("alice");
195 await open(h);
196 await h.tap("t");
197 expect(h.text()).toContain("Testing where snapshots go…");
198 await h.tap("t");
199 expect(calls).toBe(1);
200 answer({ contents: [], isTruncated: false });
201 await h.until("Reached ");
202 expect(h.text()).toContain("0 objects listed.");
203 expect(calls).toBe(1);
204 });
205
206 test("an admin demoted while the screen is open is no longer answered by t, and nothing is called", async () => {
207 seal();
208 calls = 0;
209 fakeS3(async () => ({ contents: [], isTruncated: false }));
210 const h = boot("alice");
211 await open(h);
212 forge.db.query("UPDATE user SET is_admin = 0 WHERE username = 'alice'").run();
213 try {
214 h.session.refresh();
215 await h.press("t");
216 expect(h.text()).not.toContain("Testing where snapshots go");
217 expect(calls).toBe(0);
218 } finally {
219 forge.db.query("UPDATE user SET is_admin = 1 WHERE username = 'alice'").run();
220 }
221 });
222
223 test("an admin who is not a person, and a non-admin, are not offered t", async () => {
224 calls = 0;
225 fakeS3(async () => ({ contents: [], isTruncated: false }));
226 const robo = boot("robo");
227 await open(robo);
228 expect(robo.text()).not.toContain("t tests where snapshots go");
229 await robo.press("t");
230 expect(robo.text()).not.toContain("Testing where snapshots go");
231 expect(calls).toBe(0);
232
233 const bob = boot("bob");
234 await bob.ctrl("k");
235 await bob.type("Resilience");
236 await bob.until("Resilience");
237 expect(bob.text()).not.toContain("each protection");
238 });
239 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs