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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7297-the-terminal-s-resilience-screen-tests-w → main 6 commits fork 3 threads · 3 unresolved
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 | ||
| 457 | 457 | - 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. |
| 458 | 458 | - 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`). |
| 459 | 459 | - 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. | |
| 461 | 461 | - [](docs/PLANNING.md) — how issues depend on issues: the edge, the map, and what "ready now" means to a person and to the crew. |
| 462 | 462 | - [](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. |
| 463 | 463 | - [](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 @@ | ||
| 15 | 15 | * `f` forgets it, through `actions/mirror.ts` — the Backups page's and MCP's |
| 16 | 16 | * rules and words. Each asks five prompts (endpoint, bucket, region, access |
| 17 | 17 | * key, secret key — both keys typed masked; a blank keeps what was sealed). |
| 18 | * `t` tries the destination in effect, as `testDestination` says it. | |
| 18 | 19 | * A key is never drawn, kept past the save, or put in a note. |
| 19 | 20 | */ |
| 20 | 21 | |
| @@ -25,6 +26,7 @@ import { | ||
| 25 | 26 | invalidField, |
| 26 | 27 | mayChangeDestination, |
| 27 | 28 | saveDestination, |
| 29 | testDestination, | |
| 28 | 30 | type DestinationInput, |
| 29 | 31 | } from "../../actions/destination.ts"; |
| 30 | 32 | import { forgetMirror, saveMirror } from "../../actions/mirror.ts"; |
| @@ -196,6 +198,22 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen { | ||
| 196 | 198 | }, |
| 197 | 199 | }); |
| 198 | 200 | |
| 201 | // All fields blank: the destination in effect is tried, and the sentence never carries a key. | |
outdated
The |
||
| 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 | ||
| 199 | 217 | const body = (): string[] => { |
| 200 | 218 | // The question and the answer come first: a long list must not clip them. |
| 201 | 219 | const out: string[] = []; |
| @@ -203,7 +221,7 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen { | ||
| 203 | 221 | else if (said !== "") out.push(...wrap(said, INNER), ""); |
| 204 | 222 | out.push(...wrap(destinationLine(f), INNER), ...wrap(mirrorLine(f), INNER)); |
| 205 | 223 | 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."); | |
| 207 | 225 | out.push("", "Snapshots, newest first:"); |
| 208 | 226 | if (snapshots === undefined) out.push(" reading…"); |
| 209 | 227 | else if (snapshots === "unreadable") out.push(" the snapshots could not be read"); |
| @@ -225,6 +243,7 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen { | ||
| 225 | 243 | binding(["x"], "forget destination"), |
| 226 | 244 | binding(["m"], "set the mirror"), |
| 227 | 245 | binding(["f"], "forget the mirror"), |
| 246 | binding(["t"], "test where snapshots go"), | |
| 228 | 247 | ] |
| 229 | 248 | : []), |
| 230 | 249 | binding(["enter", "esc", "q"], "close"), |
| @@ -258,6 +277,7 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen { | ||
| 258 | 277 | else if (e.key === "x") ctx.push(forgetDest()); |
| 259 | 278 | else if (e.key === "m") ctx.push(step(mirror, 0, BLANK)); |
| 260 | 279 | else if (e.key === "f") ctx.push(forgetMir()); |
| 280 | else if (e.key === "t") void test(); | |
| 261 | 281 | return true; |
| 262 | 282 | }, |
| 263 | 283 | }; |
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 | }); |
new above
offline
This puts the
tsentence in the middle of the prompts paragraph, so 'Each asks five prompts' no longer reads asdandm. Move it after the prompts text.