Public: anyone can read this; the crew can change it.
The terminal's resilience screen sets or forgets where snapshots go, never showing a key, as MCP `backups_destination` does #7278
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7269-the-terminal-s-resilience-screen-sets-or → main 6 commits fork 1 thread · 1 unresolved
8 files
changed +351
−81
README.md +1 −1
| @@ -424,7 +424,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 424 | 424 | - The flow panel's Hot files card and the terminal Flow dialog also name files that gained open pulls over the last hour ("warming: src/a.ts 2 → 4 pulls", at most 5; `src/flow/hot_trend.ts`). The hour-old counts are samples kept in memory by `src/crew/hot_history.ts`, so after a restart nothing is named until a half-hour-old sample exists; an agent sees public repositories only. The MCP `flow` tool does not carry it yet. |
| 425 | 425 | - 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. |
| 426 | 426 | - 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`). |
| 427 | - 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. | |
| 427 | - 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. | |
| 428 | 428 | - [](docs/PLANNING.md) — how issues depend on issues: the edge, the map, and what "ready now" means to a person and to the crew. |
| 429 | 429 | - [](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. |
| 430 | 430 | - [](docs/HOME-EVAL.md) — scoring the owner's home with a reader who has no context. |
docs/CREW.md +1 −1
| @@ -336,7 +336,7 @@ kill, which leave nothing in the log themselves). | ||
| 336 | 336 | `docs/FLOW.md` is where every issue stands: `GET /api/v1/repos/:owner/:name/flow` returns each open issue's stage, since, stalled and unblock, plus what went live in the last 24 h. |
| 337 | 337 | Where the whole forge waits is the owner's, on `/boards` (the old `/flow` redirects there, query kept; an agent granted `telemetry:read` reads the same from `/api/v1/telemetry/flow` and the MCP `flow` tool): the flow panel shows each stage from ready to live with what waits, the oldest wait and its capacity, under one sentence naming the bottleneck, and beside each what it let out in the last hour with a 24-hour sparkline, under a line of landings and the median ask→live — every count links to the items it counts. A stalled step is a card on the home (Studio mode) for anyone who can write to the repository: an open pull whose check was never asked for, or is red, past its stage's allowance, with its stage, how long it has been stuck, and one button — Run check or Re-run — that asks for a check at the head and returns to the home. The terminal's **Stalled steps** command (palette) lists the same steps, live, and Enter or `r` does the same re-run; both read `flow/stalled.ts` and act through `flow/unblock.ts`, and neither shows anything to someone who cannot write. A conflicted pull never sits: every tick the crew's open pulls that no longer merge get one bring-up-to-date run each (one in flight per pull), oldest pull first and only as many as the check lane has slots for, since each ends in a push and a check: check slots minus the checks queued and running minus the runs already in flight, with one always allowed (`crew/conflict_room.ts`, #4459); after 2 failed tries in the same conflict, or 24 h since the first, the pull is closed with the conflicting files named and its issue goes back to ready, unassigned, with no strike against the builder. Conflicted pulls are listed oldest first on `/boards`. The terminal's Flow command and the MCP `flow` tool read the same: the sentence, the landings line, and each stage's count out in the last hour and in 24 h. |
| 338 | 338 | |
| 339 | `/resilience` is a forge admin's too (a guest is sent to sign in, anyone else gets 404): each protection in `docs/THREATS.md` as one line, green, red, untested or "no test yet", judged by the test file it cites in the last full gate of the forge's own repository (`FORGE_REPO`; other repositories' gates never touch it). The terminal's **Resilience** command (palette, admins) lists the same lines. | |
| 339 | `/resilience` is a forge admin's too (a guest is sent to sign in, anyone else gets 404): each protection in `docs/THREATS.md` as one line, green, red, untested or "no test yet", judged by the test file it cites in the last full gate of the forge's own repository (`FORGE_REPO`; other repositories' gates never touch it). The terminal's **Resilience** command (palette, admins) lists the same lines under where snapshots go now; for an admin who is a person, `d` seals a destination field by field (both keys typed masked, checked as the Backups page checks them) and `x` forgets it after a confirm; `m` and `f` do the same for the off-box mirror. No key is ever drawn. | |
| 340 | 340 | |
| 341 | 341 | The terminal also has an Access command (`:` → Access), a site admin's: it reads the same last-day non-browser requests as Settings → Access and the `access` MCP tool, newest first, and filters them with `s` (since), `u` (user agent) and `p` (path), `c` to clear. |
| 342 | 342 |
src/actions/destination.ts +6 −2
| @@ -61,13 +61,17 @@ export function invalidField(i: DestinationInput): string | undefined { | ||
| 61 | 61 | |
| 62 | 62 | const isAdmin = (actor: User | undefined): boolean => actor?.isAdmin === true; |
| 63 | 63 | |
| 64 | /** Who may set or forget where snapshots go, through every door — page, MCP, terminal: a site admin who is a person, never an agent. */ | |
| 65 | export const mayChangeDestination = (actor: Pick<User, "isAdmin" | "kind"> | null | undefined): boolean => | |
| 66 | actor?.isAdmin === true && actor.kind !== "agent"; | |
| 67 | ||
| 64 | 68 | /** Seal what was given (an error is `not_found` or a sentence for the page); blank fields keep their sealed value. Says what is still missing. */ |
| 65 | 69 | export function saveDestination( |
| 66 | 70 | app: App, |
| 67 | 71 | actor: User | undefined, |
| 68 | 72 | input: DestinationInput, |
| 69 | 73 | ): Result<string, string> { |
| 70 | if (!isAdmin(actor)) return err("not_found"); | |
| 74 | if (!mayChangeDestination(actor)) return err("not_found"); | |
| 71 | 75 | const bad = invalidField(input); |
| 72 | 76 | if (bad) return err(bad); |
| 73 | 77 | const fields: [string, string][] = [ |
| @@ -103,7 +107,7 @@ export function forgetDestination( | ||
| 103 | 107 | app: App, |
| 104 | 108 | actor: User | undefined, |
| 105 | 109 | ): Result<void, "not_found"> { |
| 106 | if (!isAdmin(actor)) return err("not_found"); | |
| 110 | if (!mayChangeDestination(actor)) return err("not_found"); | |
| 107 | 111 | clearSealed(app.db); |
| 108 | 112 | app.bus.append({ |
| 109 | 113 | kind: "secret.deleted", |
src/tui/forge.ts +2 −2
| @@ -376,12 +376,12 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 376 | 376 | }, |
| 377 | 377 | }); |
| 378 | 378 | } |
| 379 | // Each THREATS.md protection's standing, as /resilience says it: a site admin's. | |
| 379 | // Where snapshots go and each THREATS.md protection's standing, as /resilience says it: a site admin's; a person admin sets or forgets the destination and the mirror here. | |
| 380 | 380 | if (session.user?.isAdmin === true) |
| 381 | 381 | commands.push({ |
| 382 | 382 | id: "resilience", |
| 383 | 383 | title: "Resilience", |
| 384 | hint: "each protection green, red or untested", | |
| 384 | hint: "where snapshots go and the mirror; each protection green, red or untested", | |
| 385 | 385 | run: () => ctx.push(resilienceScreen(session, ctx)), |
| 386 | 386 | }); |
| 387 | 387 | // The last releases with their pulls, as /changelog shows them: for anyone. |
src/tui/screens/resilience.ts +139 −67
| @@ -1,28 +1,35 @@ | ||
| 1 | 1 | /** |
| 2 | * The Resilience command's dialog, a forge admin's: one line per THREATS.md | |
| 3 | * protection with its standing in the last gate, as /resilience says it. | |
| 4 | * Enter, Esc or q closes it. | |
| 2 | * The Resilience command's dialog, a forge admin's: where snapshots go now and | |
| 3 | * where the off-box mirror is, the newest snapshots, then one line per | |
| 4 | * THREATS.md protection with its standing in the last gate, as /resilience | |
| 5 | * says it. Enter, Esc or q closes it. | |
| 5 | 6 | * |
| 6 | * Above the protections, the newest snapshots, newest first. A forge admin | |
| 7 | * takes one now with `n` and brings the mirror level with `l`, each after a | |
| 8 | * `y/n` asked on the dialog's first line, through `actions/backups_now.ts` — | |
| 9 | * MCP `snapshot_now`'s and `mirror_now`'s actions — and the answer is said | |
| 10 | * there in the page's words. While one runs, neither key is offered. The | |
| 11 | * command is a forge admin's only (`tui/forge.ts`), and the actions refuse | |
| 12 | * anyone else again. | |
| 7 | * A forge admin takes a snapshot now with `n` and brings the mirror level with | |
| 8 | * `l`, each after a `y/n` asked on the dialog's first line, through | |
| 9 | * `actions/backups_now.ts` — MCP `snapshot_now`'s and `mirror_now`'s actions — | |
| 10 | * and the answer is said there in the page's words. While one runs, neither | |
| 11 | * key is offered. | |
| 13 | 12 | * |
| 14 | * A forge admin who is a person also sees where the off-box snapshot mirror | |
| 15 | * is, `M` seals it (five prompts, the secret masked, a blank keeps what was | |
| 16 | * sealed) and `F` forgets it after a confirm; the rules are | |
| 17 | * `actions/mirror.ts`, the Backups page's and MCP `mirror_set`'s. No key is | |
| 18 | * ever drawn or read back. | |
| 13 | * For an admin who is a person, `d` sets the destination and `x` forgets it | |
| 14 | * after a confirm, through `actions/destination.ts`; `m` seals the mirror and | |
| 15 | * `f` forgets it, through `actions/mirror.ts` — the Backups page's and MCP's | |
| 16 | * rules and words. Each asks five prompts (endpoint, bucket, region, access | |
| 17 | * key, secret key — both keys typed masked; a blank keeps what was sealed). | |
| 18 | * A key is never drawn, kept past the save, or put in a note. | |
| 19 | 19 | */ |
| 20 | 20 | |
| 21 | 21 | import { mirrorNow, recentSnapshots, snapshotNow } from "../../actions/backups_now.ts"; |
| 22 | import { forgetMirror, saveMirror } from "../../actions/mirror.ts"; | |
| 23 | import type { DestinationInput } from "../../actions/destination.ts"; | |
| 24 | import { snapshotsOf } from "../../snapshot/service.ts"; | |
| 25 | 22 | import { readResilience } from "../../checks/resilience.ts"; |
| 23 | import { | |
| 24 | forgetDestination, | |
| 25 | invalidField, | |
| 26 | mayChangeDestination, | |
| 27 | saveDestination, | |
| 28 | type DestinationInput, | |
| 29 | } from "../../actions/destination.ts"; | |
| 30 | import { forgetMirror, saveMirror } from "../../actions/mirror.ts"; | |
| 31 | import { resolveDestination } from "../../snapshot/destination.ts"; | |
| 32 | import { snapshotsOf } from "../../snapshot/service.ts"; | |
| 26 | 33 | import type { Ctx, Screen } from "../app/app.ts"; |
| 27 | 34 | import { binding } from "../app/keys.ts"; |
| 28 | 35 | import type { Session } from "../session.ts"; |
| @@ -40,19 +47,42 @@ export function resilienceLines(dataDir: string): string[] { | ||
| 40 | 47 | return rows.flatMap(({ p, s }) => wrap(`${pad(s, 11)} ${p.text}`, INNER)); |
| 41 | 48 | } |
| 42 | 49 | |
| 50 | /** The source of the destination in effect, in the Backups page's words. */ | |
| 51 | const SOURCE: Record<string, string> = { | |
| 52 | sealed: "sealed in this forge", | |
| 53 | dir: "the directory named by FORGE_SNAPSHOT_DIR", | |
| 54 | env: "the host's S3_* environment variables", | |
| 55 | }; | |
| 56 | ||
| 57 | /** Where snapshots go now — kind and place — or that they stay on this box. Never a key. */ | |
| 58 | export function destinationLine(f: Session): string { | |
| 59 | const dest = resolveDestination({ db: f.app.db, dataDir: f.app.config.dataDir, bus: f.app.bus }); | |
| 60 | return dest === undefined | |
| 61 | ? "Snapshots stay on this box: no destination is set." | |
| 62 | : `Snapshots go to ${dest.store.where} (${SOURCE[dest.source] ?? dest.source}).`; | |
| 63 | } | |
| 64 | ||
| 43 | 65 | /** Where the mirror is, or that there is none; never a key. */ |
| 44 | 66 | export function mirrorLine(f: Session): string { |
| 45 | 67 | const where = snapshotsOf(f.app)?.mirror?.where; |
| 46 | 68 | return where === undefined ? "Mirror: none." : `Mirror: ${where}`; |
| 47 | 69 | } |
| 48 | 70 | |
| 49 | const FIELDS = [ | |
| 50 | ["endpoint", "endpoint (https://…)", false], | |
| 51 | ["bucket", "bucket", false], | |
| 52 | ["region", "region", false], | |
| 53 | ["accessKey", "access key", true], | |
| 54 | ["secretKey", "secret key", true], | |
| 55 | ] as const; | |
| 71 | const STEPS: readonly { key: keyof DestinationInput; label: string; placeholder?: string; mask?: true }[] = [ | |
| 72 | { key: "endpoint", label: "Endpoint, an http(s) URL", placeholder: "https://s3.example.com" }, | |
| 73 | { key: "bucket", label: "Bucket" }, | |
| 74 | { key: "region", label: "Region (optional)", placeholder: "us-east-1" }, | |
| 75 | { key: "accessKey", label: "Access key (sealed, never shown)", mask: true }, | |
| 76 | { key: "secretKey", label: "Secret key (sealed, never shown)", mask: true }, | |
| 77 | ]; | |
| 78 | ||
| 79 | const BLANK: DestinationInput = { endpoint: "", bucket: "", region: "", accessKey: "", secretKey: "" }; | |
| 80 | ||
| 81 | /** One of the two off-box places this screen sets: its prompt title and its save. */ | |
| 82 | type Target = { | |
| 83 | readonly title: string; | |
| 84 | readonly save: (input: DestinationInput) => string; | |
| 85 | }; | |
| 56 | 86 | |
| 57 | 87 | type Asking = "snapshot" | "mirror"; |
| 58 | 88 | |
| @@ -62,7 +92,9 @@ const QUESTION: Record<Asking, string> = { | ||
| 62 | 92 | }; |
| 63 | 93 | |
| 64 | 94 | export function resilienceScreen(f: Session, ctx: Ctx): Screen { |
| 65 | const mayMirror = f.user?.isAdmin === true && f.user.kind === "user"; | |
| 95 | // One rule for the destination and the mirror: a site admin who is a person. | |
| 96 | const mayChange = (): boolean => mayChangeDestination(f.user); | |
| 97 | const refusedAll = "Where snapshots go is a forge admin's."; | |
| 66 | 98 | /** The newest snapshots, `unreadable` when the store could not be listed; undefined until read. */ |
| 67 | 99 | let snapshots: string[] | "unreadable" | undefined; |
| 68 | 100 | let asking: Asking | undefined; |
| @@ -71,6 +103,11 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen { | ||
| 71 | 103 | let said = ""; |
| 72 | 104 | let note = ""; |
| 73 | 105 | |
| 106 | const say = (text: string): void => { | |
| 107 | note = text; | |
| 108 | ctx.invalidate(); | |
| 109 | }; | |
| 110 | ||
| 74 | 111 | const read = async (): Promise<void> => { |
| 75 | 112 | const listed = await recentSnapshots(f.app, f.user, SNAPSHOTS); |
| 76 | 113 | snapshots = listed.ok ? listed.value : "unreadable"; |
| @@ -98,14 +135,75 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen { | ||
| 98 | 135 | }); |
| 99 | 136 | }; |
| 100 | 137 | |
| 138 | const destination: Target = { | |
| 139 | title: "Snapshot destination", | |
| 140 | save: (input) => { | |
| 141 | const saved = saveDestination(f.app, f.user, input); | |
| 142 | return saved.ok ? saved.value : saved.error === "not_found" ? refusedAll : saved.error; | |
| 143 | }, | |
| 144 | }; | |
| 145 | const mirror: Target = { | |
| 146 | title: "Mirror", | |
| 147 | save: (input) => { | |
| 148 | const saved = saveMirror(f.app, f.user, input); | |
| 149 | return saved.ok ? saved.value : saved.error === "not_found" ? refusedAll : saved.error; | |
| 150 | }, | |
| 151 | }; | |
| 152 | ||
| 153 | /** One prompt per field, each validated as typed; the last one seals them all. */ | |
| 154 | const step = (target: Target, i: number, given: DestinationInput): Screen => { | |
| 155 | const s = STEPS[i]!; | |
| 156 | return promptScreen(ctx, screen, { | |
| 157 | title: `${target.title} ${i + 1}/${STEPS.length}`, | |
| 158 | label: s.label, | |
| 159 | hint: "Blank keeps it; a refusal is said whole behind.", | |
| 160 | ...(s.placeholder === undefined ? {} : { placeholder: s.placeholder }), | |
| 161 | ...(s.mask ? { mask: true } : {}), | |
| 162 | onSubmit: (value) => { | |
| 163 | const refused = invalidField({ ...BLANK, [s.key]: value }); | |
| 164 | // The prompt has room for the start of it; the screen behind says it whole. | |
| 165 | if (refused) { | |
| 166 | say(refused); | |
| 167 | return refused; | |
| 168 | } | |
| 169 | const next = { ...given, [s.key]: value }; | |
| 170 | if (i + 1 < STEPS.length) return later(ctx, () => step(target, i + 1, next)); | |
| 171 | say(target.save(next)); | |
| 172 | return undefined; | |
| 173 | }, | |
| 174 | }); | |
| 175 | }; | |
| 176 | ||
| 177 | const forgetDest = (): Screen => | |
| 178 | confirmScreen(ctx, screen, { | |
| 179 | title: "Forget the destination?", | |
| 180 | body: ["The sealed endpoint, bucket and keys are cleared;", "the environment's destination, if any, takes over."], | |
| 181 | actions: [{ label: "Cancel", variant: "ghost" }, { label: "Forget", variant: "danger" }], | |
| 182 | onChoose: (index) => { | |
| 183 | if (index !== 1) return; | |
| 184 | say(forgetDestination(f.app, f.user).ok ? "The sealed destination is cleared." : refusedAll); | |
| 185 | }, | |
| 186 | }); | |
| 187 | ||
| 188 | const forgetMir = (): Screen => | |
| 189 | confirmScreen(ctx, screen, { | |
| 190 | title: "Forget the mirror?", | |
| 191 | body: ["The sealed mirror settings are cleared;", "snapshots stay only on this machine."], | |
| 192 | actions: [{ label: "Cancel", variant: "ghost" }, { label: "Forget", variant: "danger" }], | |
| 193 | onChoose: (index) => { | |
| 194 | if (index !== 1) return; | |
| 195 | say(forgetMirror(f.app, f.user).ok ? "The sealed mirror is cleared." : refusedAll); | |
| 196 | }, | |
| 197 | }); | |
| 198 | ||
| 101 | 199 | const body = (): string[] => { |
| 102 | 200 | // The question and the answer come first: a long list must not clip them. |
| 103 | 201 | const out: string[] = []; |
| 104 | 202 | if (asking !== undefined) out.push(QUESTION[asking], ""); |
| 105 | 203 | else if (said !== "") out.push(...wrap(said, INNER), ""); |
| 106 | out.push(...wrap(mirrorLine(f), INNER)); | |
| 107 | if (mayMirror) out.push("M sets the mirror, F forgets it."); | |
| 204 | out.push(...wrap(destinationLine(f), INNER), ...wrap(mirrorLine(f), INNER)); | |
| 108 | 205 | 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."); | |
| 109 | 207 | out.push("", "Snapshots, newest first:"); |
| 110 | 208 | if (snapshots === undefined) out.push(" reading…"); |
| 111 | 209 | else if (snapshots === "unreadable") out.push(" the snapshots could not be read"); |
| @@ -121,7 +219,14 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen { | ||
| 121 | 219 | if (asking !== undefined) return [binding(["y"], "yes"), binding(["n", "esc"], "no")]; |
| 122 | 220 | return [ |
| 123 | 221 | ...(busy ? [] : [binding(["n"], "take a snapshot now"), binding(["l"], "bring the mirror level now")]), |
| 124 | ...(mayMirror ? [binding(["m"], "set the mirror"), binding(["f"], "forget the mirror")] : []), | |
| 222 | ...(mayChange() | |
| 223 | ? [ | |
| 224 | binding(["d"], "set destination"), | |
| 225 | binding(["x"], "forget destination"), | |
| 226 | binding(["m"], "set the mirror"), | |
| 227 | binding(["f"], "forget the mirror"), | |
| 228 | ] | |
| 229 | : []), | |
| 125 | 230 | binding(["enter", "esc", "q"], "close"), |
| 126 | 231 | ]; |
| 127 | 232 | }, |
| @@ -148,46 +253,13 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen { | ||
| 148 | 253 | asking = e.key === "n" ? "snapshot" : "mirror"; |
| 149 | 254 | said = ""; |
| 150 | 255 | } else if (e.key === "enter" || e.key === "esc" || e.key === "q") ctx.pop(); |
| 151 | else if (mayMirror && e.key === "m") ctx.push(ask(0, {})); | |
| 152 | else if (mayMirror && e.key === "f") | |
| 153 | ctx.push( | |
| 154 | confirmScreen(ctx, screen, { | |
| 155 | title: "Forget the mirror?", | |
| 156 | body: ["The sealed mirror settings are cleared;", "snapshots stay only on this machine."], | |
| 157 | actions: [{ label: "Cancel", variant: "ghost" }, { label: "Forget", variant: "danger" }], | |
| 158 | onChoose: (i) => { | |
| 159 | if (i !== 1) return; | |
| 160 | note = forgetMirror(f.app, f.user).ok ? "The sealed mirror is cleared." : "Not found."; | |
| 161 | }, | |
| 162 | }), | |
| 163 | ); | |
| 256 | else if (!mayChange()) return true; | |
| 257 | else if (e.key === "d") ctx.push(step(destination, 0, BLANK)); | |
| 258 | else if (e.key === "x") ctx.push(forgetDest()); | |
| 259 | else if (e.key === "m") ctx.push(step(mirror, 0, BLANK)); | |
| 260 | else if (e.key === "f") ctx.push(forgetMir()); | |
| 164 | 261 | return true; |
| 165 | 262 | }, |
| 166 | 263 | }; |
| 167 | // One field at a time; the last submit seals, and a refusal keeps that prompt open. | |
| 168 | const ask = (n: number, got: Record<string, string>): Screen => { | |
| 169 | const [name, label, mask] = FIELDS[n]!; | |
| 170 | return promptScreen(ctx, screen, { | |
| 171 | title: `Mirror ${n + 1}/${FIELDS.length}`, | |
| 172 | label, | |
| 173 | hint: "blank keeps what was sealed", | |
| 174 | mask, | |
| 175 | onSubmit: (value) => { | |
| 176 | const next = { ...got, [name]: value }; | |
| 177 | if (n + 1 < FIELDS.length) return later(ctx, () => ask(n + 1, next)); | |
| 178 | const input: DestinationInput = { | |
| 179 | endpoint: next["endpoint"] ?? "", | |
| 180 | bucket: next["bucket"] ?? "", | |
| 181 | region: next["region"] ?? "", | |
| 182 | accessKey: next["accessKey"] ?? "", | |
| 183 | secretKey: next["secretKey"] ?? "", | |
| 184 | }; | |
| 185 | const saved = saveMirror(f.app, f.user, input); | |
| 186 | if (!saved.ok) return saved.error === "not_found" ? "Not found." : saved.error; | |
| 187 | note = saved.value; | |
| 188 | return undefined; | |
| 189 | }, | |
| 190 | }); | |
| 191 | }; | |
| 192 | 264 | return screen; |
| 193 | 265 | } |
test/merge-queue.test.ts +14 −7
| @@ -456,16 +456,23 @@ describe("the merge queue", () => { | ||
| 456 | 456 | test("a docs-only move voids the gate: tests read docs, so the pull is gated again on the new base (#6640)", async () => { |
| 457 | 457 | const repo = await autonomousRepo("docs-mid-gate"); |
| 458 | 458 | const pull = await greenPull(repo, "a.ts"); |
| 459 | held = (async () => { | |
| 460 | const moved = await commitToBranch(app, alice, repo, { | |
| 461 | message: "A doc", | |
| 462 | files: [{ path: "docs/NOTE.md", content: "# note\n" }], | |
| 463 | }); | |
| 464 | if (!moved.ok) throw new Error(JSON.stringify(moved.error)); | |
| 465 | })(); | |
| 459 | // The doc lands while the gate runs, not before it starts: on a loaded | |
| 460 | // machine a move begun up front can beat the sweep, and the gate then | |
| 461 | // rightly runs on the new base and lands. | |
| 462 | atGate = () => { | |
| 463 | atGate = undefined; | |
| 464 | held = (async () => { | |
| 465 | const moved = await commitToBranch(app, alice, repo, { | |
| 466 | message: "A doc", | |
| 467 | files: [{ path: "docs/NOTE.md", content: "# note\n" }], | |
| 468 | }); | |
| 469 | if (!moved.ok) throw new Error(JSON.stringify(moved.error)); | |
| 470 | })(); | |
| 471 | }; | |
| 466 | 472 | try { |
| 467 | 473 | await sweepGreen(app, repo); |
| 468 | 474 | } finally { |
| 475 | atGate = undefined; | |
| 469 | 476 | held = undefined; |
| 470 | 477 | } |
| 471 | 478 | // Voided: not landed on the gate that ran before the move. |
test/tui-backups.test.ts added +186
| @@ -0,0 +1,186 @@ | ||
| 1 | /** | |
| 2 | * The snapshot destination in the terminal (#7269): the Resilience screen | |
| 3 | * says where snapshots go; `d` seals a destination field by field, `f` | |
Non-blocking: the header comment says |
||
| 4 | * forgets it after a confirm. Every frame drawn is kept, and none may hold | |
| 5 | * the access key or the secret key. | |
| 6 | */ | |
| 7 | import { afterAll, 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, mayChangeDestination, saveDestination } from "../src/actions/destination.ts"; | |
| 13 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 14 | import { sealedSet } from "../src/snapshot/destination.ts"; | |
| 15 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 16 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 17 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | import { settleApp } from "./helpers/tui.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | const ACCESS = "AKIAEXAMPLEACCESS7269"; | |
| 24 | const SECRET = "s3cr3t/Key+ThatMustNeverShow7269"; | |
| 25 | ||
| 26 | describe("the snapshot destination in the terminal", () => { | |
| 27 | let dataDir: string; | |
| 28 | let forge: Forge; | |
| 29 | const frames: string[] = []; | |
| 30 | const saved = { ...process.env }; | |
| 31 | ||
| 32 | function boot(username: string) { | |
| 33 | const terminal = headlessTerminal(140, 40); | |
| 34 | const user = getUserByName(forge.db, username)!; | |
| 35 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 36 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 37 | app.start(); | |
| 38 | const frame = () => { | |
| 39 | const t = app.screen.toText(); | |
| 40 | frames.push(t); | |
| 41 | return t; | |
| 42 | }; | |
| 43 | const key = (k: string, ctrl = false) => { | |
| 44 | app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 45 | frame(); | |
| 46 | }; | |
| 47 | const press = async (...keys: string[]) => { | |
| 48 | for (const k of keys) key(k); | |
| 49 | await settleApp(app); | |
| 50 | return frame(); | |
| 51 | }; | |
| 52 | const type = async (text: string) => { | |
| 53 | for (const ch of text) key(ch); | |
| 54 | await settleApp(app); | |
| 55 | return frame(); | |
| 56 | }; | |
| 57 | const open = async () => { | |
| 58 | key("k", true); | |
| 59 | await settleApp(app); | |
| 60 | await type("Resilience"); | |
| 61 | return press("enter"); | |
| 62 | }; | |
| 63 | return { text: frame, press, type, open, stop: () => app.stop() }; | |
| 64 | } | |
| 65 | ||
| 66 | beforeAll(async () => { | |
| 67 | for (const k of Object.keys(process.env)) | |
| 68 | if (k.startsWith("S3_") || k === "FORGE_SNAPSHOT_DIR") delete process.env[k]; | |
| 69 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-backups-")); | |
| 70 | forge = createApp({ dataDir, port: 0 }); | |
| 71 | for (const [username, adminIfFirst] of [["alice", true], ["carol", false]] as const) { | |
| 72 | const made = await createUser(forge.db, { | |
| 73 | origin: "seed", | |
| 74 | trusted: true, | |
| 75 | username, | |
| 76 | email: `${username}@forge.local`, | |
| 77 | password: "a-good-passphrase", | |
| 78 | adminIfFirst, | |
| 79 | }); | |
| 80 | if (!made.ok) throw new Error(made.error); | |
| 81 | } | |
| 82 | forge.db.query("UPDATE user SET is_admin = 1, is_agent = 1 WHERE username = 'carol'").run(); | |
| 83 | }); | |
| 84 | ||
| 85 | afterAll(async () => { | |
| 86 | forge.close(); | |
| 87 | await rm(dataDir, { recursive: true, force: true }); | |
| 88 | process.env = saved; | |
| 89 | }); | |
| 90 | ||
| 91 | test("with nothing set, snapshots stay on this box", async () => { | |
| 92 | const h = boot("alice"); | |
| 93 | const t = await h.open(); | |
| 94 | expect(t).toContain("Snapshots stay on this box"); | |
| 95 | expect(t).toContain("d sets where snapshots go, x forgets it."); | |
| 96 | h.stop(); | |
| 97 | }); | |
| 98 | ||
| 99 | test("a bad endpoint is refused in the page's words, and nothing is sealed", async () => { | |
| 100 | const h = boot("alice"); | |
| 101 | await h.open(); | |
| 102 | // The prompt says up front that a refusal is told in full on the screen behind it. | |
| 103 | expect(await h.press("d")).toContain("a refusal is said whole behind"); | |
| 104 | await h.type("ftp://user:[email protected]"); | |
| 105 | const t = await h.press("enter"); | |
| 106 | expect(t).toContain("The endpoint must be an http(s) URL without credentials in it."); | |
| 107 | expect(t).toContain("1/5"); | |
| 108 | await h.press("esc"); | |
| 109 | expect(sealedSet(forge.db)).toEqual([]); | |
| 110 | h.stop(); | |
| 111 | }); | |
| 112 | ||
| 113 | test("d seals each field, and the screen reads back kind and place without a key", async () => { | |
| 114 | const h = boot("alice"); | |
| 115 | await h.open(); | |
| 116 | await h.press("d"); | |
| 117 | await h.type("https://s3.example.com"); | |
| 118 | expect(await h.press("enter")).toContain("2/5"); | |
| 119 | await h.type("forge-snaps"); | |
| 120 | await h.press("enter"); | |
| 121 | await h.type("eu-west-1"); | |
| 122 | await h.press("enter"); | |
| 123 | await h.type(ACCESS); | |
| 124 | expect(h.text()).toContain("•".repeat(ACCESS.length)); | |
| 125 | await h.press("enter"); | |
| 126 | await h.type(SECRET); | |
| 127 | const t = await h.press("enter"); | |
| 128 | expect(t).toContain("Destination sealed; the next snapshot goes there."); | |
| 129 | expect(t).toContain("Snapshots go to s3.example.com/forge-snaps (sealed in this forge)."); | |
| 130 | expect(sealedSet(forge.db).length).toBeGreaterThanOrEqual(4); | |
| 131 | h.stop(); | |
| 132 | // Opened afresh, it still says where, and still no key. | |
| 133 | const again = boot("alice"); | |
| 134 | expect(await again.open()).toContain("s3.example.com/forge-snaps"); | |
| 135 | again.stop(); | |
| 136 | }); | |
| 137 | ||
| 138 | test("an agent admin reads the destination but cannot change it", async () => { | |
| 139 | const h = boot("carol"); | |
| 140 | const t = await h.open(); | |
| 141 | expect(t).toContain("s3.example.com/forge-snaps"); | |
| 142 | expect(t).not.toContain("d sets where snapshots go, x forgets it."); | |
| 143 | expect(await h.press("x")).not.toContain("Forget the destination?"); | |
| 144 | expect(await h.press("d")).not.toContain("1/5"); | |
| 145 | h.stop(); | |
| 146 | // The action refuses it too, so no other door (page, MCP) lets an agent change it. | |
| 147 | const carol = getUserByName(forge.db, "carol")!; | |
| 148 | const before = sealedSet(forge.db); | |
| 149 | expect(saveDestination(forge, carol, { endpoint: "https://x.example", bucket: "b", region: "", accessKey: "", secretKey: "" })).toEqual({ ok: false, error: "not_found" }); | |
| 150 | expect(forgetDestination(forge, carol).ok).toBe(false); | |
| 151 | expect(sealedSet(forge.db)).toEqual(before); | |
| 152 | }); | |
| 153 | ||
| 154 | test("x forgets only after the confirm; f is the mirror's", async () => { | |
| 155 | const h = boot("alice"); | |
| 156 | await h.open(); | |
| 157 | // `f` forgets the mirror (#7281), never the destination. | |
| 158 | expect(await h.press("f")).toContain("Forget the mirror?"); | |
| 159 | await h.press("esc"); | |
| 160 | expect(await h.press("x")).toContain("Forget the destination?"); | |
| 161 | await h.press("esc"); | |
| 162 | expect(sealedSet(forge.db).length).toBeGreaterThan(0); | |
| 163 | await h.press("x"); | |
| 164 | const t = await h.press("right", "enter"); | |
| 165 | expect(t).toContain("The sealed destination is cleared."); | |
| 166 | expect(t).toContain("Snapshots stay on this box"); | |
| 167 | expect(sealedSet(forge.db)).toEqual([]); | |
| 168 | h.stop(); | |
| 169 | }); | |
| 170 | ||
| 171 | test("one rule offers set and forget: a site admin who is a person", () => { | |
| 172 | expect(mayChangeDestination({ isAdmin: true, kind: "user" })).toBe(true); | |
| 173 | expect(mayChangeDestination({ isAdmin: true, kind: "agent" })).toBe(false); | |
| 174 | expect(mayChangeDestination({ isAdmin: false, kind: "user" })).toBe(false); | |
| 175 | expect(mayChangeDestination(undefined)).toBe(false); | |
| 176 | }); | |
| 177 | ||
| 178 | test("no frame ever held the access key or the secret key", () => { | |
| 179 | expect(frames.length).toBeGreaterThan(50); | |
| 180 | for (const f of frames) { | |
| 181 | expect(f).not.toContain(ACCESS); | |
| 182 | expect(f).not.toContain(SECRET); | |
| 183 | expect(f).not.toContain(SECRET.slice(0, 10)); | |
| 184 | } | |
| 185 | }); | |
| 186 | }); |
test/tui-mirror-set.test.ts +2 −1
| @@ -97,6 +97,7 @@ describe("mirror in the terminal", () => { | ||
| 97 | 97 | const h = boot("alice"); |
| 98 | 98 | await open(h); |
| 99 | 99 | expect(h.text()).toContain("Mirror: none."); |
| 100 | expect(h.text()).toContain("m sets the mirror, f forgets it."); | |
| 100 | 101 | await h.press("m"); |
| 101 | 102 | await field(h, "http://s3.example.test"); |
| 102 | 103 | await field(h, "offbox-copy"); |
| @@ -155,7 +156,7 @@ describe("mirror in the terminal", () => { | ||
| 155 | 156 | const before = mirrorSealedSet(forge.db); |
| 156 | 157 | const h = boot("robo"); |
| 157 | 158 | await open(h); |
| 158 | expect(h.text()).not.toContain("M sets the mirror"); | |
| 159 | expect(h.text()).not.toContain("m sets the mirror"); | |
| 159 | 160 | await h.press("m"); |
| 160 | 161 | expect(h.text()).not.toContain("Mirror 1/5"); |
| 161 | 162 | await h.press("f"); |
new above
offline
Non-blocking: the header says
dseals andfforgets the destination; it isxnow (fis the mirror).