Public: anyone can read this; the crew can change it.
Take updates from trevato/forge-upstream #2601
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/2600-take-updates-from-trevato-forge-upstream → main 1 commit fork
142 files
changed +7130
−557
.forgejo/workflows/outside-gate.yml added +38
| @@ -0,0 +1,38 @@ | ||
| 1 | name: outside-gate | |
| 2 | # Nightly: every file in EXCLUDED (scripts/gate.ts), one at a time with the | |
| 3 | # gate's 60 s timeout, so a suite outside the gate that breaks is seen the | |
| 4 | # day it breaks. A newly red file opens one `Red: test/….test.ts` issue | |
| 5 | # naming its first failing test; a file green again closes it | |
| 6 | # (scripts/flake-hunt.ts outside). The list is EXCLUDED itself: a file moved | |
| 7 | # into the gate stops being run here. | |
| 8 | # | |
| 9 | # Needs secret FORGE_TOKEN and variable FORGE_URL, as flake-hunt does. | |
| 10 | on: | |
| 11 | schedule: | |
| 12 | - cron: "47 3 * * *" | |
| 13 | workflow_dispatch: {} | |
| 14 | ||
| 15 | jobs: | |
| 16 | outside: | |
| 17 | runs-on: isolated | |
| 18 | timeout-minutes: 30 | |
| 19 | env: | |
| 20 | REGISTRY: forgejo-http.forgejo.svc.cluster.local:3000 | |
| 21 | steps: | |
| 22 | - name: clone main | |
| 23 | env: | |
| 24 | TOKEN: ${{ github.token }} | |
| 25 | run: | | |
| 26 | set -eu | |
| 27 | git clone -q "http://x-access-token:$TOKEN@$REGISTRY/$GITHUB_REPOSITORY.git" src | |
| 28 | cd src | |
| 29 | bun install --frozen-lockfile | |
| 30 | ||
| 31 | - name: run the excluded files | |
| 32 | working-directory: src | |
| 33 | env: | |
| 34 | FORGE_API: ${{ vars.FORGE_URL }}/api/v1 | |
| 35 | FORGE_REPO: ${{ github.repository }} | |
| 36 | FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} | |
| 37 | FORGE_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }} | |
| 38 | run: bun scripts/flake-hunt.ts outside --file-issues |
CLAUDE.md +6 −3
| @@ -254,9 +254,12 @@ flag exists only in the stdio dev runner and is never reachable over SSH. | ||
| 254 | 254 | whose default export is the SQL — never the next number: `0001`–`0091` |
| 255 | 255 | are the numbered history, and a shared next number is what parallel pulls |
| 256 | 256 | collided on (#1837). Files run in name order, once each, recognised by a |
| 257 | hash of their text in `schema_migration` (`kernel/migrate.ts`), so two | |
| 258 | pulls' migrations land in either order. Never edit an existing migration | |
| 259 | — an edited one is a new one and runs again. It must only add | |
| 257 | hash of their text in `schema_migration` (`kernel/migrate.ts`, which also | |
| 258 | records each name), so two pulls' migrations land in either order, and a | |
| 259 | database with one this build does not know boots and names it. Never edit | |
| 260 | an existing migration — an edited one is a new one and runs again; once | |
| 261 | pinned in `core/migration-pins.json` (`bun scripts/pin-migrations.ts`, run | |
| 262 | by the release agent, never by a pull) the schema test fails on an edit. It must only add | |
| 260 | 263 | (`test/schema-additive.test.ts`); the release agent lists its id in |
| 261 | 264 | `core/releases.json`. |
| 262 | 265 | - **New git read:** put it in `git/read.ts`; reuse the long-lived `cat-file` |
README.md +73 −4
Diff not shown — a line 4,879 characters wide (limit 2,000). +73 −4
View filedocs/APPS.md +1 −1
| @@ -1114,7 +1114,7 @@ a link to the milestone (`docs/PLANNING.md`); its progress never does. | ||
| 1114 | 1114 | | `FORGE_APP_DOMAIN` | the host in `FORGE_URL`/`_HOST` | what app hostnames attach to (`example` or `--x`) | |
| 1115 | 1115 | | `FORGE_APPS_CONCURRENCY` | `2` | deploys running their heavy steps at once | |
| 1116 | 1116 | | `FORGE_PREVIEW_IDLE_DAYS` | `3` | days a pull may sit untouched before its preview sleeps; `0` = never | |
| 1117 | | `FORGE_CREW_CONCURRENCY` | `2` | agent runs at once (live: Settings → Capacity) | | |
| 1117 | | `FORGE_CREW_CONCURRENCY` | 1 per 3 GiB above 2 GiB, 2–16 | agent runs at once (live: Settings → Capacity) | | |
| 1118 | 1118 | | `FORGE_CHECKS_CONCURRENCY` | `1` | checks at once (live: Settings → Capacity) | |
| 1119 | 1119 | | `FORGE_CHECKS_PLACEMENT` | `process` | `job` runs a check's commands as a cluster Job (see Placement above) | |
| 1120 | 1120 | | `FORGE_CHECKS_DRAIN_S` | `300` | seconds a roll lets running checks finish first | |
docs/ARCHITECTURE.md +3 −2
| @@ -176,8 +176,9 @@ NOT NULL column without a default, and `test/schema-additive.test.ts` | ||
| 176 | 176 | refuses one in any migration not shipped by both of the two newest releases |
| 177 | 177 | recorded in `src/core/releases.json` (by id: a count, and the stamped ids), |
| 178 | 178 | which the release agent bumps when it cuts one. |
| 179 | An old process that restarts mid-roll refuses a database ahead of it | |
| 180 | (`user_version`), as it always has. | |
| 179 | An old process that restarts mid-roll boots on a database ahead of it and | |
| 180 | logs, by name, the migrations it does not know (`schema_migration` records | |
| 181 | each name, #783) — they only add, so it serves as it did. | |
| 181 | 182 | |
| 182 | 183 | State transitions use compare-and-swap (`UPDATE … WHERE state = ?`), and every |
| 183 | 184 | transition writes its event row in the same transaction. On boot, a |
docs/DOMAINS.md +3 −2
| @@ -35,13 +35,14 @@ two devices backed by the same physical disk, are not. | ||
| 35 | 35 | ## Running a drill |
| 36 | 36 | |
| 37 | 37 | `scripts/drills/run.ts` walks you through one. It refuses a domain whose drill |
| 38 | is none, prints the fault to cause, and waits for Enter. It then polls | |
| 38 | is none, prints the fault to cause, and waits for Enter (a line on stdin, so piping one works; closing stdin aborts). It then polls | |
| 39 | 39 | `/api/v1/health` and `/` once a second, keeping each distinct line the forge |
| 40 | 40 | says while unhealthy; when the forge is healthy again it posts what it saw and |
| 41 | 41 | the seconds it took to `POST /api/v1/drills` and prints the verdict. If the |
| 42 | 42 | forge is still down after an hour it posts nothing, says so, and exits 0. A |
| 43 | 43 | refused post exits non-zero with the forge's message. `FORGE_TOKEN` must be a |
| 44 | site admin's token. | |
| 44 | site admin's token; without it the script still watches, prints the result as | |
| 45 | JSON and says that nothing was posted. | |
| 45 | 46 | |
| 46 | 47 | |
| 47 | 48 | FORGE_TOKEN=… bun scripts/drills/run.ts runner --forge https://forge.open-platform.sh |
docs/MCP.md +2 −1
| @@ -28,6 +28,7 @@ for a tunnel and a forge that sleeps. | ||
| 28 | 28 | | tool | does | writes | |
| 29 | 29 | | -------------- | ------------------------------------------------------------------------------------------------- | ------ | |
| 30 | 30 | | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | | |
| 31 | | `repo` | `{ repo }`: one repository's description, visibility, autonomy and `audience { level, sentence }`, the same as `GET /api/v1/repos/:owner/:name` | | | |
| 31 | 32 | | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes | |
| 32 | 33 | | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes | |
| 33 | 34 | | `pull` | `{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks` | | |
| @@ -39,7 +40,7 @@ for a tunnel and a forge that sleeps. | ||
| 39 | 40 | | `audit` | who can do what, per app, read from its contract.json in git: yes, own or no per door, and whether outsiders may direct its crew (`crewOutsiders`) | | |
| 40 | 41 | | `outsiders` | `{ repo, on?, dryRun? }`: whether people who cannot write may direct the crew; with `on`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes | |
| 41 | 42 | | `decisions_by` | `{ repo, by?, dryRun? }`: the standing rule for a repository's hand-overs (`me`, `decider`, `decider_tell`); with `by`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes | |
| 42 | | `archive` | `{ repo, archived?, dryRun? }`: archive (true) or wake (false) a repository, by name; planned first (`dryRun` is true unless `false`); only the repository's admin with an admin token, never an agent | yes | | |
| 43 | | `archive` | `{ repo | repos, archived?, dryRun? }`: archive (true) or wake (false) a repository, by name; planned first (`dryRun` is true unless `false`); only the repository's admin with an admin token, never an agent. `repos` (at most 50) runs the fleet `archive` operation over them, answers each one's outcome and the `operation` id; `limb` with `action: "undo"`, that `operation` and any one of the repositories as `app` reverts it for a day | yes | | |
| 43 | 44 | | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | | |
| 44 | 45 | | `following` | where a fork stands against what it follows, and what the crew is doing about it | | |
| 45 | 46 | | `spawn` | `{ name, apps?, crew?, dryRun? }`: a child forge, planned first (`dryRun` is true unless `false`); an admin's, and an `ro` token may plan but not make | yes | |
docs/RECOVERY.md +2 −2
| @@ -16,8 +16,8 @@ Sundays 09:00 UTC the forge drills a restore of the newest snapshot, boots it | ||
| 16 | 16 | on a side port and reads `/api/v1/health`. One comment lands on the recovery |
| 17 | 17 | issue (`FORGE_RECOVERY_ISSUE` in `FORGE_RECOVERY_REPO`): snapshot, restore |
| 18 | 18 | time, health, counts, pass or fail. A failed drill is an inbox card naming |
| 19 | the step (fetch, restore, open, count, unseal, health) until one passes. No | |
| 20 | comment this week means the backups are unproven. | |
| 19 | the step (fetch, restore, open, count, unseal, health), run again at boot and | |
| 20 | every three hours until one passes. No comment this week: backups unproven. | |
| 21 | 21 | |
| 22 | 22 | ## What a restore needs |
| 23 | 23 |
docs/SNAPSHOT.md +51 −4
| @@ -8,7 +8,23 @@ empty data directory. `src/snapshot/`. | ||
| 8 | 8 | ## What a snapshot holds |
| 9 | 9 | |
| 10 | 10 | - `forge.db`, copied consistently while the forge runs (`VACUUM INTO`, |
| 11 | never a raw copy of a file in WAL mode). | |
| 11 | never a raw copy of a file in WAL mode), then zstd-compressed and cut in | |
| 12 | 16 MB parts: `forge.db.zst.0000`, `forge.db.zst.0001`, … Each part is its | |
| 13 | own put with its own deadline and retries, so a slow uplink carries it | |
| 14 | and a dropped connection costs one part, not the database. The manifest's | |
| 15 | `packed` says what the parts make and how big that is; a restore joins | |
| 16 | them, unpacks, and refuses a size that does not match. The log line | |
| 17 | `database packed` gives each night's ratio. Snapshots from before this | |
| 18 | hold a plain `forge.db` and still restore. The parts go up through the | |
| 19 | snapshot's upload queue like every other object: at most eight objects | |
| 20 | or 64 MB in flight, so four 16 MB parts at a time. | |
| 21 | ||
| 22 | Packing and restoring both hold the database in memory: the copy, its | |
| 23 | compressed form (the parts are views of it, not copies), and on restore | |
| 24 | the joined parts and the unpacked file, about three times the database | |
| 25 | at the peak (a 334 MB database at about 1 GB). A database that outgrows | |
| 26 | the app's memory limit needs a streaming pack; until then that limit, | |
| 27 | not the uplink, is the ceiling. | |
| 12 | 28 | - The key the database's sealed values need, **only wrapped**: |
| 13 | 29 | `secret.key.wrapped`, AES-256-GCM under a key scrypt-derived from the |
| 14 | 30 | operator's passphrase. The raw `secret.key` is never written to the store, |
| @@ -24,8 +40,8 @@ empty data directory. `src/snapshot/`. | ||
| 24 | 40 | stay out. |
| 25 | 41 | - The crew's run logs (`runs/`). |
| 26 | 42 | - `manifest.json`: the name, when, the forge's version, counts, every file |
| 27 | with its size, and `key`: `wrapped` or `none` (older snapshots: `raw`). A | |
| 28 | restore checks each size. | |
| 43 | with its size, `packed` (the files that went up in parts), and `key`: | |
| 44 | `wrapped` or `none` (older snapshots: `raw`). A restore checks each size. | |
| 29 | 45 | |
| 30 | 46 | What stays out is what a boot or a deploy rebuilds: clones, checks, |
| 31 | 47 | pictures, previews. |
| @@ -86,7 +102,24 @@ mirror is copied from the primary store, not written in parallel, so a snapshot | ||
| 86 | 102 | that never landed on the primary is not seen by it. |
| 87 | 103 | |
| 88 | 104 | The store is `Bun.S3Client` or a directory behind one small interface |
| 89 | (`src/snapshot/store.ts`): put, get, list, delete. Nothing else. | |
| 105 | (`src/snapshot/store.ts`): put, get, list, delete. Nothing else. Every call | |
| 106 | is tried five times; an attempt that has not answered by its deadline counts | |
| 107 | as failed. A put's deadline follows its size: its bytes at a floor of | |
| 108 | 5 Mbit/s, plus 60 s. A fixed 180 s once failed every attempt at a 334 MB | |
| 109 | database behind a 15 Mbit/s uplink (#2536). | |
| 110 | ||
| 111 | ## When a snapshot fails | |
| 112 | ||
| 113 | Its outcome is kept in `snapshot/state.json` under the data directory, so a | |
| 114 | restart forgets nothing. A failure shows straight away as a card on the board | |
| 115 | (`snapshot-failed`, pointing at Backups), in the terminal's dashboard among | |
| 116 | the forge's needs, and as a red line on the Backups page with the reason and | |
| 117 | since when. The forge tries again within the hour, not the next night. The | |
| 118 | next snapshot that lands, nightly or on demand, clears both. | |
| 119 | ||
| 120 | A forge given nowhere to put a snapshot has not failed: nothing is kept, no | |
| 121 | card is raised, and nothing is retried sooner than the nightly. The Backups | |
| 122 | page says there is no destination. | |
| 90 | 123 | |
| 91 | 124 | ## The restore drill |
| 92 | 125 | |
| @@ -96,12 +129,26 @@ scratch directory under the data directory, opens the database read-only, | ||
| 96 | 129 | counts repositories and issues, opens one sealed value with the restored key, |
| 97 | 130 | and removes the scratch whatever happened. The page waits until it finishes, |
| 98 | 131 | for up to 10 minutes; a step that hangs past that is red and names the step. |
| 132 | The restore reads several objects at a time, under the same limits as the | |
| 133 | upload (`IN_FLIGHT` gets, `IN_FLIGHT_BYTES` held): read one after another, a | |
| 134 | snapshot of thousands of objects spent the drill's whole 10 minutes in the | |
| 135 | restore step (#2539). | |
| 136 | Repositories come back four clones at a time, not one after another (#2543). | |
| 99 | 137 | One drill runs at a time: a second click, or a retry after a proxy timeout, |
| 100 | 138 | joins the drill already running rather than starting another restore. Every |
| 101 | 139 | run is a row in the `drill` table, and the last one shows as a line on the |
| 102 | 140 | Backups page: when, which snapshot, how long, the counts, and green or the |
| 103 | 141 | step that failed. |
| 104 | 142 | |
| 143 | The drill runs on its own every Sunday 09:00 UTC, and a forge that was down | |
| 144 | then catches up five minutes after boot. A red drill does not keep the | |
| 145 | week's slot: it is owed again `RED_RETRY_MS` (three hours) after it ran, | |
| 146 | and at a boot once it is `BOOT_RED_FLOOR_MS` (30 minutes) old, until one | |
| 147 | passes (`src/snapshot/due.ts`). The floor keeps a crash loop with a broken | |
| 148 | restore from running a restore on every boot. Kept for the | |
| 149 | week, a drill red at 09:11 stood as a card long after the fix deployed at | |
| 150 | 10:18 (#2541). | |
| 151 | ||
| 105 | 152 | ## Restore |
| 106 | 153 | |
| 107 | 154 | The printable card, seven lines, is [](RESTORE.md); a test runs it. |
scripts/drills/run.ts +45 −79
| @@ -4,11 +4,13 @@ | ||
| 4 | 4 | * polls `/api/v1/health` and `/` once a second keeping each distinct line the |
| 5 | 5 | * forge says while unhealthy, and when the forge is healthy again posts what |
| 6 | 6 | * it saw and how long it took to `POST /api/v1/drills` (`FORGE_TOKEN` from |
| 7 | * the environment). If the forge never comes back within the ceiling it posts | |
| 8 | * nothing. Zero dependencies; it never shells out. | |
| 7 | * the environment). Without a token it prints the result and says nothing was | |
| 8 | * posted. If the forge never comes back within the ceiling it posts nothing. | |
| 9 | * Zero dependencies; it never shells out. | |
| 9 | 10 | */ |
| 10 | 11 | |
| 11 | 12 | import { DOMAINS } from "../../src/kernel/domains.ts"; |
| 13 | import { UNREACHABLE, clean, linesOf, watchPolls, type Poll } from "./watch.ts"; | |
| 12 | 14 | |
| 13 | 15 | export interface DrillRun { |
| 14 | 16 | domain: string; |
| @@ -27,50 +29,17 @@ export interface DrillRun { | ||
| 27 | 29 | const SEEN_MAX = 8_000; |
| 28 | 30 | const DEFAULT_CEILING_MS = 3_600_000; |
| 29 | 31 | |
| 30 | /** C0 and C1 control characters out (tab stays): the forge's words never drive the terminal. */ | |
| 31 | export const clean = (s: string): string => s.replace(/[\x00-\x08\x0a-\x1f\x7f-\x9f]/g, ""); | |
| 32 | export { clean }; | |
| 32 | 33 | |
| 33 | /** Every string in a JSON value, or the text's own lines when it is not JSON. */ | |
| 34 | function linesOf(text: string, contentType: string): string[] { | |
| 35 | return rawLinesOf(text, contentType).map(clean).filter((l) => l.trim() !== ""); | |
| 36 | } | |
| 37 | ||
| 38 | function rawLinesOf(text: string, contentType: string): string[] { | |
| 39 | if (contentType.includes("json")) { | |
| 40 | try { | |
| 41 | const out: string[] = []; | |
| 42 | const walk = (v: unknown): void => { | |
| 43 | if (typeof v === "string") out.push(v); | |
| 44 | else if (Array.isArray(v)) v.forEach(walk); | |
| 45 | else if (v && typeof v === "object") Object.values(v).forEach(walk); | |
| 46 | }; | |
| 47 | walk(JSON.parse(text)); | |
| 48 | return out; | |
| 49 | } catch { | |
| 50 | /* fall through to plain lines */ | |
| 51 | } | |
| 52 | } | |
| 53 | return text | |
| 54 | .replace(/<(script|style)[\s\S]*?<\/\1>/gi, " ") | |
| 55 | .replace(/<[^>]*>/g, "\n") | |
| 56 | .replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") | |
| 57 | .replace(/"/g, '"').replace(/�?39;/g, "'") | |
| 58 | .split("\n") | |
| 59 | .map((l) => l.trim()) | |
| 60 | .filter((l) => l !== ""); | |
| 61 | } | |
| 62 | ||
| 63 | interface Look { | |
| 64 | healthy: boolean; | |
| 65 | health: string[]; | |
| 66 | board: string[]; | |
| 67 | } | |
| 68 | ||
| 69 | async function look(forge: string, token: string): Promise<Look> { | |
| 34 | /** | |
| 35 | * One look at the forge: healthy only when `/api/v1/health` reports ok and `/` | |
| 36 | * answers; what each said is kept under `health` and `board`. | |
| 37 | */ | |
| 38 | export function forgePoll(forge: string, token: string, fetchIt: typeof fetch = fetch): () => Promise<Poll> { | |
| 70 | 39 | const get = async (path: string): Promise<{ ok: boolean; lines: string[]; json: unknown }> => { |
| 71 | 40 | try { |
| 72 | const res = await fetch(`${forge}${path}`, { | |
| 73 | headers: { authorization: `Bearer ${token}` }, | |
| 41 | const res = await fetchIt(`${forge}${path}`, { | |
| 42 | headers: token === "" ? {} : { authorization: `Bearer ${token}` }, | |
| 74 | 43 | redirect: "manual", |
| 75 | 44 | signal: AbortSignal.timeout(5_000), |
| 76 | 45 | }); |
| @@ -82,17 +51,16 @@ async function look(forge: string, token: string): Promise<Look> { | ||
| 82 | 51 | } |
| 83 | 52 | return { ok: res.status < 400, lines: linesOf(text, type), json }; |
| 84 | 53 | } catch { |
| 85 | return { ok: false, lines: [], json: null }; | |
| 54 | return { ok: false, lines: [UNREACHABLE], json: null }; | |
| 86 | 55 | } |
| 87 | 56 | }; |
| 88 | const [h, b] = await Promise.all([get("/api/v1/health"), get("/")]); | |
| 89 | const reported = (h.json as { ok?: unknown } | null)?.ok === true; | |
| 90 | return { healthy: h.ok && b.ok && reported, health: h.lines, board: b.lines }; | |
| 57 | return async () => { | |
| 58 | const [h, b] = await Promise.all([get("/api/v1/health"), get("/")]); | |
| 59 | const reported = (h.json as { ok?: unknown } | null)?.ok === true; | |
| 60 | return { healthy: h.ok && b.ok && reported, said: { health: h.lines, board: b.lines } }; | |
| 61 | }; | |
| 91 | 62 | } |
| 92 | 63 | |
| 93 | /** A line with its numbers blanked, so a clock or a counter is not a new line. */ | |
| 94 | const shape = (l: string): string => l.replace(/\d+/g, "#"); | |
| 95 | ||
| 96 | 64 | const cap = (lines: Iterable<string>): string => [...lines].join("\n").slice(0, SEEN_MAX); |
| 97 | 65 | |
| 98 | 66 | /** Runs the drill; resolves to the process exit code. */ |
| @@ -116,29 +84,29 @@ export async function runDrill(run: DrillRun): Promise<number> { | ||
| 116 | 84 | run.say(`The forge should be back within ${domain.backMinutes} minutes. Press Enter when you have caused it.`); |
| 117 | 85 | await run.enter(); |
| 118 | 86 | |
| 119 | const started = now(); | |
| 120 | const health = new Map<string, string>(); | |
| 121 | const board = new Map<string, string>(); | |
| 122 | let sawDown = false; | |
| 123 | for (;;) { | |
| 124 | const seen = await look(run.forge, run.token); | |
| 125 | if (seen.healthy && sawDown) break; | |
| 126 | if (!seen.healthy) { | |
| 127 | sawDown = true; | |
| 128 | for (const l of seen.health) if (!health.has(shape(l))) { health.set(shape(l), l); run.say(`health: ${l}`); } | |
| 129 | for (const l of seen.board) if (!board.has(shape(l))) { board.set(shape(l), l); run.say(`board: ${l}`); } | |
| 130 | } | |
| 131 | if (now() - started >= ceilingMs) { | |
| 132 | const ceilingS = Math.round(ceilingMs / 1000); | |
| 133 | run.say(`The forge did not come back within the ceiling of ${ceilingS}s; nothing was posted.`); | |
| 134 | run.say(JSON.stringify({ domain: domain.key, backSeconds: null, posted: false, ceilingSeconds: ceilingS })); | |
| 135 | return 0; | |
| 136 | } | |
| 137 | await sleep(intervalMs); | |
| 87 | const watched = await watchPolls({ | |
| 88 | poll: forgePoll(run.forge, run.token), | |
| 89 | sleep, | |
| 90 | limit: Infinity, | |
| 91 | ceilingMs, | |
| 92 | intervalMs, | |
| 93 | now, | |
| 94 | onLine: (l, source) => run.say(`${source}: ${l}`), | |
| 95 | }); | |
| 96 | if (watched.timeout) { | |
| 97 | const ceilingS = Math.round(ceilingMs / 1000); | |
| 98 | run.say(`The forge did not come back within the ceiling of ${ceilingS}s; nothing was posted.`); | |
| 99 | run.say(JSON.stringify({ domain: domain.key, backSeconds: null, posted: false, ceilingSeconds: ceilingS })); | |
| 100 | return 0; | |
| 101 | } | |
| 102 | const { health = [], board = [] } = watched.said; | |
| 103 | const backSeconds = Math.max(0, Math.round(watched.healthyAt / 1000)); | |
| 104 | const body = { domain: domain.key, seen: { health: cap(health), board: cap(board) }, backSeconds }; | |
| 105 | if (run.token === "") { | |
| 106 | run.say("FORGE_TOKEN is not set; nothing was posted. The result:"); | |
| 107 | run.say(JSON.stringify({ domain: domain.key, backSeconds, posted: false, body })); | |
| 108 | return 0; | |
| 138 | 109 | } |
| 139 | ||
| 140 | const backSeconds = Math.max(0, Math.round((now() - started) / 1000)); | |
| 141 | const body = { domain: domain.key, seen: { health: cap(health.values()), board: cap(board.values()) }, backSeconds }; | |
| 142 | 110 | let res: Response; |
| 143 | 111 | try { |
| 144 | 112 | res = await fetch(`${run.forge}/api/v1/drills`, { |
| @@ -172,14 +140,13 @@ export interface DrillArgs { | ||
| 172 | 140 | token: string; |
| 173 | 141 | } |
| 174 | 142 | |
| 175 | const USAGE = "usage: FORGE_TOKEN=… bun scripts/drills/run.ts <domain> --forge <url> [--insecure]"; | |
| 143 | const USAGE = "usage: [FORGE_TOKEN=…] bun scripts/drills/run.ts <domain> --forge <url> [--insecure]"; | |
| 176 | 144 | const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); |
| 177 | 145 | |
| 178 | 146 | /** The options, or the one plain line to print before exiting 2. */ |
| 179 | 147 | export function checkArgs( |
| 180 | 148 | argv: string[], |
| 181 | 149 | env: Record<string, string | undefined>, |
| 182 | isTTY: boolean, | |
| 183 | 150 | ): { ok: true; args: DrillArgs } | { ok: false; error: string } { |
| 184 | 151 | const at = argv.indexOf("--forge"); |
| 185 | 152 | const forge = at >= 0 ? argv[at + 1] : undefined; |
| @@ -193,16 +160,14 @@ export function checkArgs( | ||
| 193 | 160 | return { ok: false, error: `--forge must be an http(s) URL; ${USAGE}` }; |
| 194 | 161 | } |
| 195 | 162 | const token = env.FORGE_TOKEN ?? ""; |
| 196 | if (token === "") return { ok: false, error: `FORGE_TOKEN is empty; ${USAGE}` }; | |
| 197 | if (!isTTY) return { ok: false, error: "stdin is not a terminal; run the drill from an interactive shell" }; | |
| 198 | if (url.protocol === "http:" && !LOOPBACK.has(url.hostname) && !insecure) { | |
| 163 | if (token !== "" && url.protocol === "http:" && !LOOPBACK.has(url.hostname) && !insecure) { | |
| 199 | 164 | return { ok: false, error: `refusing to send the admin token over plain http to ${url.hostname}; use https, or pass --insecure` }; |
| 200 | 165 | } |
| 201 | 166 | return { ok: true, args: { domain, forge: forge.replace(/\/+$/, ""), token } }; |
| 202 | 167 | } |
| 203 | 168 | |
| 204 | 169 | if (import.meta.main) { |
| 205 | const checked = checkArgs(process.argv.slice(2), process.env, process.stdin.isTTY === true); | |
| 170 | const checked = checkArgs(process.argv.slice(2), process.env); | |
| 206 | 171 | if (!checked.ok) { |
| 207 | 172 | console.error(checked.error); |
| 208 | 173 | process.exit(2); |
| @@ -211,7 +176,8 @@ if (import.meta.main) { | ||
| 211 | 176 | ...checked.args, |
| 212 | 177 | say: (l) => console.log(clean(l)), |
| 213 | 178 | enter: async () => { |
| 214 | for await (const _ of console) break; | |
| 179 | for await (const _ of console) return; | |
| 180 | throw new Error("stdin closed before Enter; nothing was caused or watched"); | |
| 215 | 181 | }, |
| 216 | 182 | }); |
| 217 | 183 | process.exit(code); |
scripts/drills/watch.ts added +140
| @@ -0,0 +1,140 @@ | ||
| 1 | /** | |
| 2 | * `watchDrill`: polls one URL until the forge is healthy again after having | |
| 3 | * been unhealthy, and returns each distinct line it said meanwhile. Pure: the | |
| 4 | * fetch and the sleep come in as arguments, nothing is read, posted or global. | |
| 5 | * Times are milliseconds: the clock you pass, or else polls made times the | |
| 6 | * interval. `watchPolls` is the loop for any poll that says healthy or not and | |
| 7 | * what each of its sources said; `watchDrill` is it over one URL. | |
| 8 | */ | |
| 9 | ||
| 10 | /** C0 and C1 control characters out (tab stays): the forge's words never drive the terminal. */ | |
| 11 | export const clean = (s: string): string => s.replace(/[\x00-\x08\x0a-\x1f\x7f-\x9f]/g, ""); | |
| 12 | ||
| 13 | /** Every string in a JSON value, or the text's own lines when it is not JSON. */ | |
| 14 | export function linesOf(text: string, contentType: string): string[] { | |
| 15 | return rawLinesOf(text, contentType).map(clean).filter((l) => l.trim() !== ""); | |
| 16 | } | |
| 17 | ||
| 18 | function rawLinesOf(text: string, contentType: string): string[] { | |
| 19 | if (contentType.includes("json")) { | |
| 20 | try { | |
| 21 | const out: string[] = []; | |
| 22 | const walk = (v: unknown): void => { | |
| 23 | if (typeof v === "string") out.push(v); | |
| 24 | else if (Array.isArray(v)) v.forEach(walk); | |
| 25 | else if (v && typeof v === "object") Object.values(v).forEach(walk); | |
| 26 | }; | |
| 27 | walk(JSON.parse(text)); | |
| 28 | return out; | |
| 29 | } catch { | |
| 30 | /* fall through to plain lines */ | |
| 31 | } | |
| 32 | } | |
| 33 | return text | |
| 34 | .replace(/<(script|style)[\s\S]*?<\/\1>/gi, " ") | |
| 35 | .replace(/<[^>]*>/g, "\n") | |
| 36 | .replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") | |
| 37 | .replace(/"/g, '"').replace(/�?39;/g, "'") | |
| 38 | .split("\n") | |
| 39 | .map((l) => l.trim()) | |
| 40 | .filter((l) => l !== ""); | |
| 41 | } | |
| 42 | ||
| 43 | /** A line with its numbers blanked, so a clock or a counter is not a new line. */ | |
| 44 | const shape = (l: string): string => l.replace(/\d+/g, "#"); | |
| 45 | ||
| 46 | /** The part of a Response that a watch reads. */ | |
| 47 | export interface Reply { | |
| 48 | status: number; | |
| 49 | text: () => Promise<string>; | |
| 50 | headers: { get: (name: string) => string | null }; | |
| 51 | } | |
| 52 | ||
| 53 | /** What a fetch that threw is recorded as saying. */ | |
| 54 | export const UNREACHABLE = "unreachable"; | |
| 55 | ||
| 56 | /** One look at the forge: is it healthy, and what each source said (named by source). */ | |
| 57 | export interface Poll { | |
| 58 | healthy: boolean; | |
| 59 | said: Record<string, string[]>; | |
| 60 | } | |
| 61 | ||
| 62 | export interface Watch { | |
| 63 | poll: () => Promise<Poll>; | |
| 64 | /** Waits one interval. */ | |
| 65 | sleep: (ms: number) => Promise<unknown>; | |
| 66 | /** The most polls to make before giving up. */ | |
| 67 | limit: number; | |
| 68 | intervalMs?: number; | |
| 69 | /** Milliseconds on any clock; without it, time is the polls made times `intervalMs`. */ | |
| 70 | now?: () => number; | |
| 71 | /** Gives up once this many milliseconds have passed, however few polls that took. */ | |
| 72 | ceilingMs?: number; | |
| 73 | /** Called once with each new distinct line and its source, as it is first said. */ | |
| 74 | onLine?: (line: string, source: string) => void; | |
| 75 | } | |
| 76 | ||
| 77 | export type Watched = | |
| 78 | | { timeout: false; startedAt: number; healthyAt: number; said: Record<string, string[]> } | |
| 79 | | { timeout: true; startedAt: number | null; said: Record<string, string[]> }; | |
| 80 | ||
| 81 | /** Polls until healthy again after having been unhealthy; keeps each distinct line said while unhealthy. */ | |
| 82 | export async function watchPolls(w: Watch): Promise<Watched> { | |
| 83 | const intervalMs = w.intervalMs ?? 1_000; | |
| 84 | const seen = new Map<string, Map<string, string>>(); | |
| 85 | const said = (): Record<string, string[]> => | |
| 86 | Object.fromEntries([...seen].map(([source, m]) => [source, [...m.values()]])); | |
| 87 | const began = w.now?.() ?? 0; | |
| 88 | let startedAt: number | null = null; | |
| 89 | for (let n = 0; n < w.limit; n++) { | |
| 90 | const look = await w.poll(); | |
| 91 | const at = w.now ? w.now() - began : n * intervalMs; | |
| 92 | if (look.healthy && startedAt !== null) return { timeout: false, startedAt, healthyAt: at, said: said() }; | |
| 93 | if (!look.healthy) { | |
| 94 | startedAt ??= at; | |
| 95 | for (const [source, lines] of Object.entries(look.said)) { | |
| 96 | const m = seen.get(source) ?? new Map<string, string>(); | |
| 97 | seen.set(source, m); | |
| 98 | for (const l of lines) if (!m.has(shape(l))) { m.set(shape(l), l); w.onLine?.(l, source); } | |
| 99 | } | |
| 100 | } | |
| 101 | if (w.ceilingMs !== undefined && at >= w.ceilingMs) break; | |
| 102 | await w.sleep(intervalMs); | |
| 103 | } | |
| 104 | return { timeout: true, startedAt, said: said() }; | |
| 105 | } | |
| 106 | ||
| 107 | export interface WatchDrill extends Omit<Watch, "poll" | "onLine"> { | |
| 108 | url: string; | |
| 109 | fetch: (url: string) => Promise<Reply>; | |
| 110 | onLine?: (line: string) => void; | |
| 111 | } | |
| 112 | ||
| 113 | export type WatchedDrill = | |
| 114 | | { timeout: false; startedAt: number; healthyAt: number; lines: string[] } | |
| 115 | | { timeout: true; startedAt: number | null; lines: string[] }; | |
| 116 | ||
| 117 | /** `watchPolls` over one URL: healthy is a status under 400, and the lines are its body's. */ | |
| 118 | export async function watchDrill(w: WatchDrill): Promise<WatchedDrill> { | |
| 119 | const poll = async (): Promise<Poll> => { | |
| 120 | try { | |
| 121 | const res = await w.fetch(w.url); | |
| 122 | if (res.status < 400) return { healthy: true, said: {} }; | |
| 123 | let lines: string[] = []; | |
| 124 | try { | |
| 125 | lines = linesOf(await res.text(), res.headers.get("content-type") ?? ""); | |
| 126 | } catch { | |
| 127 | /* an unreadable body is unhealthy and says nothing */ | |
| 128 | } | |
| 129 | return { healthy: false, said: { "": lines } }; | |
| 130 | } catch { | |
| 131 | return { healthy: false, said: { "": [UNREACHABLE] } }; | |
| 132 | } | |
| 133 | }; | |
| 134 | const { url: _url, fetch: _fetch, onLine, ...rest } = w; | |
| 135 | const r = await watchPolls({ ...rest, poll, ...(onLine && { onLine: (l: string) => onLine(l) }) }); | |
| 136 | const lines = r.said[""] ?? []; | |
| 137 | return r.timeout | |
| 138 | ? { timeout: true, startedAt: r.startedAt, lines } | |
| 139 | : { timeout: false, startedAt: r.startedAt, healthyAt: r.healthyAt, lines }; | |
| 140 | } |
scripts/flake-hunt.ts +112 −6
| @@ -9,6 +9,12 @@ | ||
| 9 | 9 | * `--file-issues` each gets an issue carrying the failure text, the |
| 10 | 10 | * seed and the run (FORGE_API, FORGE_REPO, FORGE_TOKEN; FORGE_RUN_URL |
| 11 | 11 | * names the run). Exits 1 when it found one. |
| 12 | * bun scripts/flake-hunt.ts outside [--file-issues] | |
| 13 | * Every file in EXCLUDED once, one at a time, with the gate's timeout: | |
| 14 | * the schedule for what the gate does not run (#812). With | |
| 15 | * `--file-issues` a newly red file opens one `Red: test/….test.ts` | |
| 16 | * issue naming its first failing test, a file red on another test | |
| 17 | * updates it, and a file green again closes it. | |
| 12 | 18 | * bun scripts/flake-hunt.ts quarantine |
| 13 | 19 | * Puts every file an open `Flaky: test/….test.ts` issue the hunt filed |
| 14 | 20 | * (author is the token's user, not untrusted) names into |
| @@ -30,12 +36,16 @@ import { | ||
| 30 | 36 | huntedFile, |
| 31 | 37 | HUNT_RUNS, |
| 32 | 38 | issueFor, |
| 39 | redActions, | |
| 40 | redFile, | |
| 41 | redIssueFor, | |
| 42 | type OpenRed, | |
| 33 | 43 | quarantineFlaky, |
| 34 | 44 | readRun, |
| 35 | 45 | shuffled, |
| 36 | 46 | type FileRun, |
| 37 | 47 | } from "./flake.ts"; |
| 38 | import { gateFiles, presentTests } from "./gate.ts"; | |
| 48 | import { gateFiles, outsideFiles, presentTests } from "./gate.ts"; | |
| 39 | 49 | import { parseShardSpec, sliceFiles } from "./shards.ts"; |
| 40 | 50 | |
| 41 | 51 | const GATE_FILE = new URL("./gate.ts", import.meta.url).pathname; |
| @@ -66,7 +76,7 @@ function runsOf(fallback: number): number { | ||
| 66 | 76 | |
| 67 | 77 | const freshSeed = (): number => crypto.getRandomValues(new Uint32Array(1))[0]!; |
| 68 | 78 | |
| 69 | /** One file, one seed, one `bun test`: the gate's own unit. */ | |
| 79 | /** One file, one `bun test`: the gate's own unit (random order when seeded). */ | |
| 70 | 80 | async function runFile(file: string, seed: number): Promise<FileRun> { |
| 71 | 81 | const env = { ...process.env }; |
| 72 | 82 | // The gate hands its children none of these (scripts/test-shards.ts). |
| @@ -84,8 +94,7 @@ async function runFile(file: string, seed: number): Promise<FileRun> { | ||
| 84 | 94 | "test", |
| 85 | 95 | "--timeout", |
| 86 | 96 | "60000", |
| 87 | "--randomize", | |
| 88 | `--seed=${seed}`, | |
| 97 | ...(seed === 0 ? [] : ["--randomize", `--seed=${seed}`]), | |
| 89 | 98 | `./${file}`, |
| 90 | 99 | ], |
| 91 | 100 | { stdin: "ignore", stdout: "pipe", stderr: "pipe", env }, |
| @@ -171,6 +180,102 @@ async function openFlakyIssues(): Promise<Map<string, number>> { | ||
| 171 | 180 | return found; |
| 172 | 181 | } |
| 173 | 182 | |
| 183 | async function forgeJson( | |
| 184 | method: string, | |
| 185 | path: string, | |
| 186 | body: unknown, | |
| 187 | ): Promise<Response> { | |
| 188 | const { api, repo, token } = forgeApi(); | |
| 189 | return fetch(`${api}/repos/${repo}${path}`, { | |
| 190 | method, | |
| 191 | headers: { | |
| 192 | authorization: `Bearer ${token}`, | |
| 193 | "content-type": "application/json", | |
| 194 | }, | |
| 195 | body: JSON.stringify(body), | |
| 196 | }); | |
| 197 | } | |
| 198 | ||
| 199 | /** Open `Red:` issues the scheduled run filed: file → issue. */ | |
| 200 | async function openRedIssues(): Promise<Map<string, OpenRed>> { | |
| 201 | const { api, repo, token } = forgeApi(); | |
| 202 | const self = await huntUserId(api, token); | |
| 203 | const found = new Map<string, OpenRed>(); | |
| 204 | let cursor: string | number | null = ""; | |
| 205 | for (let page = 0; page < 100 && cursor !== null; page++) { | |
| 206 | const query = cursor === "" ? "" : `&cursor=${encodeURIComponent(cursor)}`; | |
| 207 | const res = await fetch( | |
| 208 | `${api}/repos/${repo}/issues?state=open&limit=100${query}`, | |
| 209 | { headers: { authorization: `Bearer ${token}` } }, | |
| 210 | ); | |
| 211 | if (!res.ok) fail(`listing issues: ${res.status}`); | |
| 212 | const body = (await res.json()) as { | |
| 213 | items?: { | |
| 214 | number?: unknown; | |
| 215 | title?: unknown; | |
| 216 | body?: unknown; | |
| 217 | authorId?: unknown; | |
| 218 | untrusted?: unknown; | |
| 219 | }[]; | |
| 220 | next_cursor?: string | number | null; | |
| 221 | }; | |
| 222 | for (const issue of body.items ?? []) { | |
| 223 | if (issue.authorId !== self || (issue.untrusted ?? false) !== false) | |
| 224 | continue; | |
| 225 | const file = | |
| 226 | typeof issue.title === "string" ? redFile(issue.title) : undefined; | |
| 227 | if (file !== undefined && Number.isInteger(issue.number)) | |
| 228 | found.set(file, { | |
| 229 | number: issue.number as number, | |
| 230 | ...(typeof issue.body === "string" ? { body: issue.body } : {}), | |
| 231 | }); | |
| 232 | } | |
| 233 | cursor = body.next_cursor ?? null; | |
| 234 | } | |
| 235 | return found; | |
| 236 | } | |
| 237 | ||
| 238 | /** The files outside the gate, one at a time; red ones reported to the forge. */ | |
| 239 | async function outside(): Promise<number> { | |
| 240 | const files = outsideFiles(presentTests()); | |
| 241 | const fileIssues = rest.includes("--file-issues"); | |
| 242 | const open = fileIssues ? await openRedIssues() : new Map<string, OpenRed>(); | |
| 243 | const runs: FileRun[] = []; | |
| 244 | for (const file of files) { | |
| 245 | const run = await runFile(file, 0); | |
| 246 | console.log(`${run.green ? "green" : "red "}: ${file}`); | |
| 247 | runs.push(run); | |
| 248 | } | |
| 249 | const where = process.env["FORGE_RUN_URL"] ?? ""; | |
| 250 | for (const action of redActions(runs, open)) { | |
| 251 | if (!fileIssues) continue; | |
| 252 | if (action.kind === "open") { | |
| 253 | const res = await forgeJson("POST", "/issues", redIssueFor(action.run, where)); | |
| 254 | const made = (await res.json().catch(() => ({}))) as { number?: unknown }; | |
| 255 | if (!res.ok) fail(`filing the issue for ${action.run.file}: ${res.status}`); | |
| 256 | console.log(` filed #${made.number} for ${action.run.file}`); | |
| 257 | } else if (action.kind === "update") { | |
| 258 | const { body } = redIssueFor(action.run, where); | |
| 259 | const res = await forgeJson("PATCH", `/issues/${action.number}`, { body }); | |
| 260 | if (!res.ok) { | |
| 261 | const note = await forgeJson("POST", `/issues/${action.number}/comments`, { body }); | |
| 262 | if (!note.ok) fail(`updating #${action.number}: ${note.status}`); | |
| 263 | } | |
| 264 | console.log(` updated #${action.number} for ${action.run.file}`); | |
| 265 | } else { | |
| 266 | await forgeJson("POST", `/issues/${action.number}/comments`, { | |
| 267 | body: `\`${action.file}\` passed in the scheduled run${where ? ` (${where})` : ""}.`, | |
| 268 | }); | |
| 269 | const res = await forgeJson("PATCH", `/issues/${action.number}`, { state: "closed" }); | |
| 270 | if (!res.ok) fail(`closing #${action.number}: ${res.status}`); | |
| 271 | console.log(` closed #${action.number} for ${action.file}`); | |
| 272 | } | |
| 273 | } | |
| 274 | const red = runs.filter((run) => !run.green).length; | |
| 275 | console.log(`${files.length} files outside the gate, ${red} red`); | |
| 276 | return 0; | |
| 277 | } | |
| 278 | ||
| 174 | 279 | async function hunt(): Promise<number> { |
| 175 | 280 | const runs = runsOf(HUNT_RUNS); |
| 176 | 281 | const spec = parseShardSpec(valueOf("--shard")); |
| @@ -263,10 +368,11 @@ async function admit(): Promise<number> { | ||
| 263 | 368 | return admitted(results) ? 0 : 1; |
| 264 | 369 | } |
| 265 | 370 | |
| 266 | if (mode === "hunt") process.exit(await hunt()); | |
| 371 | if (mode === "outside") process.exit(await outside()); | |
| 372 | else if (mode === "hunt") process.exit(await hunt()); | |
| 267 | 373 | else if (mode === "quarantine") process.exit(await quarantineAll()); |
| 268 | 374 | else if (mode === "admit") process.exit(await admit()); |
| 269 | 375 | else |
| 270 | 376 | fail( |
| 271 | "usage: flake-hunt.ts hunt [--runs N] [--shard N/M] [--file-issues] | quarantine | admit test/x.test.ts [--runs N]", | |
| 377 | "usage: flake-hunt.ts hunt [--runs N] [--shard N/M] [--file-issues] | outside [--file-issues] | quarantine | admit test/x.test.ts [--runs N]", | |
| 272 | 378 | ); |
scripts/flake.ts +72
| @@ -21,6 +21,9 @@ export const HUNT_RUNS = 5; | ||
| 21 | 21 | /** The title prefix of the issue the hunt files; quarantine reads it back. */ |
| 22 | 22 | export const FLAKY_TITLE = "Flaky: "; |
| 23 | 23 | |
| 24 | /** The title prefix of the issue the scheduled run files for a red file. */ | |
| 25 | export const RED_TITLE = "Red: "; | |
| 26 | ||
| 24 | 27 | /** |
| 25 | 28 | * The least a test may bound a request or a spawn by, in wall-clock ms. A |
| 26 | 29 | * loopback fetch on a loaded shared runner takes tens of milliseconds; a |
| @@ -321,3 +324,72 @@ export function shuffled(files: readonly string[], seed: number): string[] { | ||
| 321 | 324 | } |
| 322 | 325 | return out; |
| 323 | 326 | } |
| 327 | ||
| 328 | /** The first failing test's name as the issue words it. */ | |
| 329 | export function firstFailing(run: FileRun): string { | |
| 330 | return run.failing[0] !== undefined | |
| 331 | ? `\`${run.failing[0]}\`` | |
| 332 | : "none named; the file failed outside a test"; | |
| 333 | } | |
| 334 | ||
| 335 | /** The issue for a file outside the gate that is red. */ | |
| 336 | export function redIssueFor( | |
| 337 | run: FileRun, | |
| 338 | where: string, | |
| 339 | ): { title: string; body: string } { | |
| 340 | const fence = "```"; | |
| 341 | return { | |
| 342 | title: `${RED_TITLE}${run.file}`, | |
| 343 | body: [ | |
| 344 | `\`${run.file}\` is outside the gate and was red in the scheduled run. This issue closes itself when the file passes again.`, | |
| 345 | "", | |
| 346 | `- first failing test: ${firstFailing(run)}`, | |
| 347 | `- run: ${where || "(a local run)"}`, | |
| 348 | `- reproduce: \`bun test --timeout 60000 ./${run.file}\``, | |
| 349 | "", | |
| 350 | fence, | |
| 351 | run.text || "(no failure text captured)", | |
| 352 | fence, | |
| 353 | ].join("\n"), | |
| 354 | }; | |
| 355 | } | |
| 356 | ||
| 357 | /** The file an issue titled `Red: …` names, or undefined. */ | |
| 358 | export function redFile(title: string): string | undefined { | |
| 359 | if (!title.startsWith(RED_TITLE)) return undefined; | |
| 360 | return flakyFile(`${FLAKY_TITLE}${title.slice(RED_TITLE.length)}`); | |
| 361 | } | |
| 362 | ||
| 363 | /** An open `Red:` issue as the scheduled run sees it. */ | |
| 364 | export type OpenRed = { number: number; body?: string }; | |
| 365 | ||
| 366 | export type RedAction = | |
| 367 | | { kind: "open"; run: FileRun } | |
| 368 | | { kind: "update"; run: FileRun; number: number } | |
| 369 | | { kind: "close"; file: string; number: number }; | |
| 370 | ||
| 371 | /** | |
| 372 | * What the forge must be told after a scheduled run: a newly red file opens | |
| 373 | * one issue; a file still red whose first failing test is not in its issue | |
| 374 | * updates that issue; a file green again closes it. A file that stays red | |
| 375 | * on the same test says nothing, so the asks show it once. | |
| 376 | */ | |
| 377 | export function redActions( | |
| 378 | runs: readonly FileRun[], | |
| 379 | open: ReadonlyMap<string, OpenRed>, | |
| 380 | ): RedAction[] { | |
| 381 | const actions: RedAction[] = []; | |
| 382 | for (const run of runs) { | |
| 383 | const issue = open.get(run.file); | |
| 384 | if (run.green) { | |
| 385 | if (issue) | |
| 386 | actions.push({ kind: "close", file: run.file, number: issue.number }); | |
| 387 | } else if (!issue) actions.push({ kind: "open", run }); | |
| 388 | else if ( | |
| 389 | typeof issue.body === "string" && | |
| 390 | !issue.body.includes(firstFailing(run)) | |
| 391 | ) | |
| 392 | actions.push({ kind: "update", run, number: issue.number }); | |
| 393 | } | |
| 394 | return actions; | |
| 395 | } |
scripts/gate.ts +17
| @@ -15,6 +15,9 @@ | ||
| 15 | 15 | * order under load (`bun scripts/flake-hunt.ts admit <file>`). A flaky file |
| 16 | 16 | * leaves it only through `bun scripts/flake-hunt.ts quarantine`, under the |
| 17 | 17 | * issue the nightly hunt filed (`.forgejo/workflows/flake-hunt.yml`). |
| 18 | * | |
| 19 | * The excluded files are not unseen: `.forgejo/workflows/outside-gate.yml` | |
| 20 | * runs `outsideFiles` nightly and files a `Red:` issue for each that fails (#812). | |
| 18 | 21 | */ |
| 19 | 22 | |
| 20 | 23 | import { readdirSync } from "node:fs"; |
| @@ -135,6 +138,20 @@ export function gateFiles( | ||
| 135 | 138 | return present.filter((file) => !out.has(file)); |
| 136 | 139 | } |
| 137 | 140 | |
| 141 | /** | |
| 142 | * What the scheduled run runs (`bun scripts/flake-hunt.ts outside`): the | |
| 143 | * present files `EXCLUDED` names, in name order. Derived from the same list | |
| 144 | * as `gateFiles`, so a file is in exactly one of the two: moving it into | |
| 145 | * the gate stops the schedule running it. | |
| 146 | */ | |
| 147 | export function outsideFiles( | |
| 148 | present: readonly string[] = presentTests(), | |
| 149 | excluded: readonly Exclusion[] = EXCLUDED, | |
| 150 | ): string[] { | |
| 151 | const gated = new Set(gateFiles(present, excluded)); | |
| 152 | return present.filter((file) => !gated.has(file)); | |
| 153 | } | |
| 154 | ||
| 138 | 155 | /** |
| 139 | 156 | * The gate a checked-out tree's own `scripts/gate.ts` names, whichever shape |
| 140 | 157 | * it has: a tree from before #2071 lists it in `GATE`; a later one derives |
scripts/pin-migrations.ts added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | /** | |
| 2 | * `bun scripts/pin-migrations.ts`: pin every migration in | |
| 3 | * `src/core/migrations/` that `src/core/migration-pins.json` does not | |
| 4 | * name yet, by its id (`migrationId`). An existing pin is never rewritten: | |
| 5 | * once pinned, a migration's text is history, and | |
| 6 | * `test/schema-migrations.test.ts` fails if it changes (#783). | |
| 7 | * | |
| 8 | * The release agent runs it when it cuts a release, beside bumping | |
| 9 | * `src/core/releases.json`, so pulls never touch the file and two of them | |
| 10 | * adding a migration each never meet in it. | |
| 11 | */ | |
| 12 | ||
| 13 | import { readFileSync, writeFileSync } from "node:fs"; | |
| 14 | import { join } from "node:path"; | |
| 15 | import { migrationFiles } from "../src/core/schema.ts"; | |
| 16 | import { type MigrationPins, pinBreaks, pinned } from "../src/kernel/migrate.ts"; | |
| 17 | ||
| 18 | const file = join(import.meta.dir, "../src/core/migration-pins.json"); | |
| 19 | const pins = JSON.parse(readFileSync(file, "utf8")) as MigrationPins; | |
| 20 | const breaks = pinBreaks(migrationFiles, pins); | |
| 21 | if (breaks.length > 0) { | |
| 22 | for (const line of breaks) console.error(`pin-migrations: ${line}`); | |
| 23 | process.exit(1); | |
| 24 | } | |
| 25 | const next = pinned(migrationFiles, pins); | |
| 26 | const added = Object.keys(next).filter((name) => !(name in pins)); | |
| 27 | writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`); | |
| 28 | // CLI answer for the person at the terminal. | |
| 29 | console.log( | |
| 30 | added.length === 0 | |
| 31 | ? "pin-migrations: every migration is already pinned" | |
| 32 | : `pin-migrations: pinned ${added.join(", ")}`, | |
| 33 | ); |
scripts/shards.ts +34
| @@ -495,3 +495,37 @@ export function besideOthers( | ||
| 495 | 495 | const line = `fails only beside others: ${cleared.join(", ")} (shard-mates: ${mates})`; |
| 496 | 496 | return { red: stillRed, besideOthers: cleared, line }; |
| 497 | 497 | } |
| 498 | ||
| 499 | const HEADER = /^(\S+\.test\.[cm]?[jt]sx?):$/; | |
| 500 | const FAILED = /^\(fail\) /; | |
| 501 | ||
| 502 | /** | |
| 503 | * The files whose tests failed in one `bun test` report, in the order the | |
| 504 | * report reached them: bun prints `path:` before a file's tests and | |
| 505 | * `(fail) name` for each one that failed. A report that names no failing | |
| 506 | * test (a crash, a file that would not load) yields none. | |
| 507 | */ | |
| 508 | export function failingFiles(output: string): string[] { | |
| 509 | const found: string[] = []; | |
| 510 | let current: string | undefined; | |
| 511 | for (const line of output.split("\n")) { | |
| 512 | const header = HEADER.exec(line); | |
| 513 | if (header) current = header[1]!; | |
| 514 | else if (FAILED.test(line) && current !== undefined && !found.includes(current)) | |
| 515 | found.push(current); | |
| 516 | } | |
| 517 | return found; | |
| 518 | } | |
| 519 | ||
| 520 | /** | |
| 521 | * Whether every red in one `bun test` report is a failed test: the report | |
| 522 | * reached its summary, counts no errors, and has no unhandled error between | |
| 523 | * tests. A file that threw while loading, an error outside any test, or a | |
| 524 | * crash before the summary is red for a reason a second run alone does not | |
| 525 | * explain away, so nothing in such a report may be cleared. | |
| 526 | */ | |
| 527 | export function onlyTestFailures(output: string): boolean { | |
| 528 | if (!/^Ran \d+ tests? across \d+ files?/m.test(output)) return false; | |
| 529 | if (/^# Unhandled error/m.test(output)) return false; | |
| 530 | return !/^\s*[1-9][0-9]* errors?\s*$/m.test(output); | |
| 531 | } |
scripts/smoke.ts +83 −6
| @@ -4,6 +4,11 @@ | ||
| 4 | 4 | * `/healthz` is fetched once per user agent and must answer 200; the |
| 5 | 5 | * stylesheet linked from `/` must be a non-empty `text/css` 200. Exit 1 and |
| 6 | 6 | * one line per failure (url, user agent, status, content type) otherwise. |
| 7 | * | |
| 8 | * `--version <v>` first polls `/healthz` until `X-Forge-Version` is `<v>`, for | |
| 9 | * at most `--wait <seconds>` (default 120), and fails naming the last version | |
| 10 | * seen. `/sw.js` must be a 200 JavaScript response whose navigation branch | |
| 11 | * never answers with `Response.error()`. | |
| 7 | 12 | */ |
| 8 | 13 | |
| 9 | 14 | export const AGENTS: Record<string, string> = { |
| @@ -32,11 +37,61 @@ export function stylesheetHref(html: string): string | null { | ||
| 32 | 37 | return null; |
| 33 | 38 | } |
| 34 | 39 | |
| 40 | /** The part of a service worker that answers navigations, comments removed. */ | |
| 41 | export function navigationBranch(source: string): string | null { | |
| 42 | const at = source.search(/mode\s*===?\s*["']navigate["']/); | |
| 43 | if (at < 0) return null; | |
| 44 | const rest = source.slice(at); | |
| 45 | const end = rest.search(/\breturn\s*;/); | |
| 46 | return (end < 0 ? rest : rest.slice(0, end)).replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); | |
| 47 | } | |
| 48 | ||
| 49 | export interface Options { | |
| 50 | /** Wait for `/healthz` to report this `X-Forge-Version`. */ | |
| 51 | version?: string | undefined; | |
| 52 | /** Longest wait for it, in milliseconds. */ | |
| 53 | waitMs?: number; | |
| 54 | /** Pause between polls, in milliseconds. */ | |
| 55 | pollMs?: number; | |
| 56 | /** The fetch to use; the global one unless a caller (the forge proving itself, a test) gives its own. */ | |
| 57 | fetch?: typeof fetch; | |
| 58 | /** Accept a redirect from `/board` as the sign-in it is, for a caller holding no token. */ | |
| 59 | anonymousBoard?: boolean; | |
| 60 | } | |
| 61 | ||
| 62 | /** Polls `/healthz` until it reports `version`; null when it does, else a failure. */ | |
| 63 | async function waitForVersion(root: URL, version: string, waitMs: number, pollMs: number, fetch: typeof globalThis.fetch): Promise<Failure | null> { | |
| 64 | const url = new URL("/healthz", root).href; | |
| 65 | const deadline = Date.now() + waitMs; | |
| 66 | let seen = "none"; | |
| 67 | let status: number | string = "error"; | |
| 68 | for (;;) { | |
| 69 | try { | |
| 70 | const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(5_000) }); | |
| 71 | await res.body?.cancel(); | |
| 72 | status = res.status; | |
| 73 | seen = res.headers.get("x-forge-version") ?? "none"; | |
| 74 | if (seen === version) return null; | |
| 75 | } catch (error) { | |
| 76 | status = "error"; | |
| 77 | seen = `none (${error})`; | |
| 78 | } | |
| 79 | if (Date.now() + pollMs > deadline) break; | |
| 80 | await Bun.sleep(pollMs); | |
| 81 | } | |
| 82 | return { url, agent: "-", status, contentType: "", why: `wanted X-Forge-Version ${version}, last saw ${seen}` }; | |
| 83 | } | |
| 84 | ||
| 35 | 85 | /** Runs every check against `base`; returns the failures (empty is green). */ |
| 36 | export async function smoke(base: string, token = ""): Promise<Failure[]> { | |
| 86 | export async function smoke(base: string, token = "", options: Options = {}): Promise<Failure[]> { | |
| 37 | 87 | const root = new URL(base); |
| 38 | 88 | if (root.protocol !== "http:" && root.protocol !== "https:") throw new Error("url must be http(s)"); |
| 39 | 89 | const failures: Failure[] = []; |
| 90 | const fetch = options.fetch ?? globalThis.fetch; | |
| 91 | if (options.version) { | |
| 92 | const late = await waitForVersion(root, options.version, options.waitMs ?? 120_000, options.pollMs ?? 1_000, fetch); | |
| 93 | if (late) return [late]; | |
| 94 | } | |
| 40 | 95 | |
| 41 | 96 | const get = async (path: string, agent: string, headers: Record<string, string> = {}) => { |
| 42 | 97 | const url = new URL(path, root).href; |
| @@ -75,20 +130,42 @@ export async function smoke(base: string, token = ""): Promise<Failure[]> { | ||
| 75 | 130 | } |
| 76 | 131 | } |
| 77 | 132 | const board = await get("/board", agent, auth); |
| 78 | if (board && board.res.status !== 200) fail(board.url, agent, board.res, "expected 200 (a 302 is sign-in)"); | |
| 133 | const signIn = options.anonymousBoard && !token && board?.res.status === 302; | |
| 134 | if (board && board.res.status !== 200 && !signIn) fail(board.url, agent, board.res, "expected 200 (a 302 is sign-in)"); | |
| 79 | 135 | const health = await get("/healthz", agent); |
| 80 | 136 | if (health && health.res.status !== 200) fail(health.url, agent, health.res, "expected 200"); |
| 81 | 137 | } |
| 138 | ||
| 139 | const sw = await get("/sw.js", "desktop"); | |
| 140 | if (sw) { | |
| 141 | const type = sw.res.headers.get("content-type") ?? ""; | |
| 142 | if (sw.res.status !== 200) fail(sw.url, "desktop", sw.res, "expected 200"); | |
| 143 | else if (!/^(text|application)\/(x-)?javascript\b/i.test(type)) fail(sw.url, "desktop", sw.res, "expected javascript"); | |
| 144 | else { | |
| 145 | const nav = navigationBranch(sw.body); | |
| 146 | if (nav === null) fail(sw.url, "desktop", sw.res, "no navigation branch"); | |
| 147 | else if (/Response\s*\.\s*error\s*\(/.test(nav)) fail(sw.url, "desktop", sw.res, "a navigation can be answered with Response.error()"); | |
| 148 | } | |
| 149 | } | |
| 82 | 150 | return failures; |
| 83 | 151 | } |
| 84 | 152 | |
| 85 | 153 | if (import.meta.main) { |
| 86 | const base = process.argv[2]; | |
| 87 | if (!base) { | |
| 88 | console.error("usage: bun scripts/smoke.ts <url>"); | |
| 154 | const args = process.argv.slice(2); | |
| 155 | const flag = (name: string) => { | |
| 156 | const i = args.indexOf(name); | |
| 157 | if (i < 0) return undefined; | |
| 158 | return args.splice(i, 2)[1]; | |
| 159 | }; | |
| 160 | const version = flag("--version"); | |
| 161 | const wait = flag("--wait"); | |
| 162 | const base = args[0]; | |
| 163 | const seconds = wait === undefined ? 120 : Number(wait); | |
| 164 | if (!base || !Number.isFinite(seconds) || seconds < 0 || args.length > 1) { | |
| 165 | console.error("usage: bun scripts/smoke.ts <url> [--version <v>] [--wait <seconds>]"); | |
| 89 | 166 | process.exit(2); |
| 90 | 167 | } |
| 91 | const failures = await smoke(base, process.env["FORGE_TOKEN"] ?? ""); | |
| 168 | const failures = await smoke(base, process.env["FORGE_TOKEN"] ?? "", { version, waitMs: seconds * 1000 }); | |
| 92 | 169 | for (const f of failures) |
| 93 | 170 | console.error(`FAIL ${f.url} agent=${f.agent} status=${f.status} content-type=${f.contentType || "-"} (${f.why})`); |
| 94 | 171 | if (failures.length) process.exit(1); |
scripts/test-shards.ts +59 −18
| @@ -58,6 +58,9 @@ import { | ||
| 58 | 58 | splitCapped, |
| 59 | 59 | tally, |
| 60 | 60 | weigher, |
| 61 | besideOthers, | |
| 62 | failingFiles, | |
| 63 | onlyTestFailures, | |
| 61 | 64 | type ShardResult, |
| 62 | 65 | } from "./shards.ts"; |
| 63 | 66 | import { gateFiles, gateRefusal, presentTests } from "./gate.ts"; |
| @@ -425,6 +428,12 @@ async function drain( | ||
| 425 | 428 | const measured = new Map<string, string[]>(); |
| 426 | 429 | const unmeasured = new Set<string>(); |
| 427 | 430 | |
| 431 | // Failing files a red shard re-runs alone. | |
| 432 | const RETRY_CAP = 3; | |
| 433 | // Set once any shard has gone red for good: no retries then. | |
| 434 | let halted = false; | |
| 435 | const stopping = (): boolean => halted; | |
| 436 | ||
| 428 | 437 | type Timed = ShardResult & { seconds: number }; |
| 429 | 438 | |
| 430 | 439 | /** |
| @@ -463,29 +472,60 @@ async function runShard( | ||
| 463 | 472 | const cover = covDir |
| 464 | 473 | ? ["--coverage", "--coverage-reporter=lcov", `--coverage-dir=${covDir}`] |
| 465 | 474 | : []; |
| 466 | const child = Bun.spawn(["bun", "test", ...flags, ...cover, ...paths], { | |
| 467 | stdin: "ignore", | |
| 468 | stdout: "pipe", | |
| 469 | stderr: "pipe", | |
| 470 | // Bun keeps shell-inherited vars after `delete process.env.X`; pass a | |
| 471 | // cleaned env so capacityDefault does not see the gate's shard count. | |
| 472 | env: childEnv, | |
| 473 | }); | |
| 474 | children.add(child); | |
| 475 | // One buffer, in arrival order: `bun test` splits its report across | |
| 476 | // stdout and stderr. | |
| 477 | const output: string[] = []; | |
| 475 | const run = async ( | |
| 476 | targets: string[], | |
| 477 | extra: string[], | |
| 478 | through?: (text: string) => void, | |
| 479 | ): Promise<{ code: number; output: string[] }> => { | |
| 480 | const child = Bun.spawn(["bun", "test", ...flags, ...extra, ...targets], { | |
| 481 | stdin: "ignore", | |
| 482 | stdout: "pipe", | |
| 483 | stderr: "pipe", | |
| 484 | // Bun keeps shell-inherited vars after `delete process.env.X`; pass a | |
| 485 | // cleaned env so capacityDefault does not see the gate's shard count. | |
| 486 | env: childEnv, | |
| 487 | }); | |
| 488 | children.add(child); | |
| 489 | // One buffer, in arrival order: `bun test` splits its report across | |
| 490 | // stdout and stderr. | |
| 491 | const output: string[] = []; | |
| 492 | await Promise.all([ | |
| 493 | drain(child.stdout, output, through), | |
| 494 | drain(child.stderr, output, through), | |
| 495 | ]); | |
| 496 | const code = await child.exited; | |
| 497 | children.delete(child); | |
| 498 | return { code, output }; | |
| 499 | }; | |
| 478 | 500 | const through = live |
| 479 | 501 | ? (text: string): void => { |
| 480 | 502 | process.stdout.write(text); |
| 481 | 503 | } |
| 482 | 504 | : undefined; |
| 483 | await Promise.all([ | |
| 484 | drain(child.stdout, output, through), | |
| 485 | drain(child.stderr, output, through), | |
| 486 | ]); | |
| 487 | const code = await child.exited; | |
| 488 | children.delete(child); | |
| 505 | const first = await run(paths, cover, through); | |
| 506 | let code = first.code; | |
| 507 | const output = first.output; | |
| 508 | // A sliced shard that went red: each failing file (the first few) is run | |
| 509 | // once alone, same env and flags. One that passes alone was red only | |
| 510 | // beside others, and does not fail the check; the log names it. | |
| 511 | if (code !== 0 && slice.length > 1 && !covDir && !stopping()) { | |
| 512 | const report = output.join(""); | |
| 513 | // A file that errored (not a failed test) keeps the whole shard red. | |
| 514 | const red = onlyTestFailures(report) ? failingFiles(report) : []; | |
| 515 | const alone = new Map<string, boolean>(); | |
| 516 | for (const file of red.slice(0, RETRY_CAP)) { | |
| 517 | if (stopping()) break; | |
| 518 | const again = await run([file.startsWith("/") || file.startsWith("./") ? file : `./${file}`], []); | |
| 519 | alone.set(file, again.code === 0); | |
| 520 | } | |
| 521 | const verdict = besideOthers(red, alone, RETRY_CAP); | |
| 522 | if (verdict.line !== null) { | |
| 523 | const note = `${output.join("").endsWith("\n") ? "" : "\n"}test-shards: ${verdict.line}\n`; | |
| 524 | output.push(note); | |
| 525 | through?.(note); | |
| 526 | if (verdict.red.length === 0) code = 0; | |
| 527 | } | |
| 528 | } | |
| 489 | 529 | const text = output.join(""); |
| 490 | 530 | const seconds = ((performance.now() - started) / 1000).toFixed(1); |
| 491 | 531 | process.stdout.write( |
| @@ -524,6 +564,7 @@ async function pool(width: number): Promise<Timed[]> { | ||
| 524 | 564 | results[index] = await runShard(slices[index]!, index, width === 1); |
| 525 | 565 | if (results[index]!.code !== 0) { |
| 526 | 566 | stop = true; |
| 567 | halted = true; | |
| 527 | 568 | for (const child of children) child.kill("SIGTERM"); |
| 528 | 569 | } |
| 529 | 570 | } |
src/actions/accounts.ts +2 −1
| @@ -18,6 +18,7 @@ import { appsRuntime } from "../apps/runtime.ts"; | ||
| 18 | 18 | import { |
| 19 | 19 | dropCredentials, |
| 20 | 20 | listAccounts as readAccounts, |
| 21 | originOf, | |
| 21 | 22 | scrubAccount, |
| 22 | 23 | setSuspended, |
| 23 | 24 | type AccountRow, |
| @@ -331,7 +332,7 @@ export function newAccountLines( | ||
| 331 | 332 | const id = `account:${a.username}`; |
| 332 | 333 | return { |
| 333 | 334 | id, |
| 334 | line: `${a.username} joined the forge${a.via === null ? "" : ` (${a.via})`}`, | |
| 335 | line: `${a.username} joined the forge${originOf(a) === "" ? "" : ` (${originOf(a)})`}`, | |
| 335 | 336 | items: [id], |
| 336 | 337 | }; |
| 337 | 338 | }); |
src/actions/users.ts +17 −1
| @@ -21,6 +21,7 @@ import { | ||
| 21 | 21 | deleteToken as revokeToken, |
| 22 | 22 | type TokenScope, |
| 23 | 23 | } from "../core/tokens.ts"; |
| 24 | import { inviteSpentBy } from "../core/invites.ts"; | |
| 24 | 25 | import { |
| 25 | 26 | changePassword as rehash, |
| 26 | 27 | cleanDisplayName, |
| @@ -65,6 +66,10 @@ export function signupClosed(app: App, invite?: string): boolean { | ||
| 65 | 66 | return newAccountsClosed(app.db, invite); |
| 66 | 67 | } |
| 67 | 68 | |
| 69 | /** What a person with an invite link that no longer opens the door is told. */ | |
| 70 | export const INVITE_SPENT_MESSAGE = | |
| 71 | "This invite link has been used, revoked or has expired. Ask whoever sent it for a new one."; | |
| 72 | ||
| 68 | 73 | /** What anyone is told at a door that still offers a password-only sign-up. */ |
| 69 | 74 | export const PASSKEY_REQUIRED_MESSAGE = |
| 70 | 75 | "New accounts sign up with a passkey and an email address. Open the sign-up page in a browser and choose \u201cCreate account with a passkey\u201d."; |
| @@ -122,6 +127,7 @@ export async function signUp( | ||
| 122 | 127 | ...(from.address === undefined ? {} : { address: from.address }), |
| 123 | 128 | }, |
| 124 | 129 | }); |
| 130 | auditInvite(app, created.value.id); | |
| 125 | 131 | // The address is proven by the link it is sent; until then nothing it |
| 126 | 132 | // does may create or cost. |
| 127 | 133 | await welcome(app, created.value); |
| @@ -178,10 +184,18 @@ export async function signUpWithPasskey( | ||
| 178 | 184 | ...(from.address === undefined ? {} : { address: from.address }), |
| 179 | 185 | }, |
| 180 | 186 | }); |
| 187 | auditInvite(app, created.value.id); | |
| 181 | 188 | await welcome(app, created.value); |
| 182 | 189 | return created; |
| 183 | 190 | } |
| 184 | 191 | |
| 192 | /** Using an invite link is an event: the invite's id and who it let in, never its token. */ | |
| 193 | function auditInvite(app: App, userId: string): void { | |
| 194 | const inviteId = inviteSpentBy(app.db, userId); | |
| 195 | if (inviteId !== undefined) | |
| 196 | app.bus.append({ kind: "invite.used", actorId: userId, payload: { id: inviteId } }); | |
| 197 | } | |
| 198 | ||
| 185 | 199 | /** |
| 186 | 200 | * Mint a personal access token. Callers decide what credential may mint one |
| 187 | 201 | * (a session or the password, never another token — no privilege |
| @@ -193,9 +207,11 @@ export function createToken( | ||
| 193 | 207 | name: string, |
| 194 | 208 | scope: TokenScope, |
| 195 | 209 | options: { expiresAt?: string } = {}, |
| 196 | ): Result<{ id: string; token: string }, "unauthorized"> { | |
| 210 | ): Result<{ id: string; token: string }, "unauthorized" | "forbidden"> { | |
| 197 | 211 | const user = requireUser(actor); |
| 198 | 212 | if (!user.ok) return user; |
| 213 | // A token that acts as admin is for an admin to hold; ro and rw are anyone's. | |
| 214 | if (scope === "admin" && !user.value.isAdmin) return err("forbidden"); | |
| 199 | 215 | const minted = mintToken(app.db, user.value.id, name, scope, options); |
| 200 | 216 | app.bus.append({ |
| 201 | 217 | kind: "token.created", |
src/app.ts +13 −3
| @@ -9,13 +9,14 @@ import type { Database } from "bun:sqlite"; | ||
| 9 | 9 | import type { Decider } from "./crew/decide.ts"; |
| 10 | 10 | import type { VerifyHuman } from "./core/turnstile.ts"; |
| 11 | 11 | import { randomBytes } from "node:crypto"; |
| 12 | import { mkdirSync } from "node:fs"; | |
| 12 | import { existsSync, mkdirSync } from "node:fs"; | |
| 13 | 13 | import { join, resolve } from "node:path"; |
| 14 | import { migrations } from "./core/schema.ts"; | |
| 14 | import { migrationFiles } from "./core/schema.ts"; | |
| 15 | 15 | import { dropLegacyNewAccountGuards } from "./core/schema.ts"; |
| 16 | 16 | import { EventBus } from "./core/events.ts"; |
| 17 | 17 | import { follow } from "./core/following.ts"; |
| 18 | 18 | import { enqueueForEvent } from "./core/webhooks.ts"; |
| 19 | import { seedSignup } from "./settings/signup.ts"; | |
| 19 | 20 | import { openDb } from "./kernel/db.ts"; |
| 20 | 21 | import { id } from "./kernel/id.ts"; |
| 21 | 22 | import { |
| @@ -31,6 +32,12 @@ import { closeReadersUnder } from "./git/read.ts"; | ||
| 31 | 32 | export interface ForgeConfig { |
| 32 | 33 | /** Everything lives here: index db, bare repos, the internal socket. */ |
| 33 | 34 | dataDir: string; |
| 35 | /** | |
| 36 | * A database made by this call starts with signup = invite (`seedSignup`); | |
| 37 | * one that already exists is left as it is. Off by default so a test's | |
| 38 | * forge reads the setting's `open`; `main.ts` turns it on. | |
| 39 | */ | |
| 40 | seedSignup?: boolean; | |
| 34 | 41 | /** The disk probe vitals reads; the volume under `dataDir` when unset. Tests give it a reading. */ |
| 35 | 42 | disk?: (dir: string) => { freeBytes: number; totalBytes: number }; |
| 36 | 43 | /** @default 3000 */ |
| @@ -212,7 +219,10 @@ export function createApp(config: ForgeConfig): App { | ||
| 212 | 219 | mkdirSync(reposRoot, { recursive: true }); |
| 213 | 220 | const socketPath = join(dataDir, `forge-${id()}.sock`); |
| 214 | 221 | |
| 215 | const db = openDb(join(dataDir, "forge.db"), migrations); | |
| 222 | const dbPath = join(dataDir, "forge.db"); | |
| 223 | const fresh = !existsSync(dbPath); | |
| 224 | const db = openDb(dbPath, migrationFiles); | |
| 225 | if (config.seedSignup === true) seedSignup(db, fresh); | |
| 216 | 226 | // Hand-installed new-account guards become the `guard_new_accounts` |
| 217 | 227 | // setting (default off): drop the legacy triggers once, here, on boot. |
| 218 | 228 | // Idempotent — a forge without them changes nothing. |
src/chaos/worker.ts +2 −2
| @@ -4,12 +4,12 @@ | ||
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | 6 | import { openDb } from "../kernel/db.ts"; |
| 7 | import { migrations } from "../core/schema.ts"; | |
| 7 | import { migrationFiles } from "../core/schema.ts"; | |
| 8 | 8 | import { claimRun, createRun, heartbeatRuns } from "../core/runs.ts"; |
| 9 | 9 | |
| 10 | 10 | const [dbPath, repoId] = process.argv.slice(2); |
| 11 | 11 | if (dbPath === undefined || repoId === undefined) process.exit(2); |
| 12 | const db = openDb(dbPath, migrations); | |
| 12 | const db = openDb(dbPath, migrationFiles); | |
| 13 | 13 | console.log("ready"); |
| 14 | 14 | for (;;) { |
| 15 | 15 | const run = createRun(db, { repoId, kind: "check" }); |
src/checks/progress.ts +21
| @@ -8,6 +8,8 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import type { CheckSummary } from "../core/runs.ts"; |
| 11 | import { checkSteps, stepsWords } from "./summary.ts"; | |
| 12 | import { parseTestsRow, testsRowLine } from "./tests-row.ts"; | |
| 11 | 13 | import { readRunLog } from "../runs/log.ts"; |
| 12 | 14 | |
| 13 | 15 | export type CheckProgress = { |
| @@ -90,3 +92,22 @@ export function runningProgress(dataDir: string, check: CheckSummary): string { | ||
| 90 | 92 | return ""; |
| 91 | 93 | return progressWords(parseShardProgress(readRunLog(dataDir, run.id).lines)); |
| 92 | 94 | } |
| 95 | ||
| 96 | /** | |
| 97 | * The run page's three step rows as one line, for the terminal: the same | |
| 98 | * `checkSteps` over the same log, and nothing at all when the run is not a | |
| 99 | * check or when the viewer may not read its log (`mayReadRunLog`), as the | |
| 100 | * page withholds the rows then. | |
| 101 | */ | |
| 102 | export function stepsLine( | |
| 103 | dataDir: string, | |
| 104 | check: CheckSummary, | |
| 105 | mayReadLog: boolean, | |
| 106 | ): string { | |
| 107 | const run = check.run; | |
| 108 | if (!mayReadLog || run === null || run.kind !== "check") return ""; | |
| 109 | const { lines, truncated } = readRunLog(dataDir, run.id); | |
| 110 | const steps = stepsWords(checkSteps({ ...run, state: check.state === "none" ? run.state : check.state }, lines, truncated)); | |
| 111 | const tests = testsRowLine(parseTestsRow(lines)); | |
| 112 | return tests === "" ? steps : `${steps} · ${tests}`; | |
| 113 | } |
src/checks/service.ts +10 −5
| @@ -61,6 +61,7 @@ import { appendRunLog } from "../runs/log.ts"; | ||
| 61 | 61 | import { runningLimitSentence, runningRuns } from "../settings/quotas.ts"; |
| 62 | 62 | import { exportTree, readFileAt } from "../apps/checkout.ts"; |
| 63 | 63 | import { parseCheck } from "../apps/manifest.ts"; |
| 64 | import { workerCpus } from "../../scripts/shards.ts"; | |
| 64 | 65 | import { accountActive } from "../core/accounts.ts"; |
| 65 | 66 | import { childEnv, runStep } from "../apps/process.ts"; |
| 66 | 67 | import { noteReached } from "../apps/egress_report.ts"; |
| @@ -68,6 +69,7 @@ import { | ||
| 68 | 69 | capacityCount, |
| 69 | 70 | noteBooted, |
| 70 | 71 | capacityEnv, |
| 72 | cpuLimit, | |
| 71 | 73 | egressSetting, |
| 72 | 74 | } from "../core/capacity.ts"; |
| 73 | 75 | import { |
| @@ -245,9 +247,10 @@ export function provideChecksForTest(app: App, handle: ChecksHandle): void { | ||
| 245 | 247 | export function startChecks(app: App, options: ChecksOptions): ChecksHandle { |
| 246 | 248 | // Read on every pick (`core/capacity.ts`): an admin's change is honoured |
| 247 | 249 | // by the next check, not the next boot. `concurrency` is the boot default. |
| 248 | noteBooted(app.db, "checks.concurrency", String(options.concurrency ?? 1)); | |
| 250 | const booted = options.concurrency ?? 1; | |
| 251 | noteBooted(app.db, "checks.concurrency", String(booted)); | |
| 249 | 252 | const capacity = (): number => |
| 250 | capacityCount(app.db, "checks.concurrency", options.concurrency ?? 1); | |
| 253 | capacityCount(app.db, "checks.concurrency", booted); | |
| 251 | 254 | const gate = options.gate ?? new Gate(capacity()); |
| 252 | 255 | const placement = options.placement ?? placementFromEnv(); |
| 253 | 256 | /** Full gates on land in flight: not slots in the queue, but stopped with it. */ |
| @@ -792,9 +795,11 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 792 | 795 | } |
| 793 | 796 | : undefined; |
| 794 | 797 | try { |
| 798 | const cellCpus = resolveLimits("step", manifest.limits).cpus; | |
| 795 | 799 | const env = childEnv({ |
| 796 | // The shard count an admin set (`core/capacity.ts`), never the pod's. | |
| 797 | ...capacityEnv(app.db), | |
| 800 | // The shard count an admin set (`core/capacity.ts`), else the | |
| 801 | // default sized to what this check's cell holds, never the pod's. | |
| 802 | ...capacityEnv(app.db, workerCpus(cpuLimit(), cellCpus)), | |
| 798 | 803 | CI: "1", |
| 799 | 804 | FORGE_URL: options.forgeUrl, |
| 800 | 805 | FORGE_CHECK: run.id, |
| @@ -802,7 +807,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 802 | 807 | FORGE_CHECK_IMPACT: IMPACT_FILE, |
| 803 | 808 | // The cell's CPU limit, which `availableParallelism()` may not |
| 804 | 809 | // report inside it; the shard count follows this (#2257). |
| 805 | FORGE_CELL_CPUS: String(resolveLimits("step", manifest.limits).cpus), | |
| 810 | FORGE_CELL_CPUS: String(cellCpus), | |
| 806 | 811 | ...(base === undefined |
| 807 | 812 | ? {} |
| 808 | 813 | : { |
src/checks/summary.ts +88
| @@ -111,3 +111,91 @@ export function checkWords( | ||
| 111 | 111 | } |
| 112 | 112 | return undefined; |
| 113 | 113 | } |
| 114 | ||
| 115 | export type StepName = "install" | "typecheck" | "tests"; | |
| 116 | export type StepState = | |
| 117 | | "pending" | |
| 118 | | "running" | |
| 119 | | "ok" | |
| 120 | | "failed" | |
| 121 | | "stopped" | |
| 122 | | "unknown"; | |
| 123 | ||
| 124 | export interface CheckStep { | |
| 125 | readonly name: StepName; | |
| 126 | readonly state: StepState; | |
| 127 | } | |
| 128 | ||
| 129 | /** `$ <command>` is what `runStep` and the job runner echo before each command. */ | |
| 130 | const ECHO = /^\$ /; | |
| 131 | /** The package manager's own install, never a script that merely has the word in its name. */ | |
| 132 | const INSTALL_ECHO = /^\$ (?:bun|npm|pnpm|yarn)(?: -\S+)* (?:install|ci)\b/; | |
| 133 | /** The shard runner's banner and shard headers: the tests have begun. */ | |
| 134 | const TESTS_BEGUN = /^test-shards: |^── shard /; | |
| 135 | ||
| 136 | /** | |
| 137 | * The three rows of a check's run page — install, typecheck, tests — read | |
| 138 | * from the run's state and the lines the check wrote, never from a new | |
| 139 | * record. The log carries no step boundaries of its own, so each is | |
| 140 | * inferred from the furthest sign of progress: an echoed command, then the | |
| 141 | * shard runner's banner (typecheck comes before it in the check command). | |
| 142 | * A repository without an install command has its install row `ok` once | |
| 143 | * the check begins. A job-placed check echoes every command up front, so | |
| 144 | * its install is only done once output follows the echoes. Always three | |
| 145 | * rows, so the page never shifts when a state changes. `lines` may be only | |
| 146 | * the tail of a long log (`truncated`): the echoes are then gone, so unless | |
| 147 | * the tests are seen to have begun, or the run passed, the rows are | |
| 148 | * `unknown` rather than read wrong. | |
| 149 | */ | |
| 150 | export function checkSteps( | |
| 151 | run: Pick<Run, "state" | "placement">, | |
| 152 | lines: readonly string[], | |
| 153 | truncated = false, | |
| 154 | ): readonly CheckStep[] { | |
| 155 | const echoes = lines.flatMap((line, i) => (ECHO.test(line) ? [i] : [])); | |
| 156 | const hasInstall = lines.some((line) => INSTALL_ECHO.test(line)); | |
| 157 | const needed = hasInstall ? 2 : 1; | |
| 158 | const lastEcho = echoes.at(-1); | |
| 159 | const output = lastEcho !== undefined && lastEcho < lines.length - 1; | |
| 160 | const checkBegan = | |
| 161 | echoes.length >= needed && (run.placement !== "job" || output); | |
| 162 | const testsBegan = lines.some((line) => TESTS_BEGUN.test(line)); | |
| 163 | if (truncated && run.state !== "ok" && !testsBegan) | |
| 164 | return (["install", "typecheck", "tests"] as const).map((name) => ({ | |
| 165 | name, | |
| 166 | state: "unknown", | |
| 167 | })); | |
| 168 | // A tail that begins after the echoes still shows the tests by their own banner. | |
| 169 | const live = run.state === "running"; | |
| 170 | const dead = run.state === "failed" || run.state === "cancelled"; | |
| 171 | const after = run.state === "cancelled" ? "stopped" : "failed"; | |
| 172 | ||
| 173 | // The first step not yet done is where the run is, or where it ended. | |
| 174 | const reached = testsBegan ? 2 : checkBegan ? 1 : 0; | |
| 175 | const names: StepName[] = ["install", "typecheck", "tests"]; | |
| 176 | return names.map((name, i): CheckStep => { | |
| 177 | if (run.state === "ok") return { name, state: "ok" }; | |
| 178 | if (i < reached) return { name, state: "ok" }; | |
| 179 | if (i === reached) { | |
| 180 | if (live) return { name, state: "running" }; | |
| 181 | if (dead) return { name, state: after }; | |
| 182 | } | |
| 183 | return { name, state: "pending" }; | |
| 184 | }); | |
| 185 | } | |
| 186 | ||
| 187 | const STEP_WORDS: Record<StepState, string> = { | |
| 188 | pending: "waiting", | |
| 189 | running: "running", | |
| 190 | ok: "passed", | |
| 191 | failed: "failed", | |
| 192 | stopped: "stopped", | |
| 193 | unknown: "unknown", | |
| 194 | }; | |
| 195 | ||
| 196 | /** `install passed · typecheck passed · tests running`: the page's rows, in a line. */ | |
| 197 | export const stepWord = (state: StepState): string => STEP_WORDS[state]; | |
| 198 | ||
| 199 | export function stepsWords(steps: readonly CheckStep[]): string { | |
| 200 | return steps.map((s) => `${s.name} ${stepWord(s.state)}`).join(" · "); | |
| 201 | } |
src/checks/tests-row.ts added +99
| @@ -0,0 +1,99 @@ | ||
| 1 | /** | |
| 2 | * What the tests row of a running check's run page adds to its name: how | |
| 3 | * many files the shard runner has started out of the run's count, the file | |
| 4 | * (or files) in the shard running now, and every failure so far with the | |
| 5 | * first lines `bun test` printed for it. Read only from the shard runner's | |
| 6 | * own lines and `bun test`'s `(fail)` markers; a figure the log does not | |
| 7 | * give is left out, never guessed. | |
| 8 | */ | |
| 9 | ||
| 10 | import { parseShardProgress, type CheckProgress } from "./progress.ts"; | |
| 11 | ||
| 12 | export type PinnedFailure = { | |
| 13 | readonly name: string; | |
| 14 | /** The first lines of what was printed above the `(fail)` marker. */ | |
| 15 | readonly lines: readonly string[]; | |
| 16 | }; | |
| 17 | ||
| 18 | export type TestsRow = CheckProgress & { | |
| 19 | /** Files in shards that have begun, finished or not. */ | |
| 20 | started?: number; | |
| 21 | /** The files of the live shard that has not finished, if one is open. */ | |
| 22 | running?: readonly string[]; | |
| 23 | failures: readonly PinnedFailure[]; | |
| 24 | }; | |
| 25 | ||
| 26 | const SHARD_LIVE_OPEN = /^── shard \d+\/\d+: (.+)$/; | |
| 27 | const SHARD_BATCHED = /^── shard \d+\/\d+ \(exit \d+, [0-9.]+s\): (.+)$/; | |
| 28 | const SHARD_DONE = /^── shard \d+\/\d+ done \(exit \d+, [0-9.]+s\)$/; | |
| 29 | const FAIL_LINE = /^\(fail\) (.+?)(?: \[[0-9.]+m?s\])?(?: \[shard [0-9]+\])?$/; | |
| 30 | /** Lines that end one test's output and so bound the next one's. */ | |
| 31 | const BOUNDARY = /^(?:\(pass\) |\(skip\) |\(todo\) |── |test-shards: |\$ )|\.test\.[cm]?[jt]sx?:$/; | |
| 32 | ||
| 33 | export const MAX_PINNED = 5; | |
| 34 | export const PIN_LINES = 3; | |
| 35 | const LINE_MAX = 200; | |
| 36 | ||
| 37 | const names = (text: string): string[] => text.split(" ").filter((n) => n !== ""); | |
| 38 | const bound = (text: string): string => | |
| 39 | text.length > LINE_MAX ? `${text.slice(0, LINE_MAX - 1)}…` : text; | |
| 40 | ||
| 41 | export function parseTestsRow(lines: readonly string[]): TestsRow { | |
| 42 | const base = parseShardProgress(lines); | |
| 43 | let started: number | undefined; | |
| 44 | let running: string[] | undefined; | |
| 45 | const failures: PinnedFailure[] = []; | |
| 46 | // Lines since the last boundary: the failing test's own output. | |
| 47 | let block: string[] = []; | |
| 48 | for (const raw of lines) { | |
| 49 | const line = raw.trimEnd(); | |
| 50 | const live = SHARD_LIVE_OPEN.exec(line); | |
| 51 | const batched = SHARD_BATCHED.exec(line); | |
| 52 | if (live) { | |
| 53 | running = names(live[1]!); | |
| 54 | started = (started ?? 0) + running.length; | |
| 55 | } else if (batched) { | |
| 56 | started = (started ?? 0) + names(batched[1]!).length; | |
| 57 | } else if (SHARD_DONE.test(line)) { | |
| 58 | running = undefined; | |
| 59 | } | |
| 60 | const fail = FAIL_LINE.exec(line); | |
| 61 | if (fail) { | |
| 62 | const name = bound(fail[1]!); | |
| 63 | if (failures.length < MAX_PINNED && !failures.some((f) => f.name === name)) | |
| 64 | failures.push({ name, lines: block.slice(0, PIN_LINES).map(bound) }); | |
| 65 | block = []; | |
| 66 | continue; | |
| 67 | } | |
| 68 | if (BOUNDARY.test(line)) { | |
| 69 | block = []; | |
| 70 | continue; | |
| 71 | } | |
| 72 | if (line.trim() !== "") block.push(line.trim()); | |
| 73 | } | |
| 74 | const row: TestsRow = { ...base, failures }; | |
| 75 | if (started !== undefined) row.started = started; | |
| 76 | if (running !== undefined) row.running = running; | |
| 77 | return row; | |
| 78 | } | |
| 79 | ||
| 80 | /** `3 of 9 files started`, `812 passed, 1 failed`: the parts that are known. */ | |
| 81 | export function testsRowWords(row: TestsRow): string { | |
| 82 | const parts: string[] = []; | |
| 83 | if (row.started !== undefined && row.of !== undefined && row.started <= row.of) | |
| 84 | parts.push(`${row.started} of ${row.of} files started`); | |
| 85 | if (row.passed !== undefined && row.failed !== undefined) | |
| 86 | parts.push(`${row.passed} passed, ${row.failed} failed`); | |
| 87 | return parts.join(" · "); | |
| 88 | } | |
| 89 | ||
| 90 | /** | |
| 91 | * The terminal's words for the same row: `tests 2 of 9 files started · 812 | |
| 92 | * passed, 1 failed · failing: a, b`, empty when the log gave nothing. | |
| 93 | */ | |
| 94 | export function testsRowLine(row: TestsRow): string { | |
| 95 | const words = testsRowWords(row); | |
| 96 | const pins = row.failures.map((f) => f.name).join(", "); | |
| 97 | const parts = [words, pins === "" ? "" : `failing: ${pins}`].filter((p) => p !== ""); | |
| 98 | return parts.length === 0 ? "" : `tests ${parts.join(" · ")}`; | |
| 99 | } |
src/core/accounts.ts +12
| @@ -21,6 +21,8 @@ export interface AccountRow { | ||
| 21 | 21 | readonly createdAt: string; |
| 22 | 22 | /** How the account came: "signup", "edge", or null for one older than the record. */ |
| 23 | 23 | readonly via: string | null; |
| 24 | /** The linked sign-in method it came through ("open-platform.sh" for an edge-made account), else null. */ | |
| 25 | readonly signsInThrough: string | null; | |
| 24 | 26 | /** The address it signed up from, when one was recorded. */ |
| 25 | 27 | readonly address: string | null; |
| 26 | 28 | readonly repos: number; |
| @@ -33,6 +35,15 @@ export interface AccountRow { | ||
| 33 | 35 | readonly verified: boolean; |
| 34 | 36 | } |
| 35 | 37 | |
| 38 | /** What the platform edge's sign-in is called to the owner. */ | |
| 39 | export const EDGE_METHOD = "open-platform.sh"; | |
| 40 | ||
| 41 | /** How an account came, in words: its sign-in method, else its origin, else "crew" for an agent, else "". */ | |
| 42 | export function originOf(a: Pick<AccountRow, "kind" | "via" | "signsInThrough">): string { | |
| 43 | if (a.signsInThrough !== null) return `signs in through ${a.signsInThrough}`; | |
| 44 | return a.via ?? (a.kind === "agent" ? "crew" : ""); | |
| 45 | } | |
| 46 | ||
| 36 | 47 | /** Every person and agent, newest first. Organizations are not accounts. */ |
| 37 | 48 | export function listAccounts(db: Database): AccountRow[] { |
| 38 | 49 | const rows = db |
| @@ -78,6 +89,7 @@ export function listAccounts(db: Database): AccountRow[] { | ||
| 78 | 89 | isAdmin: r.is_admin === 1, |
| 79 | 90 | createdAt: r.created_at, |
| 80 | 91 | via: origin.via, |
| 92 | signsInThrough: origin.via === "edge" ? EDGE_METHOD : null, | |
| 81 | 93 | address: origin.address, |
| 82 | 94 | repos: r.repos, |
| 83 | 95 | apps: r.apps, |
src/core/audience.ts added +223
| @@ -0,0 +1,223 @@ | ||
| 1 | /** | |
| 2 | * Who can see a repository or an app, in one sentence. | |
| 3 | * | |
| 4 | * `audienceOf` is pure: plain facts in, a level and a sentence out, so every | |
| 5 | * wording is a test with no database. `audienceFacts` reads the facts, and | |
| 6 | * asks the same two rules the forge enforces — `REPO_VISIBLE` and | |
| 7 | * `REPO_WRITABLE` — once per candidate, so the sentence cannot drift from | |
| 8 | * what the forge lets through. Site admins are not counted: their reach is | |
| 9 | * the forge's, not the repository's. Nor are suspended or removed accounts. | |
| 10 | */ | |
| 11 | ||
| 12 | import type { Database } from "bun:sqlite"; | |
| 13 | import type { Repo } from "./repos.ts"; | |
| 14 | import type { User } from "./users.ts"; | |
| 15 | import { REPO_VISIBLE, REPO_WRITABLE } from "./visibility.ts"; | |
| 16 | ||
| 17 | export interface AudienceFacts { | |
| 18 | readonly visibility: "public" | "private"; | |
| 19 | /** Who is looking: nobody signed in, a person, or a site admin. */ | |
| 20 | readonly viewer: "signed-out" | "person" | "admin"; | |
| 21 | readonly ownerKind: "user" | "org"; | |
| 22 | readonly ownerName: string; | |
| 23 | /** People (not the crew) other than the viewer who may read it. */ | |
| 24 | readonly readers: number; | |
| 25 | /** People (not the crew) who may change it, the viewer among them. */ | |
| 26 | readonly writers: number; | |
| 27 | readonly viewerWrites: boolean; | |
| 28 | /** Whether any of the crew works here. */ | |
| 29 | readonly crew: boolean; | |
| 30 | /** | |
| 31 | * The app's access, when the repository has an app. People are counted | |
| 32 | * once, never the viewer: `writers` may change the repository, `named` are | |
| 33 | * named by the app and cannot. | |
| 34 | */ | |
| 35 | readonly app?: { | |
| 36 | readonly access: "public" | "private"; | |
| 37 | readonly writers: number; | |
| 38 | readonly named: number; | |
| 39 | readonly viewerNamed: boolean; | |
| 40 | }; | |
| 41 | } | |
| 42 | ||
| 43 | export interface Audience { | |
| 44 | /** `split`: public code, private app. */ | |
| 45 | readonly level: "public" | "private" | "split"; | |
| 46 | readonly sentence: string; | |
| 47 | } | |
| 48 | ||
| 49 | const people = (n: number): string => (n === 1 ? "1 person" : `${n} people`); | |
| 50 | ||
| 51 | /** "a", "a and b", "a, b, and c". */ | |
| 52 | function list(parts: readonly string[]): string { | |
| 53 | if (parts.length <= 2) return parts.join(" and "); | |
| 54 | return `${parts.slice(0, -1).join(", ")}, and ${parts[parts.length - 1]}`; | |
| 55 | } | |
| 56 | ||
| 57 | export function audienceOf(facts: AudienceFacts): Audience { | |
| 58 | const crew = facts.crew ? ["the crew"] : []; | |
| 59 | // "you" is only said to someone signed in. | |
| 60 | const you = facts.viewer === "signed-out" ? [] : ["you"]; | |
| 61 | if (facts.visibility === "public") { | |
| 62 | if (facts.app?.access === "private") { | |
| 63 | // Only someone who can change it, or is named, is told "you". | |
| 64 | const youToo = | |
| 65 | you.length > 0 && (facts.viewerWrites || facts.app.viewerNamed) | |
| 66 | ? you | |
| 67 | : []; | |
| 68 | const parts = [ | |
| 69 | ...youToo, | |
| 70 | ...(facts.app.writers > 0 | |
| 71 | ? [`${people(facts.app.writers)} who can change it`] | |
| 72 | : []), | |
| 73 | ...(facts.app.named > 0 ? [`${people(facts.app.named)} it names`] : []), | |
| 74 | ...crew, | |
| 75 | ]; | |
| 76 | return { | |
| 77 | level: "split", | |
| 78 | sentence: `The code is public; the app answers only to ${ | |
| 79 | parts.length === 0 ? "nobody" : list(parts) | |
| 80 | }.`, | |
| 81 | }; | |
| 82 | } | |
| 83 | const changers = [ | |
| 84 | ...(facts.writers > 0 ? [people(facts.writers)] : []), | |
| 85 | ...crew, | |
| 86 | ]; | |
| 87 | return { | |
| 88 | level: "public", | |
| 89 | sentence: `Public: anyone can read this; ${ | |
| 90 | changers.length === 0 ? "nobody" : list(changers) | |
| 91 | } can change it.`, | |
| 92 | }; | |
| 93 | } | |
| 94 | const parts = [...you]; | |
| 95 | if (facts.readers > 0) { | |
| 96 | parts.push( | |
| 97 | facts.ownerKind === "org" | |
| 98 | ? `${people(facts.readers)} in ${facts.ownerName}` | |
| 99 | : facts.ownerName, | |
| 100 | ); | |
| 101 | } | |
| 102 | const alone = parts.length === 1 && you.length === 1; | |
| 103 | parts.push(...crew); | |
| 104 | return { | |
| 105 | level: "private", | |
| 106 | sentence: `Private: ${alone ? "only " : ""}${ | |
| 107 | parts.length === 0 ? "nobody" : list(parts) | |
| 108 | }.`, | |
| 109 | }; | |
| 110 | } | |
| 111 | ||
| 112 | /** How many active, non-admin people satisfy `rule` (over `?1` = repo id). */ | |
| 113 | function countPeople( | |
| 114 | db: Database, | |
| 115 | repoId: string, | |
| 116 | rule: string, | |
| 117 | exceptId: string | undefined, | |
| 118 | ): number { | |
| 119 | // The rules take the person as `?1`; here each candidate is that person. | |
| 120 | const asCandidate = rule.replaceAll("?1", "cand.id"); | |
| 121 | return db | |
| 122 | .query<{ n: number }, [string, string]>( | |
| 123 | `SELECT COUNT(*) AS n FROM user AS cand | |
| 124 | WHERE cand.kind = 'user' AND cand.is_agent = 0 AND cand.is_admin = 0 | |
| 125 | AND cand.suspended_at IS NULL AND cand.removed_at IS NULL | |
| 126 | AND cand.id != ?2 | |
| 127 | AND EXISTS (SELECT 1 FROM repo WHERE repo.id = ?1 AND ${asCandidate})`, | |
| 128 | ) | |
| 129 | .get(repoId, exceptId ?? "")!.n; | |
| 130 | } | |
| 131 | ||
| 132 | /** | |
| 133 | * `REPO_WRITABLE` without its site-admin clause: an admin's reach is the | |
| 134 | * forge's, so only membership of the repository itself earns them "you". | |
| 135 | * Throws if the rule is reshaped, so a drift cannot go unnoticed. | |
| 136 | */ | |
| 137 | const MEMBER_WRITABLE = REPO_WRITABLE.replace( | |
| 138 | /\n\s*OR EXISTS \(SELECT 1 FROM user AS me WHERE me\.id = \?1 AND me\.is_admin = 1\)/, | |
| 139 | "", | |
| 140 | ); | |
| 141 | if (MEMBER_WRITABLE === REPO_WRITABLE) { | |
| 142 | throw new Error("audience: REPO_WRITABLE lost its admin clause; update audience.ts"); | |
| 143 | } | |
| 144 | ||
| 145 | export function audienceFacts( | |
| 146 | db: Database, | |
| 147 | repo: Repo, | |
| 148 | viewer: User | undefined, | |
| 149 | ): AudienceFacts { | |
| 150 | const owner = db | |
| 151 | .query<{ kind: "user" | "org" }, [string]>( | |
| 152 | "SELECT kind FROM user WHERE id = ?1", | |
| 153 | ) | |
| 154 | .get(repo.ownerId); | |
| 155 | const viewerId = viewer?.id; | |
| 156 | const writes = | |
| 157 | viewerId !== undefined && | |
| 158 | db | |
| 159 | .query<{ n: number }, [string, string]>( | |
| 160 | `SELECT 1 AS n FROM repo WHERE repo.id = ?1 AND ${MEMBER_WRITABLE.replaceAll("?1", "?2")}`, | |
| 161 | ) | |
| 162 | .get(repo.id, viewerId) !== null; | |
| 163 | const app = db | |
| 164 | .query<{ id: string; access: "public" | "private" }, [string]>( | |
| 165 | "SELECT id, access FROM app WHERE repo_id = ?1", | |
| 166 | ) | |
| 167 | .get(repo.id); | |
| 168 | const namedRule = `EXISTS (SELECT 1 FROM app_member AS m WHERE m.app_id = ?1 AND m.user_id = cand.id)`; | |
| 169 | const activePerson = `cand.kind = 'user' AND cand.is_agent = 0 AND cand.is_admin = 0 | |
| 170 | AND cand.suspended_at IS NULL AND cand.removed_at IS NULL`; | |
| 171 | const named = app | |
| 172 | ? db | |
| 173 | .query<{ n: number }, [string, string, string]>( | |
| 174 | `SELECT COUNT(*) AS n FROM user AS cand | |
| 175 | WHERE ${activePerson} AND cand.id != ?3 AND ${namedRule} | |
| 176 | AND NOT EXISTS (SELECT 1 FROM repo WHERE repo.id = ?2 AND ${REPO_WRITABLE.replaceAll("?1", "cand.id")})`, | |
| 177 | ) | |
| 178 | .get(app.id, repo.id, viewerId ?? "")!.n | |
| 179 | : 0; | |
| 180 | const viewerNamed = | |
| 181 | app !== null && | |
| 182 | viewerId !== undefined && | |
| 183 | db | |
| 184 | .query<{ n: number }, [string, string]>( | |
| 185 | "SELECT 1 AS n FROM app_member WHERE app_id = ?1 AND user_id = ?2", | |
| 186 | ) | |
| 187 | .get(app.id, viewerId) !== null; | |
| 188 | return { | |
| 189 | visibility: repo.visibility, | |
| 190 | viewer: | |
| 191 | viewer === undefined ? "signed-out" : viewer.isAdmin ? "admin" : "person", | |
| 192 | ownerKind: owner?.kind ?? "user", | |
| 193 | ownerName: repo.ownerName, | |
| 194 | readers: countPeople(db, repo.id, REPO_VISIBLE, viewerId), | |
| 195 | writers: countPeople(db, repo.id, REPO_WRITABLE, undefined), | |
| 196 | viewerWrites: writes, | |
| 197 | crew: | |
| 198 | db | |
| 199 | .query<{ n: number }, [string]>( | |
| 200 | "SELECT 1 AS n FROM repo_crew WHERE repo_id = ?1", | |
| 201 | ) | |
| 202 | .get(repo.id) !== null, | |
| 203 | ...(app | |
| 204 | ? { | |
| 205 | app: { | |
| 206 | access: app.access, | |
| 207 | writers: countPeople(db, repo.id, REPO_WRITABLE, viewerId), | |
| 208 | named, | |
| 209 | viewerNamed, | |
| 210 | }, | |
| 211 | } | |
| 212 | : {}), | |
| 213 | }; | |
| 214 | } | |
| 215 | ||
| 216 | /** The audience of `repo` for `viewer`: the one call the API and the MCP share. */ | |
| 217 | export function audienceFor( | |
| 218 | db: Database, | |
| 219 | repo: Repo, | |
| 220 | viewer: User | undefined, | |
| 221 | ): Audience { | |
| 222 | return audienceOf(audienceFacts(db, repo, viewer)); | |
| 223 | } |
src/core/capacity.ts +96 −12
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | |
| 14 | 14 | import type { Database } from "bun:sqlite"; |
| 15 | 15 | import { readFileSync } from "node:fs"; |
| 16 | import { totalmem } from "node:os"; | |
| 16 | import { availableParallelism, totalmem } from "node:os"; | |
| 17 | 17 | |
| 18 | 18 | export type CapacityKey = |
| 19 | 19 | | "crew.concurrency" |
| @@ -34,6 +34,8 @@ interface Definition { | ||
| 34 | 34 | | { readonly type: "choice"; readonly values: readonly string[] }; |
| 35 | 35 | /** What it is when neither the store nor the environment says. */ |
| 36 | 36 | readonly fallback: string; |
| 37 | /** The default from the pod's CPUs, in place of `fallback` (#2549). */ | |
| 38 | readonly sized?: (cpus: number) => number; | |
| 37 | 39 | readonly help: string; |
| 38 | 40 | } |
| 39 | 41 | |
| @@ -43,21 +45,23 @@ export const CAPACITY: Record<CapacityKey, Definition> = { | ||
| 43 | 45 | env: "FORGE_CREW_CONCURRENCY", |
| 44 | 46 | kind: { type: "count", min: 1, max: 16 }, |
| 45 | 47 | fallback: "2", |
| 46 | help: "How many agent runs go at once. Lowering it lets running ones finish; the next one waits.", | |
| 48 | help: "How many agent runs go at once. Empty: one per 3 GiB of memory above the forge's own 2 GiB, from the pod's limit, at least 2 and at most 16. Lowering it lets running ones finish; the next one waits.", | |
| 47 | 49 | }, |
| 48 | 50 | "checks.concurrency": { |
| 49 | 51 | label: "Checks at once", |
| 50 | 52 | env: "FORGE_CHECKS_CONCURRENCY", |
| 51 | 53 | kind: { type: "count", min: 1, max: 16 }, |
| 52 | 54 | fallback: "1", |
| 53 | help: "How many repository checks run at once. Lowering it lets running ones finish; the next one waits.", | |
| 55 | sized: (cpus) => checksForCpus(cpus), | |
| 56 | help: "How many repository checks run at once. Lowering it lets running ones finish; the next one waits. Empty: the pod's CPUs over three per shard (shards × 3), from 1 to 16 — 80 CPUs give 6.", | |
| 54 | 57 | }, |
| 55 | 58 | "checks.shards": { |
| 56 | 59 | label: "Test shards per check", |
| 57 | 60 | env: "FORGE_CHECK_SHARDS", |
| 58 | 61 | kind: { type: "count", min: 1, max: 32 }, |
| 59 | fallback: "", | |
| 60 | help: "How many parallel shards a check's test run uses (handed to checks and crew shells as FORGE_CHECK_SHARDS). Empty: as many as the machine has cores.", | |
| 62 | fallback: "1", | |
| 63 | sized: (cpus) => shardsForCpus(cpus), | |
| 64 | help: "How many parallel shards a check's test run uses (handed to checks and crew shells as FORGE_CHECK_SHARDS). Empty: the pod's CPUs, at most 4 — and, in a check, no more than its cell holds.", | |
| 61 | 65 | }, |
| 62 | 66 | "children.max": { |
| 63 | 67 | label: "Forges this one may send", |
| @@ -106,10 +110,70 @@ export function validCapacity(key: CapacityKey, value: string): boolean { | ||
| 106 | 110 | } |
| 107 | 111 | |
| 108 | 112 | /** The boot default: the environment, when it holds a valid value. */ |
| 109 | export function capacityDefault(key: CapacityKey): string { | |
| 113 | export function capacityDefault(key: CapacityKey, cpus?: number): string { | |
| 114 | const env = explicitDefault(key); | |
| 115 | if (env !== undefined) return env; | |
| 116 | const sized = CAPACITY[key].sized; | |
| 117 | if (sized !== undefined) return String(sized(cpus ?? cpuLimit())); | |
| 118 | if (key === "children.max") return String(childrenForMemory(memoryLimitBytes())); | |
| 119 | if (key === "crew.concurrency") return String(crewForMemory(memoryLimitBytes())); | |
| 120 | return CAPACITY[key].fallback; | |
| 121 | } | |
| 122 | ||
| 123 | /** The environment's value for `key`, when it holds a valid one. */ | |
| 124 | function explicitDefault(key: CapacityKey): string | undefined { | |
| 110 | 125 | const env = process.env[CAPACITY[key].env]?.trim() ?? ""; |
| 111 | if (env !== "" && validCapacity(key, env)) return env; | |
| 112 | return key === "children.max" ? String(childrenForMemory(memoryLimitBytes())) : CAPACITY[key].fallback; | |
| 126 | return env !== "" && validCapacity(key, env) ? env : undefined; | |
| 127 | } | |
| 128 | ||
| 129 | /** Most shards a check takes by default: past four, a test run waits on git, not CPU. */ | |
| 130 | const SHARDS_DEFAULT_MAX = 4; | |
| 131 | /** CPUs a shard is given by default: its bun test, the git children it starts, and the forge it boots. */ | |
| 132 | const CPUS_PER_SHARD = 3; | |
| 133 | ||
| 134 | /** Shards per check by default: `min(4, cpus)`, at least one. 80 CPUs give 4; 2 give 2. */ | |
| 135 | export function shardsForCpus(cpus: number): number { | |
| 136 | const n = Math.floor(cpus); | |
| 137 | return Number.isFinite(n) ? Math.max(1, Math.min(SHARDS_DEFAULT_MAX, n)) : 1; | |
| 138 | } | |
| 139 | ||
| 140 | /** | |
| 141 | * Checks at once by default: `floor(cpus / (shards × 3))`, held to 1–16, so | |
| 142 | * every check's shards have three CPUs each. 80 CPUs: 4 shards, 80 / 12 = 6 | |
| 143 | * checks; 16 CPUs: 1; 4 CPUs: 4 shards, 4 / 12 → 1 (#2549). | |
| 144 | */ | |
| 145 | export function checksForCpus(cpus: number, shards = shardsForCpus(cpus)): number { | |
| 146 | const n = Math.floor(cpus / (shards * CPUS_PER_SHARD)); | |
| 147 | return Number.isFinite(n) ? Math.max(1, Math.min(16, n)) : 1; | |
| 148 | } | |
| 149 | ||
| 150 | let cpusForTest: number | undefined; | |
| 151 | ||
| 152 | /** | |
| 153 | * A test's CPU count in place of the pod's, so a default sized from it is | |
| 154 | * the same on a laptop and an 80-core runner; `undefined` reads the pod again. | |
| 155 | */ | |
| 156 | export function provideCpusForTest(cpus: number | undefined): void { | |
| 157 | cpusForTest = cpus; | |
| 158 | } | |
| 159 | ||
| 160 | /** | |
| 161 | * The pod's CPU limit: cgroup v2 `cpu.max` ("quota period", in µs), rounded | |
| 162 | * down and never below one, else `os.availableParallelism()` — "max" (no | |
| 163 | * quota), an unreadable file or anything not two numbers falls back. Never | |
| 164 | * more than the machine has. | |
| 165 | */ | |
| 166 | export function cpuLimit(file = "/sys/fs/cgroup/cpu.max"): number { | |
| 167 | if (cpusForTest !== undefined) return cpusForTest; | |
| 168 | const machine = availableParallelism(); | |
| 169 | try { | |
| 170 | const [quota, period] = readFileSync(file, "utf8").trim().split(/\s+/).map(Number); | |
| 171 | if (quota !== undefined && period !== undefined && quota > 0 && period > 0) | |
| 172 | return Math.max(1, Math.min(machine, Math.floor(quota / period))); | |
| 173 | } catch { | |
| 174 | // No cgroup v2 CPU controller here: the machine's count. | |
| 175 | } | |
| 176 | return machine; | |
| 113 | 177 | } |
| 114 | 178 | |
| 115 | 179 | const GIB = 1024 ** 3; |
| @@ -126,6 +190,18 @@ export function childrenForMemory(bytes: number): number { | ||
| 126 | 190 | } |
| 127 | 191 | const MIN_CHILDREN = 5; |
| 128 | 192 | |
| 193 | /** | |
| 194 | * One crew run per 3 GiB of room above the forge's own 2 GiB (32 GiB gives | |
| 195 | * 10, 64 GiB the ceiling of 16), never fewer than two: a big machine runs | |
| 196 | * the crew it can hold without an override the sentinel then flags (#2548). | |
| 197 | */ | |
| 198 | export function crewForMemory(bytes: number): number { | |
| 199 | const n = Math.floor((bytes - 2 * GIB) / (3 * GIB)); | |
| 200 | return Math.min(MAX_CREW, Math.max(MIN_CREW, Number.isFinite(n) ? n : MIN_CREW)); | |
| 201 | } | |
| 202 | const MIN_CREW = 2; | |
| 203 | const MAX_CREW = 16; | |
| 204 | ||
| 129 | 205 | /** The pod's memory limit (cgroup v2, then v1), else the machine's memory. */ |
| 130 | 206 | export function memoryLimitBytes(): number { |
| 131 | 207 | for (const f of ["/sys/fs/cgroup/memory.max", "/sys/fs/cgroup/memory/memory.limit_in_bytes"]) { |
| @@ -201,10 +277,18 @@ export function egressSetting(db: Database): "enforce" | "report" { | ||
| 201 | 277 | return capacityValue(db, "cells.egress") === "report" ? "report" : "enforce"; |
| 202 | 278 | } |
| 203 | 279 | |
| 204 | /** What a check's commands and a crew shell are told: the shard count, when one is set. */ | |
| 205 | export function capacityEnv(db: Database): Record<string, string> { | |
| 206 | const shards = capacityValue(db, "checks.shards"); | |
| 207 | return shards === "" ? {} : { FORGE_CHECK_SHARDS: shards }; | |
| 280 | /** | |
| 281 | * What a check's commands and a crew shell are told: the shard count in | |
| 282 | * force. An admin's value or the environment's is passed as it is; the | |
| 283 | * default is sized from `cpus` — a check passes what its cell holds, so a | |
| 284 | * small cell is never told the pod's count. | |
| 285 | */ | |
| 286 | export function capacityEnv(db: Database, cpus?: number): Record<string, string> { | |
| 287 | const shards = | |
| 288 | storedCapacity(db, "checks.shards") ?? | |
| 289 | explicitDefault("checks.shards") ?? | |
| 290 | String(shardsForCpus(cpus ?? cpuLimit())); | |
| 291 | return { FORGE_CHECK_SHARDS: shards }; | |
| 208 | 292 | } |
| 209 | 293 | |
| 210 | 294 | export interface CapacityChange { |
src/core/invites.ts +5
| @@ -120,3 +120,8 @@ export function revokeInvite( | ||
| 120 | 120 | if (changed === 0) return err("not_found"); |
| 121 | 121 | return ok(view(db.query<Row, [string]>("SELECT * FROM invite WHERE id = ?1").get(inviteId)!, now)); |
| 122 | 122 | } |
| 123 | ||
| 124 | /** The id of the invite an account spent, if one let it in. */ | |
| 125 | export function inviteSpentBy(db: Database, userId: string): string | undefined { | |
| 126 | return db.query<{ id: string }, [string]>("SELECT id FROM invite WHERE used_by = ?1").get(userId)?.id; | |
| 127 | } |
src/core/migration-pins.json added +119
| @@ -0,0 +1,119 @@ | ||
| 1 | { | |
| 2 | "0001-foundation": "b6c433603a3fe1c2e79b9d66fe9bc3bc67f403d0f815a9f81dc16a00ef245da5", | |
| 3 | "0002-conversations": "5af40c718d7c91c0340d2e1609ecc2edd69a91373e33d78ea561f4ef4de700e1", | |
| 4 | "0003-pull-requests": "6a53fb594a0bc023a80948c2cde3500a37a95aafe30a896b157794aee1998094", | |
| 5 | "0004-forks": "9c323d30f8afb5b5e0b26dece297d88e41c758f21bf8155c9cc32d9df7d61609", | |
| 6 | "0005-organizations-and-teams": "90ac9619b147b5818762c74e97da40fba00fdfe77071b642ddf4bb45b279f927", | |
| 7 | "0006-ssh-public-keys": "f5bcf371354ab8e32c71e33c220a5d1e9554b4a6bb5b202922a47a07aa645979", | |
| 8 | "0007-per-user-interface-preferences": "97d11a5e8f8d14bd1b4c8027201f6d286bf480d61c7d116ef3abcfa87d4824e8", | |
| 9 | "0008-origins": "ae3db8d70c0890e72362e4c4e246eb4cf34e66354de622338015b5276b22eefb", | |
| 10 | "0009-oauth-for-origins": "5e8796b669bca4271907987f88fab4f37e230247b5f49465a00ce14fedd1666a", | |
| 11 | "0010-a-short-link-for-a": "37d793621278ccb06b0f0e3c9c4f5996fddc10ac4532a24c5ed5fdee5273a931", | |
| 12 | "0011-counts-on-a-list": "a8d5bac07bdbd8eaa77a73e0344dd3b8233fea0791084b247a4d5c85288d5344", | |
| 13 | "0012-the-indexes-a-busy-forge": "6aa333646f3751566cab4892f957e4aa7ffbfd8750fa3e1b76f514eaa46a6e57", | |
| 14 | "0013-review-states": "54c0f87c1f3bbbabf131b1722d811f915c536a752d2ebbdcec3a0fccbe22b6d7", | |
| 15 | "0014-apps": "3508216ce82bf466b5fe9e07bc69dd10c5ddf1d3cbd7e95c1daa7027ab480747", | |
| 16 | "0015-an-app-s-environment": "8949dbd4c0c38431d02cc9818827b68a3f196265624df9d100afc9a0974f84ad", | |
| 17 | "0016-a-paused-run": "af46f93cd74f18e640fd0a91e02dac0c31a5a52c4d354bcdb2856e21b23e234e", | |
| 18 | "0017-checks": "69ca4560ae58d18ee836d835555d436a5ea2a48e79a1467e51b1f937172b7173", | |
| 19 | "0018-a-run-s-lease": "eca89f69fee4c278d27e8764d08ac49a3e5bdeefc54a29e22098a7110daaac2a", | |
| 20 | "0019-how-many-turns-an-agent": "1217cffaad87574c9b786de2cae43e6638a3803b6df75f0e4df5ea16bb895376", | |
| 21 | "0020-issues-that-depend-on-issues": "5f8a0b647e9060606e21eb4a982d1815225ffc365e145eb9471c297c3343af2f", | |
| 22 | "0021-a-repository-can-run-on": "85a05853ae9e636049597fe9036ac536ba3efac6bca66496b6cd75e52a70fc25", | |
| 23 | "0022-the-decision-ledger": "b5e6e4d873686180dd9e76bab6ce0f9ef402a3444d96f2706d62c07cc18e80a3", | |
| 24 | "0023-the-look": "86890cbc353427bd6abe535a5ea2cad2661d437ed285407099b73d484e72071a", | |
| 25 | "0024-a-variable-has-a-scope": "1f9049bb4d0a0843dee7975f6b6201d396ac2e6bc4a155b8c66d65dff47efad5", | |
| 26 | "0025-what-a-running-app-said": "77a8887bfb3309ca024bbd9430240d716454fae860383a6d02e95d8e47223cfa", | |
| 27 | "0026-subscriptions": "85b5a7e228cefb1689c48f6e7862b9539f80d9c4240263a7e428716ccacf123b", | |
| 28 | "0027-the-moment-a-deploy-started": "e43e6b3d4fadba7e03dfb6bb54406ac90326823e9fb8f56b127a551bf4f8ae5f", | |
| 29 | "0028-a-failed-check-s-own": "8b3ff15d07875635f1a5d378f09b0a432fa5650272b621bf1f1ba47c352c2d79", | |
| 30 | "0029-a-third-width": "b4d8527a8a9e5002d2a2b3012fe064c365c180b680a59bb2d19db718f3ec9ad0", | |
| 31 | "0030-take-it-back": "727ee2c2371a095738a3df1079d8f4f98234b695f84d63466d98444a681f0f31", | |
| 32 | "0031-a-look-knows-which-page": "a62ab5826175af737f7b56c2d209ecf61d1bcad1707d0db625ee152bea8cc70f", | |
| 33 | "0032-an-app-s-own-domains": "fa420e16fc6a7723092944018cea038e29bfed19d1cb4c7729fbcb708bf96b69", | |
| 34 | "0033-where-a-repository-s-next": "321bf75d987ac60b6a0dd58b12d461c1758be11a14e84edc8d071ca49ad3ee0d", | |
| 35 | "0034-forge-level-secrets": "2c31a3db69e0e7cb1167ed3efa2d2a496532da896bc90e39979f4793cb70cd49", | |
| 36 | "0035-what-a-viewer-did-with": "fe01b3e75c1f9d4cae068fdb696949ac2691cde2796edb513a175eed3ef2162c", | |
| 37 | "0036-a-share": "dc1c385f26c13547e79eecafaa830a4adc0f4ff0d4856b95e6a50d075f0c94e9", | |
| 38 | "0037-the-usage-ledger": "d5d815aa3bef5e1f4cceb114771cf1a3e57931fdc21235da9208c0122ab93d7b", | |
| 39 | "0038-a-subscription-s-standing": "2445f7d024399cc6f27248ad91ac6693369f6dfbcbd96702e91f1cf7ff022f59", | |
| 40 | "0039-whether-the-agent-s-shell": "cc3a57567f75490d5607e43097ecefc00bf3dc9529304be8874ae697a6231ee7", | |
| 41 | "0040-a-fleet-operation-s-receipt": "ebe243edc22f30a29b6950e0f8cfce4c7ba346d11f7a4a4c61e4a08298b14965", | |
| 42 | "0041-the-meter": "15b995bb9dcfcb16aa9dbee69372ede8e1dcee56857dfa06e5deb2a583520569", | |
| 43 | "0042-every-credential-is-a-subscription": "f56c85102d0ce700a5a3eb958a0134f47a3a0f0cfa1e60c37e0167d2be802012", | |
| 44 | "0043-an-interrupted-run-is-not": "d9949e799843d1221644af17544e4f80fd4e60232c101186432dae0b7d1d2057", | |
| 45 | "0044-why-an-issue-closed": "4f24a266d54eae3910da18f620e396b29b406ac4d993c13158875ce7590687c9", | |
| 46 | "0045-oauth-clients-registered-dynamically": "ce0cf0b16b9094766362a288b9b984a00e8394b5b4821f370acf863b308ace61", | |
| 47 | "0046-which-address-the-forge-s": "96917061cb4b064779a15dba9cbac82c4cba9ff2aefd71acd229e6aca4273190", | |
| 48 | "0047-a-report-from-outside": "be5336004ba3141e71809194b65eb333866a678c5f2bb65c16be8681c65b63e7", | |
| 49 | "0048-an-account-the-owner-suspended": "21c01ec3f30ef0e7f55a293601e95db31c1437f3da33ecf46036d9a425e5a96d", | |
| 50 | "0049-the-asks-ledger": "a29d814f26ba38e871e7271cd955f14022ff923299b7a3af1ba85866f07221e2", | |
| 51 | "0050-outsiders-may-trigger-the-crew": "28be9094e981e547f7f3bd1eaa4b0fd87acf346ccc1188469341c262901de488", | |
| 52 | "0051-the-front-page-s-picks": "e3e9f27841b17e3951e627680cdfcca30e366bc687d8fd6f4c3b56a9661a5b2a", | |
| 53 | "0052-a-whole-life-budget-on": "922ee458bbc52f0d6a9938e36ff09494066a8cb557bd7788495d259c9b335f39", | |
| 54 | "0053-whether-an-account-s-email": "1e64971a56d0c0e16ec513cc506bc3a637efe843092d8a8cc99753a6c77a32e8", | |
| 55 | "0054-the-people-a-private-app": "7670ecd442c0f1a1e23e878fe5e8accb4fd29a58c6bf3b27841a1c2a062352e6", | |
| 56 | "0055-a-hold-on-a-pull": "8a0da478bdb878d0b0bf29d5824735ac3e67a6a504877abaff81bcf36d282597", | |
| 57 | "0056-what-an-account-has-spent": "be08ec2eab632260848ed4c9951291907ace5bd81c514c4e3e53cd6cd0ab85cb", | |
| 58 | "0057-spend-reported-from-outside": "15000df57c5e4f686dc91bb0c937d9484a4466bc424be5b7570c22d4f820c7d4", | |
| 59 | "0058-a-signed-in-browser-has": "32b86a5f3074b1bce792e99aadc1f432b8ccff508703c9fcf6fa3ae537ad4eb4", | |
| 60 | "0059-every-message-the-forge-sent": "4b7829c5fc97384cb4d5d9005168288eeab9419003552d8051ec5561bc9ef242", | |
| 61 | "0060-who-a-picture-was-taken": "7d2319f66c78239cc662477d4ceda2ac50fd670342ebd69db1b7736041c1ed47", | |
| 62 | "0061-an-agent-never-owns-an": "7d4b174818752723caaef32300a0a21783e1db9aca6b72334a44dd7ca092fd1f", | |
| 63 | "0062-only-an-agent-is-ever": "57c2dc3400fc1e2db9acf86f2eb0231561e8e6ed87b54770a9d79493137cfe0e", | |
| 64 | "0063-the-forge-s-capacity": "7721b2146736a958a08eb3e6f77b5ea6e179fd920dfc94e9a9ae98c88ce4eaba", | |
| 65 | "0064-the-front-page-s-settings": "e15834cc1c7e45a58f4849cbad2ee5b627c3ad895c716030431b4961a47f30e9", | |
| 66 | "0065-a-sealed-issue": "46d20691683748274ff0aeab0cd8d2640bb3f0427a4a3235e6a8b828ab0d2396", | |
| 67 | "0066-a-milestone-a-person-watches": "782bfde34aac27ea01782ae22bbf3152e6f32925443e5ce212594f29402af0ff", | |
| 68 | "0067-every-fork-a-person-made": "c2f5bf838522142e42b047e400b6725f711bd0280536a8c143aa5f30a8f24ad9", | |
| 69 | "0068-the-run-a-comment-was": "1a92482c52858a1a326539c0aa1ed9c94d52dd9a2f274c7645a44c4a8840b2a2", | |
| 70 | "0069-an-event-s-place-in": "b266f8f36f9a9a2382e7c5107dc9c5ce70a0c097ad8f5dc0c3f53f893c7a3476", | |
| 71 | "0070-a-sealed-repository-is-never": "be9c7af107676fee4a652dc45580c817309a917211dc90435eb94fa5e2a46e45", | |
| 72 | "0071-an-optional-anthropic-compatible-api": "f5e6a4df5bd7cca6371d164a7e254ec19d5a5db9986c8b120b2f398d2af0fc05", | |
| 73 | "0072-when-a-mirror-lost-its": "34867bc4869e7d8928fa284027179adb6b86680ae540cd31564d4bb941c6eb94", | |
| 74 | "0073-authorization-codes-for-the-mcp": "a1d0617595b908c81a4aba107a00b600c980b8048ffeea98975b6e724dd385e6", | |
| 75 | "0074-a-look-a-person-attached": "4fd216ce408ee877ffbb8b38cab0234c22242da61377e9011f7ccf3189096d74", | |
| 76 | "0075-a-limb-s-place-among": "bc117e5988500afdbc7b4406a232449d1881cd1e78a6f9e9edbabb09cff9c53f", | |
| 77 | "0076-the-sentinel-s-memory": "ff8882d41e1702253a32d09324b1d9f00f29b21a22511779c914dbd7adfd7a01", | |
| 78 | "0077-openid-connect-over-the-same": "1507c0bc5b5168e4b440d9963be59dcb10b94b14d18da1182ca0da6787f22090", | |
| 79 | "0078-variants": "8c983ce83e8cad50f1e015ee5f4eefe77e503471d46bc46a43e5e3b71180d06d", | |
| 80 | "0079-degraded-history": "6c58267cfafaa53117a3945013e9591ae98a8fcc5f1f3998bc9113e4ea62d892", | |
| 81 | "0080-restore-drills": "35147ba9da81cda49cc77346c5c2d5eb71c72afc108676480237bd8d01f98f06", | |
| 82 | "0081-thoughts": "b8fca6511f61669d33d34eeb180a64767b08b744e41496b2968b19943f4a6574", | |
| 83 | "0082-passkeys": "074a5d91370d9d0ec073d9abbeded908a6541a743dcf55456e758422381614e4", | |
| 84 | "0083-releases": "c0e3680642b226841bf039c48b5ae806c97ac0ce18fc8d71fc8eec49aeefe27c", | |
| 85 | "0084-placements": "60036ee7281d17aad4a034409daaaab027ca6c524297da7d29618ef9eaa957df", | |
| 86 | "0085-a-standing-rule-per-repository": "8c47857982bb540589b1b418926218475036050ef9449f3a5f80ef2ed3f5c8f0", | |
| 87 | "0086-what-a-production-deploy-carries": "62379dffd5a0273355fcd1e5e353e046283c65a8ab7c068958efaa866d5db37d", | |
| 88 | "0087-supersession-and-repeats": "bc2647e3fcb0f32b7dda800cb6a38eb0d5e340243e07bd6ad7cf8291d48efbcb", | |
| 89 | "0088-what-the-machine-used": "afc9a4d24ead17763ccd7bdb4aeae80b9bafc3af769c5ded89bef921c7f622ad", | |
| 90 | "0089-a-parked-pull": "7b564005fff642a38dc60cd58c805e97888845f89ad884af9baf79fa615cf77f", | |
| 91 | "0090-a-green-check-proven": "21f33d1dabaf9722611db3a1630bc1c81aca6535a32398b7bbe7ddc47259994f", | |
| 92 | "0091-where-a-check-ran": "8ac84fa10e0079815a48cb7e57dfbf3e5f67bcf820695e57b7841374d570f943", | |
| 93 | "20261015000100-plans-need-a-nod": "754eb716bfe65056915e5f01bcbd6f15cf6b73b413da952c0a9b6ea1990b26ba", | |
| 94 | "20261015090000-a-repository-put-away": "692ca4ed5c82f4c9a281a74ee0716182fd4067a148a2652575c33e9e2a0055c4", | |
| 95 | "20261015090000-feed-chips": "a6057329aa558e6d25916f1edb88fffb30383eac87e24e966994631051029bfb", | |
| 96 | "20261015120000-check-base-tip": "14e4279c9da82e3f7f9a5e163c0f8c75dcaa436e628586af9cab9c25e2db653d", | |
| 97 | "20261015230000-a-domain-drill": "8db343e16d0bfacda42598908cb7ec113485b5aff30c7f663dffe0198cfbeb5c", | |
| 98 | "20261015230532-telemetry-read": "8106ad063b8b13c22c0115dfc23141dab081cb2a23168a1eb891b2fe30b4cc65", | |
| 99 | "20261016090000-sentinel-kept-settings": "03c30ac6d4e9d3164cc0f32af1c0f9d1f69a02e1147dc25bd1fe1838c077d5aa", | |
| 100 | "20261016120000-kept-is-a-person-s": "d12558f4a7395c91e46b6abddf836719509e0531630a046b6114926c5aad4a91", | |
| 101 | "20261016120000-proposal-state": "f24b95f42c1ceee8ed0f69093500365c218be3ec2c00be47a60ab77383a833f7", | |
| 102 | "20261016130000-sign-in-with-the-parent": "cd2a2f60afac864446fee454e746ca3dac0a29848446bd27697a5dc0ba5a7124", | |
| 103 | "20261016130000-usage-count": "1e715bfd5e4e28709903ee3a380fd1e8bf6b2be0a0321186ffa803676e6d5906", | |
| 104 | "20261016140000-a-model-grant": "7ec2dbdfe5e782eb98b5c42f2ae79a686631c3416c9787ab3708adab8fec0777", | |
| 105 | "20261016140000-drill-counts": "2ec120b90753826a5cbb6c7416b84a563566a0995ccd6d0350e00cf63ea16d45", | |
| 106 | "20261016140000-flag-use": "a3375653f892f6eccff8493aebd2b42084d68b4612e80b595cd1f09dcda988da", | |
| 107 | "20261016140000-machine-temperature": "74754feca643996c3b214f799a57868b0ab298a6ac9d9fa85905cd16e8907e9d", | |
| 108 | "20261016150000-memory-hour": "41077b8c7b9b0421255f82b6c14954b4abad249cb51472e064cb130bc0aa0128", | |
| 109 | "20261016160000-cloud-audit": "a64b2356735e172b21a8c462418b84065de9a54f121fa8371e812d97264d00d1", | |
| 110 | "20261016160000-proposal-tokens": "335e0558843d8c103213ef8b9a1202e52cc8bd0674dc0e5db73f3429d38cc7c9", | |
| 111 | "20261016170000-pull-run-grant": "dca1fa79e48ece7ba962b6e2eabeda513b7b364ed68e41a814ca73e123a9b811", | |
| 112 | "20261016170000-upstream-proposals": "ea8c2b809705a5ac38dd94470e3024a560e61f44f01e4f51a84f7d9699df3338", | |
| 113 | "20261016180000-account-ceiling": "0077901b08d41b6af61fe4b7ad7bf3bb16c923e755b5898c2f6d86837e9a42d0", | |
| 114 | "20261016180000-signin-only-code": "c69edd5906a2f3425d6ce867ef9c7b6d59c40fb582ef6cbea0f4c86a67eb0030", | |
| 115 | "20261016190000-member": "9076848fe032dea6905927a1b454c2381a3129acb3ce357c2726e82f1c161bc4", | |
| 116 | "20261016200000-invite": "72bc7e9be6a7223048d1d1cf0226bc93ee4dbea9f00dbb20a6d457305886cbf9", | |
| 117 | "20261017195831-ceiling-reserved": "1e6756752ae06c78a5920ae839e911e837a14cff3ac766cbc38e117c0a40186b", | |
| 118 | "20261018100000-incident-remembered": "61fefa1d0750f3be617fdc23c1e6e17abc1ba610d980d85aff8824f14bf3e253" | |
| 119 | } |
src/core/migrations/0062-only-an-agent-is-ever.ts +3
| @@ -4,6 +4,9 @@ | ||
| 4 | 4 | // check (`REPO_WRITABLE`'s crew clause) and `onCrew` can never drift. |
| 5 | 5 | // A person already there is reported at boot (`peopleOnCrews`), never |
| 6 | 6 | // removed by a migration. |
| 7 | // The RAISE messages below are shown to any caller, anonymous ones included | |
| 8 | // (#723); they are written for anyone, and are known by name in | |
| 9 | // src/http/refusal.ts because the numbered migrations' text is frozen. | |
| 7 | 10 | export default ` |
| 8 | 11 | CREATE TRIGGER repo_crew_agents_only BEFORE INSERT ON repo_crew |
| 9 | 12 | WHEN NOT EXISTS (SELECT 1 FROM user WHERE id = NEW.user_id AND is_agent = 1) |
src/core/migrations/0070-a-sealed-repository-is-never.ts +3
| @@ -1,6 +1,9 @@ | ||
| 1 | 1 | // 70 — a sealed repository is never public, said by the database (#865's |
| 2 | 2 | // review): the rule lived in `updateRepoSettings`; these triggers hold |
| 3 | 3 | // it for every writer of `repo.visibility`, now and later. |
| 4 | // The RAISE messages below are shown to any caller, anonymous ones included | |
| 5 | // (#723); they are written for anyone, and are known by name in | |
| 6 | // src/http/refusal.ts because the numbered migrations' text is frozen. | |
| 4 | 7 | export default ` |
| 5 | 8 | CREATE TRIGGER repo_sealed_never_public BEFORE UPDATE OF visibility, sealed_for ON repo |
| 6 | 9 | WHEN NEW.sealed_for IS NOT NULL AND NEW.visibility = 'public' |
src/core/migrations/0081-thoughts.ts +3
| @@ -2,6 +2,9 @@ | ||
| 2 | 2 | // row per thought, appended and never changed: the triggers refuse an |
| 3 | 3 | // UPDATE or DELETE, and there is no foreign key so no cascade can remove |
| 4 | 4 | // one when an account goes. The author is an id, not a reference. |
| 5 | // The RAISE messages below are shown to any caller, anonymous ones included | |
| 6 | // (#723); they are written for anyone, and are known by name in | |
| 7 | // src/http/refusal.ts because the numbered migrations' text is frozen. | |
| 5 | 8 | export default ` |
| 6 | 9 | CREATE TABLE thought ( |
| 7 | 10 | id TEXT PRIMARY KEY, |
src/core/migrations/20261018100000-incident-remembered.ts added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | // A degraded finding remembers the incident it was filed for (#2553): the | |
| 2 | // time the cause first opened, and when its closed issue was last told the | |
| 3 | // incident goes on. Both are null for every other finding. | |
| 4 | export default ` | |
| 5 | ALTER TABLE sentinel_finding ADD COLUMN incident TEXT; | |
| 6 | ALTER TABLE sentinel_finding ADD COLUMN commented_at TEXT; | |
| 7 | `; |
src/core/releases.json +1 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | { |
| 2 | "note": "The migrations each release applied, newest first. The release agent adds the new release here when it cuts one: `migrations` counts the migration ids it applied (every file in src/core/migrations/), and `ids` names the stamped ones among them — every file past 0090, its name without .ts (`ls src/core/migrations | grep -v ^0 | sed s/.ts$//`). A release from before #1837 has a count only: the first that many numbered files. Every migration that is not in both of the two newest releases must be additive (test/schema-additive.test.ts, notAdditive in src/kernel/migrate.ts): the old process keeps serving on the same database while the new one rolls in.", | |
| 2 | "note": "The migrations each release applied, newest first. The release agent adds the new release here when it cuts one: `migrations` counts the migration ids it applied (every file in src/core/migrations/), and `ids` names the stamped ones among them — every file past 0090, its name without .ts (`ls src/core/migrations | grep -v ^0 | sed s/.ts$//`) — and runs `bun scripts/pin-migrations.ts`, which pins each new migration's text in src/core/migration-pins.json. A release from before #1837 has a count only: the first that many numbered files. Every migration that is not in both of the two newest releases must be additive (test/schema-additive.test.ts, notAdditive in src/kernel/migrate.ts): the old process keeps serving on the same database while the new one rolls in.", | |
| 3 | 3 | "releases": [ |
| 4 | 4 | { |
| 5 | 5 | "version": "0.7.165", |
src/core/reserved.ts +1
| @@ -72,6 +72,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 72 | 72 | "flow", |
| 73 | 73 | "front", |
| 74 | 74 | "go", |
| 75 | "home", | |
| 75 | 76 | "icons", |
| 76 | 77 | "live", |
| 77 | 78 | "login", |
src/core/schema.ts +3 −1
| @@ -9,7 +9,9 @@ | ||
| 9 | 9 | * new one is named by the UTC second it was written |
| 10 | 10 | * (`date -u +%Y%m%d%H%M%S`, e.g. `20261015093000-deploy-notes.ts`), so two |
| 11 | 11 | * pulls that each add one add two files and never touch the same line |
| 12 | * (#1837). Each file is one `export default` of SQL. | |
| 12 | * (#1837). Each file is one `export default` of SQL. The database records | |
| 13 | * each applied one by name (#783); `migration-pins.json` holds what each | |
| 14 | * said when it was released, and a test fails if one says something else. | |
| 13 | 15 | */ |
| 14 | 16 | |
| 15 | 17 | import type { Database } from "bun:sqlite"; |
src/core/tokens.ts +3
| @@ -13,6 +13,9 @@ import { getUserById, isActive, type User } from "./users.ts"; | ||
| 13 | 13 | |
| 14 | 14 | export type TokenScope = "ro" | "rw" | "admin"; |
| 15 | 15 | |
| 16 | /** What a non-admin is told when they ask for a token that acts as admin. */ | |
| 17 | export const ADMIN_TOKEN_WORDS = "Only an admin can make a token with admin scope; ro and rw are open to you."; | |
| 18 | ||
| 16 | 19 | const rank: Record<TokenScope, number> = { ro: 0, rw: 1, admin: 2 }; |
| 17 | 20 | |
| 18 | 21 | export function scopeAllows(held: TokenScope, needed: TokenScope): boolean { |
src/crew/incidents.ts +1 −1
| @@ -48,7 +48,7 @@ export const HOLD_MS = 5 * 60_000; | ||
| 48 | 48 | |
| 49 | 49 | /** The vitals causes this pager owns; the rest are paged elsewhere or are settings. */ |
| 50 | 50 | export function owned(cause: string): boolean { |
| 51 | return cause === "errors" || cause === "internal" || cause === "disk" || cause.startsWith("queue:"); | |
| 51 | return cause === "errors" || cause === "internal" || cause === "disk" || cause === "release" || cause.startsWith("queue:"); | |
| 52 | 52 | } |
| 53 | 53 | |
| 54 | 54 | /** One owned cause being watched. */ |
src/crew/parks.ts +7 −2
| @@ -23,6 +23,7 @@ import type { Database } from "bun:sqlite"; | ||
| 23 | 23 | import { getIssueById, standsForPerson } from "../core/issues.ts"; |
| 24 | 24 | import type { Repo } from "../core/repos.ts"; |
| 25 | 25 | import { |
| 26 | alreadyDone, | |
| 26 | 27 | BY_CREW, |
| 27 | 28 | DONE_LIKE, |
| 28 | 29 | LATER, |
| @@ -195,7 +196,7 @@ export interface ParkedTwice { | ||
| 195 | 196 | * nothing open. So an issue leaves the list when it is closed (split, or |
| 196 | 197 | * replaced by a fresh one), when it is put behind something open (its |
| 197 | 198 | * parts, or the Later gate), or when someone who directs the crew says what |
| 198 | * to try. One read for every repository, and two more for each that has | |
| 199 | * to try, or when commits on main already name it (`alreadyDone`). One read for every repository, and two more for each that has | |
| 199 | 200 | * any. |
| 200 | 201 | */ |
| 201 | 202 | export function parkedTwice( |
| @@ -288,9 +289,13 @@ export function parkedTwice( | ||
| 288 | 289 | : issue; |
| 289 | 290 | }); |
| 290 | 291 | if (struck.length === 0) continue; |
| 292 | // Finished work is the asker's to close, not the planner's to split. | |
| 293 | const done = alreadyDone(db, struck.map((issue) => issue.issueId)); | |
| 294 | const open = struck.filter((issue) => !done.has(issue.issueId)); | |
| 295 | if (open.length === 0) continue; | |
| 291 | 296 | // An open pull that would close it is someone's answer already. |
| 292 | 297 | const proposed = proposedWork(db, repo); |
| 293 | out.push(...struck.filter((issue) => !proposed.has(issue.number))); | |
| 298 | out.push(...open.filter((issue) => !proposed.has(issue.number))); | |
| 294 | 299 | } |
| 295 | 300 | return out; |
| 296 | 301 | } |
src/crew/sentinel.ts +48 −3
| @@ -64,6 +64,8 @@ export interface Finding { | ||
| 64 | 64 | readonly fix?: string; |
| 65 | 65 | /** A setting finding: the value and `updated_at` a person may have kept (#2001). */ |
| 66 | 66 | readonly kept?: { readonly value: string; readonly updatedAt: string }; |
| 67 | /** A degraded finding: when its cause first opened. Same cause and time, same incident (#2553). */ | |
| 68 | readonly incident?: string; | |
| 67 | 69 | } |
| 68 | 70 | |
| 69 | 71 | const clip = (text: string, n = 200): string => |
| @@ -386,7 +388,7 @@ export function repeatedErrors(now: number): Finding[] { | ||
| 386 | 388 | */ |
| 387 | 389 | export function degradedCauses(db: Database, now: number): Finding[] { |
| 388 | 390 | const from = now - DAY_MS; |
| 389 | const byCause = new Map<string, { sentence: string; href: string; first: number; ms: number }>(); | |
| 391 | const byCause = new Map<string, { sentence: string; href: string; first: number; opened: number; ms: number }>(); | |
| 390 | 392 | for (const s of spans(db, from, now)) { |
| 391 | 393 | const start = Math.max(s.openedAt, from); |
| 392 | 394 | const held = Math.min(s.closedAt ?? now, now) - start; |
| @@ -396,6 +398,7 @@ export function degradedCauses(db: Database, now: number): Finding[] { | ||
| 396 | 398 | sentence: s.sentence, |
| 397 | 399 | href: s.href, |
| 398 | 400 | first: seen?.first ?? start, |
| 401 | opened: seen?.opened ?? s.openedAt, | |
| 399 | 402 | ms: (seen?.ms ?? 0) + Math.max(0, held), |
| 400 | 403 | }); |
| 401 | 404 | } |
| @@ -415,6 +418,7 @@ export function degradedCauses(db: Database, now: number): Finding[] { | ||
| 415 | 418 | ], |
| 416 | 419 | pillar: "P0 Trust: the forge says when it is not well, and is well again", |
| 417 | 420 | lookFor: `\`${cause}\` stays out of the degraded list for a day, or holds under ${DEGRADED_DAY_MS / 60_000} minutes.`, |
| 421 | incident: new Date(d.opened).toISOString(), | |
| 418 | 422 | }); |
| 419 | 423 | } |
| 420 | 424 | return out; |
| @@ -626,6 +630,42 @@ export function recordKept(db: Database, key: string, value: string, updatedAt: | ||
| 626 | 630 | ).run(key, value, updatedAt, at); |
| 627 | 631 | } |
| 628 | 632 | |
| 633 | /** | |
| 634 | * A degraded incident whose issue was closed as done and which goes on (same | |
| 635 | * cause, same first-open time) is told to that issue, not filed again: the | |
| 636 | * fix is likely merged but not live. At most one comment a day. True when | |
| 637 | * the incident is that one: the comments made (0 or 1), else undefined. | |
| 638 | */ | |
| 639 | function continuesDone(app: App, repo: Repo, actor: User, f: Finding, evidence: string, at: string, now: number): 0 | 1 | undefined { | |
| 640 | const prior = app.db | |
| 641 | .query<{ issue_id: string; commented_at: string | null }, [string, string]>( | |
| 642 | `SELECT t.issue_id, t.commented_at FROM sentinel_finding t JOIN issue i ON i.id = t.issue_id | |
| 643 | WHERE t.key = ?1 AND t.incident = ?2 AND i.state = 'closed' | |
| 644 | ORDER BY t.first_seen DESC LIMIT 1`, | |
| 645 | ) | |
| 646 | .get(f.key, f.incident ?? ""); | |
| 647 | if (prior === null) return undefined; | |
| 648 | const issue = getIssueById(app.db, prior.issue_id); | |
| 649 | if (issue === undefined || issue.closeReason !== "done") return undefined; | |
| 650 | if (prior.commented_at !== null && now - Date.parse(prior.commented_at) < DAY_MS) return 0; | |
| 651 | const minutes = f.evidence.find((e) => e.startsWith("total: ")); | |
| 652 | const said = addComment( | |
| 653 | app, | |
| 654 | actor, | |
| 655 | repo, | |
| 656 | issue, | |
| 657 | `Still degraded after the fix; the fix may not be live yet. At ${at}${minutes === undefined ? "" : `, ${minutes.slice(7)}`}; evidence now:\n${f.evidence.map((e) => `- ${e}`).join("\n")}`, | |
| 658 | ); | |
| 659 | if (!said.ok) { | |
| 660 | log.warn("sentinel could not comment", { key: f.key, error: String(said.error) }); | |
| 661 | return 0; | |
| 662 | } | |
| 663 | app.db | |
| 664 | .query("UPDATE sentinel_finding SET commented_at = ?1, last_seen = ?1, evidence = ?2 WHERE key = ?3 AND issue_id = ?4") | |
| 665 | .run(at, evidence, f.key, prior.issue_id); | |
| 666 | return 1; | |
| 667 | } | |
| 668 | ||
| 629 | 669 | /** |
| 630 | 670 | * Read, file, record. One open issue per finding: a tracked finding whose |
| 631 | 671 | * issue is open is commented on only when its evidence changed; one whose |
| @@ -674,6 +714,11 @@ export function hunt( | ||
| 674 | 714 | .run(at, evidence, f.key, tracked.issue_id); |
| 675 | 715 | continue; |
| 676 | 716 | } |
| 717 | const told = f.incident === undefined ? undefined : continuesDone(app, repo, actor, f, evidence, at, now); | |
| 718 | if (told !== undefined) { | |
| 719 | commented += told; | |
| 720 | continue; | |
| 721 | } | |
| 677 | 722 | const title = `Sentinel: ${clip(f.title, 200)}`; |
| 678 | 723 | // An issue already filed under this title whose record was lost |
| 679 | 724 | // (the insert failed last hunt) is adopted, not filed twice. |
| @@ -694,8 +739,8 @@ export function hunt( | ||
| 694 | 739 | if (f.urgent && f.kept === undefined) handOver(app, repo, actor, made.value); |
| 695 | 740 | } |
| 696 | 741 | app.db |
| 697 | .query("INSERT INTO sentinel_finding (key, issue_id, evidence, first_seen, last_seen) VALUES (?1, ?2, ?3, ?4, ?4)") | |
| 698 | .run(f.key, issueId, evidence, at); | |
| 742 | .query("INSERT INTO sentinel_finding (key, issue_id, evidence, first_seen, last_seen, incident) VALUES (?1, ?2, ?3, ?4, ?4, ?5)") | |
| 743 | .run(f.key, issueId, evidence, at, f.incident ?? null); | |
| 699 | 744 | } catch (error) { |
| 700 | 745 | log.error("sentinel filing failed", { key: f.key, error: String(error) }); |
| 701 | 746 | } |
src/http/api.ts +10 −2
| @@ -160,6 +160,7 @@ import { | ||
| 160 | 160 | updateProfile, |
| 161 | 161 | } from "../actions/users.ts"; |
| 162 | 162 | import { listEvents } from "../core/events.ts"; |
| 163 | import { audienceFor } from "../core/audience.ts"; | |
| 163 | 164 | import { audienceFilter } from "../core/event_audience.ts"; |
| 164 | 165 | import { |
| 165 | 166 | forkParentFor, |
| @@ -221,7 +222,7 @@ import { | ||
| 221 | 222 | } from "../core/reviews.ts"; |
| 222 | 223 | import { createSession } from "../core/sessions.ts"; |
| 223 | 224 | import { mintProposal } from "../actions/proposal_tokens.ts"; |
| 224 | import { listTokens } from "../core/tokens.ts"; | |
| 225 | import { ADMIN_TOKEN_WORDS, listTokens } from "../core/tokens.ts"; | |
| 225 | 226 | import { |
| 226 | 227 | headInNamespace, |
| 227 | 228 | PROPOSAL_BODY_MAX, |
| @@ -1854,6 +1855,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1854 | 1855 | str(input.name) || "unnamed", |
| 1855 | 1856 | scope, |
| 1856 | 1857 | ); |
| 1858 | if (!minted.ok && minted.error === "forbidden") | |
| 1859 | return apiError("forbidden", 403, ADMIN_TOKEN_WORDS); | |
| 1857 | 1860 | if (!minted.ok) return unauthorized(); |
| 1858 | 1861 | return json(minted.value, 201); |
| 1859 | 1862 | }); |
| @@ -2146,7 +2149,12 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2146 | 2149 | |
| 2147 | 2150 | router.add("GET", "/api/v1/repos/:owner/:name", (ctx) => { |
| 2148 | 2151 | const repo = loadRepo(ctx); |
| 2149 | return repo ? json(repoJson(ctx, repo)) : notFound(); | |
| 2152 | return repo | |
| 2153 | ? json({ | |
| 2154 | ...repoJson(ctx, repo), | |
| 2155 | audience: audienceFor(ctx.app.db, repo, ctx.auth.user), | |
| 2156 | }) | |
| 2157 | : notFound(); | |
| 2150 | 2158 | }); |
| 2151 | 2159 | |
| 2152 | 2160 | router.add("PATCH", "/api/v1/repos/:owner/:name", async (ctx) => { |
src/http/mcp.ts +93 −3
| @@ -130,7 +130,7 @@ import { | ||
| 130 | 130 | } from "../crew/direct.ts"; |
| 131 | 131 | import { setArchived, setCrewOutsiders, setDecisionsBy } from "../actions/repos.ts"; |
| 132 | 132 | import { mapJson, marksFor } from "../web/pages/map.ts"; |
| 133 | import { undoFleetOp } from "../actions/fleet.ts"; | |
| 133 | import { applyFleetIds, previewFleetIds, undoFleetOp } from "../actions/fleet.ts"; | |
| 134 | 134 | import { applyLimb, isLimbAction, previewLimb } from "../actions/limbs.ts"; |
| 135 | 135 | import { |
| 136 | 136 | milestoneData, |
| @@ -151,6 +151,7 @@ import { | ||
| 151 | 151 | isAskedOrder, |
| 152 | 152 | isAskedShow, |
| 153 | 153 | } from "../core/asked.ts"; |
| 154 | import { audienceFor } from "../core/audience.ts"; | |
| 154 | 155 | import { linksFrom, linksTo } from "../core/thought_links.ts"; |
| 155 | 156 | import { streamJson, thoughtStream } from "../core/thought_stream.ts"; |
| 156 | 157 | import { |
| @@ -264,6 +265,67 @@ function repoArg(ctx: Ctx, user: User, args: Args): Repo | Answer { | ||
| 264 | 265 | : bad("not_found", "no such app or repository (owner/name)"); |
| 265 | 266 | } |
| 266 | 267 | |
| 268 | const MAX_ARCHIVE_REPOS = 50; | |
| 269 | ||
| 270 | /** `archive` over `repos`: the fleet's own operation, each row's outcome and the id to undo. */ | |
| 271 | async function archiveMany(ctx: Ctx, user: User, args: Args): Promise<Answer> { | |
| 272 | const names = args["repos"]; | |
| 273 | if ( | |
| 274 | !Array.isArray(names) || | |
| 275 | names.length === 0 || | |
| 276 | names.length > MAX_ARCHIVE_REPOS || | |
| 277 | names.some((n) => typeof n !== "string") | |
| 278 | ) | |
| 279 | return bad("invalid", `\`repos\` is one to ${MAX_ARCHIVE_REPOS} names, owner/name each`); | |
| 280 | if (args["repo"] !== undefined) | |
| 281 | return bad("invalid", "give `repo` or `repos`, not both"); | |
| 282 | if (args["archived"] !== undefined && typeof args["archived"] !== "boolean") | |
| 283 | return bad("invalid", "`archived` is true or false"); | |
| 284 | const archived = args["archived"] ?? true; | |
| 285 | const dryRun = args["dryRun"] !== false; | |
| 286 | if (user.kind === "agent") | |
| 287 | return bad("forbidden", "archiving or waking is a person's call, not an agent's"); | |
| 288 | if (!dryRun && !scopeAllows(ctx.auth.scope ?? "ro", "admin")) | |
| 289 | return bad("forbidden", "archiving or waking needs an admin token"); | |
| 290 | const ids: string[] = []; | |
| 291 | const missing: string[] = []; | |
| 292 | for (const name of new Set(names as string[])) { | |
| 293 | const repo = repoArg(ctx, user, { repo: name }); | |
| 294 | if (isAnswer(repo)) missing.push(name); | |
| 295 | else ids.push(repo.id); | |
| 296 | } | |
| 297 | const query = `mcp: ${ids.length + missing.length} named`; | |
| 298 | const action = { verb: "archive", value: archived }; | |
| 299 | const unknown = missing.map((repo) => ({ | |
| 300 | repo, | |
| 301 | outcome: "refused", | |
| 302 | reason: "no such app or repository (owner/name)", | |
| 303 | })); | |
| 304 | const rowsOf = (rows: readonly { label: string; outcome: string; reason: string }[]) => [ | |
| 305 | ...rows.map((r) => ({ repo: r.label, outcome: r.outcome, reason: r.reason })), | |
| 306 | ...unknown, | |
| 307 | ]; | |
| 308 | if (dryRun) { | |
| 309 | const plan = previewFleetIds(ctx.app, user, "repo", ids, query, action); | |
| 310 | if (!plan.ok) return bad(plan.error.code, plan.error.message); | |
| 311 | return good({ | |
| 312 | dryRun: true, | |
| 313 | archived, | |
| 314 | rows: rowsOf(plan.value.rows), | |
| 315 | says: "This is the plan; nothing changed. Send dryRun: false to do it.", | |
| 316 | }); | |
| 317 | } | |
| 318 | const op = await applyFleetIds(ctx.app, user, "repo", ids, query, action); | |
| 319 | if (!op.ok) return bad(op.error.code, op.error.message); | |
| 320 | return good({ | |
| 321 | dryRun: false, | |
| 322 | archived, | |
| 323 | operation: op.value.id, | |
| 324 | rows: rowsOf(op.value.rows), | |
| 325 | says: `Done. The \`limb\` tool with action undo and operation ${op.value.id} reverts it for 24 hours.`, | |
| 326 | }); | |
| 327 | } | |
| 328 | ||
| 267 | 329 | const APP_ARG = { |
| 268 | 330 | type: "string", |
| 269 | 331 | description: "The app, as owner/name.", |
| @@ -343,6 +405,28 @@ const TOOLS: readonly Tool[] = [ | ||
| 343 | 405 | ); |
| 344 | 406 | }, |
| 345 | 407 | }, |
| 408 | { | |
| 409 | name: "repo", | |
| 410 | description: | |
| 411 | "One repository you can read: its name, description, visibility and autonomy, and who can see it as `audience` { level, sentence } — the same words as GET /api/v1/repos/:owner/:name.", | |
| 412 | inputSchema: { | |
| 413 | type: "object", | |
| 414 | properties: { repo: REPO_ARG }, | |
| 415 | required: ["repo"], | |
| 416 | }, | |
| 417 | writes: false, | |
| 418 | run(ctx, user, args) { | |
| 419 | const repo = repoArg(ctx, user, args); | |
| 420 | if (isAnswer(repo)) return repo; | |
| 421 | return good({ | |
| 422 | repo: `${repo.ownerName}/${repo.name}`, | |
| 423 | description: repo.description, | |
| 424 | visibility: repo.visibility, | |
| 425 | autonomy: repo.autonomy, | |
| 426 | audience: audienceFor(ctx.app.db, repo, user), | |
| 427 | }); | |
| 428 | }, | |
| 429 | }, | |
| 346 | 430 | { |
| 347 | 431 | name: "here", |
| 348 | 432 | description: |
| @@ -1209,22 +1293,28 @@ const TOOLS: readonly Tool[] = [ | ||
| 1209 | 1293 | { |
| 1210 | 1294 | name: "archive", |
| 1211 | 1295 | description: |
| 1212 | "Put a repository away (archive) or bring it back (wake), by name: nothing is deleted. Without `archived`, says which way it is. With `archived` (true to archive, false to wake), it plans — `dryRun` is true unless you say false — and only an admin of the repository, with an admin token and never as an agent, does it. Each change is recorded in the repository's activity.", | |
| 1296 | "Put a repository away (archive) or bring it back (wake), by name: nothing is deleted. Without `archived`, says which way it is. With `archived` (true to archive, false to wake), it plans — `dryRun` is true unless you say false — and only an admin of the repository, with an admin token and never as an agent, does it. Each change is recorded in the repository's activity. To archive several at once, pass `repos` (owner/name each, at most 50) instead of `repo`: it runs the fleet `archive` operation, a dry run unless `dryRun` is false, and answers each repository's outcome and the `operation` id; the `limb` tool with action `undo` and that `operation` (with `app` naming any of them) reverts it for 24 hours.", | |
| 1213 | 1297 | inputSchema: { |
| 1214 | 1298 | type: "object", |
| 1215 | 1299 | properties: { |
| 1216 | 1300 | repo: REPO_ARG, |
| 1301 | repos: { | |
| 1302 | type: "array", | |
| 1303 | items: { type: "string" }, | |
| 1304 | maxItems: MAX_ARCHIVE_REPOS, | |
| 1305 | description: "Several repositories, as owner/name, to archive in one operation.", | |
| 1306 | }, | |
| 1217 | 1307 | archived: { type: "boolean" }, |
| 1218 | 1308 | dryRun: { |
| 1219 | 1309 | type: "boolean", |
| 1220 | 1310 | description: "Plan only (the default). Send false to do it.", |
| 1221 | 1311 | }, |
| 1222 | 1312 | }, |
| 1223 | required: ["repo"], | |
| 1224 | 1313 | }, |
| 1225 | 1314 | writes: true, |
| 1226 | 1315 | dryByDefault: true, |
| 1227 | 1316 | async run(ctx, user, args) { |
| 1317 | if (args["repos"] !== undefined) return archiveMany(ctx, user, args); | |
| 1228 | 1318 | const repo = repoArg(ctx, user, args); |
| 1229 | 1319 | if (isAnswer(repo)) return repo; |
| 1230 | 1320 | const archived = args["archived"]; |
src/http/refusal.ts added +51
| @@ -0,0 +1,51 @@ | ||
| 1 | /** | |
| 2 | * What a database guard's refusal may say to the caller (#723). A trigger's | |
| 3 | * `RAISE(ABORT, '…')` message is answered to anyone, anonymous callers | |
| 4 | * included, so only a message that opts in is shown: it starts with | |
| 5 | * `forge: ` and is written for anyone. Any other message is answered with a | |
| 6 | * generic text and logged in full for the operator. | |
| 7 | */ | |
| 8 | ||
| 9 | /** The prefix a guard puts on a message that is meant for the person refused. */ | |
| 10 | export const REFUSAL_PREFIX = "forge: "; | |
| 11 | ||
| 12 | /** What a caller is told when a guard's message did not opt in. */ | |
| 13 | export const GENERIC_REFUSAL = "refused"; | |
| 14 | ||
| 15 | /** | |
| 16 | * The messages this repository's own triggers (migrations 62, 70, 81) raised | |
| 17 | * before the prefix existed. A migration is never rewritten once it has run, | |
| 18 | * so these stay as written and are known by name. | |
| 19 | */ | |
| 20 | const BUILT_IN = new Set([ | |
| 21 | "only an agent can be on a crew", | |
| 22 | "a sealed repository is never public", | |
| 23 | "a thought is never changed", | |
| 24 | "a thought is never deleted", | |
| 25 | ]); | |
| 26 | ||
| 27 | /** The words to show for a guard's message: its own, or the generic text. */ | |
| 28 | export function shownRefusal(message: string): string { | |
| 29 | return message.startsWith(REFUSAL_PREFIX) || BUILT_IN.has(message) | |
| 30 | ? message | |
| 31 | : GENERIC_REFUSAL; | |
| 32 | } | |
| 33 | ||
| 34 | /** | |
| 35 | * The original message of a refusal a database trigger raised | |
| 36 | * (`RAISE(ABORT, '…')`), or undefined for any other error. | |
| 37 | */ | |
| 38 | export function refusedByDatabase(error: unknown): string | undefined { | |
| 39 | if (!(error instanceof Error)) return undefined; | |
| 40 | const code = (error as { code?: unknown }).code; | |
| 41 | return code === "SQLITE_CONSTRAINT_TRIGGER" ? error.message : undefined; | |
| 42 | } | |
| 43 | ||
| 44 | /** | |
| 45 | * The words to show for a thrown error if a database trigger refused, else | |
| 46 | * undefined: the one door both the page and the terminal answer through. | |
| 47 | */ | |
| 48 | export function refusalText(error: unknown): string | undefined { | |
| 49 | const refused = refusedByDatabase(error); | |
| 50 | return refused === undefined ? undefined : shownRefusal(refused); | |
| 51 | } |
src/http/server.ts +10 −14
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * verdict. Auth is resolved once per request and handed to handlers as data. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { refusedByDatabase, shownRefusal } from "./refusal.ts"; | |
| 8 | 9 | import { NOT_A_MEMBER } from "../core/roles.ts"; |
| 9 | 10 | import { signupOptions, signupPasskey } from "../web/pages/signup-passkey.ts"; |
| 10 | 11 | import { toolbarComment, toolbarPage } from "../web/studio/toolbar.ts"; |
| @@ -248,6 +249,7 @@ import { | ||
| 248 | 249 | asksMute, |
| 249 | 250 | asksPage, |
| 250 | 251 | } from "../web/studio/asks_page.ts"; |
| 252 | import { homeArchive, homeArchived, homeArchiveUndo } from "../web/studio/home_archive.ts"; | |
| 251 | 253 | import { liveFeedFragment } from "../web/studio/feed.ts"; |
| 252 | 254 | import { serviceWorker } from "../web/sw.ts"; |
| 253 | 255 | import { explorePage } from "../web/studio/explore.ts"; |
| @@ -654,6 +656,9 @@ export function buildRouter(): Router<Handler> { | ||
| 654 | 656 | // Apps and people ranked by real numbers, narrowed in one line (#607). |
| 655 | 657 | router.add("GET", "/explore", explorePage); |
| 656 | 658 | router.add("POST", "/front", frontUpdate); |
| 659 | router.add("POST", "/home/archive", homeArchive); | |
| 660 | router.add("GET", "/home/archived/:op", homeArchived); | |
| 661 | router.add("POST", "/home/archived/:op/undo", homeArchiveUndo); | |
| 657 | 662 | router.add("GET", "/signin", signinPage); |
| 658 | 663 | router.add("POST", "/signin", signinSubmit); |
| 659 | 664 | router.add("GET", "/signin/parent", parentStart); |
| @@ -1853,25 +1858,16 @@ export interface Servers { | ||
| 1853 | 1858 | } |
| 1854 | 1859 | |
| 1855 | 1860 | /** |
| 1856 | * A refusal a database trigger raised (`RAISE(ABORT, '…')`): an operator's | |
| 1857 | * guard saying no, in its own words. It is the answer, not a fault, so the | |
| 1858 | * person sees the reason and a 403 rather than "internal". | |
| 1859 | */ | |
| 1860 | function refusedByDatabase(error: unknown): string | undefined { | |
| 1861 | if (!(error instanceof Error)) return undefined; | |
| 1862 | const code = (error as { code?: unknown }).code; | |
| 1863 | return code === "SQLITE_CONSTRAINT_TRIGGER" ? error.message : undefined; | |
| 1864 | } | |
| 1865 | ||
| 1866 | /** | |
| 1867 | * What a thrown handler is answered with: the guard's own words and a 403 | |
| 1861 | * What a thrown handler is answered with: the guard's words (or a generic | |
| 1862 | * "refused") and a 403 | |
| 1868 | 1863 | * when a database trigger refused, else a 500 that says nothing of the fault. |
| 1869 | 1864 | */ |
| 1870 | 1865 | function failure(app: App, error: unknown): Response { |
| 1871 | 1866 | const refused = refusedByDatabase(error); |
| 1872 | 1867 | if (refused !== undefined) { |
| 1873 | app.log.warn("refused by the database", { reason: refused }); | |
| 1874 | return securityFloor(json({ error: "refused", message: refused }, 403)); | |
| 1868 | const shown = shownRefusal(refused); | |
| 1869 | app.log.warn("refused by the database", { reason: refused, shown }); | |
| 1870 | return securityFloor(json({ error: "refused", message: shown }, 403)); | |
| 1875 | 1871 | } |
| 1876 | 1872 | app.log.error("unhandled request error", { error: String(error) }); |
| 1877 | 1873 | return securityFloor(json({ error: "internal" }, 500)); |
src/kernel/db.ts +14 −5
| @@ -7,7 +7,10 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { Database } from "bun:sqlite"; |
| 9 | 9 | import { existsSync, writeFileSync } from "node:fs"; |
| 10 | import { migrate } from "./migrate.ts"; | |
| 10 | import { createLog } from "./log.ts"; | |
| 11 | import { type Migration, migrate } from "./migrate.ts"; | |
| 12 | ||
| 13 | const log = createLog("db"); | |
| 11 | 14 | |
| 12 | 15 | /** |
| 13 | 16 | * How long a writer waits for another writer before giving up. Long by |
| @@ -34,7 +37,7 @@ const CACHE_KIB = 64 * 1024; | ||
| 34 | 37 | */ |
| 35 | 38 | const MMAP_BYTES = 512 * 1024 * 1024; |
| 36 | 39 | |
| 37 | export function openDb(path: string, migrations: readonly string[]): Database { | |
| 40 | export function openDb(path: string, migrations: readonly Migration[]): Database { | |
| 38 | 41 | const memory = path === ":memory:"; |
| 39 | 42 | const fresh = memory || !existsSync(path); |
| 40 | 43 | const image = fresh ? migratedEmpty.get(migrations) : undefined; |
| @@ -47,8 +50,14 @@ export function openDb(path: string, migrations: readonly string[]): Database { | ||
| 47 | 50 | } |
| 48 | 51 | applyPragmas(db); |
| 49 | 52 | // On a copy every migration is already recorded, so this is a no-op — |
| 50 | // and a list that grew since the copy was taken still runs what is new. | |
| 51 | migrate(db, migrations); | |
| 53 | // and a list that grew since the copy was taken still runs what is new; | |
| 54 | // one behind the copy boots and says what it does not know (#783). | |
| 55 | const { unknown } = migrate(db, migrations); | |
| 56 | if (unknown.length > 0) | |
| 57 | log.warn("database has migrations this build does not know; left as they are", { | |
| 58 | count: unknown.length, | |
| 59 | unknown: unknown.join(","), | |
| 60 | }); | |
| 52 | 61 | if (fresh && image === undefined) migratedEmpty.set(migrations, db.serialize()); |
| 53 | 62 | return db; |
| 54 | 63 | } |
| @@ -63,7 +72,7 @@ export function openDb(path: string, migrations: readonly string[]): Database { | ||
| 63 | 72 | * bytes however it was made: the migrations that draw randomness only |
| 64 | 73 | * rewrite rows an empty database does not have. |
| 65 | 74 | */ |
| 66 | const migratedEmpty = new WeakMap<readonly string[], Uint8Array>(); | |
| 75 | const migratedEmpty = new WeakMap<readonly Migration[], Uint8Array>(); | |
| 67 | 76 | |
| 68 | 77 | /** |
| 69 | 78 | * The image with its header saying "rollback journal" (bytes 18 and 19 of a |
src/kernel/migrate.ts +123 −15
| @@ -11,7 +11,8 @@ | ||
| 11 | 11 | * migration is a row in `schema_migration`, keyed by a hash of its text, and |
| 12 | 12 | * a boot runs exactly the ones whose text it has not seen. A second boot is |
| 13 | 13 | * a no-op; a migration that moved is still recognised; a sibling's migration |
| 14 | * this build does not know is left alone. | |
| 14 | * this build does not know is left alone and named, never a refusal to boot | |
| 15 | * (#783) — each row also carries its migration's file name. | |
| 15 | 16 | * |
| 16 | 17 | * Each statement is also run so that meeting its own result is a no-op — |
| 17 | 18 | * `CREATE` gains `IF NOT EXISTS`, an `ADD COLUMN` whose column is there is |
| @@ -77,10 +78,73 @@ export function migrationId(sql: string): string { | ||
| 77 | 78 | return sha256(sql.replace(/\s+/g, " ").trim()); |
| 78 | 79 | } |
| 79 | 80 | |
| 80 | export function migrate(db: Database, migrations: readonly string[]): void { | |
| 81 | /** Each pinned migration's id, by name: what it said when it was pinned. */ | |
| 82 | export type MigrationPins = Readonly<Record<string, string>>; | |
| 83 | ||
| 84 | /** | |
| 85 | * How `files` differ from what was pinned: one line for each pinned | |
| 86 | * migration whose text says something else now, or that is gone. A | |
| 87 | * migration on main is history — an edited one is a new one, and a | |
| 88 | * database that ran the old text runs the new one again on top of it. | |
| 89 | */ | |
| 90 | export function pinBreaks( | |
| 91 | files: readonly MigrationFile[], | |
| 92 | pins: MigrationPins, | |
| 93 | ): string[] { | |
| 94 | const now = new Map(files.map((m) => [m.name, migrationId(m.sql)])); | |
| 95 | const breaks: string[] = []; | |
| 96 | for (const [name, id] of Object.entries(pins)) { | |
| 97 | const current = now.get(name); | |
| 98 | if (current === undefined) breaks.push(`${name} was pinned and is gone`); | |
| 99 | else if (current !== id) | |
| 100 | breaks.push(`${name} was edited after it was pinned — add a new migration instead`); | |
| 101 | } | |
| 102 | return breaks; | |
| 103 | } | |
| 104 | ||
| 105 | /** | |
| 106 | * `pins` with every migration in `files` it lacks added; a pin already | |
| 107 | * there is never rewritten. Sorted by name, so the file it is written to | |
| 108 | * changes only where a migration was added. | |
| 109 | */ | |
| 110 | export function pinned( | |
| 111 | files: readonly MigrationFile[], | |
| 112 | pins: MigrationPins, | |
| 113 | ): MigrationPins { | |
| 114 | const next: Record<string, string> = { ...pins }; | |
| 115 | for (const m of files) next[m.name] ??= migrationId(m.sql); | |
| 116 | return Object.fromEntries( | |
| 117 | Object.entries(next).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), | |
| 118 | ); | |
| 119 | } | |
| 120 | ||
| 121 | /** | |
| 122 | * A migration as `migrate` takes it: its file, which names it, or bare SQL — | |
| 123 | * a test's own list — which is known by its position alone. | |
| 124 | */ | |
| 125 | export type Migration = string | MigrationFile; | |
| 126 | ||
| 127 | /** What a boot did to the database, by migration name. */ | |
| 128 | export interface Migrated { | |
| 129 | /** The migrations this boot applied, in the order it applied them. */ | |
| 130 | readonly applied: string[]; | |
| 131 | /** | |
| 132 | * What the database has applied that this build does not know: a newer | |
| 133 | * build's or a sibling branch's, on data copied for a preview. Each by | |
| 134 | * the name its build recorded; one recorded with none by its position and | |
| 135 | * the start of its id. Left alone, never undone. | |
| 136 | */ | |
| 137 | readonly unknown: string[]; | |
| 138 | } | |
| 139 | ||
| 140 | export function migrate(db: Database, list: readonly Migration[]): Migrated { | |
| 141 | const migrations = list.map((m) => (typeof m === "string" ? m : m.sql)); | |
| 142 | const names = list.map((m, i) => | |
| 143 | typeof m === "string" ? `migration ${i + 1}` : `migration ${m.name}`, | |
| 144 | ); | |
| 81 | 145 | const ids = migrations.map((sql, i) => { |
| 82 | 146 | if (sql.trim() === "") |
| 83 | defect("db.migration_missing", `migration ${i + 1} is empty`); | |
| 147 | defect("db.migration_missing", `${names[i]} is empty`); | |
| 84 | 148 | return migrationId(sql); |
| 85 | 149 | }); |
| 86 | 150 | const seen = new Set<string>(); |
| @@ -88,22 +152,19 @@ export function migrate(db: Database, migrations: readonly string[]): void { | ||
| 88 | 152 | if (seen.has(id)) |
| 89 | 153 | defect( |
| 90 | 154 | "db.migration_duplicate", |
| 91 | `migration ${i + 1} repeats an earlier one word for word — it would never run`, | |
| 155 | `${names[i]} repeats an earlier one word for word — it would never run`, | |
| 92 | 156 | ); |
| 93 | 157 | seen.add(id); |
| 94 | 158 | }); |
| 95 | 159 | |
| 96 | 160 | const applied = appliedIds(db, migrations, ids); |
| 97 | if (applied.size > migrations.length) { | |
| 98 | defect( | |
| 99 | "db.future_schema", | |
| 100 | `database has applied ${applied.size} migrations, this build only knows ${migrations.length} — refusing to run`, | |
| 101 | ); | |
| 102 | } | |
| 161 | recordNames(db, list, ids); | |
| 162 | const ran: string[] = []; | |
| 103 | 163 | for (let i = 0; i < migrations.length; i++) { |
| 104 | 164 | const id = ids[i]!; |
| 105 | 165 | if (applied.has(id)) continue; |
| 106 | 166 | const sql = migrations[i]!; |
| 167 | const entry = list[i]!; | |
| 107 | 168 | db.transaction(() => { |
| 108 | 169 | for (const statement of statements(sql)) { |
| 109 | 170 | try { |
| @@ -111,19 +172,63 @@ export function migrate(db: Database, migrations: readonly string[]): void { | ||
| 111 | 172 | } catch (error) { |
| 112 | 173 | defect( |
| 113 | 174 | "db.migration_failed", |
| 114 | `migration ${i + 1} failed at ${quote(statement)}: ${messageOf(error)}`, | |
| 175 | `${names[i]} failed at ${quote(statement)}: ${messageOf(error)}`, | |
| 115 | 176 | error, |
| 116 | 177 | ); |
| 117 | 178 | } |
| 118 | 179 | } |
| 119 | 180 | db.query( |
| 120 | "INSERT INTO schema_migration (id, position, applied_at) VALUES (?1, ?2, ?3)", | |
| 121 | ).run(id, i + 1, new Date().toISOString()); | |
| 181 | "INSERT INTO schema_migration (id, position, applied_at, name) VALUES (?1, ?2, ?3, ?4)", | |
| 182 | ).run( | |
| 183 | id, | |
| 184 | i + 1, | |
| 185 | new Date().toISOString(), | |
| 186 | typeof entry === "string" ? null : entry.name, | |
| 187 | ); | |
| 122 | 188 | applied.add(id); |
| 123 | 189 | // PRAGMA can't be parameterized; the value is a count, not input. |
| 124 | 190 | db.exec(`PRAGMA user_version = ${applied.size}`); |
| 125 | 191 | })(); |
| 192 | ran.push(typeof entry === "string" ? String(i + 1) : entry.name); | |
| 126 | 193 | } |
| 194 | const known = new Set(ids); | |
| 195 | const unknown = db | |
| 196 | .query<{ id: string; position: number; name: string | null }, []>( | |
| 197 | "SELECT id, position, name FROM schema_migration ORDER BY position, id", | |
| 198 | ) | |
| 199 | .all() | |
| 200 | .filter((row) => !known.has(row.id)) | |
| 201 | .map((row) => row.name ?? `#${row.position} (${row.id.slice(0, 12)})`); | |
| 202 | return { applied: ran, unknown }; | |
| 203 | } | |
| 204 | ||
| 205 | /** | |
| 206 | * Write each known migration's name beside its id, where a build from | |
| 207 | * before names were recorded left none — so the database says which | |
| 208 | * migrations it has by name, and a later build that does not know one can | |
| 209 | * say which. A database written by test lists keeps its blanks. | |
| 210 | */ | |
| 211 | function recordNames( | |
| 212 | db: Database, | |
| 213 | list: readonly Migration[], | |
| 214 | ids: readonly string[], | |
| 215 | ): void { | |
| 216 | if (!hasColumn(db, "schema_migration", "name")) | |
| 217 | db.exec("ALTER TABLE schema_migration ADD COLUMN name TEXT"); | |
| 218 | const blank = db | |
| 219 | .query<{ n: number }, []>( | |
| 220 | "SELECT COUNT(*) AS n FROM schema_migration WHERE name IS NULL", | |
| 221 | ) | |
| 222 | .get()!.n; | |
| 223 | if (blank === 0) return; | |
| 224 | const name = db.query( | |
| 225 | "UPDATE schema_migration SET name = ?2 WHERE id = ?1 AND name IS NULL", | |
| 226 | ); | |
| 227 | db.transaction(() => { | |
| 228 | list.forEach((m, i) => { | |
| 229 | if (typeof m !== "string") name.run(ids[i]!, m.name); | |
| 230 | }); | |
| 231 | })(); | |
| 127 | 232 | } |
| 128 | 233 | |
| 129 | 234 | /** |
| @@ -132,7 +237,9 @@ export function migrate(db: Database, migrations: readonly string[]): void { | ||
| 132 | 237 | * taken as applied — what that number always meant — unless one plainly |
| 133 | 238 | * did not run (a table it creates or a column it adds is missing: a |
| 134 | 239 | * branch's migration sat at that number instead). They are written down |
| 135 | * once, after which positions never matter again. | |
| 240 | * once, after which positions never matter again. Such a database ahead of | |
| 241 | * this build is still refused: a count names nothing, so there is no | |
| 242 | * telling which of its migrations this build lacks. | |
| 136 | 243 | */ |
| 137 | 244 | function appliedIds( |
| 138 | 245 | db: Database, |
| @@ -153,7 +260,8 @@ function appliedIds( | ||
| 153 | 260 | db.exec(`CREATE TABLE schema_migration ( |
| 154 | 261 | id TEXT PRIMARY KEY, |
| 155 | 262 | position INTEGER NOT NULL, |
| 156 | applied_at TEXT NOT NULL | |
| 263 | applied_at TEXT NOT NULL, | |
| 264 | name TEXT | |
| 157 | 265 | )`); |
| 158 | 266 | const insert = db.query( |
| 159 | 267 | "INSERT INTO schema_migration (id, position, applied_at) VALUES (?1, ?2, ?3)", |
src/kernel/preflight.ts +9 −4
| @@ -24,7 +24,7 @@ import { join } from "node:path"; | ||
| 24 | 24 | import { parseCidrs } from "./cidr.ts"; |
| 25 | 25 | import { dataHomeRefusal } from "./data_home.ts"; |
| 26 | 26 | import { applyPragmas } from "./db.ts"; |
| 27 | import { migrate } from "./migrate.ts"; | |
| 27 | import { type Migration, migrate } from "./migrate.ts"; | |
| 28 | 28 | |
| 29 | 29 | /** A scratch directory older than this was left by a killed run. */ |
| 30 | 30 | const STALE_MS = 60 * 60 * 1000; |
| @@ -81,13 +81,14 @@ export function configProblem( | ||
| 81 | 81 | export function preflight( |
| 82 | 82 | env: Record<string, string | undefined>, |
| 83 | 83 | treeDir: string, |
| 84 | migrations: readonly string[], | |
| 84 | migrations: readonly Migration[], | |
| 85 | 85 | ): Preflight { |
| 86 | 86 | const bad = configProblem(env, treeDir); |
| 87 | 87 | if (bad !== null) return { ok: false, line: `preflight failed: ${bad}` }; |
| 88 | 88 | |
| 89 | 89 | const dataDir = dataDirOf(env); |
| 90 | 90 | let scratch: string | undefined; |
| 91 | let unknown: string[] = []; | |
| 91 | 92 | try { |
| 92 | 93 | mkdirSync(dataDir, { recursive: true }); |
| 93 | 94 | sweepStale(dataDir); |
| @@ -101,13 +102,17 @@ export function preflight( | ||
| 101 | 102 | const db = new Database(copy, { create: true, strict: true }); |
| 102 | 103 | try { |
| 103 | 104 | applyPragmas(db); |
| 104 | migrate(db, migrations); | |
| 105 | unknown = migrate(db, migrations).unknown; | |
| 105 | 106 | } finally { |
| 106 | 107 | db.close(); |
| 107 | 108 | } |
| 108 | 109 | return { |
| 109 | 110 | ok: true, |
| 110 | line: `preflight ok: ${migrations.length} migrations apply to a copy of the database`, | |
| 111 | line: | |
| 112 | `preflight ok: ${migrations.length} migrations apply to a copy of the database` + | |
| 113 | (unknown.length > 0 | |
| 114 | ? `; it also has ${unknown.length} this build does not know (${unknown.join(", ")})` | |
| 115 | : ""), | |
| 111 | 116 | }; |
| 112 | 117 | } catch (error) { |
| 113 | 118 | const message = (error instanceof Error ? error.message : String(error)) |
src/kernel/vitals.ts +11 −1
| @@ -23,7 +23,9 @@ import { | ||
| 23 | 23 | import { STALE_AFTER_MS } from "../runs/lease.ts"; |
| 24 | 24 | import { BUDGETS } from "./budgets.ts"; |
| 25 | 25 | import { lastMirror, mirrorNeed } from "../snapshot/mirror.ts"; |
| 26 | import { lastSnapshotOutcome, snapshotNeed } from "../snapshot/outcome.ts"; | |
| 26 | 27 | import { drillNeed, firstSeen as drillFirstSeen, lastVerdict } from "../snapshot/verdict.ts"; |
| 28 | import { proofNeed } from "../release/prove.ts"; | |
| 27 | 29 | import { newestDrills } from "../core/domain_drills.ts"; |
| 28 | 30 | import { DOMAINS } from "./domains.ts"; |
| 29 | 31 | import { domainDrillNeeds } from "./domain_drill.ts"; |
| @@ -50,7 +52,7 @@ export interface EnvCount { | ||
| 50 | 52 | /** |
| 51 | 53 | * One cause of the forge being off: the `needs` sentence, a `cause` key that |
| 52 | 54 | * holds for as long as its condition does (`disk`, `errors`, `internal`, |
| 53 | * `queue:<kind>`, `stuck:<runId>`, `red:<repoId>`, `shared:<repoId>:<test>`), when it began by the forge's own evidence | |
| 55 | * `queue:<kind>`, `release`, `stuck:<runId>`, `red:<repoId>`, `shared:<repoId>:<test>`), when it began by the forge's own evidence | |
| 54 | 56 | * (the oldest evidence the forge holds, else the first time this process saw |
| 55 | 57 | * it, held until the cause clears; `at` is never the answer to a repeat |
| 56 | 58 | * call), and where to look. |
| @@ -384,10 +386,18 @@ export function vitals(input: VitalsInput): Vitals { | ||
| 384 | 386 | )) |
| 385 | 387 | need(owed.cause, owed.sentence, owed.since, "/"); |
| 386 | 388 | |
| 389 | // A snapshot that failed is a card from that morning until one lands. | |
| 390 | const failing = snapshotNeed(lastSnapshotOutcome(input.dataDir)); | |
| 391 | if (failing !== undefined) need("snapshot-failed", failing.sentence, failing.since, "/settings/backups"); | |
| 392 | ||
| 387 | 393 | // A snapshot that did not land off the box, or a mirror a day behind, is a card until it does. |
| 388 | 394 | const behind = mirrorNeed(lastMirror(input.dataDir), now); |
| 389 | 395 | if (behind !== undefined) need("mirror", behind.sentence, behind.since, "/settings/backups"); |
| 390 | 396 | |
| 397 | // A release the forge could not prove against its own public URL is a card until a release does. | |
| 398 | const unproven = proofNeed(input.dataDir); | |
| 399 | if (unproven !== undefined) need("release", unproven.sentence, unproven.since, "/board"); | |
| 400 | ||
| 391 | 401 | const apps = { serving: 0, asleep: 0, previews: 0 }; |
| 392 | 402 | for (const e of input.envs ?? []) { |
| 393 | 403 | if (e.env !== "production") apps.previews++; |
src/main.ts +16 −4
| @@ -54,7 +54,9 @@ import { configProblem, dataDirOf, preflight } from "./kernel/preflight.ts"; | ||
| 54 | 54 | import { running } from "./kernel/version.ts"; |
| 55 | 55 | import { record as recordDegraded } from "./kernel/degraded_history.ts"; |
| 56 | 56 | import { vitals } from "./kernel/vitals.ts"; |
| 57 | import { proveRelease } from "./release/prove.ts"; | |
| 57 | 58 | import { snapshotPlace } from "./actions/health.ts"; |
| 59 | import { capacityDefault } from "./core/capacity.ts"; | |
| 58 | 60 | |
| 59 | 61 | // `forge hook <name>` re-enters here from a git hook stub. It relays to the |
| 60 | 62 | // running forge over its socket and exits — it must never boot a server. |
| @@ -72,8 +74,8 @@ if (process.argv[2] === "hook") { | ||
| 72 | 74 | // config parsed, migrations run on a copy of the database. No socket, no |
| 73 | 75 | // timers, and the live database is never opened. |
| 74 | 76 | if (process.argv.includes("--preflight")) { |
| 75 | const { migrations } = await import("./core/schema.ts"); | |
| 76 | const result = preflight(process.env, import.meta.dir, migrations); | |
| 77 | const { migrationFiles } = await import("./core/schema.ts"); | |
| 78 | const result = preflight(process.env, import.meta.dir, migrationFiles); | |
| 77 | 79 | // CLI answer on the stream a person or a script reads. |
| 78 | 80 | (result.ok ? console.log : console.error)(result.line); |
| 79 | 81 | process.exit(result.ok ? 0 : 1); |
| @@ -176,6 +178,8 @@ if (parentProblem !== null) { | ||
| 176 | 178 | |
| 177 | 179 | const app = createApp({ |
| 178 | 180 | dataDir, |
| 181 | // A new forge starts with signup = invite (FORGE_SIGNUP below still wins). | |
| 182 | seedSignup: true, | |
| 179 | 183 | ...(parentUrl === "" ? {} : { parentUrl }), |
| 180 | 184 | port: Number(process.env.PORT ?? 3000), |
| 181 | 185 | ...(process.env.FORGE_HOST === undefined |
| @@ -332,7 +336,8 @@ const checks = | ||
| 332 | 336 | ? undefined |
| 333 | 337 | : startChecks(app, { |
| 334 | 338 | forgeUrl, |
| 335 | concurrency: positive(process.env.FORGE_CHECKS_CONCURRENCY, 1), | |
| 339 | // Sized to the pod's CPUs unless the environment says (#2549). | |
| 340 | concurrency: positive(process.env.FORGE_CHECKS_CONCURRENCY, Number(capacityDefault("checks.concurrency"))), | |
| 336 | 341 | redHoldMs: RED_HOLD_MS, |
| 337 | 342 | // A roll lets running checks finish this long before cutting them off. |
| 338 | 343 | drainMs: positive(process.env.FORGE_CHECKS_DRAIN_S, 300) * 1000, |
| @@ -379,7 +384,7 @@ const crew = appsOn && !crewHeldOff | ||
| 379 | 384 | forgeUrl, |
| 380 | 385 | localUrl: servers.url, |
| 381 | 386 | binary: process.env.FORGE_CLAUDE ?? "claude", |
| 382 | concurrency: positive(process.env.FORGE_CREW_CONCURRENCY, hosted ? 1 : 2), | |
| 387 | concurrency: positive(process.env.FORGE_CREW_CONCURRENCY, hosted ? 1 : Number(capacityDefault("crew.concurrency"))), | |
| 383 | 388 | budgetUsd: Number(process.env.FORGE_CREW_BUDGET_USD) || 5, |
| 384 | 389 | runTimeoutMs: crewRunMs, |
| 385 | 390 | allowHosts: (process.env.FORGE_CREW_ALLOW_HOSTS ?? "") |
| @@ -478,6 +483,13 @@ const staleTimer = setInterval(() => void tickAsks(), 120_000); | ||
| 478 | 483 | void tickAsks(); |
| 479 | 484 | |
| 480 | 485 | app.log.info("forge is up", { url: servers.url, data: app.config.dataDir }); |
| 486 | // A new version proves itself against the public URL once (release/prove.ts). | |
| 487 | void proveRelease({ | |
| 488 | dataDir: app.config.dataDir, | |
| 489 | version: running().version, | |
| 490 | url: forgeUrl, | |
| 491 | token: process.env["FORGE_TOKEN"], | |
| 492 | }).then((p) => p && app.log.info("release proof", { version: p.version, ok: String(p.ok), ...(p.failure ? { failure: p.failure } : {}) })); | |
| 481 | 493 | // Only an agent is on a crew (the `repo_crew_agents_only` trigger refuses anyone else). A person |
| 482 | 494 | // put on one before that is reported here for the owner to take off — the |
| 483 | 495 | // forge never removes it on its own. |
src/release/prove.ts added +109
| @@ -0,0 +1,109 @@ | ||
| 1 | /** | |
| 2 | * Is this release so: once per version, after a boot that changed it, the | |
| 3 | * forge waits for its own public URL to answer `/healthz` as that version, | |
| 4 | * then runs the smoke checks (`scripts/smoke.ts`) against it. The verdict is | |
| 5 | * one small file under the data directory (`release/proof.json`), so a | |
| 6 | * restart of the same version never runs it again and vitals reads it | |
| 7 | * without the network. A failure is a `release` card, paged once by the | |
| 8 | * cause pager, until a later version passes. It gives up after ten minutes. | |
| 9 | */ | |
| 10 | ||
| 11 | import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { smoke, type Failure } from "../../scripts/smoke.ts"; | |
| 14 | import { UNSTAMPED } from "../kernel/version.ts"; | |
| 15 | ||
| 16 | /** How long the public URL has to answer as the new version. */ | |
| 17 | export const GIVE_UP_MS = 10 * 60_000; | |
| 18 | const POLL_MS = 2_000; | |
| 19 | ||
| 20 | export interface Proof { | |
| 21 | readonly version: string; | |
| 22 | readonly at: string; | |
| 23 | readonly ok: boolean; | |
| 24 | /** The first failing check, or the give-up, when `ok` is false. */ | |
| 25 | readonly failure?: string; | |
| 26 | } | |
| 27 | ||
| 28 | const proofFile = (dataDir: string): string => join(dataDir, "release", "proof.json"); | |
| 29 | ||
| 30 | /** The last verdict kept, or undefined when none is or the file is not one. */ | |
| 31 | export function lastProof(dataDir: string): Proof | undefined { | |
| 32 | try { | |
| 33 | const p = JSON.parse(readFileSync(proofFile(dataDir), "utf8")) as Proof; | |
| 34 | if (typeof p.version !== "string" || typeof p.at !== "string" || typeof p.ok !== "boolean") return undefined; | |
| 35 | return p; | |
| 36 | } catch { | |
| 37 | return undefined; | |
| 38 | } | |
| 39 | } | |
| 40 | ||
| 41 | function keep(dataDir: string, proof: Proof): Proof { | |
| 42 | mkdirSync(join(dataDir, "release"), { recursive: true }); | |
| 43 | const tmp = `${proofFile(dataDir)}.tmp`; | |
| 44 | writeFileSync(tmp, JSON.stringify(proof)); | |
| 45 | renameSync(tmp, proofFile(dataDir)); | |
| 46 | return proof; | |
| 47 | } | |
| 48 | ||
| 49 | /** The card an unproven release is, or undefined while the last verdict is a pass. */ | |
| 50 | export function proofNeed(dataDir: string): { sentence: string; since: string } | undefined { | |
| 51 | const p = lastProof(dataDir); | |
| 52 | if (p === undefined || p.ok) return undefined; | |
| 53 | return { sentence: `Release ${p.version} is unproven: ${p.failure ?? "a smoke check failed"}.`, since: p.at }; | |
| 54 | } | |
| 55 | ||
| 56 | /** A failing check as one line: where, as whom, and why. */ | |
| 57 | export function checkLine(f: Failure): string { | |
| 58 | return `${f.url}${f.agent === "-" ? "" : ` as ${f.agent}`} ${f.why} (status ${f.status})`; | |
| 59 | } | |
| 60 | ||
| 61 | export interface ProveOptions { | |
| 62 | readonly dataDir: string; | |
| 63 | readonly version: string; | |
| 64 | /** The forge's public URL. */ | |
| 65 | readonly url: string; | |
| 66 | /** Signs `/board` in; without it a redirect to sign-in passes. */ | |
| 67 | readonly token?: string | undefined; | |
| 68 | readonly fetch?: typeof fetch; | |
| 69 | readonly now?: () => number; | |
| 70 | readonly sleep?: (ms: number) => Promise<void>; | |
| 71 | readonly giveUpMs?: number; | |
| 72 | } | |
| 73 | ||
| 74 | /** | |
| 75 | * Proves `version` unless it was proven (or failed) before. Returns what was | |
| 76 | * kept, or undefined when nothing ran. Never throws. | |
| 77 | */ | |
| 78 | export async function proveRelease(o: ProveOptions): Promise<Proof | undefined> { | |
| 79 | if (o.version === UNSTAMPED || lastProof(o.dataDir)?.version === o.version) return undefined; | |
| 80 | const now = o.now ?? Date.now; | |
| 81 | const sleep = o.sleep ?? Bun.sleep; | |
| 82 | const doFetch = o.fetch ?? fetch; | |
| 83 | const finish = (failure?: string): Proof => | |
| 84 | keep(o.dataDir, { | |
| 85 | version: o.version, | |
| 86 | at: new Date(now()).toISOString(), | |
| 87 | ok: failure === undefined, | |
| 88 | ...(failure === undefined ? {} : { failure }), | |
| 89 | }); | |
| 90 | try { | |
| 91 | const health = new URL("/healthz", o.url).href; | |
| 92 | const deadline = now() + (o.giveUpMs ?? GIVE_UP_MS); | |
| 93 | for (;;) { | |
| 94 | try { | |
| 95 | const res = await doFetch(health, { redirect: "manual", signal: AbortSignal.timeout(5_000) }); | |
| 96 | await res.body?.cancel(); | |
| 97 | if (res.headers.get("x-forge-version") === o.version) break; | |
| 98 | } catch { | |
| 99 | // not up yet at its public address | |
| 100 | } | |
| 101 | if (now() + POLL_MS > deadline) return finish(`the public URL never answered as ${o.version}`); | |
| 102 | await sleep(POLL_MS); | |
| 103 | } | |
| 104 | const failures = await smoke(o.url, o.token ?? "", { fetch: doFetch, anonymousBoard: true }); | |
| 105 | return finish(failures[0] === undefined ? undefined : checkLine(failures[0])); | |
| 106 | } catch (error) { | |
| 107 | return finish(`the smoke checks could not run: ${String(error)}`); | |
| 108 | } | |
| 109 | } |
src/release/rollback.ts +3 −2
| @@ -4,8 +4,9 @@ | ||
| 4 | 4 | * each take one line: the release is not kept, or its schema is behind the |
| 5 | 5 | * live database's (a down-migration is not something this forge has). |
| 6 | 6 | * A release whose schema is ahead of the live database is fine: it migrates |
| 7 | * forward on boot, and the database's own refusal of a future schema is | |
| 8 | * untouched. | |
| 7 | * forward on boot. A release behind it is refused here, not by the | |
| 8 | * database: a build boots on migrations it does not know and only names | |
| 9 | * them (#783). | |
| 9 | 10 | */ |
| 10 | 11 | |
| 11 | 12 | import { Database } from "bun:sqlite"; |
src/settings/signup.ts +20 −2
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Who may make an account: `open`, `invite` or `closed`. Stored in |
| 3 | 3 | * `forge_setting` under `signup`, and every change is kept in |
| 4 | * `forge_setting_change` (who, when, from what to what). Default `open`. | |
| 4 | * `forge_setting_change` (who, when, from what to what). Default `open` when nothing is stored; a new forge stores `invite` (`seedSignup`). | |
| 5 | 5 | * `/signup`, `POST /api/v1/users`, passkey signup and a first edge sign-in ask |
| 6 | 6 | * it (`signupClosed` in `actions/users.ts`); `invite` lets in only a person with |
| 7 | 7 | * an open invite link (`core/invites.ts`), spent with the account. |
| @@ -36,6 +36,22 @@ export function getSignup(db: Database): SignupMode { | ||
| 36 | 36 | return parsed.ok ? parsed.value : SIGNUP_DEFAULT; |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | /** The actor kept for the value a new forge starts with. */ | |
| 40 | export const FRESH_ACTOR = "fresh"; | |
| 41 | ||
| 42 | /** | |
| 43 | * What a new forge starts with: `invite`, stored so it is the forge's own | |
| 44 | * value from then on. Only a forge that made its database just now is new | |
| 45 | * (`fresh`); one that stored a value keeps it, and one that never stored any | |
| 46 | * keeps the `open` it has always read as. A spawned child is a fresh data | |
| 47 | * directory, so it starts here too. | |
| 48 | */ | |
| 49 | export function seedSignup(db: Database, fresh: boolean): void { | |
| 50 | if (!fresh) return; | |
| 51 | const stored = db.query("SELECT 1 FROM forge_setting WHERE key = ?1").get(STORE_KEY); | |
| 52 | if (stored === null) writeSetting(db, STORE_KEY, "invite", SIGNUP_DEFAULT, "invite", FRESH_ACTOR); | |
| 53 | } | |
| 54 | ||
| 39 | 55 | /** Set the mode and keep the change; the same mode again keeps nothing. */ |
| 40 | 56 | export function setSignup(db: Database, value: SignupMode, actorId: string): void { |
| 41 | 57 | const from = getSignup(db); |
| @@ -77,7 +93,9 @@ export function signupChanges(db: Database, limit = 20): SignupChange[] { | ||
| 77 | 93 | .map((r) => ({ |
| 78 | 94 | from: r.from_value, |
| 79 | 95 | to: r.to_value, |
| 80 | actorName: r.username ?? (r.actor_id === BOOT_ACTOR ? "FORGE_SIGNUP at boot" : ""), | |
| 96 | actorName: | |
| 97 | r.username ?? | |
| 98 | (r.actor_id === BOOT_ACTOR ? "FORGE_SIGNUP at boot" : r.actor_id === FRESH_ACTOR ? "new forge" : ""), | |
| 81 | 99 | at: r.at, |
| 82 | 100 | })); |
| 83 | 101 | } |
src/snapshot/drill.ts +1 −1
| @@ -56,7 +56,7 @@ class StepTimeout extends Error {} | ||
| 56 | 56 | function within<T>(work: Promise<T>, ms: number): Promise<T> { |
| 57 | 57 | let timer: ReturnType<typeof setTimeout>; |
| 58 | 58 | const late = new Promise<never>((_, reject) => { |
| 59 | timer = setTimeout(() => reject(new StepTimeout(`no answer in ${ms} ms`)), ms); | |
| 59 | timer = setTimeout(() => reject(new StepTimeout(`no answer in ${Math.round(ms)} ms`)), ms); | |
| 60 | 60 | }); |
| 61 | 61 | return Promise.race([work, late]).finally(() => clearTimeout(timer)); |
| 62 | 62 | } |
src/snapshot/due.ts +23 −7
| @@ -2,11 +2,21 @@ | ||
| 2 | 2 | * When the weekly drill is owed: every Sunday 09:00 UTC is a slot, and a |
| 3 | 3 | * slot is kept by one drill at or after it, however it was asked for. A |
| 4 | 4 | * forge that was down at the slot runs it on waking; one that restarts |
| 5 | * after the drill ran does not run it twice. Plain data in, plain data out. | |
| 5 | * after the drill ran does not run it twice. A red drill keeps no slot for | |
| 6 | * long: it is owed again `RED_RETRY_MS` after it ran, and on a boot once it | |
| 7 | * is `BOOT_RED_FLOOR_MS` old, since a restart is most often the deploy of | |
| 8 | * its fix (#2541: a drill red at 09:11 stood as a card for the week after | |
| 9 | * the fix landed at 10:18). The floor keeps a crash loop with a broken | |
| 10 | * restore from running a restore on every boot. Plain data in, plain data out. | |
| 6 | 11 | */ |
| 7 | 12 | |
| 8 | 13 | const DAY = 86_400_000; |
| 9 | 14 | |
| 15 | /** A red drill is owed again this long after it ran. */ | |
| 16 | export const RED_RETRY_MS = 3 * 3_600_000; | |
| 17 | /** On a boot, a red drill at least this old is owed again. */ | |
| 18 | export const BOOT_RED_FLOOR_MS = 30 * 60_000; | |
| 19 | ||
| 10 | 20 | /** The latest Sunday 09:00 UTC at or before `now`, in ms. */ |
| 11 | 21 | export function lastSlot(now: number): number { |
| 12 | 22 | const d = new Date(now); |
| @@ -17,14 +27,20 @@ export function lastSlot(now: number): number { | ||
| 17 | 27 | } |
| 18 | 28 | |
| 19 | 29 | /** |
| 20 | * Whether a drill is owed at `now`. `lastAt` is the newest drill's time, | |
| 21 | * `since` when the forge first owed one; a forge younger than the slot | |
| 22 | * owes nothing for it. | |
| 30 | * Whether a drill is owed at `now`. `last` is the newest drill, `since` | |
| 31 | * when the forge first owed one; a forge younger than the slot owes | |
| 32 | * nothing for it. `redRetryMs` is how long a red drill stands before it is | |
| 33 | * owed again; a boot passes `BOOT_RED_FLOOR_MS`. | |
| 23 | 34 | */ |
| 24 | export function drillDue(lastAt: string | undefined, since: string | undefined, now: number): boolean { | |
| 35 | export function drillDue( | |
| 36 | last: { readonly at: string; readonly verdict: "green" | "red" } | undefined, | |
| 37 | since: string | undefined, | |
| 38 | now: number, | |
| 39 | redRetryMs: number = RED_RETRY_MS, | |
| 40 | ): boolean { | |
| 25 | 41 | const slot = lastSlot(now); |
| 26 | const last = Date.parse(lastAt ?? ""); | |
| 27 | if (Number.isFinite(last)) return last < slot; | |
| 42 | const at = Date.parse(last?.at ?? ""); | |
| 43 | if (Number.isFinite(at)) return at < slot || (last?.verdict === "red" && now - at >= redRetryMs); | |
| 28 | 44 | const began = Date.parse(since ?? ""); |
| 29 | 45 | return Number.isFinite(began) && began < slot; |
| 30 | 46 | } |
src/snapshot/outcome.ts added +70
| @@ -0,0 +1,70 @@ | ||
| 1 | /** | |
| 2 | * How the last snapshot went, kept where the board can read it. A nightly | |
| 3 | * that failed used to be one line in the log and nothing else: two nights | |
| 4 | * of no off-box backup went by unseen (2026-10-17, #2536). Each attempt's | |
| 5 | * outcome is one small file under the data directory | |
| 6 | * (`snapshot/state.json`), written when it ends, so vitals and Backups read | |
| 7 | * it without the network and a restart forgets nothing. A failure is a | |
| 8 | * card from the moment it is written until a snapshot lands. | |
| 9 | */ | |
| 10 | ||
| 11 | import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; | |
| 12 | import { join } from "node:path"; | |
| 13 | ||
| 14 | export interface SnapshotOutcome { | |
| 15 | /** When the last attempt ended. */ | |
| 16 | readonly at: string; | |
| 17 | /** The newest snapshot that landed, and when; absent until one has. */ | |
| 18 | readonly landed?: string; | |
| 19 | readonly landedAt?: string; | |
| 20 | /** Set while attempts are failing: since when, and the last one's reason. */ | |
| 21 | readonly failingSince?: string; | |
| 22 | readonly error?: string; | |
| 23 | } | |
| 24 | ||
| 25 | const stateFile = (dataDir: string): string => join(dataDir, "snapshot", "state.json"); | |
| 26 | ||
| 27 | /** The outcome kept, or undefined when none is or the file is not one. */ | |
| 28 | export function lastSnapshotOutcome(dataDir: string): SnapshotOutcome | undefined { | |
| 29 | try { | |
| 30 | const s = JSON.parse(readFileSync(stateFile(dataDir), "utf8")) as SnapshotOutcome; | |
| 31 | if (typeof s.at !== "string") return undefined; | |
| 32 | return s; | |
| 33 | } catch { | |
| 34 | return undefined; | |
| 35 | } | |
| 36 | } | |
| 37 | ||
| 38 | /** Keep an attempt's end: a name that landed, or the error it failed with. Returns what was kept. */ | |
| 39 | export function recordSnapshotOutcome( | |
| 40 | dataDir: string, | |
| 41 | result: { readonly landed: string } | { readonly error: string }, | |
| 42 | at: string = new Date().toISOString(), | |
| 43 | ): SnapshotOutcome { | |
| 44 | const prev = lastSnapshotOutcome(dataDir); | |
| 45 | const kept = { | |
| 46 | ...(prev?.landed === undefined ? {} : { landed: prev.landed }), | |
| 47 | ...(prev?.landedAt === undefined ? {} : { landedAt: prev.landedAt }), | |
| 48 | }; | |
| 49 | const next: SnapshotOutcome = | |
| 50 | "landed" in result | |
| 51 | ? { at, landed: result.landed, landedAt: at } | |
| 52 | : { at, ...kept, failingSince: prev?.failingSince ?? at, error: result.error }; | |
| 53 | mkdirSync(join(dataDir, "snapshot"), { recursive: true }); | |
| 54 | const tmp = `${stateFile(dataDir)}.tmp`; | |
| 55 | writeFileSync(tmp, JSON.stringify(next)); | |
| 56 | renameSync(tmp, stateFile(dataDir)); | |
| 57 | return next; | |
| 58 | } | |
| 59 | ||
| 60 | /** The card a failing snapshot is, or undefined while the last one landed. */ | |
| 61 | export function snapshotNeed( | |
| 62 | s: SnapshotOutcome | undefined, | |
| 63 | ): { sentence: string; since: string } | undefined { | |
| 64 | if (s?.failingSince === undefined) return undefined; | |
| 65 | const last = s.landedAt ? ` The newest that landed is from ${s.landedAt.slice(0, 10)}.` : ""; | |
| 66 | return { | |
| 67 | sentence: `The last snapshot failed: ${(s.error ?? "unknown reason").replace(/\.$/, "")}.${last} The forge tries again within the hour.`, | |
| 68 | since: s.failingSince, | |
| 69 | }; | |
| 70 | } |
src/snapshot/parts.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | /** | |
| 2 | * A large file packed for a slow uplink: zstd over the whole of it, then | |
| 3 | * cut into parts of `PART_BYTES`, each its own object. Each part is one put | |
| 4 | * with its own deadline and its own retries, so a link that drops halfway | |
| 5 | * loses one part's progress, never the file's; and the mirror, which copies | |
| 6 | * object by object, moves parts the same way. | |
| 7 | * | |
| 8 | * The parts of `forge.db` are `forge.db.zst.0000`, `forge.db.zst.0001`, …; | |
| 9 | * the manifest lists them as files, like any other, and says once in | |
| 10 | * `packed` what they make together and how big that is. A restore reads | |
| 11 | * the parts back in order, joins them, and unpacks to exactly the bytes | |
| 12 | * that went in, or refuses. | |
| 13 | * | |
| 14 | * Plain data in, plain data out: no store, no files. | |
| 15 | */ | |
| 16 | ||
| 17 | /** A part's size: one put carries it within its own deadline at the floor rate (27 s at 5 Mbit/s). */ | |
| 18 | export const PART_BYTES = 16 * 1_048_576; | |
| 19 | ||
| 20 | /** What the manifest says of a file that went up in parts. */ | |
| 21 | export interface PackedFile { | |
| 22 | /** The file's own path, as a restore writes it back. */ | |
| 23 | readonly path: string; | |
| 24 | /** Its size as it was, before compression. */ | |
| 25 | readonly bytes: number; | |
| 26 | /** Its size compressed: the parts' sizes added. */ | |
| 27 | readonly packedBytes: number; | |
| 28 | readonly parts: number; | |
| 29 | } | |
| 30 | ||
| 31 | /** The key of part `n` of `path`, under a snapshot's prefix. */ | |
| 32 | export const partPath = (path: string, n: number): string => | |
| 33 | `${path}.zst.${String(n).padStart(4, "0")}`; | |
| 34 | ||
| 35 | /** Every part path of a packed file, in order. */ | |
| 36 | export const partPaths = (p: PackedFile): string[] => | |
| 37 | Array.from({ length: p.parts }, (_, n) => partPath(p.path, n)); | |
| 38 | ||
| 39 | /** A path a restore may write under its data directory: relative, and never climbing out. */ | |
| 40 | export const safePath = (path: string): boolean => | |
| 41 | path !== "" && | |
| 42 | !path.startsWith("/") && | |
| 43 | !path.includes("\\") && | |
| 44 | !path.split("/").some((s) => s === "" || s === "." || s === ".."); | |
| 45 | ||
| 46 | /** | |
| 47 | * `body` compressed and cut in parts. The parts are views of one buffer, | |
| 48 | * not copies; an empty body is one empty-content part, so every packed file | |
| 49 | * has at least one. | |
| 50 | */ | |
| 51 | export async function pack( | |
| 52 | path: string, | |
| 53 | body: Uint8Array, | |
| 54 | partBytes = PART_BYTES, | |
| 55 | ): Promise<{ packed: PackedFile; parts: Uint8Array[] }> { | |
| 56 | if (!safePath(path)) throw new Error(`refusing to pack ${path}`); | |
| 57 | if (!(partBytes > 0)) throw new Error(`a part of ${partBytes} bytes`); | |
| 58 | const zst = new Uint8Array(await Bun.zstdCompress(body)); | |
| 59 | const parts: Uint8Array[] = []; | |
| 60 | for (let at = 0; at < zst.byteLength || parts.length === 0; at += partBytes) | |
| 61 | parts.push(zst.subarray(at, at + partBytes)); | |
| 62 | return { | |
| 63 | packed: { | |
| 64 | path, | |
| 65 | bytes: body.byteLength, | |
| 66 | packedBytes: zst.byteLength, | |
| 67 | parts: parts.length, | |
| 68 | }, | |
| 69 | parts, | |
| 70 | }; | |
| 71 | } | |
| 72 | ||
| 73 | /** The file the parts make, checked against what the manifest says it was. */ | |
| 74 | export async function unpack( | |
| 75 | p: PackedFile, | |
| 76 | parts: readonly Uint8Array[], | |
| 77 | ): Promise<Uint8Array> { | |
| 78 | if (parts.length !== p.parts) | |
| 79 | throw new Error(`${p.path} came back in ${parts.length} parts, not ${p.parts}`); | |
| 80 | const total = parts.reduce((n, b) => n + b.byteLength, 0); | |
| 81 | if (total !== p.packedBytes) | |
| 82 | throw new Error(`${p.path}'s parts are ${total} bytes, not ${p.packedBytes}`); | |
| 83 | const joined = new Uint8Array(total); | |
| 84 | let at = 0; | |
| 85 | for (const b of parts) { | |
| 86 | joined.set(b, at); | |
| 87 | at += b.byteLength; | |
| 88 | } | |
| 89 | const body = new Uint8Array(await Bun.zstdDecompress(joined)); | |
| 90 | if (body.byteLength !== p.bytes) | |
| 91 | throw new Error(`${p.path} unpacks to ${body.byteLength} bytes, not ${p.bytes}`); | |
| 92 | return body; | |
| 93 | } | |
| 94 | ||
| 95 | /** Whether a manifest's `packed` entry reads whole: the restore trusts nothing else. */ | |
| 96 | export function isPackedFile(v: unknown): v is PackedFile { | |
| 97 | if (typeof v !== "object" || v === null) return false; | |
| 98 | const p = v as Record<string, unknown>; | |
| 99 | const count = (n: unknown): boolean => Number.isSafeInteger(n) && (n as number) >= 0; | |
| 100 | return ( | |
| 101 | typeof p["path"] === "string" && | |
| 102 | safePath(p["path"]) && | |
| 103 | count(p["bytes"]) && | |
| 104 | count(p["packedBytes"]) && | |
| 105 | count(p["parts"]) && | |
| 106 | (p["parts"] as number) >= 1 | |
| 107 | ); | |
| 108 | } |
src/snapshot/restore.ts +142 −28
| @@ -16,6 +16,10 @@ | ||
| 16 | 16 | * with the same `S3_*` variables the forge runs under, or |
| 17 | 17 | * `FORGE_SNAPSHOT_DIR` for a directory store. |
| 18 | 18 | * |
| 19 | * A file that went up compressed in parts (the database, `parts.ts`) | |
| 20 | * comes back whole: its parts read in order, joined, unpacked, and checked | |
| 21 | * against the size the manifest gives it. | |
| 22 | * | |
| 19 | 23 | * The key comes back only from its wrapped form and the operator's |
| 20 | 24 | * passphrase (`FORGE_SNAPSHOT_PASSPHRASE`). A wrong passphrase refuses the |
| 21 | 25 | * restore; none leaves the key out, and every sealed value that will not |
| @@ -28,6 +32,8 @@ import { runGit } from "../git/env.ts"; | ||
| 28 | 32 | import { installHooks } from "../git/hooks.ts"; |
| 29 | 33 | import { repoDir } from "../git/repos.ts"; |
| 30 | 34 | import { |
| 35 | IN_FLIGHT, | |
| 36 | IN_FLIGHT_BYTES, | |
| 31 | 37 | isRunBundle, |
| 32 | 38 | keyOf, |
| 33 | 39 | listSnapshots, |
| @@ -36,6 +42,7 @@ import { | ||
| 36 | 42 | type Manifest, |
| 37 | 43 | } from "./snapshot.ts"; |
| 38 | 44 | import { unwrapKey, WRAPPED_KEY } from "./wrap.ts"; |
| 45 | import { isPackedFile, partPaths, unpack, type PackedFile } from "./parts.ts"; | |
| 39 | 46 | import { resolveDestination } from "./destination.ts"; |
| 40 | 47 | import type { Store } from "./store.ts"; |
| 41 | 48 | |
| @@ -49,6 +56,9 @@ export interface Restored { | ||
| 49 | 56 | readonly sealedMissing: readonly string[]; |
| 50 | 57 | } |
| 51 | 58 | |
| 59 | /** Repositories cloned at once while restoring. */ | |
| 60 | export const CLONES = 4; | |
| 61 | ||
| 52 | 62 | /** Names of every sealed value in a restored database; never the values. */ |
| 53 | 63 | async function sealedNames(dbFile: string): Promise<string[]> { |
| 54 | 64 | const { Database } = await import("bun:sqlite"); |
| @@ -74,11 +84,67 @@ async function sealedNames(dbFile: string): Promise<string[]> { | ||
| 74 | 84 | } |
| 75 | 85 | } |
| 76 | 86 | |
| 87 | /** | |
| 88 | * Every file the manifest lists, read with several gets in flight at once | |
| 89 | * and checked against its size, each handed to `each` as it lands. Read | |
| 90 | * one after another, the restore drill of a snapshot with thousands of | |
| 91 | * objects ran out of its ten minutes in the restore step (2026-10-18), as | |
| 92 | * the upload once ran an hour on latency alone; the same limits bound both. | |
| 93 | */ | |
| 94 | async function fetchAll( | |
| 95 | store: Store, | |
| 96 | prefix: string, | |
| 97 | entries: readonly { readonly path: string; readonly bytes: number }[], | |
| 98 | each: (entry: { readonly path: string; readonly bytes: number }, body: Uint8Array, at: number) => Promise<void>, | |
| 99 | name: string, | |
| 100 | maxBytes: number, | |
| 101 | ): Promise<void> { | |
| 102 | const pending = new Set<Promise<void>>(); | |
| 103 | let failure: unknown; | |
| 104 | let held = 0; | |
| 105 | for (const [at, entry] of entries.entries()) { | |
| 106 | while ( | |
| 107 | (pending.size >= IN_FLIGHT || | |
| 108 | (pending.size > 0 && held + entry.bytes > maxBytes)) && | |
| 109 | failure === undefined | |
| 110 | ) | |
| 111 | await Promise.race(pending); | |
| 112 | if (failure !== undefined) break; | |
| 113 | held += entry.bytes; | |
| 114 | const run: Promise<void> = (async () => { | |
| 115 | const body = await store.get(prefix + entry.path); | |
| 116 | if (body === undefined) | |
| 117 | throw new Error(`snapshot ${name} is missing ${entry.path}`); | |
| 118 | if (body.byteLength !== entry.bytes) | |
| 119 | throw new Error( | |
| 120 | `snapshot ${name}: ${entry.path} is ${body.byteLength} bytes, not ${entry.bytes}`, | |
| 121 | ); | |
| 122 | await each(entry, body, at); | |
| 123 | })() | |
| 124 | .catch((error: unknown) => { | |
| 125 | failure ??= error; | |
| 126 | }) | |
| 127 | .finally(() => { | |
| 128 | held -= entry.bytes; | |
| 129 | pending.delete(run); | |
| 130 | }); | |
| 131 | pending.add(run); | |
| 132 | } | |
| 133 | await Promise.all([...pending]); | |
| 134 | if (failure !== undefined) throw failure; | |
| 135 | } | |
| 136 | ||
| 77 | 137 | export async function restoreSnapshot( |
| 78 | 138 | store: Store, |
| 79 | 139 | name: string, |
| 80 | 140 | dataDir: string, |
| 81 | options: { readonly passphrase?: string | undefined } = {}, | |
| 141 | options: { | |
| 142 | readonly passphrase?: string | undefined; | |
| 143 | /** Bytes held by reads in flight; `IN_FLIGHT_BYTES` unless a test shrinks it. */ | |
| 144 | readonly inFlightBytes?: number | undefined; | |
| 145 | /** Test seam: the git runner the clones go through. */ | |
| 146 | readonly git?: typeof runGit | undefined; | |
| 147 | } = {}, | |
| 82 | 148 | ): Promise<Restored> { |
| 83 | 149 | if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}-[0-9]{2}-[0-9]{2}Z$/.test(name)) |
| 84 | 150 | throw new Error(`not a snapshot name: ${name}`); |
| @@ -104,20 +170,29 @@ export async function restoreSnapshot( | ||
| 104 | 170 | "the passphrase does not open this snapshot's wrapped key; check FORGE_SNAPSHOT_PASSPHRASE, or restore without it to leave sealed values behind", |
| 105 | 171 | ); |
| 106 | 172 | } |
| 173 | const packed: readonly unknown[] = manifest.packed ?? []; | |
| 174 | if (!packed.every(isPackedFile)) | |
| 175 | throw new Error(`snapshot ${name}: a packed file in its manifest does not read whole`); | |
| 176 | const partOf = new Map<string, { file: PackedFile; n: number }>(); | |
| 177 | for (const file of packed) | |
| 178 | for (const [n, path] of partPaths(file).entries()) | |
| 179 | partOf.set(path, { file, n }); | |
| 180 | const gathered = new Map<string, Uint8Array[]>(); | |
| 107 | 181 | mkdirSync(dataDir, { recursive: true }); |
| 108 | 182 | |
| 109 | 183 | let files = 0; |
| 110 | 184 | let repos = 0; |
| 111 | const bundles: { owner: string; name: string; file: string }[] = []; | |
| 112 | for (const entry of manifest.files) { | |
| 113 | const body = await store.get(prefix + entry.path); | |
| 114 | if (body === undefined) | |
| 115 | throw new Error(`snapshot ${name} is missing ${entry.path}`); | |
| 116 | if (body.byteLength !== entry.bytes) | |
| 117 | throw new Error( | |
| 118 | `snapshot ${name}: ${entry.path} is ${body.byteLength} bytes, not ${entry.bytes}`, | |
| 119 | ); | |
| 120 | if (entry.path === WRAPPED_KEY) continue; | |
| 185 | const bundles: { at: number; owner: string; name: string; file: string }[] = []; | |
| 186 | await fetchAll(store, prefix, manifest.files, async (entry, body, at) => { | |
| 187 | if (entry.path === WRAPPED_KEY) return; | |
| 188 | // A part waits for the rest of its file. | |
| 189 | const part = partOf.get(entry.path); | |
| 190 | if (part !== undefined) { | |
| 191 | const got = gathered.get(part.file.path) ?? []; | |
| 192 | got[part.n] = body; | |
| 193 | gathered.set(part.file.path, got); | |
| 194 | return; | |
| 195 | } | |
| 121 | 196 | // A day's run logs in one object come back as the logs they were. |
| 122 | 197 | if (isRunBundle(entry.path)) { |
| 123 | 198 | for (const run of unpackRuns(body)) { |
| @@ -126,7 +201,7 @@ export async function restoreSnapshot( | ||
| 126 | 201 | await Bun.write(out, run.body); |
| 127 | 202 | files++; |
| 128 | 203 | } |
| 129 | continue; | |
| 204 | return; | |
| 130 | 205 | } |
| 131 | 206 | const bundle = entry.path.match(/^repos\/([^/]+)\/([^/]+)\.bundle$/); |
| 132 | 207 | // A bundle is not a file of the forge's; it becomes a repository below. |
| @@ -141,23 +216,62 @@ export async function restoreSnapshot( | ||
| 141 | 216 | chmodSync(target, 0o600); |
| 142 | 217 | files++; |
| 143 | 218 | if (bundle) |
| 144 | bundles.push({ owner: bundle[1]!, name: bundle[2]!, file: target }); | |
| 145 | } | |
| 146 | for (const b of bundles) { | |
| 147 | const dir = repoDir(join(dataDir, "repos"), b.owner, b.name); | |
| 148 | mkdirSync(dirname(dir), { recursive: true }); | |
| 149 | const cloned = await runGit(["clone", "--quiet", "--mirror", b.file, dir]); | |
| 150 | if (!cloned.ok) | |
| 151 | throw new Error( | |
| 152 | `could not restore ${b.owner}/${b.name}: ${cloned.error.stderr}`, | |
| 153 | ); | |
| 154 | // A mirror clone remembers the bundle as its origin; the forge's | |
| 155 | // repositories have no origin of their own. | |
| 156 | await runGit(["remote", "remove", "origin"], { cwd: dir }); | |
| 157 | await runGit(["config", "core.logAllRefUpdates", "true"], { cwd: dir }); | |
| 158 | await installHooks(dir); | |
| 159 | repos++; | |
| 219 | bundles.push({ at, owner: bundle[1]!, name: bundle[2]!, file: target }); | |
| 220 | }, name, options.inFlightBytes ?? IN_FLIGHT_BYTES); | |
| 221 | // Reads land in any order; repositories come back in the manifest's. | |
| 222 | bundles.sort((a, b) => a.at - b.at); | |
| 223 | for (const file of packed) { | |
| 224 | const got = gathered.get(file.path) ?? []; | |
| 225 | const parts = partPaths(file).map((path, n) => { | |
| 226 | const body = got[n]; | |
| 227 | if (body === undefined) | |
| 228 | throw new Error(`snapshot ${name} is missing ${path}`); | |
| 229 | return body; | |
| 230 | }); | |
| 231 | gathered.delete(file.path); | |
| 232 | const target = join(dataDir, file.path); | |
| 233 | mkdirSync(dirname(target), { recursive: true }); | |
| 234 | await Bun.write(target, await unpack(file, parts)); | |
| 235 | files++; | |
| 160 | 236 | } |
| 237 | // Each repository is its own clone, so several at once: a forge of | |
| 238 | // hundreds of them spent the drill's minutes here, one after another. | |
| 239 | const git = options.git ?? runGit; | |
| 240 | let next = 0; | |
| 241 | // Clones finish in any order; a refusal names the earliest bundle in the | |
| 242 | // manifest that failed, so it reads the same however the clones raced. | |
| 243 | const failed: { at: number; error: string }[] = []; | |
| 244 | await Promise.all( | |
| 245 | Array.from({ length: Math.min(CLONES, bundles.length) }, async () => { | |
| 246 | for (let b = bundles[next++]; b !== undefined; b = bundles[next++]) { | |
| 247 | const dir = repoDir(join(dataDir, "repos"), b.owner, b.name); | |
| 248 | mkdirSync(dirname(dir), { recursive: true }); | |
| 249 | const cloned = await git([ | |
| 250 | "clone", | |
| 251 | "--quiet", | |
| 252 | "--mirror", | |
| 253 | b.file, | |
| 254 | dir, | |
| 255 | ]); | |
| 256 | if (!cloned.ok) { | |
| 257 | next = bundles.length; | |
| 258 | failed.push({ | |
| 259 | at: b.at, | |
| 260 | error: `could not restore ${b.owner}/${b.name}: ${cloned.error.stderr}`, | |
| 261 | }); | |
| 262 | return; | |
| 263 | } | |
| 264 | // A mirror clone remembers the bundle as its origin; the forge's | |
| 265 | // repositories have no origin of their own. | |
| 266 | await git(["remote", "remove", "origin"], { cwd: dir }); | |
| 267 | await git(["config", "core.logAllRefUpdates", "true"], { cwd: dir }); | |
| 268 | await installHooks(dir); | |
| 269 | repos++; | |
| 270 | } | |
| 271 | }), | |
| 272 | ); | |
| 273 | if (failed.length > 0) | |
| 274 | throw new Error(failed.sort((a, b) => a.at - b.at)[0]!.error); | |
| 161 | 275 | const { rmSync } = await import("node:fs"); |
| 162 | 276 | rmSync(join(dataDir, "restore-bundles"), { recursive: true, force: true }); |
| 163 | 277 | if (key !== undefined) { |
src/snapshot/service.ts +81 −21
| @@ -5,7 +5,9 @@ | ||
| 5 | 5 | * snapshots and says so on its settings page, rather than pretending. |
| 6 | 6 | * |
| 7 | 7 | * Runs are serialized: a nightly that meets an on-demand one waits its |
| 8 | * turn, and a forge stopping waits for the one in flight. | |
| 8 | * turn, and a forge stopping waits for the one in flight. A run that fails | |
| 9 | * is kept (`outcome.ts`) and shows on the board at once, and the next try | |
| 10 | * is within the hour, not the next night. | |
| 9 | 11 | */ |
| 10 | 12 | |
| 11 | 13 | import type { App } from "../app.ts"; |
| @@ -20,15 +22,29 @@ import { | ||
| 20 | 22 | type Progress, |
| 21 | 23 | } from "./snapshot.ts"; |
| 22 | 24 | import { runDrill, type DrillDeps, type DrillResult } from "./drill.ts"; |
| 23 | import { drillDue } from "./due.ts"; | |
| 25 | import { BOOT_RED_FLOOR_MS, drillDue, RED_RETRY_MS } from "./due.ts"; | |
| 24 | 26 | import { lastDrill, recordDrill } from "./drills.ts"; |
| 25 | 27 | import { lastMirror, mirrorPass, MIRROR_EVERY_MS, recordMirror, type MirrorState } from "./mirror.ts"; |
| 28 | import { recordSnapshotOutcome } from "./outcome.ts"; | |
| 26 | 29 | import type { Store } from "./store.ts"; |
| 27 | 30 | |
| 28 | 31 | const log = createLog("snapshots"); |
| 29 | 32 | |
| 30 | 33 | /** When the nightly snapshot is taken, in UTC: before the probe round. */ |
| 31 | 34 | const NIGHTLY_HOUR_UTC = 8; |
| 35 | /** After a failed snapshot, the next try is this soon (or the nightly, if sooner). */ | |
| 36 | export const RETRY_MS = 3_600_000; | |
| 37 | ||
| 38 | /** | |
| 39 | * A forge given nowhere to put a snapshot. Not a failure: the Backups page | |
| 40 | * says so calmly, so it is never kept as an outcome, never a card, and | |
| 41 | * never retried sooner than the nightly. | |
| 42 | */ | |
| 43 | class Nowhere extends Error { | |
| 44 | constructor() { | |
| 45 | super("this forge has nowhere to put a snapshot"); | |
| 46 | } | |
| 47 | } | |
| 32 | 48 | |
| 33 | 49 | export interface SnapshotsHandle { |
| 34 | 50 | /** The store in effect now; undefined when the forge has been given none. */ |
| @@ -45,7 +61,7 @@ export interface SnapshotsHandle { | ||
| 45 | 61 | readonly last: () => { manifest?: Manifest; error?: string; at?: string }; |
| 46 | 62 | /** Drill the newest snapshot now and keep the result; waits for any snapshot in flight. */ |
| 47 | 63 | readonly drill: () => Promise<DrillResult>; |
| 48 | /** Drill if this week's slot has no drill yet; says whether it did. */ | |
| 64 | /** Drill if this week's slot has no drill yet, or the last one has stood red long enough; says whether it did. */ | |
| 49 | 65 | readonly drillIfDue: () => Promise<boolean>; |
| 50 | 66 | /** The off-box mirror's store; undefined when the forge has none. */ |
| 51 | 67 | readonly mirror: Store | undefined; |
| @@ -94,9 +110,15 @@ export function startSnapshots( | ||
| 94 | 110 | /** Test seam: how often the mirror is looked at. */ |
| 95 | 111 | readonly mirrorEveryMs?: number | undefined; |
| 96 | 112 | readonly nightly?: boolean; |
| 113 | /** Test seam: how soon a failed snapshot is tried again. */ | |
| 114 | readonly retryMs?: number | undefined; | |
| 97 | 115 | readonly passphrase?: string | undefined; |
| 98 | 116 | /** Test seam: the drill itself. */ |
| 99 | 117 | readonly drill?: ((deps: DrillDeps) => Promise<DrillResult>) | undefined; |
| 118 | /** Test seams: how soon after boot an owed drill runs, and when a red one is owed again. */ | |
| 119 | readonly catchUpMs?: number | undefined; | |
| 120 | readonly redRetryMs?: number | undefined; | |
| 121 | readonly bootRedFloorMs?: number | undefined; | |
| 100 | 122 | } = { |
| 101 | 123 | version: "0", |
| 102 | 124 | }, |
| @@ -122,12 +144,13 @@ export function startSnapshots( | ||
| 122 | 144 | let last: { manifest?: Manifest; error?: string; at?: string } = {}; |
| 123 | 145 | |
| 124 | 146 | const take = (): Promise<Manifest> => { |
| 125 | const run = (inFlight ?? Promise.resolve()).then(async () => { | |
| 147 | // After the one before it, however that ended: chained on a failure as it | |
| 148 | // was, every later run failed at once with the old error until a restart. | |
| 149 | const run = (inFlight ?? Promise.resolve()).catch(() => {}).then(async () => { | |
| 126 | 150 | // Resolved per run, so a destination saved on the Backups page is the |
| 127 | 151 | // very next snapshot's. |
| 128 | 152 | const store = current(); |
| 129 | if (store === undefined) | |
| 130 | throw new Error("this forge has nowhere to put a snapshot"); | |
| 153 | if (store === undefined) throw new Nowhere(); | |
| 131 | 154 | running = { objects: 0, bytes: 0, elapsedMs: 0 }; |
| 132 | 155 | const manifest = await takeSnapshot( |
| 133 | 156 | { |
| @@ -153,11 +176,19 @@ export function startSnapshots( | ||
| 153 | 176 | inFlight = run.then( |
| 154 | 177 | (m) => { |
| 155 | 178 | last = { manifest: m, at: m.finishedAt }; |
| 179 | keep({ landed: m.name }); | |
| 180 | arm(untilNightly()); | |
| 156 | 181 | return m; |
| 157 | 182 | }, |
| 158 | 183 | (error) => { |
| 159 | 184 | last = { error: String(error), at: new Date().toISOString() }; |
| 185 | if (error instanceof Nowhere) { | |
| 186 | arm(untilNightly()); | |
| 187 | throw error; | |
| 188 | } | |
| 160 | 189 | log.error("snapshot failed", { error: String(error) }); |
| 190 | keep({ error: error instanceof Error ? error.message : String(error) }); | |
| 191 | arm(Math.min(options.retryMs ?? RETRY_MS, untilNightly())); | |
| 161 | 192 | throw error; |
| 162 | 193 | }, |
| 163 | 194 | ); |
| @@ -217,20 +248,30 @@ export function startSnapshots( | ||
| 217 | 248 | return result; |
| 218 | 249 | })().finally(() => { |
| 219 | 250 | drilling = undefined; |
| 251 | // However it was asked for, a red drill is tried again on its own. | |
| 252 | armRedRetry(); | |
| 220 | 253 | }); |
| 221 | 254 | return drilling; |
| 222 | 255 | }; |
| 223 | 256 | |
| 224 | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 225 | const schedule = (): void => { | |
| 226 | if (options.nightly === false || stopped) return; | |
| 227 | timer = setTimeout(() => { | |
| 228 | take() | |
| 229 | .catch(() => {}) | |
| 230 | .finally(schedule); | |
| 231 | }, untilNightly()); | |
| 257 | const keep = (result: { landed: string } | { error: string }): void => { | |
| 258 | try { | |
| 259 | recordSnapshotOutcome(app.config.dataDir, result); | |
| 260 | } catch (error) { | |
| 261 | log.warn("snapshot outcome not kept; the board will not show it", { error: String(error) }); | |
| 262 | } | |
| 232 | 263 | }; |
| 233 | schedule(); | |
| 264 | ||
| 265 | // One timer for the next snapshot: the nightly, or sooner after a failure. | |
| 266 | // Every run that ends sets it again, whoever asked for the run. | |
| 267 | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 268 | function arm(ms: number): void { | |
| 269 | if (timer !== undefined) clearTimeout(timer); | |
| 270 | timer = undefined; | |
| 271 | if (options.nightly === false || stopped) return; | |
| 272 | timer = setTimeout(() => void take().catch(() => {}), ms); | |
| 273 | } | |
| 274 | arm(untilNightly()); | |
| 234 | 275 | |
| 235 | 276 | // The board only reads; the time the forge first owed a drill is kept here. |
| 236 | 277 | try { |
| @@ -241,22 +282,40 @@ export function startSnapshots( | ||
| 241 | 282 | |
| 242 | 283 | let catchUp: ReturnType<typeof setTimeout> | undefined; |
| 243 | 284 | let drillTimer: ReturnType<typeof setTimeout> | undefined; |
| 285 | let redRetry: ReturnType<typeof setTimeout> | undefined; | |
| 286 | const catchUpMs = options.catchUpMs ?? CATCH_UP_MS; | |
| 287 | const redRetryMs = options.redRetryMs ?? RED_RETRY_MS; | |
| 244 | 288 | const scheduleDrill = (): void => { |
| 245 | 289 | if (options.nightly === false || stopped) return; |
| 246 | 290 | drillTimer = setTimeout(() => { |
| 247 | 291 | runIfDue().finally(scheduleDrill); |
| 248 | 292 | }, untilDrill()); |
| 249 | 293 | }; |
| 250 | // Owed and not yet kept: run it once. A restart after the drill ran finds it kept. | |
| 251 | const runIfDue = async (): Promise<boolean> => { | |
| 252 | if (!drillDue(lastDrill(app.db)?.at, firstSeen(app.config.dataDir), Date.now())) return false; | |
| 294 | // While the newest drill is red, look again when it is owed again. | |
| 295 | const armRedRetry = (): void => { | |
| 296 | if (redRetry !== undefined) clearTimeout(redRetry); | |
| 297 | redRetry = undefined; | |
| 298 | if (options.nightly === false || stopped) return; | |
| 299 | const newest = lastDrill(app.db); | |
| 300 | if (newest?.verdict !== "red") return; | |
| 301 | const wait = Math.max(catchUpMs, Date.parse(newest.at) + redRetryMs - Date.now()); | |
| 302 | redRetry = setTimeout(() => void runIfDue().finally(armRedRetry), wait); | |
| 303 | }; | |
| 304 | // Owed and not yet kept: run it once. A restart after a green drill finds it | |
| 305 | // kept; one after a red drill runs it again, once it is past the boot floor. | |
| 306 | const runIfDue = async (redAfterMs: number = redRetryMs): Promise<boolean> => { | |
| 307 | if (!drillDue(lastDrill(app.db), firstSeen(app.config.dataDir), Date.now(), redAfterMs)) return false; | |
| 253 | 308 | await drill().catch((error) => log.error("restore drill failed to run", { error: String(error) })); |
| 254 | 309 | return true; |
| 255 | 310 | }; |
| 256 | 311 | scheduleDrill(); |
| 257 | // A forge that was down at the slot catches up shortly after boot. | |
| 312 | // A forge that was down at the slot, or whose last drill was red, drills | |
| 313 | // shortly after boot; a red one is looked at again until one passes. | |
| 258 | 314 | if (options.nightly !== false) { |
| 259 | catchUp = setTimeout(() => void runIfDue(), CATCH_UP_MS); | |
| 315 | catchUp = setTimeout( | |
| 316 | () => void runIfDue(options.bootRedFloorMs ?? BOOT_RED_FLOOR_MS).finally(armRedRetry), | |
| 317 | catchUpMs, | |
| 318 | ); | |
| 260 | 319 | } |
| 261 | 320 | |
| 262 | 321 | // Within the hour: look for snapshots the mirror lacks, soon after boot and then hourly. |
| @@ -279,7 +338,7 @@ export function startSnapshots( | ||
| 279 | 338 | wraps: !!options.passphrase, |
| 280 | 339 | now: take, |
| 281 | 340 | drill, |
| 282 | drillIfDue: runIfDue, | |
| 341 | drillIfDue: () => runIfDue(), | |
| 283 | 342 | list: async () => { |
| 284 | 343 | const store = current(); |
| 285 | 344 | return store === undefined ? [] : listSnapshots(store); |
| @@ -301,6 +360,7 @@ export function startSnapshots( | ||
| 301 | 360 | if (timer !== undefined) clearTimeout(timer); |
| 302 | 361 | if (drillTimer !== undefined) clearTimeout(drillTimer); |
| 303 | 362 | if (catchUp !== undefined) clearTimeout(catchUp); |
| 363 | if (redRetry !== undefined) clearTimeout(redRetry); | |
| 304 | 364 | if (mirrorTimer !== undefined) clearTimeout(mirrorTimer); |
| 305 | 365 | if (mirroring !== undefined) await mirroring.catch(() => {}); |
| 306 | 366 | if (inFlight !== undefined) await inFlight.catch(() => {}); |
src/snapshot/snapshot.ts +31 −5
| @@ -12,10 +12,13 @@ | ||
| 12 | 12 | * checks, previews, pictures. |
| 13 | 13 | * |
| 14 | 14 | * A snapshot is named by the moment it began, in UTC, so a listing sorts |
| 15 | * itself. Nothing is compressed or bundled beyond git's own bundles and | |
| 16 | * the crew's older run logs, one compressed bundle per day: a bucket costs | |
| 17 | * a round trip per object, so thousands of tiny logs are one object a day. | |
| 18 | * A restore is reading files back, and a person can read the manifest. | |
| 15 | * itself. Little is compressed or bundled beyond git's own bundles: the | |
| 16 | * crew's older run logs, one compressed bundle per day, because a bucket | |
| 17 | * costs a round trip per object and thousands of tiny logs are one object | |
| 18 | * a day; and the database, compressed and cut in parts (`parts.ts`), | |
| 19 | * because whole it was more than a home uplink carries in one put's | |
| 20 | * deadline. A restore is reading files back, and a person can read the | |
| 21 | * manifest. | |
| 19 | 22 | * |
| 20 | 23 | * Objects go up through a bounded queue, eight in flight; the manifest |
| 21 | 24 | * lists them in the order they were handed over, not the order they landed. |
| @@ -32,6 +35,7 @@ import { runGit } from "../git/env.ts"; | ||
| 32 | 35 | import { repoDir } from "../git/repos.ts"; |
| 33 | 36 | import { createLog } from "../kernel/log.ts"; |
| 34 | 37 | import { countRows, type DrillCounts } from "./counts.ts"; |
| 38 | import { pack, partPath, PART_BYTES, type PackedFile } from "./parts.ts"; | |
| 35 | 39 | import type { Store } from "./store.ts"; |
| 36 | 40 | import { unwrapKey, wrapKey, WRAPPED_KEY } from "./wrap.ts"; |
| 37 | 41 | |
| @@ -63,6 +67,8 @@ export interface Manifest { | ||
| 63 | 67 | * `raw` in snapshots from before the key was wrapped. |
| 64 | 68 | */ |
| 65 | 69 | readonly key?: "wrapped" | "none" | "raw"; |
| 70 | /** Files that went up compressed in parts, each listed in `files` by its parts. Absent before the database was packed. */ | |
| 71 | readonly packed?: readonly PackedFile[]; | |
| 66 | 72 | } |
| 67 | 73 | |
| 68 | 74 | /** What a manifest says of the key, including manifests older than the field. */ |
| @@ -162,6 +168,8 @@ export interface Snapshotter { | ||
| 162 | 168 | readonly onLand?: ((p: Progress) => void) | undefined; |
| 163 | 169 | /** Overrides of the progress cadence, for tests. */ |
| 164 | 170 | readonly progressEvery?: { objects?: number; ms?: number } | undefined; |
| 171 | /** Override of the database's part size, for tests. */ | |
| 172 | readonly partBytes?: number | undefined; | |
| 165 | 173 | } |
| 166 | 174 | |
| 167 | 175 | /** `2026-09-22T09-00-00Z`: sortable, and a name a bucket allows. */ |
| @@ -216,6 +224,7 @@ export async function takeSnapshot( | ||
| 216 | 224 | const prefix = `${SNAPSHOT_PREFIX}${name}/`; |
| 217 | 225 | const scratch = mkdtempSync(join(tmpdir(), "forge-snapshot-")); |
| 218 | 226 | const files: SnapshotFile[] = []; |
| 227 | const packed: PackedFile[] = []; | |
| 219 | 228 | let dbCounts: DrillCounts | undefined; |
| 220 | 229 | const started = Date.now(); |
| 221 | 230 | const everyObjects = forge.progressEvery?.objects ?? PROGRESS_OBJECTS; |
| @@ -305,7 +314,23 @@ export async function takeSnapshot( | ||
| 305 | 314 | } finally { |
| 306 | 315 | copy.close(); |
| 307 | 316 | } |
| 308 | await putFile("forge.db", dbCopy); | |
| 317 | // Compressed and in parts: each part one put, with its own deadline and | |
| 318 | // retries, so a slow uplink carries it and a dropped one costs a part. | |
| 319 | const database = await pack( | |
| 320 | "forge.db", | |
| 321 | new Uint8Array(await Bun.file(dbCopy).arrayBuffer()), | |
| 322 | forge.partBytes ?? PART_BYTES, | |
| 323 | ); | |
| 324 | packed.push(database.packed); | |
| 325 | log.info("database packed", { | |
| 326 | mb: (database.packed.bytes / 1_048_576).toFixed(1), | |
| 327 | packedMb: (database.packed.packedBytes / 1_048_576).toFixed(1), | |
| 328 | ratio: (database.packed.bytes / Math.max(1, database.packed.packedBytes)).toFixed(2), | |
| 329 | parts: database.packed.parts, | |
| 330 | }); | |
| 331 | for (const [n, part] of database.parts.entries()) | |
| 332 | await put(partPath("forge.db", n), part); | |
| 333 | rmSync(dbCopy, { force: true }); | |
| 309 | 334 | // The key travels only wrapped. Without a passphrase it stays home. |
| 310 | 335 | let key: Manifest["key"] = "none"; |
| 311 | 336 | const keyFile = Bun.file(join(forge.dataDir, "secret.key")); |
| @@ -434,6 +459,7 @@ export async function takeSnapshot( | ||
| 434 | 459 | bytes: files.reduce((n, f) => n + f.bytes, 0), |
| 435 | 460 | ...(dbCounts === undefined ? {} : { counts: dbCounts }), |
| 436 | 461 | key, |
| 462 | packed, | |
| 437 | 463 | }; |
| 438 | 464 | await store.put( |
| 439 | 465 | `${prefix}manifest.json`, |
src/snapshot/store.ts +43 −9
| @@ -51,17 +51,40 @@ export function s3FromEnv( | ||
| 51 | 51 | }; |
| 52 | 52 | } |
| 53 | 53 | |
| 54 | /** The slowest uplink a put may assume: 5 Mbit/s, a third of the home line it runs behind. */ | |
| 55 | export const FLOOR_BYTES_PER_S = 5_000_000 / 8; | |
| 56 | /** What every attempt gets on top of its bytes' time: the handshake, the server's thought, a queue ahead of it. */ | |
| 57 | export const SLACK_MS = 60_000; | |
| 58 | /** How long a read, a listing or a delete may go unanswered. */ | |
| 59 | export const CALL_DEADLINE_MS = 180_000; | |
| 60 | ||
| 61 | /** How long a put of `bytes` may take before it counts as failed: its bytes at the floor rate, plus slack. */ | |
| 62 | export const deadlineFor = ( | |
| 63 | bytes: number, | |
| 64 | floorBytesPerS = FLOOR_BYTES_PER_S, | |
| 65 | slackMs = SLACK_MS, | |
| 66 | ): number => slackMs + Math.ceil((bytes / floorBytesPerS) * 1000); | |
| 67 | ||
| 68 | const sizeOf = (body: Uint8Array | string): number => | |
| 69 | typeof body === "string" ? Buffer.byteLength(body) : body.byteLength; | |
| 70 | ||
| 54 | 71 | /** |
| 55 | 72 | * A store that tries again. Object stores answer a burst, a flake or a |
| 56 | 73 | * moment of maintenance with a 5xx — Backblaze says so in its own docs — |
| 57 | 74 | * and one such answer in a thousand puts used to fail the whole snapshot |
| 58 | 75 | * with "S3Error: an unexpected error has occurred" and no key named. Every |
| 59 | 76 | * call is tried `attempts` times with doubling waits, and what finally |
| 60 | * fails says which key it was. An attempt that has not answered within | |
| 61 | * `deadlineMs` counts as failed too: a connection that died quietly never | |
| 77 | * fails says which key it was. An attempt that has not answered within its | |
| 78 | * deadline counts as failed too: a connection that died quietly never | |
| 62 | 79 | * settles on its own, and once held a snapshot on its first object for an |
| 63 | 80 | * hour with the network fine (2026-10-13), so the next attempt opens a |
| 64 | 81 | * fresh request instead. |
| 82 | * | |
| 83 | * A put's deadline follows its size (`deadlineFor`): a fixed 180 s once | |
| 84 | * failed every attempt at a 334 MB database behind a 15 Mbit/s uplink | |
| 85 | * (2026-10-17). `deadlineMs` fixes every deadline instead, for tests; | |
| 86 | * `floorBytesPerS` and `slackMs` scale the size rule, for tests that run a | |
| 87 | * slow link at a thousand times its speed. | |
| 65 | 88 | */ |
| 66 | 89 | export function withRetries( |
| 67 | 90 | inner: Store, |
| @@ -69,14 +92,23 @@ export function withRetries( | ||
| 69 | 92 | attempts?: number; |
| 70 | 93 | baseMs?: number; |
| 71 | 94 | deadlineMs?: number; |
| 95 | floorBytesPerS?: number; | |
| 96 | slackMs?: number; | |
| 72 | 97 | sleep?: (ms: number) => Promise<void>; |
| 73 | 98 | } = {}, |
| 74 | 99 | ): Store { |
| 75 | 100 | const attempts = Math.max(1, options.attempts ?? 5); |
| 76 | 101 | const baseMs = options.baseMs ?? 500; |
| 77 | const deadlineMs = options.deadlineMs ?? 180_000; | |
| 102 | const fixed = options.deadlineMs; | |
| 103 | const callMs = fixed ?? CALL_DEADLINE_MS; | |
| 104 | const putMs = (body: Uint8Array | string): number => | |
| 105 | fixed ?? deadlineFor(sizeOf(body), options.floorBytesPerS, options.slackMs); | |
| 78 | 106 | const sleep = options.sleep ?? ((ms) => Bun.sleep(ms)); |
| 79 | async function tried<T>(what: string, fn: () => Promise<T>): Promise<T> { | |
| 107 | async function tried<T>( | |
| 108 | what: string, | |
| 109 | deadlineMs: number, | |
| 110 | fn: () => Promise<T>, | |
| 111 | ): Promise<T> { | |
| 80 | 112 | let last: unknown; |
| 81 | 113 | for (let n = 0; n < attempts; n++) { |
| 82 | 114 | try { |
| @@ -93,11 +125,13 @@ export function withRetries( | ||
| 93 | 125 | return { |
| 94 | 126 | where: inner.where, |
| 95 | 127 | ...(inner.dir === undefined ? {} : { dir: inner.dir }), |
| 96 | put: (key, body) => tried(`put ${key}`, () => inner.put(key, body)), | |
| 97 | get: (key) => tried(`get ${key}`, () => inner.get(key)), | |
| 98 | list: (prefix) => tried(`list ${prefix}`, () => inner.list(prefix)), | |
| 99 | names: (prefix) => tried(`names ${prefix}`, () => inner.names(prefix)), | |
| 100 | delete: (key) => tried(`delete ${key}`, () => inner.delete(key)), | |
| 128 | put: (key, body) => | |
| 129 | tried(`put ${key}`, putMs(body), () => inner.put(key, body)), | |
| 130 | get: (key) => tried(`get ${key}`, callMs, () => inner.get(key)), | |
| 131 | list: (prefix) => tried(`list ${prefix}`, callMs, () => inner.list(prefix)), | |
| 132 | names: (prefix) => | |
| 133 | tried(`names ${prefix}`, callMs, () => inner.names(prefix)), | |
| 134 | delete: (key) => tried(`delete ${key}`, callMs, () => inner.delete(key)), | |
| 101 | 135 | }; |
| 102 | 136 | } |
| 103 | 137 |
src/tui/app/app.ts +3 −2
| @@ -63,6 +63,7 @@ import { | ||
| 63 | 63 | type CursorPos, |
| 64 | 64 | } from "../ui/widgets.ts"; |
| 65 | 65 | import { binding, find, keyFor, type Binding } from "./keys.ts"; |
| 66 | import { message } from "../screens/common.ts"; | |
| 66 | 67 | import { Palette, type Command, type PaletteSearch } from "./palette.ts"; |
| 67 | 68 | import { truncate } from "../term/width.ts"; |
| 68 | 69 | |
| @@ -470,7 +471,7 @@ export class App { | ||
| 470 | 471 | // Every failure becomes a toast; nothing throws into the loop, and no |
| 471 | 472 | // single bad read may drop the session. |
| 472 | 473 | if (!entry.abort.signal.aborted) |
| 473 | this.toast(e instanceof Error ? e.message : String(e), "danger"); | |
| 474 | this.toast(message(e), "danger"); | |
| 474 | 475 | }) |
| 475 | 476 | .finally(() => { |
| 476 | 477 | entry.loading = false; |
| @@ -876,7 +877,7 @@ export class App { | ||
| 876 | 877 | private run(command: Command): void { |
| 877 | 878 | this.overlay = "none"; |
| 878 | 879 | void Promise.resolve(command.run()).catch((err: unknown) => |
| 879 | this.toast(err instanceof Error ? err.message : String(err), "danger"), | |
| 880 | this.toast(message(err), "danger"), | |
| 880 | 881 | ); |
| 881 | 882 | } |
| 882 | 883 |
src/tui/screens/about.ts +13
| @@ -15,6 +15,7 @@ | ||
| 15 | 15 | * card is an OSC 8 hyperlink besides. |
| 16 | 16 | */ |
| 17 | 17 | |
| 18 | import { audienceFor } from "../../core/audience.ts"; | |
| 18 | 19 | import { isPinned } from "../../core/pins.ts"; |
| 19 | 20 | import { parentReleases, releasesSentence } from "../../core/parent_releases.ts"; |
| 20 | 21 | import { running } from "../../kernel/version.ts"; |
| @@ -128,6 +129,18 @@ export function aboutScreen( | ||
| 128 | 129 | style(description === "" ? c.faint : c.fg, c.card), |
| 129 | 130 | edge, |
| 130 | 131 | ); |
| 132 | // Who can see it, in the web page's words (#809). | |
| 133 | if (repo !== undefined) | |
| 134 | s.text( | |
| 135 | lead, | |
| 136 | inner.y + 2, | |
| 137 | truncate( | |
| 138 | audienceFor(f.db, repo, f.user).sentence, | |
| 139 | Math.max(0, edge - lead), | |
| 140 | ), | |
| 141 | style(c.mutedFg, c.card), | |
| 142 | edge, | |
| 143 | ); | |
| 131 | 144 | drawFacts( |
| 132 | 145 | s, |
| 133 | 146 | { |
src/tui/screens/common.ts +3
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * of them. Nothing here knows about repos or issues — that is the point. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { refusalText } from "../../http/refusal.ts"; | |
| 10 | 11 | import { NOT_A_MEMBER } from "../../core/roles.ts"; |
| 11 | 12 | import type { Ctx, Screen } from "../app/app.ts"; |
| 12 | 13 | import { binding, type Binding } from "../app/keys.ts"; |
| @@ -996,6 +997,8 @@ export function errorText(code: string): string { | ||
| 996 | 997 | |
| 997 | 998 | /** Anything thrown, as a string — the toast text of last resort. */ |
| 998 | 999 | export function message(error: unknown): string { |
| 1000 | const refused = refusalText(error); | |
| 1001 | if (refused !== undefined) return refused; | |
| 999 | 1002 | return error instanceof Error ? error.message : String(error); |
| 1000 | 1003 | } |
| 1001 | 1004 |
src/tui/screens/pull.ts +27 −3
| @@ -39,7 +39,7 @@ import { | ||
| 39 | 39 | submitReview, |
| 40 | 40 | } from "../../actions/pulls.ts"; |
| 41 | 41 | import { openEnvUrl } from "../../actions/apps.ts"; |
| 42 | import { listRepoRuns } from "../../actions/runs.ts"; | |
| 42 | import { listRepoRuns, mayReadRunLog } from "../../actions/runs.ts"; | |
| 43 | 43 | import { previewEnv } from "../../apps/data.ts"; |
| 44 | 44 | import { |
| 45 | 45 | appsRuntime, |
| @@ -49,7 +49,7 @@ import { proposedByText } from "../../core/proposal_token.ts"; | ||
| 49 | 49 | import { getAppByRepo } from "../../core/apps.ts"; |
| 50 | 50 | import { getIssue, type Issue } from "../../core/issues.ts"; |
| 51 | 51 | import { isProposed, pullMayRun } from "../../core/pull_trust.ts"; |
| 52 | import { runningProgress } from "../../checks/progress.ts"; | |
| 52 | import { runningProgress, stepsLine } from "../../checks/progress.ts"; | |
| 53 | 53 | import { askCheck } from "../../checks/again.ts"; |
| 54 | 54 | import { checkStale, checksHold } from "../../checks/stale.ts"; |
| 55 | 55 | import { checkWords } from "../../checks/summary.ts"; |
| @@ -237,6 +237,8 @@ export function pullScreen( | ||
| 237 | 237 | /** The pull's base branch, to name what the check was merged with. */ |
| 238 | 238 | let checkBase = ""; |
| 239 | 239 | let checkProgress = ""; |
| 240 | /** The run page's three step rows as a line; empty when the log is hidden. */ | |
| 241 | let checkSteps = ""; | |
| 240 | 242 | /** The check's green was against a base that has since moved. */ |
| 241 | 243 | let checkOld = false; |
| 242 | 244 | /** Agent runs about this pull, newest first. */ |
| @@ -288,6 +290,14 @@ export function pullScreen( | ||
| 288 | 290 | : checkSummary(f.db, row.id, row.headOid || (facts?.headOid ?? "")); |
| 289 | 291 | checkBase = row?.baseBranch ?? facts?.baseBranch ?? ""; |
| 290 | 292 | checkProgress = runningProgress(f.app.config.dataDir, check); |
| 293 | checkSteps = | |
| 294 | settings === undefined | |
| 295 | ? "" | |
| 296 | : stepsLine( | |
| 297 | f.app.config.dataDir, | |
| 298 | check, | |
| 299 | mayReadRunLog(f.app, f.user, settings), | |
| 300 | ); | |
| 291 | 301 | checkOld = |
| 292 | 302 | repo !== undefined && |
| 293 | 303 | row !== undefined && |
| @@ -790,7 +800,7 @@ export function pullScreen( | ||
| 790 | 800 | // The box is exactly as tall as what is in it: the state, then a row |
| 791 | 801 | // for the verdicts when there are any, then a row for the checks when |
| 792 | 802 | // a deploy has run or the gate demands one. |
| 793 | const checksLine = checksText(check, requireChecks, isApp, checkBase, checkOld, checkProgress); | |
| 803 | const checksLine = checksText(check, requireChecks, isApp, checkBase, checkOld, checkProgress, checkSteps); | |
| 794 | 804 | const boxH = |
| 795 | 805 | 3 + |
| 796 | 806 | (standingText(standing(), requireReview) === "" ? 0 : 1) + |
| @@ -1251,6 +1261,20 @@ export function checksText( | ||
| 1251 | 1261 | stale = false, |
| 1252 | 1262 | /** `runningProgress` of the check's log; empty when none is known. */ |
| 1253 | 1263 | progress = "", |
| 1264 | /** `stepsLine` of the check's log; empty when none is shown. */ | |
| 1265 | steps = "", | |
| 1266 | ): string { | |
| 1267 | const line = checksLine(check, required, isApp, baseBranch, stale, progress); | |
| 1268 | return line === "" || steps === "" ? line : `${line} · ${steps}`; | |
| 1269 | } | |
| 1270 | ||
| 1271 | function checksLine( | |
| 1272 | check: CheckSummary, | |
| 1273 | required: boolean, | |
| 1274 | isApp: boolean, | |
| 1275 | baseBranch: string, | |
| 1276 | stale: boolean, | |
| 1277 | progress: string, | |
| 1254 | 1278 | ): string { |
| 1255 | 1279 | if (check.state === "none") |
| 1256 | 1280 | return required |
src/tui/screens/signin.ts +31 −14
| @@ -8,9 +8,10 @@ | ||
| 8 | 8 | * account while signup allows — not an admin; the operator makes the admin. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts"; | |
| 11 | import { INVITE_SPENT_MESSAGE, SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts"; | |
| 12 | 12 | import { SignupSlot, waitText } from "../../actions/signup-window.ts"; |
| 13 | 13 | import { hasAnyUser } from "../../core/users.ts"; |
| 14 | import { getSignup } from "../../settings/signup.ts"; | |
| 14 | 15 | import type { Ctx, Screen } from "../app/app.ts"; |
| 15 | 16 | import { binding } from "../app/keys.ts"; |
| 16 | 17 | import { style } from "../term/style.ts"; |
| @@ -37,36 +38,51 @@ const CREATE: Field[] = [ | ||
| 37 | 38 | { label: "Password", secret: true, hint: "At least 10 characters." }, |
| 38 | 39 | ]; |
| 39 | 40 | |
| 41 | /** While signup is `invite` the create form also asks for the link's token. */ | |
| 42 | const INVITE_BLANK_MESSAGE = "Paste the token from your invite link."; | |
| 43 | const INVITE: Field = { label: "Invite", secret: true, hint: "The token from your invite link." }; | |
| 44 | ||
| 40 | 45 | export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen { |
| 41 | 46 | // "Is this forge empty?" is a existence question, not a count: `COUNT(*)` |
| 42 | 47 | // walks the whole user table on the one screen every session paints. |
| 43 | 48 | const first = !hasAnyUser(f.db); |
| 44 | 49 | // Closed signup offers no create mode at all, as the page offers no form. |
| 45 | const closed = signupClosed(f.app); | |
| 46 | let mode: "signin" | "create" = first && !closed ? "create" : "signin"; | |
| 47 | let values = ["", "", ""]; | |
| 50 | // With signup = invite the form is offered, and asks for the invite. | |
| 51 | // Both are read where they are used, so the screen follows the live setting. | |
| 52 | const invited = (): boolean => getSignup(f.db) === "invite"; | |
| 53 | const closed = (): boolean => !invited() && signupClosed(f.app); | |
| 54 | let mode: "signin" | "create" = first && !closed() ? "create" : "signin"; | |
| 55 | let values = ["", "", "", ""]; | |
| 48 | 56 | let focus = 0; |
| 49 | 57 | let error: string | undefined; |
| 50 | 58 | let busy = false; |
| 51 | 59 | |
| 52 | const fields = (): Field[] => (mode === "create" ? CREATE : SIGN_IN); | |
| 60 | const fields = (): Field[] => (mode === "create" ? (invited() ? [...CREATE, INVITE] : CREATE) : SIGN_IN); | |
| 53 | 61 | |
| 54 | 62 | const submit = (): void => { |
| 55 | 63 | if (busy) return; |
| 56 | if (closed && mode === "create") { | |
| 64 | if (closed() && mode === "create") { | |
| 57 | 65 | error = SIGNUP_CLOSED_MESSAGE; |
| 58 | 66 | return; |
| 59 | 67 | } |
| 68 | const needsInvite = mode === "create" && invited(); | |
| 69 | if (needsInvite && (values[3] ?? "").trim() === "") { | |
| 70 | error = INVITE_BLANK_MESSAGE; | |
| 71 | focus = 3; | |
| 72 | return; | |
| 73 | } | |
| 60 | 74 | busy = true; |
| 61 | 75 | error = undefined; |
| 62 | 76 | runAsync(ctx, async () => { |
| 63 | 77 | try { |
| 64 | 78 | if (mode === "create") { |
| 65 | const [username, email, password] = values as [ | |
| 79 | const [username, email, password, token] = values as [ | |
| 80 | string, | |
| 66 | 81 | string, |
| 67 | 82 | string, |
| 68 | 83 | string, |
| 69 | 84 | ]; |
| 85 | const invite = needsInvite ? token.trim() : ""; | |
| 70 | 86 | const slot = SignupSlot.take(f.app, f.identity.remoteAddress); |
| 71 | 87 | if (typeof slot === "number") { |
| 72 | 88 | error = waitText(slot); |
| @@ -75,10 +91,11 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen { | ||
| 75 | 91 | const created = await signUp( |
| 76 | 92 | f.app, |
| 77 | 93 | { username, email, password }, |
| 78 | { slot, address: f.identity.remoteAddress }, | |
| 94 | { slot, address: f.identity.remoteAddress, ...(invite === "" ? {} : { invite }) }, | |
| 79 | 95 | ); |
| 80 | 96 | if (!created.ok) { |
| 81 | error = errorText(created.error); | |
| 97 | // With signup = invite a refusal means the token did not open the door. | |
| 98 | error = needsInvite && created.error === "signup_closed" ? INVITE_SPENT_MESSAGE : errorText(created.error); | |
| 82 | 99 | return; |
| 83 | 100 | } |
| 84 | 101 | const adopted = f.adopt(created.value); |
| @@ -109,21 +126,21 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen { | ||
| 109 | 126 | keys: [ |
| 110 | 127 | binding(["tab"], "next field"), |
| 111 | 128 | binding(["enter"], mode === "create" ? "create" : "sign in"), |
| 112 | ...(closed ? [] : [binding(["ctrl+t"], first ? "sign in instead" : "create account")]), | |
| 129 | ...(closed() ? [] : [binding(["ctrl+t"], first ? "sign in instead" : "create account")]), | |
| 113 | 130 | binding(["esc"], "back"), |
| 114 | 131 | ], |
| 115 | 132 | render(area, s) { |
| 116 | 133 | const c = ctx.theme.c; |
| 117 | 134 | const [head, body] = split(area, "v", [len(2), fill(1)]); |
| 118 | 135 | segmented(s, head!, c, { |
| 119 | items: closed ? ["Sign in"] : ["Sign in", "Create account"], | |
| 136 | items: closed() ? ["Sign in"] : ["Sign in", "Create account"], | |
| 120 | 137 | active: mode === "create" ? 1 : 0, |
| 121 | 138 | }); |
| 122 | 139 | s.text( |
| 123 | 140 | head!.x, |
| 124 | 141 | head!.y + 1, |
| 125 | 142 | truncate( |
| 126 | closed | |
| 143 | closed() | |
| 127 | 144 | ? SIGNUP_CLOSED_MESSAGE |
| 128 | 145 | : first |
| 129 | 146 | ? "This forge has no accounts yet. The operator makes the admin." |
| @@ -162,9 +179,9 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen { | ||
| 162 | 179 | ctx.setCursor(cursor); |
| 163 | 180 | }, |
| 164 | 181 | key(e) { |
| 165 | if (e.ctrl && e.key === "t" && !closed) { | |
| 182 | if (e.ctrl && e.key === "t" && !closed()) { | |
| 166 | 183 | mode = mode === "create" ? "signin" : "create"; |
| 167 | values = ["", "", ""]; | |
| 184 | values = ["", "", "", ""]; | |
| 168 | 185 | focus = 0; |
| 169 | 186 | error = undefined; |
| 170 | 187 | return true; |
src/web/chrome.ts +16
| @@ -12,6 +12,7 @@ import { sshCommand } from "../ssh/command.ts"; | ||
| 12 | 12 | import { mayDirectCrew } from "../crew/direct.ts"; |
| 13 | 13 | import { mayReadRuns } from "../actions/runs.ts"; |
| 14 | 14 | import { getAppByRepo } from "../core/apps.ts"; |
| 15 | import { audienceFacts, audienceOf } from "../core/audience.ts"; | |
| 15 | 16 | import { edgeLoginUrl } from "../http/edge.ts"; |
| 16 | 17 | import { |
| 17 | 18 | canAdmin, |
| @@ -310,6 +311,7 @@ export function scopeHeader( | ||
| 310 | 311 | ${facts.visibility === "private" |
| 311 | 312 | ? badge("Private", { icon: "lock", variant: "secondary" }) |
| 312 | 313 | : badge("Public", { variant: "secondary" })} |
| 314 | ${repo === undefined ? null : audienceLine(ctx, repo, admin, base)} | |
| 313 | 315 | ${actions.text.trim() !== "" |
| 314 | 316 | ? html`<span class="repo-actions">${actions}</span>` |
| 315 | 317 | : null}`; |
| @@ -347,6 +349,20 @@ export function scopeHeader( | ||
| 347 | 349 | </header>`; |
| 348 | 350 | } |
| 349 | 351 | |
| 352 | /** | |
| 353 | * Who can see this repository, in the one sentence `audienceOf` gives, beside | |
| 354 | * the badge. A link to the settings for whoever can open them, plain text for | |
| 355 | * everyone else. One line: CSS truncates it, `title` keeps all of it. | |
| 356 | */ | |
| 357 | function audienceLine(ctx: Ctx, repo: Repo, admin: boolean, base: string): Html { | |
| 358 | const { sentence } = audienceOf(audienceFacts(ctx.app.db, repo, ctx.auth.user)); | |
| 359 | return admin | |
| 360 | ? html`<a class="repo-audience" href="${base}/settings" title="${sentence}" | |
| 361 | >${sentence}</a | |
| 362 | >` | |
| 363 | : html`<span class="repo-audience" title="${sentence}">${sentence}</span>`; | |
| 364 | } | |
| 365 | ||
| 350 | 366 | export { hostOf }; |
| 351 | 367 | |
| 352 | 368 | /** This repo's own rows for the command palette — the studio's nouns for a |
src/web/client.ts +41
| @@ -295,6 +295,40 @@ export const studioRunJs = /* js */ ` | ||
| 295 | 295 | |
| 296 | 296 | ${elapsedTickJs} |
| 297 | 297 | |
| 298 | // A check's step rows are server-derived from the log; when a line that | |
| 299 | // can move them lands (a command's echo, the shard runner's banner or a | |
| 300 | // shard header) or the run ends, the rows are fetched again and swapped in. | |
| 301 | var stepsTimer = 0; | |
| 302 | function steps() { | |
| 303 | var cur = document.getElementById('check-steps'); | |
| 304 | if (!cur || !window.fetch || !window.DOMParser) return; | |
| 305 | fetch(location.href, { credentials: 'same-origin', headers: { accept: 'text/html' } }) | |
| 306 | .then(function (r) { return r.ok ? r.text() : ''; }) | |
| 307 | .then(function (body) { | |
| 308 | var doc = new DOMParser().parseFromString(body, 'text/html'); | |
| 309 | var next = doc.getElementById('check-steps'); | |
| 310 | var now = document.getElementById('check-steps'); | |
| 311 | if (next && now && now.parentNode) now.parentNode.replaceChild(next, now); | |
| 312 | // The pin slot is only patched, never replaced: a keyboard user | |
| 313 | // reading a failure keeps focus and scroll. | |
| 314 | var wrap = document.getElementById('check-progress'), pw = doc.getElementById('check-progress'); | |
| 315 | var slot = document.getElementById('check-pins'), ps = doc.getElementById('check-pins'); | |
| 316 | if (wrap && pw) wrap.setAttribute('data-state', pw.getAttribute('data-state') || ''); | |
| 317 | if (slot && ps) { | |
| 318 | if (slot.innerHTML !== ps.innerHTML) slot.innerHTML = ps.innerHTML; | |
| 319 | ['role', 'tabindex', 'aria-label'].forEach(function (a) { | |
| 320 | var v = ps.getAttribute(a); | |
| 321 | if (v === null) slot.removeAttribute(a); else if (slot.getAttribute(a) !== v) slot.setAttribute(a, v); | |
| 322 | }); | |
| 323 | } | |
| 324 | }) | |
| 325 | .catch(function () {}); | |
| 326 | } | |
| 327 | function stepsSoon() { | |
| 328 | if (!document.getElementById('check-steps') || stepsTimer) return; | |
| 329 | stepsTimer = window.setTimeout(function () { stepsTimer = 0; steps(); }, 400); | |
| 330 | } | |
| 331 | ||
| 298 | 332 | if (steer && window.fetch) { |
| 299 | 333 | var text = steer.querySelector('textarea'); |
| 300 | 334 | function size() { if (!text) return; text.style.height = 'auto'; text.style.height = Math.min(160, text.scrollHeight) + 'px'; fit(); } |
| @@ -341,12 +375,15 @@ export const studioRunJs = /* js */ ` | ||
| 341 | 375 | var text = event.data; |
| 342 | 376 | try { var parsed = JSON.parse(text); if (typeof parsed === 'string') text = parsed; } catch (err) {} |
| 343 | 377 | add(draw(text)); |
| 378 | if (/^(\\$ |test-shards: |\\(fail\\) |\u2500\u2500 shard )/.test(text)) stepsSoon(); | |
| 344 | 379 | }); |
| 345 | 380 | source.addEventListener('done', function (event) { |
| 346 | 381 | source.close(); |
| 347 | 382 | var run; |
| 348 | 383 | try { run = JSON.parse(event.data); } catch (err) { return; } |
| 349 | 384 | paint(run); |
| 385 | if (stepsTimer) { window.clearTimeout(stepsTimer); stepsTimer = 0; } | |
| 386 | steps(); | |
| 350 | 387 | if (steer) { |
| 351 | 388 | steer.setAttribute('data-running', '0'); |
| 352 | 389 | var n = document.getElementById('run-steer-note'); |
| @@ -1930,6 +1967,10 @@ export const liveRegionsJs = /* js */ ` | ||
| 1930 | 1967 | // post is in flight) is never pulled out from under the click; the next |
| 1931 | 1968 | // matching event tries the swap again. |
| 1932 | 1969 | if (mine.querySelector('[data-busy]')) return; |
| 1970 | // Likewise a region with a ticked box (apps picked for Archive, or the | |
| 1971 | // Select toggle itself): a tap on a phone does not focus a checkbox, so | |
| 1972 | // focus cannot be what protects the picks. | |
| 1973 | if (mine.querySelector('input[type=checkbox]:checked')) return; | |
| 1933 | 1974 | var active = document.activeElement; |
| 1934 | 1975 | if (active && mine.contains(active) && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return; |
| 1935 | 1976 | if (same(mine.innerHTML, theirs.innerHTML)) return; |
src/web/pages/account.ts +6 −2
| @@ -31,7 +31,7 @@ import { ABILITIES, roleOf, rowOf, type Ability } from "../../core/roles.ts"; | ||
| 31 | 31 | import { listKeys } from "../../core/keys.ts"; |
| 32 | 32 | import { endAccountSession } from "../../actions/signins.ts"; |
| 33 | 33 | import { listSessions, type SessionInfo } from "../../core/sessions.ts"; |
| 34 | import { listTokens, type TokenScope } from "../../core/tokens.ts"; | |
| 34 | import { ADMIN_TOKEN_WORDS, listTokens, type TokenScope } from "../../core/tokens.ts"; | |
| 35 | 35 | import type { CommitEmail, User } from "../../core/users.ts"; |
| 36 | 36 | import { cookieValue, type Ctx } from "../../http/context.ts"; |
| 37 | 37 | import { sshCommand } from "../../ssh/command.ts"; |
| @@ -539,7 +539,9 @@ function tokensView(ctx: Ctx): Html { | ||
| 539 | 539 | options: [ |
| 540 | 540 | { value: "rw", label: "read + write — clone, push, open issues" }, |
| 541 | 541 | { value: "ro", label: "read only — clone and browse" }, |
| 542 | { value: "admin", label: "admin — everything, including settings" }, | |
| 542 | ...(ctx.auth.user?.isAdmin | |
| 543 | ? [{ value: "admin", label: "admin — everything, including settings" }] | |
| 544 | : []), | |
| 543 | 545 | ], |
| 544 | 546 | hint: "A token can never mint another token, whatever its scope.", |
| 545 | 547 | })} |
| @@ -594,6 +596,8 @@ export async function tokenCreate(ctx: Ctx): Promise<Response> { | ||
| 594 | 596 | field(form, "name").trim() || "token", |
| 595 | 597 | chosen, |
| 596 | 598 | ); |
| 599 | if (!minted.ok && minted.error === "forbidden") | |
| 600 | return flashTo(ctx, "/settings/tokens", { tone: "danger", message: ADMIN_TOKEN_WORDS }); | |
| 597 | 601 | if (!minted.ok) return signInFirst(ctx); |
| 598 | 602 | // Post-redirect-get: the secret rides a one-shot flash, so a refresh shows |
| 599 | 603 | // the list rather than minting a second token. |
src/web/pages/accounts.ts +2 −2
| @@ -20,7 +20,7 @@ import { | ||
| 20 | 20 | revokeAccountToken, |
| 21 | 21 | } from "../../actions/signins.ts"; |
| 22 | 22 | import { mailConfigured } from "../../actions/verify.ts"; |
| 23 | import type { AccountRow } from "../../core/accounts.ts"; | |
| 23 | import { originOf, type AccountRow } from "../../core/accounts.ts"; | |
| 24 | 24 | import { |
| 25 | 25 | OWNERS_TEAM, |
| 26 | 26 | ownerlessOrgs, |
| @@ -73,7 +73,7 @@ function standing(a: AccountRow): Html { | ||
| 73 | 73 | function row(a: AccountRow, sole: readonly string[]): Html { |
| 74 | 74 | const at = `/settings/accounts/${encodeURIComponent(a.username)}`; |
| 75 | 75 | const how = [ |
| 76 | a.via ?? (a.kind === "agent" ? "crew" : ""), | |
| 76 | originOf(a), | |
| 77 | 77 | a.address ?? "", |
| 78 | 78 | ].filter((s) => s !== ""); |
| 79 | 79 | const holds = [ |
src/web/pages/backups.ts +16 −6
| @@ -20,6 +20,7 @@ import { resolveDestination, sealedSet } from "../../snapshot/destination.ts"; | ||
| 20 | 20 | import { keyOf } from "../../snapshot/snapshot.ts"; |
| 21 | 21 | import { lastDrill } from "../../snapshot/drills.ts"; |
| 22 | 22 | import { snapshotsOf } from "../../snapshot/service.ts"; |
| 23 | import { lastSnapshotOutcome } from "../../snapshot/outcome.ts"; | |
| 23 | 24 | import type { Ctx } from "../../http/context.ts"; |
| 24 | 25 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; |
| 25 | 26 | import { field, readForm, type Form } from "../form.ts"; |
| @@ -183,9 +184,18 @@ export async function backupsPage(ctx: Ctx): Promise<Response> { | ||
| 183 | 184 | note: "This forge was given nowhere to put them. On the platform an app's bucket (S3_*) is the place; on a machine, FORGE_SNAPSHOT_DIR names a directory. Seal a bucket below, or restart with one set, and a snapshot is taken every night at 08:00 UTC.", |
| 184 | 185 | })}${form}`, |
| 185 | 186 | ); |
| 187 | // A failure is kept on disk, so it is shown after a restart too; this | |
| 188 | // process's own failure is newer when the file could not be written. | |
| 189 | const kept = lastSnapshotOutcome(ctx.app.config.dataDir); | |
| 190 | const last = svc.last(); | |
| 191 | const failing = | |
| 192 | kept?.failingSince !== undefined | |
| 193 | ? { at: kept.at, since: kept.failingSince, error: kept.error ?? "unknown reason" } | |
| 194 | : last.error !== undefined && (kept === undefined || (last.at ?? "") > kept.at) | |
| 195 | ? { at: last.at ?? "", since: last.at ?? "", error: last.error } | |
| 196 | : undefined; | |
| 186 | 197 | // The store is somewhere else; a listing that fails is a sentence on the |
| 187 | 198 | // page, never a page that fails. |
| 188 | const last = svc.last(); | |
| 189 | 199 | let snapshots: Awaited<ReturnType<typeof svc.list>> = []; |
| 190 | 200 | let listing: string | undefined; |
| 191 | 201 | try { |
| @@ -227,11 +237,11 @@ bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre>` | ||
| 227 | 237 | </p>`, |
| 228 | 238 | )} |
| 229 | 239 | ${when( |
| 230 | last.error, | |
| 231 | (error) => | |
| 232 | html`<p class="backups-error"> | |
| 233 | ${icon("alert")} The last snapshot failed ${time(last.at ?? "")}: | |
| 234 | ${error} | |
| 240 | failing, | |
| 241 | (f) => | |
| 242 | html`<p class="backups-error" role="alert"> | |
| 243 | ${icon("alert")} The last snapshot failed ${time(f.at)}, failing | |
| 244 | since ${time(f.since)}: ${f.error} | |
| 235 | 245 | </p>`, |
| 236 | 246 | )} |
| 237 | 247 | ${when( |
src/web/pages/site.ts +1 −5
| @@ -15,7 +15,7 @@ import { clientAddress } from "../../http/peer.ts"; | ||
| 15 | 15 | import { verifyEmail } from "../../actions/verify.ts"; |
| 16 | 16 | import { STRANGER_MAX } from "../../http/limits.ts"; |
| 17 | 17 | import { createRepo, repositoryLimitMessage, resolveOwner } from "../../actions/repos.ts"; |
| 18 | import { HUMAN_CHECK_MESSAGE, PASSKEY_REQUIRED_MESSAGE, SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts"; | |
| 18 | import { HUMAN_CHECK_MESSAGE, INVITE_SPENT_MESSAGE, PASSKEY_REQUIRED_MESSAGE, SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts"; | |
| 19 | 19 | import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts"; |
| 20 | 20 | import { listPins } from "../../core/pins.ts"; |
| 21 | 21 | import { getReposByIds } from "../../core/repos.ts"; |
| @@ -419,10 +419,6 @@ export function inviteOf(ctx: Ctx): string | undefined { | ||
| 419 | 419 | return token === "" ? undefined : token; |
| 420 | 420 | } |
| 421 | 421 | |
| 422 | /** What a person with a link that no longer opens the door is told. */ | |
| 423 | const INVITE_SPENT_MESSAGE = | |
| 424 | "This invite link has been used, revoked or has expired. Ask whoever sent it for a new one."; | |
| 425 | ||
| 426 | 422 | /** The whole of `/signup` while the forge makes no new accounts: a sentence, no form. */ |
| 427 | 423 | function signupClosedPage(ctx: Ctx): Response { |
| 428 | 424 | const said = inviteOf(ctx) === undefined ? SIGNUP_CLOSED_MESSAGE : INVITE_SPENT_MESSAGE; |
src/web/studio/feed_style.ts +3
| @@ -27,6 +27,9 @@ export const feedCss = /* css */ ` | ||
| 27 | 27 | /* Your apps: a narrow column on a desk, a tab away on a phone. */ |
| 28 | 28 | .home-tabs { display: none; margin-bottom: 12px; } |
| 29 | 29 | .home-app { gap: 8px; } |
| 30 | .home-pick { display: none; } | |
| 31 | #apps-select:checked ~ * .home-pick { display: inline-flex; } | |
| 32 | #apps-select:focus-visible ~ * .home-apps-verbs label { outline: 2px solid var(--accent, currentColor); } | |
| 30 | 33 | .home-apps-verbs { flex-wrap: wrap; padding: 10px 14px; } |
| 31 | 34 | @media (max-width: 899px) { |
| 32 | 35 | .home-tabs { display: inline-flex; } |
src/web/studio/home.ts +31 −4
| @@ -60,7 +60,7 @@ import { passkeyJs } from "../passkey-client.ts"; | ||
| 60 | 60 | const ON_YOU_LIVE = |
| 61 | 61 | "issue.assigned,pull.assigned,issue.closed,pull.closed,issue.reopened,pull.reopened,pull.merged,fleet.operated"; |
| 62 | 62 | /** The events that can change how an app on the rail is. */ |
| 63 | const APPS_LIVE = "app.serving,app.stopped,run.finished"; | |
| 63 | const APPS_LIVE = "app.serving,app.stopped,run.finished,fleet.operated"; | |
| 64 | 64 | /** How many of your apps the rail names before "All apps". */ |
| 65 | 65 | const RAIL_APPS = 8; |
| 66 | 66 | |
| @@ -83,13 +83,23 @@ function yourApps(ctx: Ctx, user: User): Html { | ||
| 83 | 83 | ? undefined |
| 84 | 84 | : runtime.status(record).find((env) => env.env === "production")?.state; |
| 85 | 85 | return html`<li class="row home-app"> |
| 86 | ${when( | |
| 87 | user.isAdmin, | |
| 88 | () => | |
| 89 | html`<input | |
| 90 | class="home-pick" | |
| 91 | type="checkbox" | |
| 92 | name="repo" | |
| 93 | value="${repo.id}" | |
| 94 | aria-label="Select ${repo.name}" | |
| 95 | />`, | |
| 96 | )} | |
| 86 | 97 | <a class="truncate" href="${repoBase(repo)}">${repo.ownerName}/${repo.name}</a> |
| 87 | 98 | <span class="spacer"></span> |
| 88 | 99 | ${state === undefined ? null : envStateBadge(state)} |
| 89 | 100 | </li>`; |
| 90 | 101 | }); |
| 91 | return card( | |
| 92 | html`${rows.length === 0 | |
| 102 | const list = html`${rows.length === 0 | |
| 93 | 103 | ? html`<p class="muted small">No apps yet.</p>` |
| 94 | 104 | : html`<ul class="list"> |
| 95 | 105 | ${rows} |
| @@ -108,7 +118,24 @@ function yourApps(ctx: Ctx, user: User): Html { | ||
| 108 | 118 | ${when(user.isAdmin, () => |
| 109 | 119 | btn("Front page", { href: "/front", size: "sm", icon: "globe" }), |
| 110 | 120 | )} |
| 111 | </div>`, | |
| 121 | ${when( | |
| 122 | user.isAdmin && rows.length > 0, | |
| 123 | () => html`<label class="btn btn-sm" for="apps-select">Select</label> | |
| 124 | <span class="home-pick">${btn("Archive", { size: "sm" })}</span>`, | |
| 125 | )} | |
| 126 | </div>`; | |
| 127 | return card( | |
| 128 | // An admin's list is a form: ticks cost no space until Select shows them. | |
| 129 | user.isAdmin && rows.length > 0 | |
| 130 | ? html`<form method="post" action="/home/archive"> | |
| 131 | <input | |
| 132 | class="sr-only" | |
| 133 | type="checkbox" | |
| 134 | id="apps-select" | |
| 135 | aria-label="Select apps" | |
| 136 | />${list} | |
| 137 | </form>` | |
| 138 | : list, | |
| 112 | 139 | { |
| 113 | 140 | head: html`${icon("rocket")} |
| 114 | 141 | <h2>Your apps</h2>`, |
src/web/studio/home_archive.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | /** | |
| 2 | * Archiving apps from the home: an admin ticks apps in the rail and archives | |
| 3 | * them in one step. It is the fleet's own `archive` operation over the ticked | |
| 4 | * repositories (`actions/fleet.ts`) — nothing new is written here — and the | |
| 5 | * page that answers lists what was archived with the operation's undo. | |
| 6 | */ | |
| 7 | ||
| 8 | import { applyFleetIds, getFleetOp, undoFleetOp } from "../../actions/fleet.ts"; | |
| 9 | import type { Ctx } from "../../http/context.ts"; | |
| 10 | import { signInFirst } from "../chrome.ts"; | |
| 11 | import { readForm } from "../form.ts"; | |
| 12 | import { html } from "../html.ts"; | |
| 13 | import { page, redirect } from "../layout.ts"; | |
| 14 | import { btn } from "../ui.ts"; | |
| 15 | import { MAX_ROWS } from "../../fleet/selection.ts"; | |
| 16 | ||
| 17 | /** The ticked repository ids, once each, as long as they are plausible ids. */ | |
| 18 | function picked(form: Awaited<ReturnType<typeof readForm>>): string[] { | |
| 19 | if (form instanceof Response) return []; | |
| 20 | const ids = form | |
| 21 | .getAll("repo") | |
| 22 | .filter((v): v is string => typeof v === "string" && /^[\w-]{1,64}$/.test(v)); | |
| 23 | return [...new Set(ids)]; | |
| 24 | } | |
| 25 | ||
| 26 | // POST /home/archive — archive the ticked apps, a site admin's. | |
| 27 | export async function homeArchive(ctx: Ctx): Promise<Response> { | |
| 28 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 29 | const form = await readForm(ctx, "admin"); | |
| 30 | if (form instanceof Response) return form; | |
| 31 | if (!ctx.auth.user.isAdmin) return new Response("forbidden", { status: 403 }); | |
| 32 | const ids = picked(form); | |
| 33 | if (ids.length === 0 || ids.length > MAX_ROWS) | |
| 34 | return redirect("/", { | |
| 35 | url: ctx.url, | |
| 36 | flash: { tone: "danger", message: "Tick the apps to archive first." }, | |
| 37 | }); | |
| 38 | const op = await applyFleetIds( | |
| 39 | ctx.app, | |
| 40 | ctx.auth.user, | |
| 41 | "repo", | |
| 42 | ids, | |
| 43 | `home: ${ids.length} selected`, | |
| 44 | { verb: "archive", value: true }, | |
| 45 | ); | |
| 46 | if (!op.ok) | |
| 47 | return redirect("/", { | |
| 48 | url: ctx.url, | |
| 49 | flash: { tone: "danger", message: op.error.message }, | |
| 50 | }); | |
| 51 | return redirect(`/home/archived/${op.value.id}`); | |
| 52 | } | |
| 53 | ||
| 54 | // GET /home/archived/:op — what was archived, and the undo. | |
| 55 | export function homeArchived(ctx: Ctx): Response { | |
| 56 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 57 | const found = getFleetOp(ctx.app, ctx.auth.user, ctx.params.op ?? ""); | |
| 58 | if (!found.ok || found.value.verb !== "archive") | |
| 59 | return new Response("not found", { status: 404 }); | |
| 60 | const op = found.value; | |
| 61 | const wasUndo = op.undoOf !== null; | |
| 62 | const rows = op.rows.filter((r) => r.outcome === "ok"); | |
| 63 | const left = op.rows.filter((r) => r.outcome !== "ok"); | |
| 64 | // Only the person who ran it may undo it (`undoFleetOp`), whoever may read it. | |
| 65 | const canUndo = | |
| 66 | op.reversible && op.undoneBy === null && !wasUndo && op.actorId === ctx.auth.user.id; | |
| 67 | const body = html`<h1>${wasUndo ? "Brought back" : "Archived"}</h1> | |
| 68 | <p id="home-archived" role="status"> | |
| 69 | ${rows.length} app${rows.length === 1 ? "" : "s"} | |
| 70 | ${wasUndo ? "back on the home" : "archived"}${left.length === 0 | |
| 71 | ? "" | |
| 72 | : `, ${left.length} left as they were`}. | |
| 73 | </p> | |
| 74 | <ul class="list"> | |
| 75 | ${rows.map((r) => html`<li class="row">${r.label}</li>`)} | |
| 76 | </ul> | |
| 77 | ${left.length === 0 | |
| 78 | ? null | |
| 79 | : html`<ul class="list muted"> | |
| 80 | ${left.map((r) => html`<li class="row">${r.label}: ${r.reason}</li>`)} | |
| 81 | </ul>`} | |
| 82 | <div class="bar mt"> | |
| 83 | ${canUndo | |
| 84 | ? html`<form method="post" action="/home/archived/${op.id}/undo"> | |
| 85 | ${btn("Undo", { variant: "primary" })} | |
| 86 | </form>` | |
| 87 | : null} | |
| 88 | ${btn("Home", { href: "/" })} | |
| 89 | </div> | |
| 90 | ${canUndo ? html`<p class="small muted">Undo is kept for a day.</p>` : null}`; | |
| 91 | return page({ ctx, title: wasUndo ? "Brought back" : "Archived", body }); | |
| 92 | } | |
| 93 | ||
| 94 | // POST /home/archived/:op/undo — the operation's own undo. | |
| 95 | export async function homeArchiveUndo(ctx: Ctx): Promise<Response> { | |
| 96 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 97 | const form = await readForm(ctx, "admin"); | |
| 98 | if (form instanceof Response) return form; | |
| 99 | const undone = await undoFleetOp(ctx.app, ctx.auth.user, ctx.params.op ?? ""); | |
| 100 | if (!undone.ok && undone.error.code === "not_found") | |
| 101 | return new Response("not found", { status: 404 }); | |
| 102 | if (!undone.ok) | |
| 103 | return redirect("/", { | |
| 104 | url: ctx.url, | |
| 105 | flash: { tone: "danger", message: undone.error.message }, | |
| 106 | }); | |
| 107 | return redirect(`/home/archived/${undone.value.id}`); | |
| 108 | } |
src/web/studio/previews.ts +6
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * deploy event refreshes (`appStateFragment`) share. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { audienceFacts, audienceOf } from "../../core/audience.ts"; | |
| 10 | 11 | import { sealedScope } from "../../core/sealed.ts"; |
| 11 | 12 | import { terminalFor } from "../../actions/apps.ts"; |
| 12 | 13 | import { envPull, previewEnv } from "../../apps/data.ts"; |
| @@ -298,6 +299,10 @@ export function appPanels( | ||
| 298 | 299 | // The state in one word, and under it the facts — why it is not up, and |
| 299 | 300 | // the deploy whose log says the rest. |
| 300 | 301 | const live = envPhrase(production); |
| 302 | // Who the app answers to, in the sentence the forge's own rules produce. | |
| 303 | const audience = audienceOf( | |
| 304 | audienceFacts(db, repo, ctx.auth.user), | |
| 305 | ).sentence; | |
| 301 | 306 | const head = html`<div class="page-head studio-head"> |
| 302 | 307 | <div class="stack stack-tight"> |
| 303 | 308 | <h1> |
| @@ -320,6 +325,7 @@ export function appPanels( | ||
| 320 | 325 | >` |
| 321 | 326 | : html`· <span class="mono">${url.replace(/^https?:\/\//, "")}</span>`} |
| 322 | 327 | </p> |
| 328 | <p class="muted small" id="app-audience">${audience}</p> | |
| 323 | 329 | ${when(production, (env) => envFacts(base, env))} |
| 324 | 330 | ${when(writer, () => |
| 325 | 331 | terminalLine( |
src/web/studio/run.ts +55 −1
| @@ -8,6 +8,8 @@ | ||
| 8 | 8 | * at `?all=1`. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { checkSteps, stepWord } from "../../checks/summary.ts"; | |
| 12 | import { parseTestsRow, testsRowWords } from "../../checks/tests-row.ts"; | |
| 11 | 13 | import { directionNote } from "../../crew/direct.ts"; |
| 12 | 14 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 13 | 15 | import { getAppByRepo } from "../../core/apps.ts"; |
| @@ -24,7 +26,7 @@ import { repoDir } from "../../git/repos.ts"; | ||
| 24 | 26 | import type { Ctx } from "../../http/context.ts"; |
| 25 | 27 | import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts"; |
| 26 | 28 | import { studioRunJs } from "../client.ts"; |
| 27 | import { html, when, type Html } from "../html.ts"; | |
| 29 | import { html, raw, when, type Html } from "../html.ts"; | |
| 28 | 30 | import { runLine } from "../describe.ts"; |
| 29 | 31 | import { page } from "../layout.ts"; |
| 30 | 32 | import { btn, sha } from "../ui.ts"; |
| @@ -244,6 +246,58 @@ export async function runView( | ||
| 244 | 246 | Nothing has been logged yet. |
| 245 | 247 | </p>`, |
| 246 | 248 | )} |
| 249 | ${when( | |
| 250 | // Progress is read from the log, so a reader the log is hidden from | |
| 251 | // gets no rows either. | |
| 252 | run.kind === "check" && !logHidden, | |
| 253 | () => { | |
| 254 | const steps = checkSteps(run, lines, truncated); | |
| 255 | const row = parseTestsRow(lines); | |
| 256 | const words = testsRowWords(row); | |
| 257 | const known = row.of !== undefined && row.started !== undefined && row.started <= row.of; | |
| 258 | const open = run.state === "queued" || run.state === "running"; | |
| 259 | const running = row.running ?? []; | |
| 260 | const runningWords = | |
| 261 | running.length === 0 | |
| 262 | ? "" | |
| 263 | : `running ${running.slice(0, 2).join(", ")}${running.length > 2 ? ` +${running.length - 2}` : ""}`; | |
| 264 | // While the run is open every part is rendered at a fixed size (the | |
| 265 | // pin slot by CSS), so the log below never moves as numbers and pins | |
| 266 | // arrive; a run that has ended reserves nothing. | |
| 267 | return html`<div class="check-progress" id="check-progress" data-state="${run.state}"> | |
| 268 | <div id="check-steps" role="status" aria-live="polite"> | |
| 269 | <ol class="check-steps"> | |
| 270 | ${steps.map( | |
| 271 | (s) => | |
| 272 | html`<li data-step="${s.name}" data-state="${s.state}"> | |
| 273 | <span class="step-name">${s.name}</span> | |
| 274 | <span class="step-state">${stepWord(s.state)}</span> | |
| 275 | </li>`, | |
| 276 | )} | |
| 277 | </ol> | |
| 278 | ${known | |
| 279 | ? html`<progress class="tests-bar" aria-label="Test files started" max="${row.of ?? 0}" value="${row.started ?? 0}"></progress>` | |
| 280 | : open | |
| 281 | ? html`<progress class="tests-bar" aria-label="Test files started"></progress>` | |
| 282 | : ""} | |
| 283 | <p class="tests-words small muted">${[words, runningWords].filter((w) => w !== "").join(" · ")}</p> | |
| 284 | </div> | |
| 285 | <div class="pinned-slot" id="check-pins"${row.failures.length > 0 ? raw(' role="region" tabindex="0" aria-label="Failed tests"') : ""}> | |
| 286 | ${when( | |
| 287 | row.failures.length > 0, | |
| 288 | () => html`<ul class="pinned-failures"> | |
| 289 | ${row.failures.map( | |
| 290 | (f) => html`<li><strong>${f.name}</strong>${when( | |
| 291 | f.lines.length > 0, | |
| 292 | () => html`<pre>${f.lines.join("\n")}</pre>`, | |
| 293 | )}</li>`, | |
| 294 | )} | |
| 295 | </ul>`, | |
| 296 | )} | |
| 297 | </div> | |
| 298 | </div>`; | |
| 299 | }, | |
| 300 | )} | |
| 247 | 301 | <div class="run-live" id="run-live" data-state="${run.state}"> |
| 248 | 302 | ${when(!logHidden, () => logBox({ |
| 249 | 303 | id: "run-log", |
src/web/style/content.ts +25
| @@ -25,6 +25,16 @@ export const content = /* css */ ` | ||
| 25 | 25 | } |
| 26 | 26 | .repo-title .owner { font-weight: 400; color: var(--muted-fg); } |
| 27 | 27 | .repo-title .slash { color: var(--faint); font-weight: 400; } |
| 28 | .repo-audience { | |
| 29 | flex: 1 1 16ch; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | |
| 30 | font-size: 13px; font-weight: 400; letter-spacing: 0; color: var(--muted-fg); | |
| 31 | } | |
| 32 | a.repo-audience:hover { color: var(--fg); } | |
| 33 | /* A phone has no hover for the title: the whole sentence, on its own line. */ | |
| 34 | @media (max-width: 640px) { | |
| 35 | .repo-audience { flex-basis: 100%; white-space: normal; text-overflow: clip; } | |
| 36 | a.repo-audience { display: block; padding: 12px 0; min-height: 44px; box-sizing: border-box; } | |
| 37 | } | |
| 28 | 38 | .repo-desc { margin-top: 4px; color: var(--muted-fg); font-size: 13.5px; text-wrap: pretty; } |
| 29 | 39 | .repo-lineage { margin-top: 3px; font-size: 12.5px; color: var(--faint); } |
| 30 | 40 | .repo-head .tabs { margin-top: 12px; } |
| @@ -865,6 +875,21 @@ form.inline { display: inline; margin-left: auto; } | ||
| 865 | 875 | .checks-line .ok { display: inline-flex; align-items: center; gap: 4px; color: var(--success); } |
| 866 | 876 | .checks-line .bad { display: inline-flex; align-items: center; gap: 4px; color: var(--danger); } |
| 867 | 877 | .checks-line .now { display: inline-flex; align-items: center; gap: 4px; color: var(--attn); } |
| 878 | .check-steps { list-style: none; margin: 12px 0 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } | |
| 879 | .check-steps li { display: flex; justify-content: space-between; gap: 8px; padding: 8px 12px; min-height: 2.5em; border: 1px solid var(--border); border-radius: var(--radius); } | |
| 880 | .tests-bar { display: block; width: 100%; height: 8px; margin-top: 8px; } | |
| 881 | .tests-words { margin: 4px 0 0; min-height: 1.4em; } | |
| 882 | /* Reserved only while the run is open: pins arriving never move the log. */ | |
| 883 | .pinned-slot { max-height: 9em; overflow-y: auto; } | |
| 884 | .check-progress[data-state="queued"] .pinned-slot, .check-progress[data-state="running"] .pinned-slot { height: 9em; } | |
| 885 | .pinned-failures { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 6px; } | |
| 886 | .pinned-failures li { padding: 6px 12px; border: 1px solid var(--danger); border-radius: var(--radius); } | |
| 887 | .pinned-failures pre { margin: 4px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 0.85em; } | |
| 888 | @media (max-width: 480px) { .check-steps { grid-template-columns: minmax(0, 1fr); } } | |
| 889 | .check-steps .step-state { color: var(--faint); } | |
| 890 | .check-steps [data-state="ok"] .step-state { color: var(--success); } | |
| 891 | .check-steps [data-state="failed"] .step-state { color: var(--danger); } | |
| 892 | .check-steps [data-state="running"] .step-state { color: var(--attn); } | |
| 868 | 893 | /* A run, live: a transcript with a speaker column, bounded so the page |
| 869 | 894 | head stays reachable, and a composer that sits under it. */ |
| 870 | 895 | .run-live { position: relative; margin-top: 12px; } |
test/accounts.test.ts +46 −2
| @@ -13,6 +13,7 @@ import { | ||
| 13 | 13 | import { queueAgentRun } from "../src/crew/dispatch.ts"; |
| 14 | 14 | import { fetchInternal } from "../src/git/hook-address.ts"; |
| 15 | 15 | import { addComment, openIssue } from "../src/actions/issues.ts"; |
| 16 | import { newAccountLines } from "../src/actions/accounts.ts"; | |
| 16 | 17 | import { createRepo } from "../src/actions/repos.ts"; |
| 17 | 18 | import { createOrg } from "../src/core/orgs.ts"; |
| 18 | 19 | import { getAgent } from "../src/core/agents.ts"; |
| @@ -175,6 +176,7 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 175 | 176 | items: { |
| 176 | 177 | username: string; |
| 177 | 178 | via: string | null; |
| 179 | signsInThrough: string | null; | |
| 178 | 180 | repos: number; |
| 179 | 181 | spentUsd: number; |
| 180 | 182 | suspendedAt: string | null; |
| @@ -186,6 +188,8 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 186 | 188 | expect(ada.spentUsd).toBe(0); |
| 187 | 189 | expect(ada.suspendedAt).toBeNull(); |
| 188 | 190 | expect(items.find((i) => i.username === "edgy")?.via).toBe("edge"); |
| 191 | expect(items.find((i) => i.username === "edgy")?.signsInThrough).toBe("open-platform.sh"); | |
| 192 | expect(ada.signsInThrough).toBeNull(); | |
| 189 | 193 | const page = await fetch(`${base}/settings/accounts`, { |
| 190 | 194 | headers: { authorization: `Bearer ${ownerToken}` }, |
| 191 | 195 | }); |
| @@ -193,6 +197,12 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 193 | 197 | const html = await page.text(); |
| 194 | 198 | expect(html).toContain("ada"); |
| 195 | 199 | expect(html).toContain("Suspend"); |
| 200 | expect(html).toContain("signs in through open-platform.sh"); | |
| 201 | expect(html.match(/signs in through/g)?.length).toBe(1); | |
| 202 | const owner = getUserByName(app.db, "olive")!; | |
| 203 | const lines = newAccountLines(app.db, owner).map((l) => l.line); | |
| 204 | expect(lines).toContain("edgy joined the forge (signs in through open-platform.sh)"); | |
| 205 | expect(lines).toContain("ada joined the forge (signup)"); | |
| 196 | 206 | }); |
| 197 | 207 | |
| 198 | 208 | test("suspending stops every credential at once", async () => { |
| @@ -598,7 +608,7 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 598 | 608 | test("a refusal the database raises answers 403 with its reason, not 500", async () => { |
| 599 | 609 | // An operator's guard, the way production holds new accounts back. |
| 600 | 610 | app.db.exec( |
| 601 | "CREATE TRIGGER test_refuse BEFORE INSERT ON repo BEGIN SELECT RAISE(ABORT, 'new repositories are paused here'); END;", | |
| 611 | "CREATE TRIGGER test_refuse BEFORE INSERT ON repo BEGIN SELECT RAISE(ABORT, 'forge: new repositories are paused here'); END;", | |
| 602 | 612 | ); |
| 603 | 613 | try { |
| 604 | 614 | const res = await api("/api/v1/repos", { |
| @@ -609,7 +619,7 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 609 | 619 | expect(res.status).toBe(403); |
| 610 | 620 | expect(await res.json()).toEqual({ |
| 611 | 621 | error: "refused", |
| 612 | message: "new repositories are paused here", | |
| 622 | message: "forge: new repositories are paused here", | |
| 613 | 623 | }); |
| 614 | 624 | // The refusal leaves nothing on disk. |
| 615 | 625 | expect(existsSync(repoDir(app.reposRoot, "olive", "guarded"))).toBe(false); |
| @@ -618,6 +628,40 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 618 | 628 | } |
| 619 | 629 | }); |
| 620 | 630 | |
| 631 | test("a refusal without the forge: prefix is answered generically to anyone, and logged in full (#723)", async () => { | |
| 632 | const secret = "constraint on table repo_internal_x failed"; | |
| 633 | app.db.exec( | |
| 634 | `CREATE TRIGGER test_refuse_plain BEFORE INSERT ON repo BEGIN SELECT RAISE(ABORT, '${secret}'); END;`, | |
| 635 | ); | |
| 636 | const logged: { message: string; fields: Record<string, unknown> | undefined }[] = []; | |
| 637 | const log = app.log; | |
| 638 | (app as { log: typeof log }).log = { | |
| 639 | ...log, | |
| 640 | warn: (message, fields) => { | |
| 641 | logged.push({ message, fields }); | |
| 642 | log.warn(message, fields); | |
| 643 | }, | |
| 644 | }; | |
| 645 | try { | |
| 646 | const res = await api("/api/v1/repos", { | |
| 647 | method: "POST", | |
| 648 | token: ownerToken, | |
| 649 | body: JSON.stringify({ name: "guarded-plain" }), | |
| 650 | }); | |
| 651 | expect(res.status).toBe(403); | |
| 652 | expect(await res.json()).toEqual({ | |
| 653 | error: "refused", | |
| 654 | message: "refused", | |
| 655 | }); | |
| 656 | expect( | |
| 657 | logged.some((l) => l.fields?.reason === secret), | |
| 658 | ).toBe(true); | |
| 659 | } finally { | |
| 660 | (app as { log: typeof log }).log = log; | |
| 661 | app.db.exec("DROP TRIGGER test_refuse_plain"); | |
| 662 | } | |
| 663 | }); | |
| 664 | ||
| 621 | 665 | test("the owner can verify a newcomer's email, and it can create at once", async () => { |
| 622 | 666 | // Made first: the helper proves every account waiting on a link. |
| 623 | 667 | const { token: other } = await outsider("mallet"); |
test/app-audience.test.ts added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | /** | |
| 2 | * The app page says who the app answers to, in the head beside its state: | |
| 3 | * a public app, a private app, and a private app on a public repository | |
| 4 | * (#620). | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { newApp } from "../src/actions/apps.ts"; | |
| 11 | import { updateApp } from "../src/core/apps.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { createUser, type User } from "../src/core/users.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | describe("the app page's audience sentence", () => { | |
| 20 | let dataDir: string; | |
| 21 | let app: App; | |
| 22 | let servers: Servers; | |
| 23 | let alice: User; | |
| 24 | let cookie = ""; | |
| 25 | ||
| 26 | const sentence = async (name: string, withCookie = cookie) => { | |
| 27 | const res = await fetch(`${servers.url}/alice/${name}/app`, { | |
| 28 | headers: withCookie ? { cookie: withCookie } : {}, | |
| 29 | }); | |
| 30 | expect(res.status).toBe(200); | |
| 31 | const body = await res.text(); | |
| 32 | return /<p class="muted small" id="app-audience">([^<]*)<\/p>/.exec(body)?.[1]; | |
| 33 | }; | |
| 34 | ||
| 35 | beforeAll(async () => { | |
| 36 | dataDir = await mkdtemp(join(tmpdir(), "forge-app-audience-")); | |
| 37 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 38 | servers = await serve(app); | |
| 39 | const made = await createUser(app.db, { | |
| 40 | origin: "seed", | |
| 41 | trusted: true, | |
| 42 | username: "alice", | |
| 43 | email: "[email protected]", | |
| 44 | password: "a-good-passphrase", | |
| 45 | adminIfFirst: true, | |
| 46 | }); | |
| 47 | if (!made.ok) throw new Error(made.error); | |
| 48 | alice = made.value; | |
| 49 | for (const [name, visibility, access] of [ | |
| 50 | ["open", "public", "public"], | |
| 51 | ["closed", "private", "private"], | |
| 52 | ["split", "public", "private"], | |
| 53 | ] as const) { | |
| 54 | const r = await newApp(app, alice, { name, description: "x", visibility }); | |
| 55 | if (!r.ok) throw new Error(String(r.error)); | |
| 56 | updateApp(app.db, r.value.app.id, { access }); | |
| 57 | } | |
| 58 | const res = await fetch(`${servers.url}/signin`, { | |
| 59 | method: "POST", | |
| 60 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 61 | redirect: "manual", | |
| 62 | }); | |
| 63 | cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 64 | }, 120_000); | |
| 65 | ||
| 66 | afterAll(async () => { | |
| 67 | servers.stop(); | |
| 68 | app.close(); | |
| 69 | await rm(dataDir, { recursive: true, force: true }); | |
| 70 | }); | |
| 71 | ||
| 72 | test("a public app says anyone can read it", async () => { | |
| 73 | expect(await sentence("open")).toStartWith("Public: anyone can read this;"); | |
| 74 | }); | |
| 75 | ||
| 76 | test("a private app says it is private", async () => { | |
| 77 | expect(await sentence("closed")).toStartWith("Private: only you"); | |
| 78 | }); | |
| 79 | ||
| 80 | test("a private app on a public repository says the code is public (#620)", async () => { | |
| 81 | expect(await sentence("split")).toStartWith( | |
| 82 | "The code is public; the app answers only to you", | |
| 83 | ); | |
| 84 | // Signed out, the page is seen as a stranger: never told "you". | |
| 85 | const out = await sentence("split", ""); | |
| 86 | expect(out).toStartWith("The code is public; the app answers only to "); | |
| 87 | expect(out).not.toContain("you"); | |
| 88 | }); | |
| 89 | }); |
test/apps-api.test.ts +2 −14
| @@ -17,6 +17,7 @@ import { getUserByName } from "../src/core/users.ts"; | ||
| 17 | 17 | import { serve, type Servers } from "../src/http/server.ts"; |
| 18 | 18 | import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts"; |
| 19 | 19 | import { seedAdmin } from "./helpers/admin.ts"; |
| 20 | import { tokenFor } from "./helpers/tokens.ts"; | |
| 20 | 21 | |
| 21 | 22 | /** Read from an SSE body until `predicate(buffer)` holds or we time out — |
| 22 | 23 | * never to a "done" that #284's still-live stream no longer sends. */ |
| @@ -455,20 +456,7 @@ describe("apps, runs and the crew over the API", () => { | ||
| 455 | 456 | }, |
| 456 | 457 | null, |
| 457 | 458 | ); |
| 458 | const mallory = ( | |
| 459 | (await ( | |
| 460 | await post( | |
| 461 | "/api/v1/tokens", | |
| 462 | { | |
| 463 | username: "mallory", | |
| 464 | password: "a-good-passphrase", | |
| 465 | name: "admin", | |
| 466 | scope: "admin", | |
| 467 | }, | |
| 468 | null, | |
| 469 | ) | |
| 470 | ).json()) as { token: string } | |
| 471 | ).token; | |
| 459 | const mallory = tokenFor(app, "mallory", "admin", "admin"); | |
| 472 | 460 | expect((await api(env, { token: mallory })).status).toBe(404); |
| 473 | 461 | expect((await put("GREETING", "x", mallory)).status).toBe(404); |
| 474 | 462 | expect( |
test/archive-mcp.test.ts +53 −2
| @@ -18,6 +18,7 @@ let dataDir: string; | ||
| 18 | 18 | let app: App; |
| 19 | 19 | let servers: Servers; |
| 20 | 20 | let repo: Repo; |
| 21 | let second: Repo; | |
| 21 | 22 | const tok: Record<string, string> = {}; |
| 22 | 23 | |
| 23 | 24 | const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { |
| @@ -26,7 +27,7 @@ const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => | ||
| 26 | 27 | }; |
| 27 | 28 | const archived = (): boolean => getRepoById(app.db, repo.id)!.archivedAt !== null; |
| 28 | 29 | |
| 29 | async function mcp(token: string, args: unknown): Promise<{ error?: string; value?: any }> { | |
| 30 | async function mcp(token: string, args: unknown, tool = "archive"): Promise<{ error?: string; value?: any }> { | |
| 30 | 31 | const res = await fetch(`${servers.url}/mcp`, { |
| 31 | 32 | method: "POST", |
| 32 | 33 | headers: { |
| @@ -38,7 +39,7 @@ async function mcp(token: string, args: unknown): Promise<{ error?: string; valu | ||
| 38 | 39 | jsonrpc: "2.0", |
| 39 | 40 | id: 1, |
| 40 | 41 | method: "tools/call", |
| 41 | params: { name: "archive", arguments: args }, | |
| 42 | params: { name: tool, arguments: args }, | |
| 42 | 43 | }), |
| 43 | 44 | }); |
| 44 | 45 | const body = (await res.json()) as any; |
| @@ -65,6 +66,9 @@ beforeAll(async () => { | ||
| 65 | 66 | repo = ok( |
| 66 | 67 | await createRepo(app.db, app.reposRoot, { ownerId: org.id, name: "svc", visibility: "public" }), |
| 67 | 68 | ); |
| 69 | second = ok( | |
| 70 | await createRepo(app.db, app.reposRoot, { ownerId: org.id, name: "web", visibility: "public" }), | |
| 71 | ); | |
| 68 | 72 | const bot = ok(await createAgent(app, founder, { username: "bot" })); |
| 69 | 73 | expect(addToCrew(app, founder, repo, "bot").ok).toBe(true); |
| 70 | 74 | const owners = listTeams(app.db, org.id).find((t) => t.name === OWNERS_TEAM)!; |
| @@ -111,3 +115,50 @@ test("an admin needs an admin token; then archives and wakes", async () => { | ||
| 111 | 115 | expect(woke.value).toMatchObject({ archived: false, was: true }); |
| 112 | 116 | expect(archived()).toBe(false); |
| 113 | 117 | }); |
| 118 | ||
| 119 | const asleep = (r: Repo): boolean => getRepoById(app.db, r.id)!.archivedAt !== null; | |
| 120 | ||
| 121 | test("`repos`: a dry run over two changes nothing; applying archives both; the undo wakes both", async () => { | |
| 122 | const repos = ["acme/svc", "acme/web"]; | |
| 123 | const planned = await mcp(tok["founder"]!, { repos }); | |
| 124 | expect(planned.value.dryRun).toBe(true); | |
| 125 | expect(planned.value.rows.map((r: any) => r.outcome)).toEqual(["change", "change"]); | |
| 126 | expect(asleep(repo) || asleep(second)).toBe(false); | |
| 127 | ||
| 128 | const done = await mcp(tok["founder"]!, { repos, dryRun: false }); | |
| 129 | expect(done.value.rows.map((r: any) => r.repo)).toEqual(["acme/svc", "acme/web"]); | |
| 130 | expect(done.value.rows.every((r: any) => r.outcome === "ok")).toBe(true); | |
| 131 | expect(asleep(repo) && asleep(second)).toBe(true); | |
| 132 | ||
| 133 | const undone = await mcp( | |
| 134 | tok["founder"]!, | |
| 135 | { app: "acme/svc", action: "undo", operation: done.value.operation, dryRun: false }, | |
| 136 | "limb", | |
| 137 | ); | |
| 138 | expect(undone.error).toBeUndefined(); | |
| 139 | expect(asleep(repo) || asleep(second)).toBe(false); | |
| 140 | }); | |
| 141 | ||
| 142 | test("`repos`: a non-admin is refused per row, an agent and a small token are refused outright", async () => { | |
| 143 | const repos = ["acme/svc", "acme/web", "acme/nope"]; | |
| 144 | const tried = await mcp(tok["mallory"]!, { repos, dryRun: false }); | |
| 145 | expect(tried.value.rows.map((r: any) => r.outcome)).toEqual(["refused", "refused", "refused"]); | |
| 146 | expect(asleep(repo) || asleep(second)).toBe(false); | |
| 147 | expect((await mcp(tok["bot"]!, { repos })).error).toContain("forbidden"); | |
| 148 | expect((await mcp(tok["founderRw"]!, { repos, dryRun: false })).error).toContain("forbidden"); | |
| 149 | expect((await mcp(tok["founder"]!, { repos: [] })).error).toContain("invalid"); | |
| 150 | expect((await mcp(tok["founder"]!, { repo: "acme/svc", repos })).error).toContain("invalid"); | |
| 151 | expect((await mcp(tok["founder"]!, { repos, archived: "yes" })).error).toContain("invalid"); | |
| 152 | expect((await mcp(tok["founder"]!, { repos: Array(51).fill("acme/svc") })).error).toContain("invalid"); | |
| 153 | expect(asleep(repo) || asleep(second)).toBe(false); | |
| 154 | }); | |
| 155 | ||
| 156 | test("`repos` with `archived: false` wakes several in one operation", async () => { | |
| 157 | const repos = ["acme/svc", "acme/web"]; | |
| 158 | await mcp(tok["founder"]!, { repos, dryRun: false }); | |
| 159 | expect(asleep(repo) && asleep(second)).toBe(true); | |
| 160 | const woke = await mcp(tok["founder"]!, { repos, archived: false, dryRun: false }); | |
| 161 | expect(woke.value.rows.map((r: any) => r.outcome)).toEqual(["ok", "ok"]); | |
| 162 | expect(typeof woke.value.operation).toBe("string"); | |
| 163 | expect(asleep(repo) || asleep(second)).toBe(false); | |
| 164 | }); |
test/audience-surfaces.test.ts added +119
| @@ -0,0 +1,119 @@ | ||
| 1 | /** | |
| 2 | * The page, the API and the MCP say the same sentence about who can see a | |
| 3 | * repository (#809); the wording itself is tested in audience.test.ts. | |
| 4 | */ | |
| 5 | import { proveAll } from "./helpers/verified.ts"; | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { audienceFor } from "../src/core/audience.ts"; | |
| 13 | import { createApp as makeApp } from "../src/core/apps.ts"; | |
| 14 | import { createToken } from "../src/core/tokens.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | ||
| 18 | const esc = (s: string): string => | |
| 19 | s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); | |
| 20 | ||
| 21 | /** The audience element of a page: its tag, link and text. */ | |
| 22 | function audienceOn(page: string) { | |
| 23 | const m = /<(a|span) class="repo-audience"(?: href="([^"]*)")? title="([^"]*)"\s*>([^<]*)<\/\1\s*>/.exec(page); | |
| 24 | if (m === null) throw new Error("no audience element on the page"); | |
| 25 | return { tag: m[1], href: m[2], title: m[3], text: m[4] }; | |
| 26 | } | |
| 27 | ||
| 28 | describe("one sentence on every surface", () => { | |
| 29 | let dataDir: string; | |
| 30 | let app: App; | |
| 31 | let servers: Servers; | |
| 32 | let olive: User; | |
| 33 | ||
| 34 | beforeAll(async () => { | |
| 35 | dataDir = await mkdtemp(join(tmpdir(), "forge-audience-")); | |
| 36 | app = createApp({ dataDir, port: 0 }); | |
| 37 | servers = await serve(app); | |
| 38 | const made = await createUser(app.db, { | |
| 39 | origin: "seed", | |
| 40 | username: "olive", | |
| 41 | email: "[email protected]", | |
| 42 | password: "a-good-passphrase", | |
| 43 | isAdmin: true, | |
| 44 | }); | |
| 45 | if (!made.ok) throw new Error(made.error); | |
| 46 | olive = made.value; | |
| 47 | proveAll(app); | |
| 48 | }); | |
| 49 | afterAll(async () => { | |
| 50 | servers.stop(); | |
| 51 | app.close(); | |
| 52 | await rm(dataDir, { recursive: true, force: true }); | |
| 53 | }); | |
| 54 | ||
| 55 | test("the API and the MCP say the same words, and the page shows them", async () => { | |
| 56 | const made = await createRepo(app, olive, { name: "garden", visibility: "public" }); | |
| 57 | if (!made.ok) throw new Error(String(made.error)); | |
| 58 | makeApp(app.db, { repoId: made.value.id, slug: "garden", access: "private" }); | |
| 59 | const token = createToken(app.db, olive.id, "t", "ro").token; | |
| 60 | const auth = { authorization: `Bearer ${token}` }; | |
| 61 | const res = await fetch(`${servers.url}/api/v1/repos/olive/garden`, { headers: auth }); | |
| 62 | const body = (await res.json()) as { audience: { level: string; sentence: string } }; | |
| 63 | expect(body.audience).toEqual(audienceFor(app.db, made.value, olive)); | |
| 64 | expect(body.audience.sentence).toStartWith("The code is public; the app answers only to"); | |
| 65 | ||
| 66 | const mcp = await fetch(`${servers.url}/mcp`, { | |
| 67 | method: "POST", | |
| 68 | headers: { | |
| 69 | ...auth, | |
| 70 | "content-type": "application/json", | |
| 71 | accept: "application/json, text/event-stream", | |
| 72 | }, | |
| 73 | body: JSON.stringify({ | |
| 74 | jsonrpc: "2.0", | |
| 75 | id: 1, | |
| 76 | method: "tools/call", | |
| 77 | params: { name: "repo", arguments: { repo: "olive/garden" } }, | |
| 78 | }), | |
| 79 | }); | |
| 80 | expect(await mcp.text()).toContain("The code is public; the app answers only to"); | |
| 81 | ||
| 82 | const page = await (await fetch(`${servers.url}/olive/garden`, { headers: auth })).text(); | |
| 83 | const said = esc(body.audience.sentence); | |
| 84 | expect(audienceOn(page)).toEqual({ | |
| 85 | tag: "a", | |
| 86 | href: "/olive/garden/settings", | |
| 87 | title: said, | |
| 88 | text: said, | |
| 89 | }); | |
| 90 | }); | |
| 91 | ||
| 92 | test("signed out, the page and the API say the same; a non-admin gets no link", async () => { | |
| 93 | const made = await createRepo(app, olive, { name: "meadow", visibility: "public" }); | |
| 94 | if (!made.ok) throw new Error(String(made.error)); | |
| 95 | const api = (await (await fetch(`${servers.url}/api/v1/repos/olive/meadow`)).json()) as { | |
| 96 | audience: { sentence: string }; | |
| 97 | }; | |
| 98 | expect(api.audience.sentence).toStartWith("Public: anyone can read this"); | |
| 99 | const said = esc(api.audience.sentence); | |
| 100 | const out = await (await fetch(`${servers.url}/olive/meadow`)).text(); | |
| 101 | expect(audienceOn(out)).toEqual({ tag: "span", href: undefined, title: said, text: said }); | |
| 102 | ||
| 103 | const wren = await createUser(app.db, { | |
| 104 | origin: "seed", | |
| 105 | username: "wren", | |
| 106 | email: "[email protected]", | |
| 107 | password: "a-good-passphrase", | |
| 108 | }); | |
| 109 | if (!wren.ok) throw new Error(wren.error); | |
| 110 | proveAll(app); | |
| 111 | const token = createToken(app.db, wren.value.id, "t", "ro").token; | |
| 112 | const seen = await ( | |
| 113 | await fetch(`${servers.url}/olive/meadow`, { | |
| 114 | headers: { authorization: `Bearer ${token}` }, | |
| 115 | }) | |
| 116 | ).text(); | |
| 117 | expect(audienceOn(seen)).toEqual({ tag: "span", href: undefined, title: said, text: said }); | |
| 118 | }); | |
| 119 | }); |
test/audience.test.ts added +245
| @@ -0,0 +1,245 @@ | ||
| 1 | /** | |
| 2 | * The audience sentence: each wording from plain facts, and the counts read | |
| 3 | * from a seeded database against what `REPO_VISIBLE` and `REPO_WRITABLE` let | |
| 4 | * through. | |
| 5 | */ | |
| 6 | import { beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | import { | |
| 9 | audienceFacts, | |
| 10 | audienceOf, | |
| 11 | type AudienceFacts, | |
| 12 | } from "../src/core/audience.ts"; | |
| 13 | import { getRepoById } from "../src/core/repos.ts"; | |
| 14 | import { getUserById } from "../src/core/users.ts"; | |
| 15 | import { migrations } from "../src/core/schema.ts"; | |
| 16 | import { REPO_VISIBLE, REPO_WRITABLE } from "../src/core/visibility.ts"; | |
| 17 | import { openDb } from "../src/kernel/db.ts"; | |
| 18 | ||
| 19 | const base: AudienceFacts = { | |
| 20 | visibility: "private", | |
| 21 | viewer: "person", | |
| 22 | ownerKind: "user", | |
| 23 | ownerName: "ann", | |
| 24 | readers: 0, | |
| 25 | writers: 1, | |
| 26 | viewerWrites: true, | |
| 27 | crew: true, | |
| 28 | }; | |
| 29 | ||
| 30 | describe("audienceOf", () => { | |
| 31 | test("a private repository of one person", () => { | |
| 32 | expect(audienceOf(base)).toEqual({ | |
| 33 | level: "private", | |
| 34 | sentence: "Private: only you and the crew.", | |
| 35 | }); | |
| 36 | expect(audienceOf({ ...base, crew: false }).sentence).toBe( | |
| 37 | "Private: only you.", | |
| 38 | ); | |
| 39 | }); | |
| 40 | ||
| 41 | test("a private organisation's people", () => { | |
| 42 | const facts = { ...base, ownerKind: "org" as const, ownerName: "acme" }; | |
| 43 | expect(audienceOf({ ...facts, readers: 3 }).sentence).toBe( | |
| 44 | "Private: you, 3 people in acme, and the crew.", | |
| 45 | ); | |
| 46 | expect(audienceOf({ ...facts, readers: 1, crew: false }).sentence).toBe( | |
| 47 | "Private: you and 1 person in acme.", | |
| 48 | ); | |
| 49 | }); | |
| 50 | ||
| 51 | test("a private repository seen by someone who is not its owner", () => { | |
| 52 | expect(audienceOf({ ...base, readers: 1 }).sentence).toBe( | |
| 53 | "Private: you, ann, and the crew.", | |
| 54 | ); | |
| 55 | }); | |
| 56 | ||
| 57 | test("a public repository", () => { | |
| 58 | expect(audienceOf({ ...base, visibility: "public", writers: 2 })).toEqual({ | |
| 59 | level: "public", | |
| 60 | sentence: | |
| 61 | "Public: anyone can read this; 2 people and the crew can change it.", | |
| 62 | }); | |
| 63 | expect( | |
| 64 | audienceOf({ ...base, visibility: "public", writers: 1 }).sentence, | |
| 65 | ).toContain("1 person and the crew can change it"); | |
| 66 | }); | |
| 67 | ||
| 68 | test("a signed-out viewer is never called you", () => { | |
| 69 | const out = { ...base, viewer: "signed-out" as const, viewerWrites: false }; | |
| 70 | expect( | |
| 71 | audienceOf({ | |
| 72 | ...out, | |
| 73 | visibility: "public", | |
| 74 | writers: 2, | |
| 75 | app: { access: "private", writers: 2, named: 1, viewerNamed: false }, | |
| 76 | }).sentence, | |
| 77 | ).toBe( | |
| 78 | "The code is public; the app answers only to 2 people who can change it, 1 person it names, and the crew.", | |
| 79 | ); | |
| 80 | expect(audienceOf({ ...out, readers: 1 }).sentence).toBe( | |
| 81 | "Private: ann and the crew.", | |
| 82 | ); | |
| 83 | expect(audienceOf({ ...out, crew: false }).sentence).toBe("Private: nobody."); | |
| 84 | expect( | |
| 85 | audienceOf({ ...out, visibility: "public", writers: 0, crew: false }).sentence, | |
| 86 | ).toBe("Public: anyone can read this; nobody can change it."); | |
| 87 | }); | |
| 88 | ||
| 89 | test("a private app on a public repository", () => { | |
| 90 | const facts: AudienceFacts = { | |
| 91 | ...base, | |
| 92 | visibility: "public", | |
| 93 | writers: 3, | |
| 94 | app: { access: "private", writers: 2, named: 2, viewerNamed: false }, | |
| 95 | }; | |
| 96 | expect(audienceOf(facts)).toEqual({ | |
| 97 | level: "split", | |
| 98 | sentence: | |
| 99 | "The code is public; the app answers only to you, 2 people who can change it, 2 people it names, and the crew.", | |
| 100 | }); | |
| 101 | expect( | |
| 102 | audienceOf({ ...facts, writers: 1, crew: false, app: { access: "private", writers: 0, named: 0, viewerNamed: false } }) | |
| 103 | .sentence, | |
| 104 | ).toBe("The code is public; the app answers only to you."); | |
| 105 | }); | |
| 106 | ||
| 107 | test("a public app on a public repository changes nothing", () => { | |
| 108 | const facts = { ...base, visibility: "public" as const }; | |
| 109 | expect(audienceOf({ ...facts, app: { access: "public", writers: 0, named: 0, viewerNamed: false } }).level).toBe( | |
| 110 | "public", | |
| 111 | ); | |
| 112 | }); | |
| 113 | }); | |
| 114 | ||
| 115 | describe("audienceFacts", () => { | |
| 116 | let db: Database; | |
| 117 | const ins = (sql: string) => db.run(sql); | |
| 118 | const user = (id: string) => | |
| 119 | ins( | |
| 120 | `INSERT INTO user (id, username, email, password_hash) VALUES ('${id}','${id}','${id}@x','x')`, | |
| 121 | ); | |
| 122 | const facts = (repoId: string, viewerId: string | undefined) => | |
| 123 | audienceFacts( | |
| 124 | db, | |
| 125 | getRepoById(db, repoId)!, | |
| 126 | viewerId ? getUserById(db, viewerId) : undefined, | |
| 127 | ); | |
| 128 | ||
| 129 | beforeAll(() => { | |
| 130 | db = openDb(":memory:", migrations); | |
| 131 | ins("INSERT INTO user (id, username, email, password_hash, kind) VALUES ('acme','acme','a@x','x','org')"); | |
| 132 | for (const id of ["ann", "bob", "cy", "dee", "gone", "root"]) user(id); | |
| 133 | ins("UPDATE user SET suspended_at = '2026-01-01T00:00:00Z' WHERE id = 'gone'"); | |
| 134 | ins("UPDATE user SET is_admin = 1 WHERE id = 'root'"); | |
| 135 | ins("INSERT INTO user (id, username, email, password_hash, is_agent) VALUES ('bot','bot','b@x','x',1)"); | |
| 136 | ins("INSERT INTO team (id, org_id, name, permission) VALUES ('t1','acme','read','read'), ('t2','acme','dev','write')"); | |
| 137 | ins("INSERT INTO team_member (team_id, user_id) VALUES ('t1','bob'),('t1','cy'),('t2','cy'),('t2','dee'),('t1','gone'),('t2','gone')"); | |
| 138 | ins("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('org-priv','acme','p','private'),('org-pub','acme','q','public'),('mine','ann','m','private')"); | |
| 139 | ins("INSERT INTO repo_crew (repo_id, user_id) VALUES ('org-priv','bot')"); | |
| 140 | ins("INSERT INTO app (id, repo_id, slug, access) VALUES ('a1','org-pub','pub-app','private')"); | |
| 141 | ins("INSERT INTO app_member (app_id, user_id) VALUES ('a1','ann'),('a1','gone'),('a1','cy')"); | |
| 142 | }); | |
| 143 | ||
| 144 | test("counts match what visibility.ts lets through", () => { | |
| 145 | const every = (rule: string, repoId: string) => | |
| 146 | db | |
| 147 | .query<{ id: string }, [string]>( | |
| 148 | `SELECT cand.id FROM user AS cand WHERE cand.kind = 'user' AND cand.is_agent = 0 | |
| 149 | AND cand.is_admin = 0 AND cand.suspended_at IS NULL | |
| 150 | AND EXISTS (SELECT 1 FROM repo WHERE repo.id = ?1 AND ${rule.replaceAll("?1", "cand.id")})`, | |
| 151 | ) | |
| 152 | .all(repoId) | |
| 153 | .map((r) => r.id); | |
| 154 | const readers = every(REPO_VISIBLE, "org-priv"); | |
| 155 | expect(readers.sort()).toEqual(["bob", "cy", "dee"]); | |
| 156 | const f = facts("org-priv", "ann"); | |
| 157 | expect(f.readers).toBe(readers.length); | |
| 158 | expect(f.writers).toBe(every(REPO_WRITABLE, "org-priv").length); | |
| 159 | expect(f.writers).toBe(2); | |
| 160 | expect(f.crew).toBe(true); | |
| 161 | expect(f.ownerKind).toBe("org"); | |
| 162 | }); | |
| 163 | ||
| 164 | test("the viewer is not counted among the readers, and may write", () => { | |
| 165 | const f = facts("org-priv", "cy"); | |
| 166 | expect(f.readers).toBe(2); | |
| 167 | expect(f.viewerWrites).toBe(true); | |
| 168 | expect(facts("org-priv", "bob").viewerWrites).toBe(false); | |
| 169 | expect(audienceOf(facts("org-priv", "cy")).sentence).toBe( | |
| 170 | "Private: you, 2 people in acme, and the crew.", | |
| 171 | ); | |
| 172 | }); | |
| 173 | ||
| 174 | test("a personal repository has no one but its owner", () => { | |
| 175 | expect(audienceOf(facts("mine", "ann"))).toEqual({ | |
| 176 | level: "private", | |
| 177 | sentence: "Private: only you.", | |
| 178 | }); | |
| 179 | expect(facts("mine", "ann").writers).toBe(1); | |
| 180 | }); | |
| 181 | ||
| 182 | test("a private app names its active people", () => { | |
| 183 | const f = facts("org-pub", "dee"); | |
| 184 | expect(f.app).toEqual({ | |
| 185 | access: "private", | |
| 186 | writers: 1, | |
| 187 | named: 1, | |
| 188 | viewerNamed: false, | |
| 189 | }); | |
| 190 | expect(audienceOf(f).sentence).toBe( | |
| 191 | "The code is public; the app answers only to you, 1 person who can change it, and 1 person it names.", | |
| 192 | ); | |
| 193 | }); | |
| 194 | ||
| 195 | test("a writer who is also named is counted once", () => { | |
| 196 | // cy writes and is named; ann is only named; dee only writes. | |
| 197 | const f = facts("org-pub", undefined); | |
| 198 | expect(f.app).toMatchObject({ writers: 2, named: 1 }); | |
| 199 | expect(audienceOf(f).sentence).toBe( | |
| 200 | "The code is public; the app answers only to 2 people who can change it and 1 person it names.", | |
| 201 | ); | |
| 202 | const asCy = facts("org-pub", "cy"); | |
| 203 | expect(asCy.app).toMatchObject({ writers: 1, named: 1, viewerNamed: true }); | |
| 204 | }); | |
| 205 | ||
| 206 | test("a named viewer who cannot write is 'you', and not counted again", () => { | |
| 207 | const f = facts("org-pub", "ann"); | |
| 208 | expect(f.viewerWrites).toBe(false); | |
| 209 | expect(f.app).toMatchObject({ writers: 2, named: 0, viewerNamed: true }); | |
| 210 | expect(audienceOf(f).sentence).toBe( | |
| 211 | "The code is public; the app answers only to you and 2 people who can change it.", | |
| 212 | ); | |
| 213 | }); | |
| 214 | ||
| 215 | test("a viewer who neither writes nor is named is not told 'you'", () => { | |
| 216 | for (const id of ["bob", "root"]) { | |
| 217 | const f = facts("org-pub", id); | |
| 218 | expect(audienceOf(f).sentence).not.toContain("you"); | |
| 219 | } | |
| 220 | expect(audienceOf(facts("org-pub", "bob")).sentence).toBe( | |
| 221 | "The code is public; the app answers only to 2 people who can change it and 1 person it names.", | |
| 222 | ); | |
| 223 | }); | |
| 224 | ||
| 225 | test("an admin viewer is told 'you' and counted like a person", () => { | |
| 226 | const f = facts("org-priv", "root"); | |
| 227 | expect(f.viewer).toBe("admin"); | |
| 228 | // Their reach is the forge's: membership of the repository is what counts. | |
| 229 | expect(f.viewerWrites).toBe(false); | |
| 230 | expect(f.readers).toBe(3); | |
| 231 | expect(audienceOf(f).sentence).toBe( | |
| 232 | "Private: you, 3 people in acme, and the crew.", | |
| 233 | ); | |
| 234 | }); | |
| 235 | ||
| 236 | test("an anonymous viewer is no one", () => { | |
| 237 | const f = facts("org-pub", undefined); | |
| 238 | expect(f.viewer).toBe("signed-out"); | |
| 239 | expect(audienceOf(f).sentence).toBe( | |
| 240 | "The code is public; the app answers only to 2 people who can change it and 1 person it names.", | |
| 241 | ); | |
| 242 | expect(f.viewerWrites).toBe(false); | |
| 243 | expect(f.writers).toBe(2); | |
| 244 | }); | |
| 245 | }); |
test/capacity-cpus.test.ts added +143
| @@ -0,0 +1,143 @@ | ||
| 1 | /** | |
| 2 | * Checks at once and shards per check default to what the pod's CPUs hold | |
| 3 | * (#2549): shards `min(4, cpus)`, checks `floor(cpus / (shards × 3))` held | |
| 4 | * to 1–16, so the vxrail's 80 cores need no override and the sentinel no | |
| 5 | * longer calls 4 shards and 6 checks away from their defaults. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterEach, describe, expect, test } from "bun:test"; | |
| 9 | import { writeFileSync } from "node:fs"; | |
| 10 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 11 | import { availableParallelism, tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { createApp } from "../src/app.ts"; | |
| 14 | import { | |
| 15 | CAPACITY, | |
| 16 | capacityDefault, | |
| 17 | capacityEnv, | |
| 18 | capacityValue, | |
| 19 | checksForCpus, | |
| 20 | cpuLimit, | |
| 21 | provideCpusForTest, | |
| 22 | setCapacity, | |
| 23 | shardsForCpus, | |
| 24 | } from "../src/core/capacity.ts"; | |
| 25 | import { settingsAway } from "../src/crew/sentinel.ts"; | |
| 26 | import { createUser } from "../src/core/users.ts"; | |
| 27 | ||
| 28 | const ENV = ["FORGE_CHECK_SHARDS", "FORGE_CHECKS_CONCURRENCY"] as const; | |
| 29 | const saved = ENV.map((k) => process.env[k]); | |
| 30 | afterEach(() => { | |
| 31 | ENV.forEach((k, i) => { | |
| 32 | if (saved[i] === undefined) delete process.env[k]; | |
| 33 | else process.env[k] = saved[i]; | |
| 34 | }); | |
| 35 | provideCpusForTest(4); | |
| 36 | }); | |
| 37 | ||
| 38 | describe("the defaults follow the CPUs", () => { | |
| 39 | // By hand: shards = min(4, cpus); checks = floor(cpus / (shards * 3)), 1..16. | |
| 40 | test.each([ | |
| 41 | // cpus, shards, checks | |
| 42 | [4, 4, 1], // 4 / 12 = 0.33 → held to 1 | |
| 43 | [16, 4, 1], // 16 / 12 = 1.33 → 1 | |
| 44 | [80, 4, 6], // 80 / 12 = 6.67 → 6 | |
| 45 | [2, 2, 1], // 2 / 6 = 0.33 → 1 | |
| 46 | [1, 1, 1], | |
| 47 | [1000, 4, 16], // 1000 / 12 = 83 → held to 16 | |
| 48 | ])("%d CPUs: %d shards, %d checks at once", (cpus, shards, checks) => { | |
| 49 | expect(shardsForCpus(cpus)).toBe(shards); | |
| 50 | expect(checksForCpus(cpus)).toBe(checks); | |
| 51 | delete process.env.FORGE_CHECK_SHARDS; | |
| 52 | delete process.env.FORGE_CHECKS_CONCURRENCY; | |
| 53 | expect(capacityDefault("checks.shards", cpus)).toBe(String(shards)); | |
| 54 | expect(capacityDefault("checks.concurrency", cpus)).toBe(String(checks)); | |
| 55 | provideCpusForTest(cpus); | |
| 56 | expect(capacityDefault("checks.shards")).toBe(String(shards)); | |
| 57 | expect(capacityDefault("checks.concurrency")).toBe(String(checks)); | |
| 58 | }); | |
| 59 | ||
| 60 | test("the environment still wins over the CPUs", () => { | |
| 61 | process.env.FORGE_CHECK_SHARDS = "8"; | |
| 62 | process.env.FORGE_CHECKS_CONCURRENCY = "3"; | |
| 63 | expect(capacityDefault("checks.shards", 80)).toBe("8"); | |
| 64 | expect(capacityDefault("checks.concurrency", 80)).toBe("3"); | |
| 65 | }); | |
| 66 | ||
| 67 | test("both help lines say how the default is reached", () => { | |
| 68 | expect(CAPACITY["checks.shards"].help).toContain("at most 4"); | |
| 69 | expect(CAPACITY["checks.concurrency"].help).toContain("shards × 3"); | |
| 70 | }); | |
| 71 | }); | |
| 72 | ||
| 73 | describe("a stored value overrides the default; the sentinel compares with it", () => { | |
| 74 | test("80 CPUs: 4 shards and 6 checks are the defaults, 8 shards is away", async () => { | |
| 75 | delete process.env.FORGE_CHECK_SHARDS; | |
| 76 | delete process.env.FORGE_CHECKS_CONCURRENCY; | |
| 77 | provideCpusForTest(80); | |
| 78 | const dataDir = await mkdtemp(join(tmpdir(), "forge-capacity-cpus-")); | |
| 79 | const app = createApp({ dataDir, port: 0 }); | |
| 80 | try { | |
| 81 | const a = await createUser(app.db, { | |
| 82 | origin: "seed", | |
| 83 | trusted: true, | |
| 84 | username: "alice", | |
| 85 | email: "[email protected]", | |
| 86 | password: "a-good-passphrase", | |
| 87 | adminIfFirst: true, | |
| 88 | }); | |
| 89 | if (!a.ok) throw new Error(a.error); | |
| 90 | expect(capacityValue(app.db, "checks.shards")).toBe("4"); | |
| 91 | expect(capacityEnv(app.db)).toEqual({ FORGE_CHECK_SHARDS: "4" }); | |
| 92 | // A check's cell of 2 CPUs is given 2, not the pod's 4. | |
| 93 | expect(capacityEnv(app.db, 2)).toEqual({ FORGE_CHECK_SHARDS: "2" }); | |
| 94 | ||
| 95 | // The owner's choice for the vxrail, set more than a day ago. | |
| 96 | setCapacity(app.db, "checks.shards", "4", a.value.id); | |
| 97 | setCapacity(app.db, "checks.concurrency", "6", a.value.id); | |
| 98 | setCapacity(app.db, "crew.concurrency", "5", a.value.id); | |
| 99 | const old = new Date(Date.now() - 3 * 24 * 3600_000).toISOString(); | |
| 100 | app.db.query("UPDATE forge_setting SET updated_at = ?1").run(old); | |
| 101 | expect(settingsAway(app.db, Date.now()).map((f) => f.key)).toEqual(["setting:crew.concurrency"]); | |
| 102 | ||
| 103 | // A stored value overrides the default, even a cell's smaller one. | |
| 104 | setCapacity(app.db, "checks.shards", "8", a.value.id); | |
| 105 | app.db.query("UPDATE forge_setting SET updated_at = ?1").run(old); | |
| 106 | expect(capacityValue(app.db, "checks.shards")).toBe("8"); | |
| 107 | expect(capacityEnv(app.db, 2)).toEqual({ FORGE_CHECK_SHARDS: "8" }); | |
| 108 | const away = settingsAway(app.db, Date.now()).find((f) => f.key === "setting:checks.shards"); | |
| 109 | expect(away?.evidence[0]).toBe('checks.shards = "8", default "4"'); | |
| 110 | } finally { | |
| 111 | app.close(); | |
| 112 | await rm(dataDir, { recursive: true, force: true }); | |
| 113 | } | |
| 114 | }); | |
| 115 | }); | |
| 116 | ||
| 117 | describe("cpuLimit reads the pod's cgroup", () => { | |
| 118 | const withFile = async (text: string | undefined, run: (file: string) => void) => { | |
| 119 | provideCpusForTest(undefined); | |
| 120 | const dir = await mkdtemp(join(tmpdir(), "forge-cpu-max-")); | |
| 121 | const file = join(dir, "cpu.max"); | |
| 122 | if (text !== undefined) writeFileSync(file, text); | |
| 123 | try { | |
| 124 | run(file); | |
| 125 | } finally { | |
| 126 | await rm(dir, { recursive: true, force: true }); | |
| 127 | } | |
| 128 | }; | |
| 129 | const machine = availableParallelism(); | |
| 130 | ||
| 131 | test("quota over period, rounded down, never more than the machine", async () => { | |
| 132 | await withFile("150000 100000\n", (f) => expect(cpuLimit(f)).toBe(Math.min(machine, 1))); | |
| 133 | await withFile("50000 100000\n", (f) => expect(cpuLimit(f)).toBe(1)); | |
| 134 | await withFile(`${machine * 100000} 100000\n`, (f) => expect(cpuLimit(f)).toBe(machine)); | |
| 135 | await withFile(`${machine * 200000} 100000\n`, (f) => expect(cpuLimit(f)).toBe(machine)); | |
| 136 | }); | |
| 137 | ||
| 138 | test("no quota, garbage or an unreadable file falls back to the machine", async () => { | |
| 139 | await withFile("max 100000\n", (f) => expect(cpuLimit(f)).toBe(machine)); | |
| 140 | await withFile("nonsense", (f) => expect(cpuLimit(f)).toBe(machine)); | |
| 141 | await withFile(undefined, (f) => expect(cpuLimit(f)).toBe(machine)); | |
| 142 | }); | |
| 143 | }); |
test/capacity-crew.test.ts added +98
| @@ -0,0 +1,98 @@ | ||
| 1 | /** | |
| 2 | * Crew runs at once defaults to what the pod's memory holds (#2548): one run | |
| 3 | * per 3 GiB above the forge's own 2 GiB, at least 2 and at most 16. The | |
| 4 | * environment still wins, then a stored value; the sentinel's | |
| 5 | * away-from-default check compares with this default, so a big machine | |
| 6 | * running its share is not flagged. | |
| 7 | */ | |
| 8 | ||
| 9 | import { afterEach, beforeEach, describe, expect, test } from "bun:test"; | |
| 10 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { | |
| 15 | CAPACITY, | |
| 16 | capacityDefault, | |
| 17 | capacityValue, | |
| 18 | crewForMemory, | |
| 19 | memoryLimitBytes, | |
| 20 | setCapacity, | |
| 21 | } from "../src/core/capacity.ts"; | |
| 22 | import { settingsAway } from "../src/crew/sentinel.ts"; | |
| 23 | ||
| 24 | const GiB = 1024 ** 3; | |
| 25 | ||
| 26 | describe("crewForMemory", () => { | |
| 27 | test("one run per 3 GiB above 2 GiB, between 2 and 16", () => { | |
| 28 | expect(crewForMemory(4 * GiB)).toBe(2); // (4 − 2) / 3 = 0 → floor of 2 | |
| 29 | expect(crewForMemory(32 * GiB)).toBe(10); // (32 − 2) / 3 = 10 | |
| 30 | expect(crewForMemory(64 * GiB)).toBe(16); // (64 − 2) / 3 = 20 → ceiling of 16 | |
| 31 | expect(crewForMemory(11 * GiB)).toBe(3); // (11 − 2) / 3 = 3 | |
| 32 | expect(crewForMemory(GiB)).toBe(2); | |
| 33 | expect(crewForMemory(Number.NaN)).toBe(2); | |
| 34 | }); | |
| 35 | ||
| 36 | test("the capacity page says how the default is reached", () => { | |
| 37 | expect(CAPACITY["crew.concurrency"].help).toContain( | |
| 38 | "Empty: one per 3 GiB of memory above the forge's own 2 GiB", | |
| 39 | ); | |
| 40 | }); | |
| 41 | }); | |
| 42 | ||
| 43 | describe("the crew's default, and what overrides it", () => { | |
| 44 | let dataDir: string; | |
| 45 | let app: App; | |
| 46 | let saved: string | undefined; | |
| 47 | ||
| 48 | beforeEach(async () => { | |
| 49 | saved = process.env.FORGE_CREW_CONCURRENCY; | |
| 50 | delete process.env.FORGE_CREW_CONCURRENCY; | |
| 51 | dataDir = await mkdtemp(join(tmpdir(), "forge-capacity-crew-")); | |
| 52 | app = createApp({ dataDir, port: 0 }); | |
| 53 | }); | |
| 54 | afterEach(async () => { | |
| 55 | if (saved === undefined) delete process.env.FORGE_CREW_CONCURRENCY; | |
| 56 | else process.env.FORGE_CREW_CONCURRENCY = saved; | |
| 57 | app.close(); | |
| 58 | await rm(dataDir, { recursive: true, force: true }); | |
| 59 | }); | |
| 60 | ||
| 61 | test("unset, it is the pod's memory's share", () => { | |
| 62 | expect(capacityDefault("crew.concurrency")).toBe(String(crewForMemory(memoryLimitBytes()))); | |
| 63 | }); | |
| 64 | ||
| 65 | test("a set FORGE_CREW_CONCURRENCY wins over memory", () => { | |
| 66 | process.env.FORGE_CREW_CONCURRENCY = "7"; | |
| 67 | expect(capacityDefault("crew.concurrency")).toBe("7"); | |
| 68 | process.env.FORGE_CREW_CONCURRENCY = "99"; // out of range: ignored | |
| 69 | expect(capacityDefault("crew.concurrency")).toBe(String(crewForMemory(memoryLimitBytes()))); | |
| 70 | }); | |
| 71 | ||
| 72 | test("a stored value overrides the default", () => { | |
| 73 | const other = capacityDefault("crew.concurrency") === "5" ? "6" : "5"; | |
| 74 | setCapacity(app.db, "crew.concurrency", other, "system"); | |
| 75 | expect(capacityValue(app.db, "crew.concurrency")).toBe(other); | |
| 76 | setCapacity(app.db, "crew.concurrency", null, "system"); | |
| 77 | expect(capacityValue(app.db, "crew.concurrency")).toBe(capacityDefault("crew.concurrency")); | |
| 78 | }); | |
| 79 | ||
| 80 | test("the sentinel flags a value away from the memory default, not one at it", () => { | |
| 81 | const now = Date.now(); | |
| 82 | const age = () => | |
| 83 | app.db | |
| 84 | .query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'crew.concurrency'") | |
| 85 | .run(new Date(now - 3 * 86_400_000).toISOString()); | |
| 86 | const crew = () => settingsAway(app.db, now).filter((f) => f.key === "setting:crew.concurrency"); | |
| 87 | const fallback = capacityDefault("crew.concurrency"); | |
| 88 | setCapacity(app.db, "crew.concurrency", fallback, "system"); | |
| 89 | age(); | |
| 90 | expect(crew()).toEqual([]); | |
| 91 | const other = fallback === "5" ? "6" : "5"; | |
| 92 | setCapacity(app.db, "crew.concurrency", other, "system"); | |
| 93 | age(); | |
| 94 | const found = crew(); | |
| 95 | expect(found).toHaveLength(1); | |
| 96 | expect(found[0]!.evidence[0]).toBe(`crew.concurrency = "${other}", default "${fallback}"`); | |
| 97 | }); | |
| 98 | }); |
test/capacity.test.ts +14 −4
| @@ -23,6 +23,7 @@ import { startChecks, type ChecksHandle } from "../src/checks/service.ts"; | ||
| 23 | 23 | import { |
| 24 | 24 | capacityChanges, |
| 25 | 25 | capacityCount, |
| 26 | capacityDefault, | |
| 26 | 27 | noteBooted, |
| 27 | 28 | capacityEnv, |
| 28 | 29 | capacityValue, |
| @@ -291,14 +292,23 @@ describe("capacity: only a forge admin reads or changes it", () => { | ||
| 291 | 292 | body: new URLSearchParams({ key: "checks.shards", reset: "1" }), |
| 292 | 293 | }); |
| 293 | 294 | expect(reset.status).toBe(303); |
| 294 | expect(capacityEnv(app.db)).toEqual({}); | |
| 295 | // Back at the default, which is sized from the CPUs it is given (#2549). | |
| 296 | const fromEnv = process.env.FORGE_CHECK_SHARDS; | |
| 297 | delete process.env.FORGE_CHECK_SHARDS; | |
| 298 | try { | |
| 299 | expect(capacityEnv(app.db, 80)).toEqual({ FORGE_CHECK_SHARDS: "4" }); | |
| 300 | expect(capacityEnv(app.db, 2)).toEqual({ FORGE_CHECK_SHARDS: "2" }); | |
| 301 | } finally { | |
| 302 | if (fromEnv !== undefined) process.env.FORGE_CHECK_SHARDS = fromEnv; | |
| 303 | } | |
| 304 | const shardsDefault = capacityDefault("checks.shards"); | |
| 295 | 305 | |
| 296 | 306 | const changes = capacityChanges(app.db); |
| 297 | 307 | expect(changes.map((c) => [c.key, c.from, c.to, c.actorName])).toEqual([ |
| 298 | ["checks.shards", "3", "", "alice"], | |
| 308 | ["checks.shards", "3", shardsDefault, "alice"], | |
| 299 | 309 | ["cells.egress", "enforce", "report", "alice"], |
| 300 | ["crew.concurrency", "2", "3", "alice"], | |
| 301 | ["checks.shards", "", "3", "alice"], | |
| 310 | ["crew.concurrency", capacityDefault("crew.concurrency"), "3", "alice"], | |
| 311 | ["checks.shards", shardsDefault, "3", "alice"], | |
| 302 | 312 | ]); |
| 303 | 313 | const after = await ( |
| 304 | 314 | await fetch(`${servers.url}/settings/capacity`, { headers: { cookie } }) |
test/check-steps.test.ts added +170
| @@ -0,0 +1,170 @@ | ||
| 1 | /** | |
| 2 | * A check's three step rows, read from the run's state and its log | |
| 3 | * (`checkSteps`, `src/checks/summary.ts`): what each row says while the | |
| 4 | * check runs, when it ends red at each step, when it is stopped, for a job | |
| 5 | * placed check, and for a log whose head was cut away. | |
| 6 | */ | |
| 7 | ||
| 8 | import { describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { stepsLine } from "../src/checks/progress.ts"; | |
| 13 | import { checkSteps } from "../src/checks/summary.ts"; | |
| 14 | import type { Run } from "../src/core/runs.ts"; | |
| 15 | import { appendRunLog } from "../src/runs/log.ts"; | |
| 16 | ||
| 17 | const read = ( | |
| 18 | state: "queued" | "running" | "ok" | "failed" | "cancelled", | |
| 19 | lines: string[], | |
| 20 | opts: { placement?: string; truncated?: boolean } = {}, | |
| 21 | ) => | |
| 22 | checkSteps( | |
| 23 | { state, placement: opts.placement ?? "process" }, | |
| 24 | lines, | |
| 25 | opts.truncated ?? false, | |
| 26 | ).map((s) => `${s.name}:${s.state}`); | |
| 27 | ||
| 28 | const INSTALL = "$ bun install"; | |
| 29 | const CHECK = "$ gate && tsc && bun scripts/test-shards.ts"; | |
| 30 | const BANNER = "test-shards: 9 files"; | |
| 31 | ||
| 32 | describe("checkSteps", () => { | |
| 33 | test("a run that has logged nothing is waiting, then running install", () => { | |
| 34 | expect(read("queued", [])).toEqual([ | |
| 35 | "install:pending", | |
| 36 | "typecheck:pending", | |
| 37 | "tests:pending", | |
| 38 | ]); | |
| 39 | expect(read("running", [INSTALL])).toEqual([ | |
| 40 | "install:running", | |
| 41 | "typecheck:pending", | |
| 42 | "tests:pending", | |
| 43 | ]); | |
| 44 | }); | |
| 45 | ||
| 46 | test("typecheck runs between the check's echo and the shard banner", () => { | |
| 47 | expect(read("running", [INSTALL, CHECK])).toEqual([ | |
| 48 | "install:ok", | |
| 49 | "typecheck:running", | |
| 50 | "tests:pending", | |
| 51 | ]); | |
| 52 | }); | |
| 53 | ||
| 54 | test("a red typecheck fails typecheck and leaves the tests unrun", () => { | |
| 55 | expect(read("failed", [INSTALL, CHECK, "src/a.ts(1,1): error TS2322"])).toEqual([ | |
| 56 | "install:ok", | |
| 57 | "typecheck:failed", | |
| 58 | "tests:pending", | |
| 59 | ]); | |
| 60 | }); | |
| 61 | ||
| 62 | test("a red install fails install", () => { | |
| 63 | expect(read("failed", [INSTALL, "error: no such package"])).toEqual([ | |
| 64 | "install:failed", | |
| 65 | "typecheck:pending", | |
| 66 | "tests:pending", | |
| 67 | ]); | |
| 68 | }); | |
| 69 | ||
| 70 | test("a repository with no install command starts at typecheck", () => { | |
| 71 | expect(read("running", [CHECK])).toEqual([ | |
| 72 | "install:ok", | |
| 73 | "typecheck:running", | |
| 74 | "tests:pending", | |
| 75 | ]); | |
| 76 | }); | |
| 77 | ||
| 78 | test("a cancelled run stops at the step it was on", () => { | |
| 79 | expect(read("cancelled", [INSTALL, CHECK, BANNER])).toEqual([ | |
| 80 | "install:ok", | |
| 81 | "typecheck:ok", | |
| 82 | "tests:stopped", | |
| 83 | ]); | |
| 84 | }); | |
| 85 | ||
| 86 | test("a passed run is passed at every step", () => { | |
| 87 | expect(read("ok", [CHECK])).toEqual([ | |
| 88 | "install:ok", | |
| 89 | "typecheck:ok", | |
| 90 | "tests:ok", | |
| 91 | ]); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a job-placed check echoes every command first: install is done only once output follows", () => { | |
| 95 | expect(read("running", [INSTALL, CHECK], { placement: "job" })).toEqual([ | |
| 96 | "install:running", | |
| 97 | "typecheck:pending", | |
| 98 | "tests:pending", | |
| 99 | ]); | |
| 100 | expect( | |
| 101 | read("running", [INSTALL, CHECK, "src/a.ts ok"], { placement: "job" }), | |
| 102 | ).toEqual(["install:ok", "typecheck:running", "tests:pending"]); | |
| 103 | }); | |
| 104 | ||
| 105 | test("a truncated log whose tests cannot be seen reads unknown, not wrong", () => { | |
| 106 | expect(read("running", ["some late output"], { truncated: true })).toEqual([ | |
| 107 | "install:unknown", | |
| 108 | "typecheck:unknown", | |
| 109 | "tests:unknown", | |
| 110 | ]); | |
| 111 | expect( | |
| 112 | read("running", ["── shard 2/4: test/a.test.ts"], { truncated: true }), | |
| 113 | ).toEqual(["install:ok", "typecheck:ok", "tests:running"]); | |
| 114 | }); | |
| 115 | }); | |
| 116 | ||
| 117 | describe("the terminal's step line", () => { | |
| 118 | const dir = mkdtempSync(join(tmpdir(), "forge-steps-")); | |
| 119 | const run = { id: "r1", kind: "check", state: "running", placement: "process" } as Run; | |
| 120 | for (const l of [INSTALL, CHECK, BANNER]) appendRunLog(dir, "r1", l); | |
| 121 | ||
| 122 | test("reads the same rows as the page, through the same function", () => { | |
| 123 | expect(stepsLine(dir, { state: "running", run }, true)).toBe( | |
| 124 | "install passed · typecheck passed · tests running", | |
| 125 | ); | |
| 126 | expect( | |
| 127 | stepsLine(dir, { state: "failed", run: { ...run, state: "failed" } }, true), | |
| 128 | ).toBe("install passed · typecheck passed · tests failed"); | |
| 129 | }); | |
| 130 | ||
| 131 | test("carries the tests row and the pinned failure names, withheld with the rest", () => { | |
| 132 | const d = mkdtempSync(join(tmpdir(), "forge-steps-")); | |
| 133 | for (const l of [ | |
| 134 | INSTALL, | |
| 135 | CHECK, | |
| 136 | "test-shards: 9 files in 3 shards", | |
| 137 | "── shard 1/3: test/a.test.ts test/b.test.ts", | |
| 138 | "error: boom", | |
| 139 | "(fail) b breaks", | |
| 140 | " 5 pass", | |
| 141 | " 1 fail", | |
| 142 | "── shard 1/3 done (exit 1, 2.0s)", | |
| 143 | ]) | |
| 144 | appendRunLog(d, "r2", l); | |
| 145 | const r = { ...run, id: "r2" } as Run; | |
| 146 | expect(stepsLine(d, { state: "running", run: r }, true)).toBe( | |
| 147 | "install passed · typecheck passed · tests running · tests 2 of 9 files started · 5 passed, 1 failed · failing: b breaks", | |
| 148 | ); | |
| 149 | expect(stepsLine(d, { state: "running", run: r }, false)).toBe(""); | |
| 150 | rmSync(d, { recursive: true, force: true }); | |
| 151 | }); | |
| 152 | ||
| 153 | test("says nothing to a reader who may not read the log, nor for a deploy", () => { | |
| 154 | expect(stepsLine(dir, { state: "running", run }, false)).toBe(""); | |
| 155 | expect( | |
| 156 | stepsLine(dir, { state: "running", run: { ...run, kind: "deploy" } }, true), | |
| 157 | ).toBe(""); | |
| 158 | rmSync(dir, { recursive: true, force: true }); | |
| 159 | }); | |
| 160 | }); | |
| 161 | ||
| 162 | describe("install detection", () => { | |
| 163 | test("a script with install in its name is not the install", () => { | |
| 164 | expect(read("running", ["$ bun scripts/install-hooks.ts"])).toEqual([ | |
| 165 | "install:ok", | |
| 166 | "typecheck:running", | |
| 167 | "tests:pending", | |
| 168 | ]); | |
| 169 | }); | |
| 170 | }); |
test/contain.test.ts +1 −82
| @@ -47,6 +47,7 @@ import type { AppRecord } from "../src/core/apps.ts"; | ||
| 47 | 47 | import type { Repo } from "../src/core/repos.ts"; |
| 48 | 48 | import { serve, type Servers } from "../src/http/server.ts"; |
| 49 | 49 | import { readRunLog } from "../src/runs/log.ts"; |
| 50 | import { expectContained, probeSource } from "./helpers/cell-probe.ts"; | |
| 50 | 51 | import { missing } from "./helpers/tools.ts"; |
| 51 | 52 | import { portInsteadOfSocket } from "../src/git/hook-address.ts"; |
| 52 | 53 | |
| @@ -497,88 +498,6 @@ const noCells = | ||
| 497 | 498 | process.platform !== "linux" || |
| 498 | 499 | portInsteadOfSocket(); |
| 499 | 500 | |
| 500 | /** What the probe tries, from inside a deploy or a check. */ | |
| 501 | function probeSource(targets: { | |
| 502 | dataDir: string; | |
| 503 | otherAppDir: string; | |
| 504 | canary: string; | |
| 505 | listenerPort: number; | |
| 506 | /** The forge's own pid: its environment holds every secret it was started with. */ | |
| 507 | forgePid: number; | |
| 508 | }): string { | |
| 509 | return ` | |
| 510 | import { readdirSync, readFileSync } from "node:fs"; | |
| 511 | const T = ${JSON.stringify(targets)}; | |
| 512 | const reached = (detail) => "reached: " + String(detail).slice(0, 80); | |
| 513 | async function attempt(fn) { | |
| 514 | try { | |
| 515 | const found = await fn(); | |
| 516 | return found === false ? "refused" : reached(found); | |
| 517 | } catch { | |
| 518 | return "refused"; | |
| 519 | } | |
| 520 | } | |
| 521 | const answered = async (url, init) => { | |
| 522 | const res = await fetch(url, { ...init, signal: AbortSignal.timeout(5000) }); | |
| 523 | await res.body?.cancel(); | |
| 524 | return res.ok ? res.status : false; | |
| 525 | }; | |
| 526 | async function probe() { | |
| 527 | return { | |
| 528 | forgeDb: await attempt(() => readFileSync(T.dataDir + "/forge.db").length), | |
| 529 | secretKey: await attempt(() => readFileSync(T.dataDir + "/secret.key").length), | |
| 530 | forgeData: await attempt(() => { | |
| 531 | const names = readdirSync(T.dataDir); | |
| 532 | return names.some((n) => n === "forge.db" || n === "secret.key" || n === "repos") && names.join(","); | |
| 533 | }), | |
| 534 | otherApp: await attempt(() => readdirSync(T.otherAppDir).join(",")), | |
| 535 | environment: await attempt(() => { | |
| 536 | if (Object.values(process.env).some((v) => v?.includes(T.canary))) return "own env"; | |
| 537 | // The pid is the forge's only outside a pid namespace of its own; inside | |
| 538 | // one the same number may be a process of the cell's. | |
| 539 | const cmdline = readFileSync("/proc/" + T.forgePid + "/cmdline", "latin1"); | |
| 540 | const forge = readFileSync("/proc/" + T.forgePid + "/environ", "latin1"); | |
| 541 | if (cmdline.includes("test") && forge.length > 0) return "the forge's environment, " + forge.length + " bytes"; | |
| 542 | for (const pid of readdirSync("/proc")) { | |
| 543 | if (!/^\\d+$/.test(pid)) continue; | |
| 544 | try { | |
| 545 | if (readFileSync("/proc/" + pid + "/environ", "latin1").includes(T.canary)) return "pid " + pid; | |
| 546 | } catch {} | |
| 547 | } | |
| 548 | return false; | |
| 549 | }), | |
| 550 | undeclaredHost: await attempt(() => answered("http://example.com/")), | |
| 551 | cluster: await attempt(() => answered("http://127.0.0.1:" + T.listenerPort + "/")), | |
| 552 | declaredButPrivate: await attempt(() => | |
| 553 | answered("http://localhost:" + T.listenerPort + "/", { proxy: process.env.HTTP_PROXY ?? undefined }), | |
| 554 | ), | |
| 555 | forge: await attempt(() => answered(process.env.FORGE_URL + "/healthz")), | |
| 556 | }; | |
| 557 | } | |
| 558 | if (process.argv[2] === "check") { | |
| 559 | console.log("PROBE " + JSON.stringify(await probe())); | |
| 560 | // A terminal is the app's own client: its app answers it on its port. | |
| 561 | if (process.env.FORGE_APP_TTY === "1") | |
| 562 | console.log("OWNAPP " + (await attempt(() => answered("http://127.0.0.1:" + process.env.PORT + "/")))); | |
| 563 | } else { | |
| 564 | Bun.serve({ | |
| 565 | port: Number(process.env.PORT), | |
| 566 | hostname: "127.0.0.1", | |
| 567 | fetch: async (req) => | |
| 568 | new URL(req.url).pathname === "/probe" ? Response.json(await probe()) : new Response("ok"), | |
| 569 | }); | |
| 570 | } | |
| 571 | `; | |
| 572 | } | |
| 573 | ||
| 574 | /** Everything refused, and the forge — the one declared way out — answering. */ | |
| 575 | function expectContained(results: Record<string, string>): void { | |
| 576 | const { forge, ...rest } = results; | |
| 577 | for (const [name, outcome] of Object.entries(rest)) | |
| 578 | expect(`${name}: ${outcome}`).toBe(`${name}: refused`); | |
| 579 | expect(forge).toStartWith("reached"); | |
| 580 | } | |
| 581 | ||
| 582 | 501 | describe.skipIf(noCells)( |
| 583 | 502 | "cells: repository code cannot take over the forge", |
| 584 | 503 | () => { |
test/crew-held.test.ts +37
| @@ -223,6 +223,43 @@ describe("the tick says why each ready issue cannot start", () => { | ||
| 223 | 223 | expect(replannable(again).map((i) => i.number)).toEqual([done.number]); |
| 224 | 224 | }); |
| 225 | 225 | |
| 226 | test("an issue the crew found done on main is held as done_on_main, not replannable", async () => { | |
| 227 | const { repo, mason } = await crewed(app, alice, "finished"); | |
| 228 | const oid = "a".repeat(40); | |
| 229 | const at = (id: string, ago: number) => | |
| 230 | app.db.query("UPDATE comment SET created_at = ?2 WHERE id = ?1").run(id, minutes(ago)); | |
| 231 | const say = (i: Issue, body: string, ago: number) => { | |
| 232 | const c = addComment(app, alice, getRepoById(app.db, repo.id)!, getIssueById(app.db, i.id)!, body); | |
| 233 | if (!c.ok) throw new Error(String(c.error)); | |
| 234 | at(c.value.id, ago); | |
| 235 | }; | |
| 236 | const done = issue(repo, "Already done"); | |
| 237 | ran(repo, done, mason, "declined: it is done", 30); | |
| 238 | say(done, `${DONE_NOTE}${oid}`, 20); | |
| 239 | const reparked = issue(repo, "Done, then parked"); | |
| 240 | ran(repo, reparked, mason, "declined: it is done", 30); | |
| 241 | say(reparked, `${DONE_NOTE}${oid}`, 20); | |
| 242 | say(reparked, `${PARK_NOTE}not so`, 10); | |
| 243 | ran(repo, reparked, mason, "declined: it is not done", 5); | |
| 244 | const spoken = issue(repo, "Done, then a word"); | |
| 245 | ran(repo, spoken, mason, "declined: it is done", 30); | |
| 246 | say(spoken, `${DONE_NOTE}${oid}`, 20); | |
| 247 | say(spoken, "Please try again", 10); | |
| 248 | ||
| 249 | const held = heldNow(app, getRepoById(app.db, repo.id)!); | |
| 250 | const named = (h: keyof typeof held.by) => held.by[h].map((i) => i.number); | |
| 251 | expect(named("done_on_main")).toContain(done.number); | |
| 252 | expect(named("stalled")).not.toContain(done.number); | |
| 253 | expect(replannable(held).map((i) => i.number)).not.toContain(done.number); | |
| 254 | // A later park is a newer word: it is the planner's to re-plan again. | |
| 255 | expect(named("done_on_main")).not.toContain(reparked.number); | |
| 256 | expect(replannable(held).map((i) => i.number)).toContain(reparked.number); | |
| 257 | // A person's comment after the done note does not make it startable. | |
| 258 | expect(held.startable.map((r) => r.issue.number)).not.toContain(spoken.number); | |
| 259 | expect(named("done_on_main")).toContain(spoken.number); | |
| 260 | expect(heldWords(held)).toContain("done_on_main="); | |
| 261 | }); | |
| 262 | ||
| 226 | 263 | test("nothing startable with a slot free asks the planner once, with the breakdown, whatever the plate", async () => { |
| 227 | 264 | const { repo, atlas, mason } = await crewed(app, alice, "stuck"); |
| 228 | 265 | const fresh = () => getRepoById(app.db, repo.id)!; |
test/crew-parks.test.ts +30
| @@ -1615,4 +1615,34 @@ describe("an issue found already done is the asker's to close, not a park", () = | ||
| 1615 | 1615 | expect(alreadyDone(e.app.db, [work.id]).has(work.id)).toBe(false); |
| 1616 | 1616 | expect(alreadyDoneOids("no changes pushed: already done: " + oid)).toEqual([]); |
| 1617 | 1617 | }); |
| 1618 | ||
| 1619 | test("an issue parked twice whose newest crew note is the done note is not handed to the planner", () => { | |
| 1620 | const park = (on: Issue) => { | |
| 1621 | for (let n = 0; n < 2; n++) | |
| 1622 | addComment(e.app, e.mason, e.fresh(), on, "Parked by the crew: " + nothingNew); | |
| 1623 | }; | |
| 1624 | const nothingNew = "the run ended without a pull: nothing to propose"; | |
| 1625 | const finished = e.issue("Parked, then found done"); | |
| 1626 | const open = e.issue("Parked, still open"); | |
| 1627 | park(finished); | |
| 1628 | park(open); | |
| 1629 | const numbers = () => parkedTwice(e.app.db, [e.fresh()]).map((i) => i.number); | |
| 1630 | expect(numbers()).toEqual([finished.number, open.number]); | |
| 1631 | addComment( | |
| 1632 | e.app, | |
| 1633 | e.mason, | |
| 1634 | e.fresh(), | |
| 1635 | finished, | |
| 1636 | "Done on main already, waiting for you to close it: " + oid, | |
| 1637 | ); | |
| 1638 | // Written in the same instant as the park: the clock cannot order them. | |
| 1639 | e.app.db.run( | |
| 1640 | `UPDATE comment SET created_at = (SELECT MAX(p.created_at) FROM comment p | |
| 1641 | WHERE p.issue_id = ?1 AND p.body LIKE 'Parked by the crew%') | |
| 1642 | WHERE issue_id = ?1 AND body LIKE 'Done on main already%'`, | |
| 1643 | [finished.id], | |
| 1644 | ); | |
| 1645 | expect(alreadyDone(e.app.db, [finished.id]).get(finished.id)).toEqual([oid]); | |
| 1646 | expect(numbers()).toEqual([open.number]); | |
| 1647 | }); | |
| 1618 | 1648 | }); |
test/cross-repo-pull.test.ts +2 −12
| @@ -7,6 +7,7 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 7 | 7 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 8 | 8 | import { proveAll } from "./helpers/verified.ts"; |
| 9 | 9 | import { promote } from "./helpers/members.ts"; |
| 10 | import { tokenFor } from "./helpers/tokens.ts"; | |
| 10 | 11 | |
| 11 | 12 | const gitEnv = gitClientEnv({ |
| 12 | 13 | GIT_AUTHOR_NAME: "X", |
| @@ -56,18 +57,7 @@ describe("cross-repo pull requests (fork → upstream)", () => { | ||
| 56 | 57 | redirect: "manual", |
| 57 | 58 | }); |
| 58 | 59 | const cookie = (signup.headers.get("set-cookie") ?? "").split(";")[0]!; |
| 59 | const token = await fetch(`${base}/api/v1/tokens`, { | |
| 60 | method: "POST", | |
| 61 | headers: { "content-type": "application/json" }, | |
| 62 | body: JSON.stringify({ adminConfirm: true, | |
| 63 | username, | |
| 64 | password: "cross-repo-pass", | |
| 65 | name: "c", | |
| 66 | scope: "admin", | |
| 67 | }), | |
| 68 | }) | |
| 69 | .then((r) => r.json()) | |
| 70 | .then((j) => (j as { token: string }).token); | |
| 60 | const token = tokenFor(app, username, "admin", "c"); | |
| 71 | 61 | return { token, cookie }; |
| 72 | 62 | } |
| 73 | 63 |
test/drill-poll.test.ts added +32
| @@ -0,0 +1,32 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { forgePoll } from "../scripts/drills/run.ts"; | |
| 3 | ||
| 4 | const answers = (health: Response | Error, board: Response | Error): typeof fetch => | |
| 5 | (async (url: string) => { | |
| 6 | const r = String(url).endsWith("/api/v1/health") ? health : board; | |
| 7 | if (r instanceof Error) throw r; | |
| 8 | return r.clone(); | |
| 9 | }) as unknown as typeof fetch; | |
| 10 | ||
| 11 | const html = (status: number, body: string) => new Response(body, { status, headers: { "content-type": "text/html" } }); | |
| 12 | const ok = () => Response.json({ ok: true }); | |
| 13 | ||
| 14 | test("healthy only when health reports ok and the board answers", async () => { | |
| 15 | expect((await forgePoll("http://f", "t", answers(ok(), html(200, "<p>hi</p>")))()).healthy).toBe(true); | |
| 16 | expect((await forgePoll("http://f", "t", answers(ok(), html(503, "<p>stuck</p>")))()).healthy).toBe(false); | |
| 17 | expect((await forgePoll("http://f", "t", answers(Response.json({ ok: false }), html(200, "hi")))()).healthy).toBe(false); | |
| 18 | expect((await forgePoll("http://f", "t", answers(new Response("ok", { status: 200 }), html(200, "hi")))()).healthy).toBe(false); | |
| 19 | }); | |
| 20 | ||
| 21 | test("what each page said is kept under its own source", async () => { | |
| 22 | const look = await forgePoll("http://f", "t", answers( | |
| 23 | Response.json({ ok: false, note: "Disk is nearly full" }, { status: 503 }), | |
| 24 | html(503, "<p>The runner is stuck</p>"), | |
| 25 | ))(); | |
| 26 | expect(look.said).toEqual({ health: ["Disk is nearly full"], board: ["The runner is stuck"] }); | |
| 27 | }); | |
| 28 | ||
| 29 | test("a page that cannot be reached says 'unreachable'", async () => { | |
| 30 | const look = await forgePoll("http://f", "t", answers(new Error("refused"), html(200, "hi")))(); | |
| 31 | expect(look).toEqual({ healthy: false, said: { health: ["unreachable"], board: ["hi"] } }); | |
| 32 | }); |
test/drill-run.test.ts +73 −8
| @@ -76,10 +76,33 @@ test("goes down and comes back: posts what it saw and prints the verdict", async | ||
| 76 | 76 | expect(body.seen.health).toContain("Disk is nearly full: 97%"); |
| 77 | 77 | expect(body.seen.board).toContain("The runner is stuck"); |
| 78 | 78 | expect(body.backSeconds).toBe(3); |
| 79 | expect(Object.keys(body).sort()).toEqual(["backSeconds", "domain", "seen"]); | |
| 79 | 80 | expect(h.lines.filter((l) => l.includes("The runner is stuck"))).toHaveLength(1); |
| 80 | 81 | expect(h.lines).toContain("GREEN: every phrase seen"); |
| 81 | 82 | }); |
| 82 | 83 | |
| 84 | test("without a token: prints the result, says nothing was posted, posts nothing", async () => { | |
| 85 | const state: Stub = { down: false, posts: [], refuse: false }; | |
| 86 | const lines: string[] = []; | |
| 87 | let t = 0; | |
| 88 | let sleeps = 0; | |
| 89 | const code = await runDrill({ | |
| 90 | domain: "runner", | |
| 91 | forge: stub(state), | |
| 92 | token: "", | |
| 93 | say: (l) => lines.push(l), | |
| 94 | enter: async () => { state.down = true; }, | |
| 95 | now: () => t, | |
| 96 | sleep: async (ms) => { t += ms; if (++sleeps === 3) state.down = false; }, | |
| 97 | intervalMs: 1000, | |
| 98 | ceilingMs: 60_000, | |
| 99 | }); | |
| 100 | expect(code).toBe(0); | |
| 101 | expect(state.posts).toHaveLength(0); | |
| 102 | expect(lines.some((l) => l.includes("nothing was posted"))).toBe(true); | |
| 103 | expect(JSON.parse(lines.at(-1)!)).toMatchObject({ domain: "runner", backSeconds: 3, posted: false }); | |
| 104 | }); | |
| 105 | ||
| 83 | 106 | test("stays down past the ceiling: posts nothing, exits 0, says so", async () => { |
| 84 | 107 | const state: Stub = { down: false, posts: [], refuse: false }; |
| 85 | 108 | const h = harness(state, { ceilingMs: 5_000 }); |
| @@ -119,17 +142,16 @@ test("a sign-in redirect from / is up, and a changing counter is one line", asyn | ||
| 119 | 142 | |
| 120 | 143 | test("checkArgs: each missing piece and the cleartext refusal is an error; the good case is options", () => { |
| 121 | 144 | const env = { FORGE_TOKEN: "t" }; |
| 122 | const bad = (argv: string[], e: Record<string, string | undefined>, tty: boolean) => { | |
| 123 | const r = checkArgs(argv, e, tty); | |
| 145 | const bad = (argv: string[], e: Record<string, string | undefined>) => { | |
| 146 | const r = checkArgs(argv, e); | |
| 124 | 147 | expect(r.ok).toBe(false); |
| 125 | 148 | return r.ok ? "" : r.error; |
| 126 | 149 | }; |
| 127 | expect(bad(["--forge", "https://f.example"], env, true)).toContain("domain"); | |
| 128 | expect(bad(["runner"], env, true)).toContain("--forge"); | |
| 129 | expect(bad(["runner", "--forge", "https://f.example"], { FORGE_TOKEN: "" }, true)).toContain("FORGE_TOKEN"); | |
| 130 | expect(bad(["runner", "--forge", "https://f.example"], env, false)).toContain("not a terminal"); | |
| 131 | expect(bad(["runner", "--forge", "http://forge.example"], env, true)).toContain("plain http"); | |
| 132 | const ok = (argv: string[]) => checkArgs(argv, env, true); | |
| 150 | expect(bad(["--forge", "https://f.example"], env)).toContain("domain"); | |
| 151 | expect(bad(["runner"], env)).toContain("--forge"); | |
| 152 | expect(checkArgs(["runner", "--forge", "https://f.example"], {}).ok).toBe(true); | |
| 153 | expect(bad(["runner", "--forge", "http://forge.example"], env)).toContain("plain http"); | |
| 154 | const ok = (argv: string[]) => checkArgs(argv, env); | |
| 133 | 155 | expect(ok(["runner", "--forge", "http://forge.example", "--insecure"]).ok).toBe(true); |
| 134 | 156 | for (const host of ["localhost:3000", "127.0.0.1:1", "[::1]:1"]) expect(ok(["runner", "--forge", `http://${host}/`]).ok).toBe(true); |
| 135 | 157 | expect(ok(["runner", "--forge", "https://f.example/"])).toEqual({ |
| @@ -161,3 +183,46 @@ test("control characters the forge sends are removed before printing or posting; | ||
| 161 | 183 | expect(said).not.toContain("\x1b"); |
| 162 | 184 | expect(JSON.stringify(state.posts[0]!.body)).not.toContain("\\u001b"); |
| 163 | 185 | }); |
| 186 | ||
| 187 | test("spawned with piped stdin: waits for Enter, watches, prints the JSON; no drill exits non-zero", async () => { | |
| 188 | const state: Stub = { down: false, posts: [], refuse: false }; | |
| 189 | const forge = stub(state); | |
| 190 | const spawn = (domain: string) => | |
| 191 | Bun.spawn(["bun", "scripts/drills/run.ts", domain, "--forge", forge], { | |
| 192 | stdin: "pipe", | |
| 193 | stdout: "pipe", | |
| 194 | stderr: "pipe", | |
| 195 | env: { ...process.env, FORGE_TOKEN: "tok" }, | |
| 196 | }); | |
| 197 | ||
| 198 | const none = spawn("state"); | |
| 199 | expect(await none.exited).not.toBe(0); | |
| 200 | expect(await new Response(none.stdout).text()).toContain("has no drill"); | |
| 201 | ||
| 202 | const p = spawn("compute"); | |
| 203 | const out = new Response(p.stdout).text(); | |
| 204 | await Bun.sleep(300); | |
| 205 | expect(state.posts).toEqual([]); | |
| 206 | state.down = true; | |
| 207 | p.stdin.write("\n"); | |
| 208 | p.stdin.end(); | |
| 209 | await Bun.sleep(1500); | |
| 210 | state.down = false; | |
| 211 | expect(await p.exited).toBe(0); | |
| 212 | const lines = (await out).trim().split("\n"); | |
| 213 | expect(lines[0]).toContain("kill-pod"); | |
| 214 | const result = JSON.parse(lines[lines.length - 1]!); | |
| 215 | expect(result).toMatchObject({ domain: "compute", posted: true }); | |
| 216 | expect(state.posts).toHaveLength(1); | |
| 217 | expect(state.posts[0]!.auth).toBe("Bearer tok"); | |
| 218 | }); | |
| 219 | ||
| 220 | test("stdin closed without Enter exits non-zero and posts nothing", async () => { | |
| 221 | const state: Stub = { down: false, posts: [], refuse: false }; | |
| 222 | const forge = stub(state); | |
| 223 | const p = Bun.spawn(["bun", "scripts/drills/run.ts", "compute", "--forge", forge], { | |
| 224 | stdin: "ignore", stdout: "pipe", stderr: "pipe", env: { ...process.env, FORGE_TOKEN: "tok" }, | |
| 225 | }); | |
| 226 | expect(await p.exited).not.toBe(0); | |
| 227 | expect(state.posts).toEqual([]); | |
| 228 | }); |
test/drill-watch.test.ts added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { watchDrill } from "../scripts/drills/watch.ts"; | |
| 3 | ||
| 4 | const reply = (status: number, body: string, type = "text/plain") => ({ | |
| 5 | status, | |
| 6 | text: async () => body, | |
| 7 | headers: { get: (n: string) => (n === "content-type" ? type : null) }, | |
| 8 | }); | |
| 9 | ||
| 10 | function script(steps: Array<ReturnType<typeof reply> | Error>) { | |
| 11 | let i = 0; | |
| 12 | const slept: unknown[] = []; | |
| 13 | return { | |
| 14 | slept, | |
| 15 | fetch: async () => { | |
| 16 | const s = steps[Math.min(i++, steps.length - 1)]!; | |
| 17 | if (s instanceof Error) throw s; | |
| 18 | return s; | |
| 19 | }, | |
| 20 | sleep: async (ms: number) => { slept.push(ms); }, | |
| 21 | }; | |
| 22 | } | |
| 23 | ||
| 24 | test("returns distinct lines said while unhealthy, with times, without waiting", async () => { | |
| 25 | const s = script([ | |
| 26 | reply(200, "ok"), | |
| 27 | reply(503, "database locked\nretry 1"), | |
| 28 | new Error("refused"), | |
| 29 | reply(503, "database locked\nretry 2"), | |
| 30 | reply(200, "ok"), | |
| 31 | ]); | |
| 32 | const r = await watchDrill({ url: "http://x/", fetch: s.fetch, sleep: s.sleep, limit: 10 }); | |
| 33 | expect(r).toEqual({ timeout: false, startedAt: 1000, healthyAt: 4000, lines: ["database locked", "retry 1", "unreachable"] }); | |
| 34 | expect(s.slept.length).toBe(4); | |
| 35 | }); | |
| 36 | ||
| 37 | test("reads JSON strings and strips control characters", async () => { | |
| 38 | const s = script([reply(500, JSON.stringify({ error: "bad\u001b[31m" }), "application/json"), reply(200, "ok")]); | |
| 39 | const r = await watchDrill({ url: "http://x/", fetch: s.fetch, sleep: s.sleep, limit: 5 }); | |
| 40 | expect(r).toMatchObject({ timeout: false, lines: ["bad[31m"] }); | |
| 41 | }); | |
| 42 | ||
| 43 | test("times out when the forge never comes back, and when it never went down", async () => { | |
| 44 | const down = script([reply(503, "down")]); | |
| 45 | expect(await watchDrill({ url: "u", fetch: down.fetch, sleep: down.sleep, limit: 3 })) | |
| 46 | .toEqual({ timeout: true, startedAt: 0, lines: ["down"] }); | |
| 47 | const up = script([reply(200, "ok")]); | |
| 48 | expect(await watchDrill({ url: "u", fetch: up.fetch, sleep: up.sleep, limit: 3 })) | |
| 49 | .toEqual({ timeout: true, startedAt: null, lines: [] }); | |
| 50 | }); | |
| 51 | ||
| 52 | test("a fetch that throws says 'unreachable'; an unreadable 5xx body says nothing but is still down", async () => { | |
| 53 | const bad = { status: 502, text: async () => { throw new Error("reset"); }, headers: { get: () => null } }; | |
| 54 | const s = script([new Error("refused"), bad, reply(200, "ok")]); | |
| 55 | const seen: string[] = []; | |
| 56 | const r = await watchDrill({ url: "u", fetch: s.fetch, sleep: s.sleep, limit: 5, onLine: (l) => seen.push(l) }); | |
| 57 | expect(r).toEqual({ timeout: false, startedAt: 0, healthyAt: 2000, lines: ["unreachable"] }); | |
| 58 | expect(seen).toEqual(["unreachable"]); | |
| 59 | }); | |
| 60 | ||
| 61 | test("an injected clock measures the times", async () => { | |
| 62 | const s = script([reply(503, "down"), reply(200, "ok")]); | |
| 63 | let t = 500; | |
| 64 | const r = await watchDrill({ url: "u", fetch: s.fetch, sleep: async () => { t += 1700; }, limit: 5, now: () => t }); | |
| 65 | expect(r).toEqual({ timeout: false, startedAt: 0, healthyAt: 1700, lines: ["down"] }); | |
| 66 | }); | |
| 67 | ||
| 68 | test("the ceiling is time, not polls: slow polls time out long before the poll limit", async () => { | |
| 69 | let t = 0; | |
| 70 | let polls = 0; | |
| 71 | const r = await watchDrill({ | |
| 72 | url: "u", | |
| 73 | fetch: async () => { polls++; t += 4_000; return reply(503, "down"); }, | |
| 74 | sleep: async () => { t += 4_000; }, | |
| 75 | limit: 1000, | |
| 76 | intervalMs: 1000, | |
| 77 | ceilingMs: 20_000, | |
| 78 | now: () => t, | |
| 79 | }); | |
| 80 | expect(r).toEqual({ timeout: true, startedAt: 4_000, lines: ["down"] }); | |
| 81 | expect(polls).toBe(3); | |
| 82 | }); |
test/drill-weekly.test.ts +165 −4
| @@ -12,7 +12,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 12 | 12 | import { createUser } from "../src/core/users.ts"; |
| 13 | 13 | import { vitals } from "../src/kernel/vitals.ts"; |
| 14 | 14 | import { runDrill, type DrillResult } from "../src/snapshot/drill.ts"; |
| 15 | import { drillDue, lastSlot } from "../src/snapshot/due.ts"; | |
| 15 | import { BOOT_RED_FLOOR_MS, drillDue, lastSlot, RED_RETRY_MS } from "../src/snapshot/due.ts"; | |
| 16 | 16 | import { lastDrill, recordDrill } from "../src/snapshot/drills.ts"; |
| 17 | 17 | import { startSnapshots } from "../src/snapshot/service.ts"; |
| 18 | 18 | import { takeSnapshot } from "../src/snapshot/snapshot.ts"; |
| @@ -20,6 +20,8 @@ import { dirStore } from "../src/snapshot/store.ts"; | ||
| 20 | 20 | import { recordVerdict } from "../src/snapshot/verdict.ts"; |
| 21 | 21 | |
| 22 | 22 | const t = (iso: string): number => Date.parse(iso); |
| 23 | const green = (at: string) => ({ at, verdict: "green" as const }); | |
| 24 | const red = (at: string) => ({ at, verdict: "red" as const }); | |
| 23 | 25 | |
| 24 | 26 | describe("when a weekly drill is owed", () => { |
| 25 | 27 | // 2026-10-11 is a Sunday. |
| @@ -30,10 +32,23 @@ describe("when a weekly drill is owed", () => { | ||
| 30 | 32 | |
| 31 | 33 | test("owed once: a drill since the slot keeps it, an older one does not", () => { |
| 32 | 34 | const now = t("2026-10-11T09:30:00Z"); |
| 33 | expect(drillDue("2026-10-04T09:05:00.000Z", undefined, now)).toBe(true); | |
| 34 | expect(drillDue("2026-10-11T09:05:00.000Z", undefined, now)).toBe(false); | |
| 35 | expect(drillDue(green("2026-10-04T09:05:00.000Z"), undefined, now)).toBe(true); | |
| 36 | expect(drillDue(green("2026-10-11T09:05:00.000Z"), undefined, now)).toBe(false); | |
| 35 | 37 | // a restart later the same day finds it kept |
| 36 | expect(drillDue("2026-10-11T09:05:00.000Z", undefined, t("2026-10-11T20:00:00Z"))).toBe(false); | |
| 38 | expect(drillDue(green("2026-10-11T09:05:00.000Z"), undefined, t("2026-10-11T20:00:00Z"))).toBe(false); | |
| 39 | }); | |
| 40 | ||
| 41 | test("a red drill is owed again after RED_RETRY_MS, and on a boot past the floor", () => { | |
| 42 | // #2541: red at 09:11, the fix deployed at 10:18; the card stood for the week. | |
| 43 | const at = "2026-10-18T09:11:45.853Z"; | |
| 44 | expect(drillDue(red(at), undefined, t(at) + 60_000)).toBe(false); | |
| 45 | expect(drillDue(red(at), undefined, t(at) + RED_RETRY_MS - 1)).toBe(false); | |
| 46 | expect(drillDue(red(at), undefined, t(at) + RED_RETRY_MS)).toBe(true); | |
| 47 | expect(drillDue(red(at), undefined, t("2026-10-18T10:23:00Z"), BOOT_RED_FLOOR_MS)).toBe(true); | |
| 48 | // a boot soon after the red one (a crash loop) does not restore again | |
| 49 | expect(drillDue(red(at), undefined, t(at) + BOOT_RED_FLOOR_MS - 1, BOOT_RED_FLOOR_MS)).toBe(false); | |
| 50 | // a green one keeps its slot on a boot too | |
| 51 | expect(drillDue(green(at), undefined, t("2026-10-18T10:23:00Z"), 0)).toBe(false); | |
| 37 | 52 | }); |
| 38 | 53 | |
| 39 | 54 | test("a forge with no drill owes one only if it is older than the slot", () => { |
| @@ -80,6 +95,152 @@ describe("the service runs a due drill once", () => { | ||
| 80 | 95 | await rm(root, { recursive: true, force: true }); |
| 81 | 96 | } |
| 82 | 97 | }); |
| 98 | ||
| 99 | test("a red drill that has stood RED_RETRY_MS is run again until one passes", async () => { | |
| 100 | const root = mkdtempSync(join(tmpdir(), "forge-drill-red-")); | |
| 101 | const app = createApp({ dataDir: root, port: 0 }); | |
| 102 | const verdicts: DrillResult["verdict"][] = []; | |
| 103 | const svc = startSnapshots(app, dirStore(join(root, "store")), { | |
| 104 | version: "t", | |
| 105 | nightly: false, | |
| 106 | drill: async (): Promise<DrillResult> => { | |
| 107 | const verdict = verdicts.length === 0 ? "red" : "green"; | |
| 108 | verdicts.push(verdict); | |
| 109 | return { | |
| 110 | // The red one ran long enough ago to be owed again. | |
| 111 | at: new Date(Date.now() - (verdict === "red" ? RED_RETRY_MS : 0)).toISOString(), | |
| 112 | snapshotId: "s", | |
| 113 | ms: 1, | |
| 114 | counts: { repositories: 0, issues: 0, users: 0, pulls: 0, apps: 0 }, | |
| 115 | health: verdict === "red" ? "unreached" : "ok", | |
| 116 | verdict, | |
| 117 | failedStep: verdict === "red" ? "restore" : null, | |
| 118 | reason: verdict === "red" ? "restore timed out: no answer in 600000 ms" : null, | |
| 119 | }; | |
| 120 | }, | |
| 121 | }); | |
| 122 | try { | |
| 123 | mkdirSync(join(root, "drill"), { recursive: true }); | |
| 124 | writeFileSync(join(root, "drill", "first-seen"), new Date(Date.now() - 30 * 86_400_000).toISOString()); | |
| 125 | expect(await svc.drillIfDue()).toBe(true); | |
| 126 | expect(lastDrill(app.db)?.verdict).toBe("red"); | |
| 127 | expect(await svc.drillIfDue()).toBe(true); | |
| 128 | expect(lastDrill(app.db)?.verdict).toBe("green"); | |
| 129 | expect(await svc.drillIfDue()).toBe(false); | |
| 130 | expect(verdicts).toEqual(["red", "green"]); | |
| 131 | } finally { | |
| 132 | await svc.stop(); | |
| 133 | app.close(); | |
| 134 | await rm(root, { recursive: true, force: true }); | |
| 135 | } | |
| 136 | }); | |
| 137 | }); | |
| 138 | ||
| 139 | describe("the service's own clock drills a red one again", () => { | |
| 140 | const result = (verdict: DrillResult["verdict"]): DrillResult => ({ | |
| 141 | at: new Date().toISOString(), | |
| 142 | snapshotId: "s", | |
| 143 | ms: 1, | |
| 144 | counts: { repositories: 0, issues: 0, users: 0, pulls: 0, apps: 0 }, | |
| 145 | health: verdict === "red" ? "unreached" : "ok", | |
| 146 | verdict, | |
| 147 | failedStep: verdict === "red" ? "restore" : null, | |
| 148 | reason: verdict === "red" ? "restore timed out: no answer in 600000 ms" : null, | |
| 149 | }); | |
| 150 | ||
| 151 | /** | |
| 152 | * A forge booted with nightly on and short delays, its newest drill | |
| 153 | * `before` (kept before boot), each drill it runs answering the next of | |
| 154 | * `answers`; and the time it was booted, for the waits. | |
| 155 | */ | |
| 156 | async function boot( | |
| 157 | before: DrillResult["verdict"] | undefined, | |
| 158 | answers: DrillResult["verdict"][], | |
| 159 | opts: { bootRedFloorMs?: number } = {}, | |
| 160 | ) { | |
| 161 | const root = mkdtempSync(join(tmpdir(), "forge-drill-clock-")); | |
| 162 | mkdirSync(join(root, "drill"), { recursive: true }); | |
| 163 | writeFileSync(join(root, "drill", "first-seen"), new Date(Date.now() - 30 * 86_400_000).toISOString()); | |
| 164 | const app = createApp({ dataDir: root, port: 0 }); | |
| 165 | if (before !== undefined) recordDrill(app.db, result(before)); | |
| 166 | const ran: DrillResult["verdict"][] = []; | |
| 167 | const svc = startSnapshots(app, dirStore(join(root, "store")), { | |
| 168 | version: "t", | |
| 169 | mirrorEveryMs: 3_600_000, | |
| 170 | catchUpMs: 20, | |
| 171 | redRetryMs: 60, | |
| 172 | bootRedFloorMs: opts.bootRedFloorMs ?? 0, | |
| 173 | drill: async () => { | |
| 174 | const verdict = answers[ran.length] ?? "green"; | |
| 175 | ran.push(verdict); | |
| 176 | return result(verdict); | |
| 177 | }, | |
| 178 | }); | |
| 179 | const done = async () => { | |
| 180 | await svc.stop(); | |
| 181 | app.close(); | |
| 182 | await rm(root, { recursive: true, force: true }); | |
| 183 | }; | |
| 184 | return { app, svc, ran, done }; | |
| 185 | } | |
| 186 | ||
| 187 | test("the boot catch-up drills a red one at once", async () => { | |
| 188 | const f = await boot("red", ["green"]); | |
| 189 | try { | |
| 190 | await Bun.sleep(150); | |
| 191 | expect(f.ran).toEqual(["green"]); | |
| 192 | expect(lastDrill(f.app.db)?.verdict).toBe("green"); | |
| 193 | } finally { | |
| 194 | await f.done(); | |
| 195 | } | |
| 196 | }); | |
| 197 | ||
| 198 | test("the boot catch-up leaves a green one of this week alone", async () => { | |
| 199 | const f = await boot("green", []); | |
| 200 | try { | |
| 201 | await Bun.sleep(150); | |
| 202 | expect(f.ran).toEqual([]); | |
| 203 | } finally { | |
| 204 | await f.done(); | |
| 205 | } | |
| 206 | }); | |
| 207 | ||
| 208 | test("a boot inside the floor does not restore again, and the retry still comes", async () => { | |
| 209 | const f = await boot("red", ["green"], { bootRedFloorMs: 3_600_000 }); | |
| 210 | try { | |
| 211 | await Bun.sleep(40); | |
| 212 | expect(f.ran).toEqual([]); | |
| 213 | await Bun.sleep(150); | |
| 214 | expect(f.ran).toEqual(["green"]); | |
| 215 | } finally { | |
| 216 | await f.done(); | |
| 217 | } | |
| 218 | }); | |
| 219 | ||
| 220 | test("a red result arms the retry, which drills again and stops after a pass", async () => { | |
| 221 | const f = await boot(undefined, ["red", "red", "green"]); | |
| 222 | try { | |
| 223 | await Bun.sleep(400); | |
| 224 | expect(f.ran).toEqual(["red", "red", "green"]); | |
| 225 | await Bun.sleep(200); | |
| 226 | expect(f.ran).toEqual(["red", "red", "green"]); | |
| 227 | } finally { | |
| 228 | await f.done(); | |
| 229 | } | |
| 230 | }); | |
| 231 | ||
| 232 | test("stop clears the retry", async () => { | |
| 233 | const f = await boot(undefined, ["red"]); | |
| 234 | try { | |
| 235 | await Bun.sleep(40); | |
| 236 | expect(f.ran).toEqual(["red"]); | |
| 237 | await f.svc.stop(); | |
| 238 | await Bun.sleep(200); | |
| 239 | expect(f.ran).toEqual(["red"]); | |
| 240 | } finally { | |
| 241 | await f.done(); | |
| 242 | } | |
| 243 | }); | |
| 83 | 244 | }); |
| 84 | 245 | |
| 85 | 246 | describe("a count that differs is red", () => { |
test/everyday.test.ts +21
| @@ -32,6 +32,7 @@ import { createUser, getUserByName } from "../src/core/users.ts"; | ||
| 32 | 32 | import { serve, type Servers } from "../src/http/server.ts"; |
| 33 | 33 | import { quietLogs } from "./helpers/globals.ts"; |
| 34 | 34 | import { passkeyFields } from "./helpers/passkey.ts"; |
| 35 | import { ADMIN_TOKEN_WORDS } from "../src/core/tokens.ts"; | |
| 35 | 36 | |
| 36 | 37 | quietLogs(); |
| 37 | 38 | |
| @@ -241,6 +242,26 @@ describe("the everyday account", () => { | ||
| 241 | 242 | ); |
| 242 | 243 | }); |
| 243 | 244 | |
| 245 | test("the token page keeps a member on the page with the sentence for admin scope, and makes rw", async () => { | |
| 246 | const kim = await signIn("kim"); | |
| 247 | const count = () => | |
| 248 | ( | |
| 249 | app.db | |
| 250 | .query("SELECT COUNT(*) AS n FROM token WHERE user_id = ?1") | |
| 251 | .get(getUserByName(app.db, "kim")!.id) as { n: number } | |
| 252 | ).n; | |
| 253 | const before = count(); | |
| 254 | expect(await page(kim, "/settings/tokens")).not.toContain('value="admin"'); | |
| 255 | const denied = await post(kim, "/settings/tokens", { name: "big", scope: "admin" }); | |
| 256 | expect(denied.status).toBe(303); | |
| 257 | expect(new URL(denied.headers.get("location")!, servers.url).pathname).toBe("/settings/tokens"); | |
| 258 | const flash = (denied.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 259 | expect(decodeURIComponent(flash.replace(/\+/g, " "))).toContain(ADMIN_TOKEN_WORDS); | |
| 260 | expect(count()).toBe(before); | |
| 261 | await post(kim, "/settings/tokens", { name: "small", scope: "rw" }); | |
| 262 | expect(count()).toBe(before + 1); | |
| 263 | }); | |
| 264 | ||
| 244 | 265 | test("from a page's session, the API refuses an admin's token without adminConfirm", async () => { |
| 245 | 266 | const root = await signIn("ada"); |
| 246 | 267 | const mint = (body: Record<string, unknown>) => |
test/fixtures/shard-broken.test.ts added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | // Fixture for test/shards.test.ts, kept out of the gate: fails alone too. | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | ||
| 4 | test("broken", () => { | |
| 5 | expect(1).toBe(2); | |
| 6 | }); |
test/fixtures/shard-errors.test.ts added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | // Fixture for test/shards.test.ts, kept out of the gate: errors while loading. | |
| 2 | throw new Error("boom at load"); |
42 more files not shown.
new above
offline