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

Take updates from trevato/forge-upstream #2601

Merged opened this in answer to

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

Merged

mason/forge:mason/2600-take-updates-from-trevato-forge-upstream → main 1 commit fork

  • Merged. mason/2600-take-updates-from-trevato-forge-upstream was merged into main as ba32c2b.

Review

Sign in to review this change.
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.
254254 whose default export is the SQL — never the next number: `0001`–`0091`
255255 are the numbered history, and a shared next number is what parallel pulls
256256 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
260263 (`test/schema-additive.test.ts`); the release agent lists its id in
261264 `core/releases.json`.
262265 - **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 file
docs/APPS.md +1 −1
@@ -1114,7 +1114,7 @@ a link to the milestone (`docs/PLANNING.md`); its progress never does.
11141114 | `FORGE_APP_DOMAIN` | the host in `FORGE_URL`/`_HOST` | what app hostnames attach to (`example` or `--x`) |
11151115 | `FORGE_APPS_CONCURRENCY` | `2` | deploys running their heavy steps at once |
11161116 | `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) |
11181118 | `FORGE_CHECKS_CONCURRENCY` | `1` | checks at once (live: Settings → Capacity) |
11191119 | `FORGE_CHECKS_PLACEMENT` | `process` | `job` runs a check's commands as a cluster Job (see Placement above) |
11201120 | `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`
176176 refuses one in any migration not shipped by both of the two newest releases
177177 recorded in `src/core/releases.json` (by id: a count, and the stamped ids),
178178 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.
181182
182183 State transitions use compare-and-swap (`UPDATE … WHERE state = ?`), and every
183184 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.
3535 ## Running a drill
3636
3737 `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
3939 `/api/v1/health` and `/` once a second, keeping each distinct line the forge
4040 says while unhealthy; when the forge is healthy again it posts what it saw and
4141 the seconds it took to `POST /api/v1/drills` and prints the verdict. If the
4242 forge is still down after an hour it posts nothing, says so, and exits 0. A
4343 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.
4546
4647 ```
4748 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.
2828 | tool | does | writes |
2929 | -------------- | ------------------------------------------------------------------------------------------------- | ------ |
3030 | `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` | |
3132 | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes |
3233 | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes |
3334 | `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.
3940 | `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`) | |
4041 | `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 |
4142 | `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 |
4344 | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | |
4445 | `following` | where a fork stands against what it follows, and what the crew is doing about it | |
4546 | `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
1616 on a side port and reads `/api/v1/health`. One comment lands on the recovery
1717 issue (`FORGE_RECOVERY_ISSUE` in `FORGE_RECOVERY_REPO`): snapshot, restore
1818 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.
2121
2222 ## What a restore needs
2323
docs/SNAPSHOT.md +51 −4
@@ -8,7 +8,23 @@ empty data directory. `src/snapshot/`.
88 ## What a snapshot holds
99
1010 - `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.
1228 - The key the database's sealed values need, **only wrapped**:
1329 `secret.key.wrapped`, AES-256-GCM under a key scrypt-derived from the
1430 operator's passphrase. The raw `secret.key` is never written to the store,
@@ -24,8 +40,8 @@ empty data directory. `src/snapshot/`.
2440 stay out.
2541 - The crew's run logs (`runs/`).
2642 - `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.
2945
3046 What stays out is what a boot or a deploy rebuilds: clones, checks,
3147 pictures, previews.
@@ -86,7 +102,24 @@ mirror is copied from the primary store, not written in parallel, so a snapshot
86102 that never landed on the primary is not seen by it.
87103
88104 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.
90123
91124 ## The restore drill
92125
@@ -96,12 +129,26 @@ scratch directory under the data directory, opens the database read-only,
96129 counts repositories and issues, opens one sealed value with the restored key,
97130 and removes the scratch whatever happened. The page waits until it finishes,
98131 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).
99137 One drill runs at a time: a second click, or a retry after a proxy timeout,
100138 joins the drill already running rather than starting another restore. Every
101139 run is a row in the `drill` table, and the last one shows as a line on the
102140 Backups page: when, which snapshot, how long, the counts, and green or the
103141 step that failed.
104142
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
105152 ## Restore
106153
107154 The printable card, seven lines, is [RESTORE.md](RESTORE.md); a test runs it.
scripts/drills/run.ts +45 −79
@@ -4,11 +4,13 @@
44 * polls `/api/v1/health` and `/` once a second keeping each distinct line the
55 * forge says while unhealthy, and when the forge is healthy again posts what
66 * 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.
910 */
1011
1112 import { DOMAINS } from "../../src/kernel/domains.ts";
13 import { UNREACHABLE, clean, linesOf, watchPolls, type Poll } from "./watch.ts";
1214
1315 export interface DrillRun {
1416 domain: string;
@@ -27,50 +29,17 @@ export interface DrillRun {
2729 const SEEN_MAX = 8_000;
2830 const DEFAULT_CEILING_MS = 3_600_000;
2931
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 };
3233
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(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
57 .replace(/&quot;/g, '"').replace(/&#0?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> {
7039 const get = async (path: string): Promise<{ ok: boolean; lines: string[]; json: unknown }> => {
7140 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}` },
7443 redirect: "manual",
7544 signal: AbortSignal.timeout(5_000),
7645 });
@@ -82,17 +51,16 @@ async function look(forge: string, token: string): Promise<Look> {
8251 }
8352 return { ok: res.status < 400, lines: linesOf(text, type), json };
8453 } catch {
85 return { ok: false, lines: [], json: null };
54 return { ok: false, lines: [UNREACHABLE], json: null };
8655 }
8756 };
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 };
9162 }
9263
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
9664 const cap = (lines: Iterable<string>): string => [...lines].join("\n").slice(0, SEEN_MAX);
9765
9866 /** Runs the drill; resolves to the process exit code. */
@@ -116,29 +84,29 @@ export async function runDrill(run: DrillRun): Promise<number> {
11684 run.say(`The forge should be back within ${domain.backMinutes} minutes. Press Enter when you have caused it.`);
11785 await run.enter();
11886
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;
138109 }
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 };
142110 let res: Response;
143111 try {
144112 res = await fetch(`${run.forge}/api/v1/drills`, {
@@ -172,14 +140,13 @@ export interface DrillArgs {
172140 token: string;
173141 }
174142
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]";
176144 const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
177145
178146 /** The options, or the one plain line to print before exiting 2. */
179147 export function checkArgs(
180148 argv: string[],
181149 env: Record<string, string | undefined>,
182 isTTY: boolean,
183150 ): { ok: true; args: DrillArgs } | { ok: false; error: string } {
184151 const at = argv.indexOf("--forge");
185152 const forge = at >= 0 ? argv[at + 1] : undefined;
@@ -193,16 +160,14 @@ export function checkArgs(
193160 return { ok: false, error: `--forge must be an http(s) URL; ${USAGE}` };
194161 }
195162 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) {
199164 return { ok: false, error: `refusing to send the admin token over plain http to ${url.hostname}; use https, or pass --insecure` };
200165 }
201166 return { ok: true, args: { domain, forge: forge.replace(/\/+$/, ""), token } };
202167 }
203168
204169 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);
206171 if (!checked.ok) {
207172 console.error(checked.error);
208173 process.exit(2);
@@ -211,7 +176,8 @@ if (import.meta.main) {
211176 ...checked.args,
212177 say: (l) => console.log(clean(l)),
213178 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");
215181 },
216182 });
217183 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(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
37 .replace(/&quot;/g, '"').replace(/&#0?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 @@
99 * `--file-issues` each gets an issue carrying the failure text, the
1010 * seed and the run (FORGE_API, FORGE_REPO, FORGE_TOKEN; FORGE_RUN_URL
1111 * 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.
1218 * bun scripts/flake-hunt.ts quarantine
1319 * Puts every file an open `Flaky: test/….test.ts` issue the hunt filed
1420 * (author is the token's user, not untrusted) names into
@@ -30,12 +36,16 @@ import {
3036 huntedFile,
3137 HUNT_RUNS,
3238 issueFor,
39 redActions,
40 redFile,
41 redIssueFor,
42 type OpenRed,
3343 quarantineFlaky,
3444 readRun,
3545 shuffled,
3646 type FileRun,
3747 } from "./flake.ts";
38 import { gateFiles, presentTests } from "./gate.ts";
48 import { gateFiles, outsideFiles, presentTests } from "./gate.ts";
3949 import { parseShardSpec, sliceFiles } from "./shards.ts";
4050
4151 const GATE_FILE = new URL("./gate.ts", import.meta.url).pathname;
@@ -66,7 +76,7 @@ function runsOf(fallback: number): number {
6676
6777 const freshSeed = (): number => crypto.getRandomValues(new Uint32Array(1))[0]!;
6878
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). */
7080 async function runFile(file: string, seed: number): Promise<FileRun> {
7181 const env = { ...process.env };
7282 // 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> {
8494 "test",
8595 "--timeout",
8696 "60000",
87 "--randomize",
88 `--seed=${seed}`,
97 ...(seed === 0 ? [] : ["--randomize", `--seed=${seed}`]),
8998 `./${file}`,
9099 ],
91100 { stdin: "ignore", stdout: "pipe", stderr: "pipe", env },
@@ -171,6 +180,102 @@ async function openFlakyIssues(): Promise<Map<string, number>> {
171180 return found;
172181 }
173182
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
174279 async function hunt(): Promise<number> {
175280 const runs = runsOf(HUNT_RUNS);
176281 const spec = parseShardSpec(valueOf("--shard"));
@@ -263,10 +368,11 @@ async function admit(): Promise<number> {
263368 return admitted(results) ? 0 : 1;
264369 }
265370
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());
267373 else if (mode === "quarantine") process.exit(await quarantineAll());
268374 else if (mode === "admit") process.exit(await admit());
269375 else
270376 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]",
272378 );
scripts/flake.ts +72
@@ -21,6 +21,9 @@ export const HUNT_RUNS = 5;
2121 /** The title prefix of the issue the hunt files; quarantine reads it back. */
2222 export const FLAKY_TITLE = "Flaky: ";
2323
24 /** The title prefix of the issue the scheduled run files for a red file. */
25 export const RED_TITLE = "Red: ";
26
2427 /**
2528 * The least a test may bound a request or a spawn by, in wall-clock ms. A
2629 * 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[] {
321324 }
322325 return out;
323326 }
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 @@
1515 * order under load (`bun scripts/flake-hunt.ts admit <file>`). A flaky file
1616 * leaves it only through `bun scripts/flake-hunt.ts quarantine`, under the
1717 * 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).
1821 */
1922
2023 import { readdirSync } from "node:fs";
@@ -135,6 +138,20 @@ export function gateFiles(
135138 return present.filter((file) => !out.has(file));
136139 }
137140
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
138155 /**
139156 * The gate a checked-out tree's own `scripts/gate.ts` names, whichever shape
140157 * 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(
495495 const line = `fails only beside others: ${cleared.join(", ")} (shard-mates: ${mates})`;
496496 return { red: stillRed, besideOthers: cleared, line };
497497 }
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 @@
44 * `/healthz` is fetched once per user agent and must answer 200; the
55 * stylesheet linked from `/` must be a non-empty `text/css` 200. Exit 1 and
66 * 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()`.
712 */
813
914 export const AGENTS: Record<string, string> = {
@@ -32,11 +37,61 @@ export function stylesheetHref(html: string): string | null {
3237 return null;
3338 }
3439
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
3585 /** 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[]> {
3787 const root = new URL(base);
3888 if (root.protocol !== "http:" && root.protocol !== "https:") throw new Error("url must be http(s)");
3989 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 }
4095
4196 const get = async (path: string, agent: string, headers: Record<string, string> = {}) => {
4297 const url = new URL(path, root).href;
@@ -75,20 +130,42 @@ export async function smoke(base: string, token = ""): Promise<Failure[]> {
75130 }
76131 }
77132 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)");
79135 const health = await get("/healthz", agent);
80136 if (health && health.res.status !== 200) fail(health.url, agent, health.res, "expected 200");
81137 }
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 }
82150 return failures;
83151 }
84152
85153 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>]");
89166 process.exit(2);
90167 }
91 const failures = await smoke(base, process.env["FORGE_TOKEN"] ?? "");
168 const failures = await smoke(base, process.env["FORGE_TOKEN"] ?? "", { version, waitMs: seconds * 1000 });
92169 for (const f of failures)
93170 console.error(`FAIL ${f.url} agent=${f.agent} status=${f.status} content-type=${f.contentType || "-"} (${f.why})`);
94171 if (failures.length) process.exit(1);
scripts/test-shards.ts +59 −18
@@ -58,6 +58,9 @@ import {
5858 splitCapped,
5959 tally,
6060 weigher,
61 besideOthers,
62 failingFiles,
63 onlyTestFailures,
6164 type ShardResult,
6265 } from "./shards.ts";
6366 import { gateFiles, gateRefusal, presentTests } from "./gate.ts";
@@ -425,6 +428,12 @@ async function drain(
425428 const measured = new Map<string, string[]>();
426429 const unmeasured = new Set<string>();
427430
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
428437 type Timed = ShardResult & { seconds: number };
429438
430439 /**
@@ -463,29 +472,60 @@ async function runShard(
463472 const cover = covDir
464473 ? ["--coverage", "--coverage-reporter=lcov", `--coverage-dir=${covDir}`]
465474 : [];
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 };
478500 const through = live
479501 ? (text: string): void => {
480502 process.stdout.write(text);
481503 }
482504 : 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 }
489529 const text = output.join("");
490530 const seconds = ((performance.now() - started) / 1000).toFixed(1);
491531 process.stdout.write(
@@ -524,6 +564,7 @@ async function pool(width: number): Promise<Timed[]> {
524564 results[index] = await runShard(slices[index]!, index, width === 1);
525565 if (results[index]!.code !== 0) {
526566 stop = true;
567 halted = true;
527568 for (const child of children) child.kill("SIGTERM");
528569 }
529570 }
src/actions/accounts.ts +2 −1
@@ -18,6 +18,7 @@ import { appsRuntime } from "../apps/runtime.ts";
1818 import {
1919 dropCredentials,
2020 listAccounts as readAccounts,
21 originOf,
2122 scrubAccount,
2223 setSuspended,
2324 type AccountRow,
@@ -331,7 +332,7 @@ export function newAccountLines(
331332 const id = `account:${a.username}`;
332333 return {
333334 id,
334 line: `${a.username} joined the forge${a.via === null ? "" : ` (${a.via})`}`,
335 line: `${a.username} joined the forge${originOf(a) === "" ? "" : ` (${originOf(a)})`}`,
335336 items: [id],
336337 };
337338 });
src/actions/users.ts +17 −1
@@ -21,6 +21,7 @@ import {
2121 deleteToken as revokeToken,
2222 type TokenScope,
2323 } from "../core/tokens.ts";
24 import { inviteSpentBy } from "../core/invites.ts";
2425 import {
2526 changePassword as rehash,
2627 cleanDisplayName,
@@ -65,6 +66,10 @@ export function signupClosed(app: App, invite?: string): boolean {
6566 return newAccountsClosed(app.db, invite);
6667 }
6768
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
6873 /** What anyone is told at a door that still offers a password-only sign-up. */
6974 export const PASSKEY_REQUIRED_MESSAGE =
7075 "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(
122127 ...(from.address === undefined ? {} : { address: from.address }),
123128 },
124129 });
130 auditInvite(app, created.value.id);
125131 // The address is proven by the link it is sent; until then nothing it
126132 // does may create or cost.
127133 await welcome(app, created.value);
@@ -178,10 +184,18 @@ export async function signUpWithPasskey(
178184 ...(from.address === undefined ? {} : { address: from.address }),
179185 },
180186 });
187 auditInvite(app, created.value.id);
181188 await welcome(app, created.value);
182189 return created;
183190 }
184191
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
185199 /**
186200 * Mint a personal access token. Callers decide what credential may mint one
187201 * (a session or the password, never another token — no privilege
@@ -193,9 +207,11 @@ export function createToken(
193207 name: string,
194208 scope: TokenScope,
195209 options: { expiresAt?: string } = {},
196 ): Result<{ id: string; token: string }, "unauthorized"> {
210 ): Result<{ id: string; token: string }, "unauthorized" | "forbidden"> {
197211 const user = requireUser(actor);
198212 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");
199215 const minted = mintToken(app.db, user.value.id, name, scope, options);
200216 app.bus.append({
201217 kind: "token.created",
src/app.ts +13 −3
@@ -9,13 +9,14 @@ import type { Database } from "bun:sqlite";
99 import type { Decider } from "./crew/decide.ts";
1010 import type { VerifyHuman } from "./core/turnstile.ts";
1111 import { randomBytes } from "node:crypto";
12 import { mkdirSync } from "node:fs";
12 import { existsSync, mkdirSync } from "node:fs";
1313 import { join, resolve } from "node:path";
14 import { migrations } from "./core/schema.ts";
14 import { migrationFiles } from "./core/schema.ts";
1515 import { dropLegacyNewAccountGuards } from "./core/schema.ts";
1616 import { EventBus } from "./core/events.ts";
1717 import { follow } from "./core/following.ts";
1818 import { enqueueForEvent } from "./core/webhooks.ts";
19 import { seedSignup } from "./settings/signup.ts";
1920 import { openDb } from "./kernel/db.ts";
2021 import { id } from "./kernel/id.ts";
2122 import {
@@ -31,6 +32,12 @@ import { closeReadersUnder } from "./git/read.ts";
3132 export interface ForgeConfig {
3233 /** Everything lives here: index db, bare repos, the internal socket. */
3334 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;
3441 /** The disk probe vitals reads; the volume under `dataDir` when unset. Tests give it a reading. */
3542 disk?: (dir: string) => { freeBytes: number; totalBytes: number };
3643 /** @default 3000 */
@@ -212,7 +219,10 @@ export function createApp(config: ForgeConfig): App {
212219 mkdirSync(reposRoot, { recursive: true });
213220 const socketPath = join(dataDir, `forge-${id()}.sock`);
214221
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);
216226 // Hand-installed new-account guards become the `guard_new_accounts`
217227 // setting (default off): drop the legacy triggers once, here, on boot.
218228 // Idempotent — a forge without them changes nothing.
src/chaos/worker.ts +2 −2
@@ -4,12 +4,12 @@
44 */
55
66 import { openDb } from "../kernel/db.ts";
7 import { migrations } from "../core/schema.ts";
7 import { migrationFiles } from "../core/schema.ts";
88 import { claimRun, createRun, heartbeatRuns } from "../core/runs.ts";
99
1010 const [dbPath, repoId] = process.argv.slice(2);
1111 if (dbPath === undefined || repoId === undefined) process.exit(2);
12 const db = openDb(dbPath, migrations);
12 const db = openDb(dbPath, migrationFiles);
1313 console.log("ready");
1414 for (;;) {
1515 const run = createRun(db, { repoId, kind: "check" });
src/checks/progress.ts +21
@@ -8,6 +8,8 @@
88 */
99
1010 import type { CheckSummary } from "../core/runs.ts";
11 import { checkSteps, stepsWords } from "./summary.ts";
12 import { parseTestsRow, testsRowLine } from "./tests-row.ts";
1113 import { readRunLog } from "../runs/log.ts";
1214
1315 export type CheckProgress = {
@@ -90,3 +92,22 @@ export function runningProgress(dataDir: string, check: CheckSummary): string {
9092 return "";
9193 return progressWords(parseShardProgress(readRunLog(dataDir, run.id).lines));
9294 }
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";
6161 import { runningLimitSentence, runningRuns } from "../settings/quotas.ts";
6262 import { exportTree, readFileAt } from "../apps/checkout.ts";
6363 import { parseCheck } from "../apps/manifest.ts";
64 import { workerCpus } from "../../scripts/shards.ts";
6465 import { accountActive } from "../core/accounts.ts";
6566 import { childEnv, runStep } from "../apps/process.ts";
6667 import { noteReached } from "../apps/egress_report.ts";
@@ -68,6 +69,7 @@ import {
6869 capacityCount,
6970 noteBooted,
7071 capacityEnv,
72 cpuLimit,
7173 egressSetting,
7274 } from "../core/capacity.ts";
7375 import {
@@ -245,9 +247,10 @@ export function provideChecksForTest(app: App, handle: ChecksHandle): void {
245247 export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
246248 // Read on every pick (`core/capacity.ts`): an admin's change is honoured
247249 // 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));
249252 const capacity = (): number =>
250 capacityCount(app.db, "checks.concurrency", options.concurrency ?? 1);
253 capacityCount(app.db, "checks.concurrency", booted);
251254 const gate = options.gate ?? new Gate(capacity());
252255 const placement = options.placement ?? placementFromEnv();
253256 /** 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 {
792795 }
793796 : undefined;
794797 try {
798 const cellCpus = resolveLimits("step", manifest.limits).cpus;
795799 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)),
798803 CI: "1",
799804 FORGE_URL: options.forgeUrl,
800805 FORGE_CHECK: run.id,
@@ -802,7 +807,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
802807 FORGE_CHECK_IMPACT: IMPACT_FILE,
803808 // The cell's CPU limit, which `availableParallelism()` may not
804809 // 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),
806811 ...(base === undefined
807812 ? {}
808813 : {
src/checks/summary.ts +88
@@ -111,3 +111,91 @@ export function checkWords(
111111 }
112112 return undefined;
113113 }
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 {
2121 readonly createdAt: string;
2222 /** How the account came: "signup", "edge", or null for one older than the record. */
2323 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;
2426 /** The address it signed up from, when one was recorded. */
2527 readonly address: string | null;
2628 readonly repos: number;
@@ -33,6 +35,15 @@ export interface AccountRow {
3335 readonly verified: boolean;
3436 }
3537
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
3647 /** Every person and agent, newest first. Organizations are not accounts. */
3748 export function listAccounts(db: Database): AccountRow[] {
3849 const rows = db
@@ -78,6 +89,7 @@ export function listAccounts(db: Database): AccountRow[] {
7889 isAdmin: r.is_admin === 1,
7990 createdAt: r.created_at,
8091 via: origin.via,
92 signsInThrough: origin.via === "edge" ? EDGE_METHOD : null,
8193 address: origin.address,
8294 repos: r.repos,
8395 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 @@
1313
1414 import type { Database } from "bun:sqlite";
1515 import { readFileSync } from "node:fs";
16 import { totalmem } from "node:os";
16 import { availableParallelism, totalmem } from "node:os";
1717
1818 export type CapacityKey =
1919 | "crew.concurrency"
@@ -34,6 +34,8 @@ interface Definition {
3434 | { readonly type: "choice"; readonly values: readonly string[] };
3535 /** What it is when neither the store nor the environment says. */
3636 readonly fallback: string;
37 /** The default from the pod's CPUs, in place of `fallback` (#2549). */
38 readonly sized?: (cpus: number) => number;
3739 readonly help: string;
3840 }
3941
@@ -43,21 +45,23 @@ export const CAPACITY: Record<CapacityKey, Definition> = {
4345 env: "FORGE_CREW_CONCURRENCY",
4446 kind: { type: "count", min: 1, max: 16 },
4547 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.",
4749 },
4850 "checks.concurrency": {
4951 label: "Checks at once",
5052 env: "FORGE_CHECKS_CONCURRENCY",
5153 kind: { type: "count", min: 1, max: 16 },
5254 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.",
5457 },
5558 "checks.shards": {
5659 label: "Test shards per check",
5760 env: "FORGE_CHECK_SHARDS",
5861 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.",
6165 },
6266 "children.max": {
6367 label: "Forges this one may send",
@@ -106,10 +110,70 @@ export function validCapacity(key: CapacityKey, value: string): boolean {
106110 }
107111
108112 /** 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 {
110125 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;
113177 }
114178
115179 const GIB = 1024 ** 3;
@@ -126,6 +190,18 @@ export function childrenForMemory(bytes: number): number {
126190 }
127191 const MIN_CHILDREN = 5;
128192
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
129205 /** The pod's memory limit (cgroup v2, then v1), else the machine's memory. */
130206 export function memoryLimitBytes(): number {
131207 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" {
201277 return capacityValue(db, "cells.egress") === "report" ? "report" : "enforce";
202278 }
203279
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 };
208292 }
209293
210294 export interface CapacityChange {
src/core/invites.ts +5
@@ -120,3 +120,8 @@ export function revokeInvite(
120120 if (changed === 0) return err("not_found");
121121 return ok(view(db.query<Row, [string]>("SELECT * FROM invite WHERE id = ?1").get(inviteId)!, now));
122122 }
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 @@
44 // check (`REPO_WRITABLE`'s crew clause) and `onCrew` can never drift.
55 // A person already there is reported at boot (`peopleOnCrews`), never
66 // 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.
710 export default `
811 CREATE TRIGGER repo_crew_agents_only BEFORE INSERT ON repo_crew
912 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 @@
11 // 70 — a sealed repository is never public, said by the database (#865's
22 // review): the rule lived in `updateRepoSettings`; these triggers hold
33 // 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.
47 export default `
58 CREATE TRIGGER repo_sealed_never_public BEFORE UPDATE OF visibility, sealed_for ON repo
69 WHEN NEW.sealed_for IS NOT NULL AND NEW.visibility = 'public'
src/core/migrations/0081-thoughts.ts +3
@@ -2,6 +2,9 @@
22 // row per thought, appended and never changed: the triggers refuse an
33 // UPDATE or DELETE, and there is no foreign key so no cascade can remove
44 // 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.
58 export default `
69 CREATE TABLE thought (
710 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 @@
11 {
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.",
33 "releases": [
44 {
55 "version": "0.7.165",
src/core/reserved.ts +1
@@ -72,6 +72,7 @@ export const SYSTEM_NAMES: readonly string[] = [
7272 "flow",
7373 "front",
7474 "go",
75 "home",
7576 "icons",
7677 "live",
7778 "login",
src/core/schema.ts +3 −1
@@ -9,7 +9,9 @@
99 * new one is named by the UTC second it was written
1010 * (`date -u +%Y%m%d%H%M%S`, e.g. `20261015093000-deploy-notes.ts`), so two
1111 * 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.
1315 */
1416
1517 import type { Database } from "bun:sqlite";
src/core/tokens.ts +3
@@ -13,6 +13,9 @@ import { getUserById, isActive, type User } from "./users.ts";
1313
1414 export type TokenScope = "ro" | "rw" | "admin";
1515
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
1619 const rank: Record<TokenScope, number> = { ro: 0, rw: 1, admin: 2 };
1720
1821 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;
4848
4949 /** The vitals causes this pager owns; the rest are paged elsewhere or are settings. */
5050 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:");
5252 }
5353
5454 /** One owned cause being watched. */
src/crew/parks.ts +7 −2
@@ -23,6 +23,7 @@ import type { Database } from "bun:sqlite";
2323 import { getIssueById, standsForPerson } from "../core/issues.ts";
2424 import type { Repo } from "../core/repos.ts";
2525 import {
26 alreadyDone,
2627 BY_CREW,
2728 DONE_LIKE,
2829 LATER,
@@ -195,7 +196,7 @@ export interface ParkedTwice {
195196 * nothing open. So an issue leaves the list when it is closed (split, or
196197 * replaced by a fresh one), when it is put behind something open (its
197198 * 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
199200 * any.
200201 */
201202 export function parkedTwice(
@@ -288,9 +289,13 @@ export function parkedTwice(
288289 : issue;
289290 });
290291 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;
291296 // An open pull that would close it is someone's answer already.
292297 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)));
294299 }
295300 return out;
296301 }
src/crew/sentinel.ts +48 −3
@@ -64,6 +64,8 @@ export interface Finding {
6464 readonly fix?: string;
6565 /** A setting finding: the value and `updated_at` a person may have kept (#2001). */
6666 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;
6769 }
6870
6971 const clip = (text: string, n = 200): string =>
@@ -386,7 +388,7 @@ export function repeatedErrors(now: number): Finding[] {
386388 */
387389 export function degradedCauses(db: Database, now: number): Finding[] {
388390 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 }>();
390392 for (const s of spans(db, from, now)) {
391393 const start = Math.max(s.openedAt, from);
392394 const held = Math.min(s.closedAt ?? now, now) - start;
@@ -396,6 +398,7 @@ export function degradedCauses(db: Database, now: number): Finding[] {
396398 sentence: s.sentence,
397399 href: s.href,
398400 first: seen?.first ?? start,
401 opened: seen?.opened ?? s.openedAt,
399402 ms: (seen?.ms ?? 0) + Math.max(0, held),
400403 });
401404 }
@@ -415,6 +418,7 @@ export function degradedCauses(db: Database, now: number): Finding[] {
415418 ],
416419 pillar: "P0 Trust: the forge says when it is not well, and is well again",
417420 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(),
418422 });
419423 }
420424 return out;
@@ -626,6 +630,42 @@ export function recordKept(db: Database, key: string, value: string, updatedAt:
626630 ).run(key, value, updatedAt, at);
627631 }
628632
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
629669 /**
630670 * Read, file, record. One open issue per finding: a tracked finding whose
631671 * issue is open is commented on only when its evidence changed; one whose
@@ -674,6 +714,11 @@ export function hunt(
674714 .run(at, evidence, f.key, tracked.issue_id);
675715 continue;
676716 }
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 }
677722 const title = `Sentinel: ${clip(f.title, 200)}`;
678723 // An issue already filed under this title whose record was lost
679724 // (the insert failed last hunt) is adopted, not filed twice.
@@ -694,8 +739,8 @@ export function hunt(
694739 if (f.urgent && f.kept === undefined) handOver(app, repo, actor, made.value);
695740 }
696741 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);
699744 } catch (error) {
700745 log.error("sentinel filing failed", { key: f.key, error: String(error) });
701746 }
src/http/api.ts +10 −2
@@ -160,6 +160,7 @@ import {
160160 updateProfile,
161161 } from "../actions/users.ts";
162162 import { listEvents } from "../core/events.ts";
163 import { audienceFor } from "../core/audience.ts";
163164 import { audienceFilter } from "../core/event_audience.ts";
164165 import {
165166 forkParentFor,
@@ -221,7 +222,7 @@ import {
221222 } from "../core/reviews.ts";
222223 import { createSession } from "../core/sessions.ts";
223224 import { mintProposal } from "../actions/proposal_tokens.ts";
224 import { listTokens } from "../core/tokens.ts";
225 import { ADMIN_TOKEN_WORDS, listTokens } from "../core/tokens.ts";
225226 import {
226227 headInNamespace,
227228 PROPOSAL_BODY_MAX,
@@ -1854,6 +1855,8 @@ export function apiRoutes(router: Router<Handler>): void {
18541855 str(input.name) || "unnamed",
18551856 scope,
18561857 );
1858 if (!minted.ok && minted.error === "forbidden")
1859 return apiError("forbidden", 403, ADMIN_TOKEN_WORDS);
18571860 if (!minted.ok) return unauthorized();
18581861 return json(minted.value, 201);
18591862 });
@@ -2146,7 +2149,12 @@ export function apiRoutes(router: Router<Handler>): void {
21462149
21472150 router.add("GET", "/api/v1/repos/:owner/:name", (ctx) => {
21482151 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();
21502158 });
21512159
21522160 router.add("PATCH", "/api/v1/repos/:owner/:name", async (ctx) => {
src/http/mcp.ts +93 −3
@@ -130,7 +130,7 @@ import {
130130 } from "../crew/direct.ts";
131131 import { setArchived, setCrewOutsiders, setDecisionsBy } from "../actions/repos.ts";
132132 import { mapJson, marksFor } from "../web/pages/map.ts";
133 import { undoFleetOp } from "../actions/fleet.ts";
133 import { applyFleetIds, previewFleetIds, undoFleetOp } from "../actions/fleet.ts";
134134 import { applyLimb, isLimbAction, previewLimb } from "../actions/limbs.ts";
135135 import {
136136 milestoneData,
@@ -151,6 +151,7 @@ import {
151151 isAskedOrder,
152152 isAskedShow,
153153 } from "../core/asked.ts";
154 import { audienceFor } from "../core/audience.ts";
154155 import { linksFrom, linksTo } from "../core/thought_links.ts";
155156 import { streamJson, thoughtStream } from "../core/thought_stream.ts";
156157 import {
@@ -264,6 +265,67 @@ function repoArg(ctx: Ctx, user: User, args: Args): Repo | Answer {
264265 : bad("not_found", "no such app or repository (owner/name)");
265266 }
266267
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
267329 const APP_ARG = {
268330 type: "string",
269331 description: "The app, as owner/name.",
@@ -343,6 +405,28 @@ const TOOLS: readonly Tool[] = [
343405 );
344406 },
345407 },
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 },
346430 {
347431 name: "here",
348432 description:
@@ -1209,22 +1293,28 @@ const TOOLS: readonly Tool[] = [
12091293 {
12101294 name: "archive",
12111295 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.",
12131297 inputSchema: {
12141298 type: "object",
12151299 properties: {
12161300 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 },
12171307 archived: { type: "boolean" },
12181308 dryRun: {
12191309 type: "boolean",
12201310 description: "Plan only (the default). Send false to do it.",
12211311 },
12221312 },
1223 required: ["repo"],
12241313 },
12251314 writes: true,
12261315 dryByDefault: true,
12271316 async run(ctx, user, args) {
1317 if (args["repos"] !== undefined) return archiveMany(ctx, user, args);
12281318 const repo = repoArg(ctx, user, args);
12291319 if (isAnswer(repo)) return repo;
12301320 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 @@
55 * verdict. Auth is resolved once per request and handed to handlers as data.
66 */
77
8 import { refusedByDatabase, shownRefusal } from "./refusal.ts";
89 import { NOT_A_MEMBER } from "../core/roles.ts";
910 import { signupOptions, signupPasskey } from "../web/pages/signup-passkey.ts";
1011 import { toolbarComment, toolbarPage } from "../web/studio/toolbar.ts";
@@ -248,6 +249,7 @@ import {
248249 asksMute,
249250 asksPage,
250251 } from "../web/studio/asks_page.ts";
252 import { homeArchive, homeArchived, homeArchiveUndo } from "../web/studio/home_archive.ts";
251253 import { liveFeedFragment } from "../web/studio/feed.ts";
252254 import { serviceWorker } from "../web/sw.ts";
253255 import { explorePage } from "../web/studio/explore.ts";
@@ -654,6 +656,9 @@ export function buildRouter(): Router<Handler> {
654656 // Apps and people ranked by real numbers, narrowed in one line (#607).
655657 router.add("GET", "/explore", explorePage);
656658 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);
657662 router.add("GET", "/signin", signinPage);
658663 router.add("POST", "/signin", signinSubmit);
659664 router.add("GET", "/signin/parent", parentStart);
@@ -1853,25 +1858,16 @@ export interface Servers {
18531858 }
18541859
18551860 /**
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
18681863 * when a database trigger refused, else a 500 that says nothing of the fault.
18691864 */
18701865 function failure(app: App, error: unknown): Response {
18711866 const refused = refusedByDatabase(error);
18721867 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));
18751871 }
18761872 app.log.error("unhandled request error", { error: String(error) });
18771873 return securityFloor(json({ error: "internal" }, 500));
src/kernel/db.ts +14 −5
@@ -7,7 +7,10 @@
77
88 import { Database } from "bun:sqlite";
99 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");
1114
1215 /**
1316 * How long a writer waits for another writer before giving up. Long by
@@ -34,7 +37,7 @@ const CACHE_KIB = 64 * 1024;
3437 */
3538 const MMAP_BYTES = 512 * 1024 * 1024;
3639
37 export function openDb(path: string, migrations: readonly string[]): Database {
40 export function openDb(path: string, migrations: readonly Migration[]): Database {
3841 const memory = path === ":memory:";
3942 const fresh = memory || !existsSync(path);
4043 const image = fresh ? migratedEmpty.get(migrations) : undefined;
@@ -47,8 +50,14 @@ export function openDb(path: string, migrations: readonly string[]): Database {
4750 }
4851 applyPragmas(db);
4952 // 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 });
5261 if (fresh && image === undefined) migratedEmpty.set(migrations, db.serialize());
5362 return db;
5463 }
@@ -63,7 +72,7 @@ export function openDb(path: string, migrations: readonly string[]): Database {
6372 * bytes however it was made: the migrations that draw randomness only
6473 * rewrite rows an empty database does not have.
6574 */
66 const migratedEmpty = new WeakMap<readonly string[], Uint8Array>();
75 const migratedEmpty = new WeakMap<readonly Migration[], Uint8Array>();
6776
6877 /**
6978 * The image with its header saying "rollback journal" (bytes 18 and 19 of a
src/kernel/migrate.ts +123 −15
@@ -11,7 +11,8 @@
1111 * migration is a row in `schema_migration`, keyed by a hash of its text, and
1212 * a boot runs exactly the ones whose text it has not seen. A second boot is
1313 * 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.
1516 *
1617 * Each statement is also run so that meeting its own result is a no-op —
1718 * `CREATE` gains `IF NOT EXISTS`, an `ADD COLUMN` whose column is there is
@@ -77,10 +78,73 @@ export function migrationId(sql: string): string {
7778 return sha256(sql.replace(/\s+/g, " ").trim());
7879 }
7980
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 );
81145 const ids = migrations.map((sql, i) => {
82146 if (sql.trim() === "")
83 defect("db.migration_missing", `migration ${i + 1} is empty`);
147 defect("db.migration_missing", `${names[i]} is empty`);
84148 return migrationId(sql);
85149 });
86150 const seen = new Set<string>();
@@ -88,22 +152,19 @@ export function migrate(db: Database, migrations: readonly string[]): void {
88152 if (seen.has(id))
89153 defect(
90154 "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`,
92156 );
93157 seen.add(id);
94158 });
95159
96160 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[] = [];
103163 for (let i = 0; i < migrations.length; i++) {
104164 const id = ids[i]!;
105165 if (applied.has(id)) continue;
106166 const sql = migrations[i]!;
167 const entry = list[i]!;
107168 db.transaction(() => {
108169 for (const statement of statements(sql)) {
109170 try {
@@ -111,19 +172,63 @@ export function migrate(db: Database, migrations: readonly string[]): void {
111172 } catch (error) {
112173 defect(
113174 "db.migration_failed",
114 `migration ${i + 1} failed at ${quote(statement)}: ${messageOf(error)}`,
175 `${names[i]} failed at ${quote(statement)}: ${messageOf(error)}`,
115176 error,
116177 );
117178 }
118179 }
119180 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 );
122188 applied.add(id);
123189 // PRAGMA can't be parameterized; the value is a count, not input.
124190 db.exec(`PRAGMA user_version = ${applied.size}`);
125191 })();
192 ran.push(typeof entry === "string" ? String(i + 1) : entry.name);
126193 }
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 })();
127232 }
128233
129234 /**
@@ -132,7 +237,9 @@ export function migrate(db: Database, migrations: readonly string[]): void {
132237 * taken as applied — what that number always meant — unless one plainly
133238 * did not run (a table it creates or a column it adds is missing: a
134239 * 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.
136243 */
137244 function appliedIds(
138245 db: Database,
@@ -153,7 +260,8 @@ function appliedIds(
153260 db.exec(`CREATE TABLE schema_migration (
154261 id TEXT PRIMARY KEY,
155262 position INTEGER NOT NULL,
156 applied_at TEXT NOT NULL
263 applied_at TEXT NOT NULL,
264 name TEXT
157265 )`);
158266 const insert = db.query(
159267 "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";
2424 import { parseCidrs } from "./cidr.ts";
2525 import { dataHomeRefusal } from "./data_home.ts";
2626 import { applyPragmas } from "./db.ts";
27 import { migrate } from "./migrate.ts";
27 import { type Migration, migrate } from "./migrate.ts";
2828
2929 /** A scratch directory older than this was left by a killed run. */
3030 const STALE_MS = 60 * 60 * 1000;
@@ -81,13 +81,14 @@ export function configProblem(
8181 export function preflight(
8282 env: Record<string, string | undefined>,
8383 treeDir: string,
84 migrations: readonly string[],
84 migrations: readonly Migration[],
8585 ): Preflight {
8686 const bad = configProblem(env, treeDir);
8787 if (bad !== null) return { ok: false, line: `preflight failed: ${bad}` };
8888
8989 const dataDir = dataDirOf(env);
9090 let scratch: string | undefined;
91 let unknown: string[] = [];
9192 try {
9293 mkdirSync(dataDir, { recursive: true });
9394 sweepStale(dataDir);
@@ -101,13 +102,17 @@ export function preflight(
101102 const db = new Database(copy, { create: true, strict: true });
102103 try {
103104 applyPragmas(db);
104 migrate(db, migrations);
105 unknown = migrate(db, migrations).unknown;
105106 } finally {
106107 db.close();
107108 }
108109 return {
109110 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 : ""),
111116 };
112117 } catch (error) {
113118 const message = (error instanceof Error ? error.message : String(error))
src/kernel/vitals.ts +11 −1
@@ -23,7 +23,9 @@ import {
2323 import { STALE_AFTER_MS } from "../runs/lease.ts";
2424 import { BUDGETS } from "./budgets.ts";
2525 import { lastMirror, mirrorNeed } from "../snapshot/mirror.ts";
26 import { lastSnapshotOutcome, snapshotNeed } from "../snapshot/outcome.ts";
2627 import { drillNeed, firstSeen as drillFirstSeen, lastVerdict } from "../snapshot/verdict.ts";
28 import { proofNeed } from "../release/prove.ts";
2729 import { newestDrills } from "../core/domain_drills.ts";
2830 import { DOMAINS } from "./domains.ts";
2931 import { domainDrillNeeds } from "./domain_drill.ts";
@@ -50,7 +52,7 @@ export interface EnvCount {
5052 /**
5153 * One cause of the forge being off: the `needs` sentence, a `cause` key that
5254 * 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
5456 * (the oldest evidence the forge holds, else the first time this process saw
5557 * it, held until the cause clears; `at` is never the answer to a repeat
5658 * call), and where to look.
@@ -384,10 +386,18 @@ export function vitals(input: VitalsInput): Vitals {
384386 ))
385387 need(owed.cause, owed.sentence, owed.since, "/");
386388
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
387393 // A snapshot that did not land off the box, or a mirror a day behind, is a card until it does.
388394 const behind = mirrorNeed(lastMirror(input.dataDir), now);
389395 if (behind !== undefined) need("mirror", behind.sentence, behind.since, "/settings/backups");
390396
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
391401 const apps = { serving: 0, asleep: 0, previews: 0 };
392402 for (const e of input.envs ?? []) {
393403 if (e.env !== "production") apps.previews++;
src/main.ts +16 −4
@@ -54,7 +54,9 @@ import { configProblem, dataDirOf, preflight } from "./kernel/preflight.ts";
5454 import { running } from "./kernel/version.ts";
5555 import { record as recordDegraded } from "./kernel/degraded_history.ts";
5656 import { vitals } from "./kernel/vitals.ts";
57 import { proveRelease } from "./release/prove.ts";
5758 import { snapshotPlace } from "./actions/health.ts";
59 import { capacityDefault } from "./core/capacity.ts";
5860
5961 // `forge hook <name>` re-enters here from a git hook stub. It relays to the
6062 // running forge over its socket and exits — it must never boot a server.
@@ -72,8 +74,8 @@ if (process.argv[2] === "hook") {
7274 // config parsed, migrations run on a copy of the database. No socket, no
7375 // timers, and the live database is never opened.
7476 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);
7779 // CLI answer on the stream a person or a script reads.
7880 (result.ok ? console.log : console.error)(result.line);
7981 process.exit(result.ok ? 0 : 1);
@@ -176,6 +178,8 @@ if (parentProblem !== null) {
176178
177179 const app = createApp({
178180 dataDir,
181 // A new forge starts with signup = invite (FORGE_SIGNUP below still wins).
182 seedSignup: true,
179183 ...(parentUrl === "" ? {} : { parentUrl }),
180184 port: Number(process.env.PORT ?? 3000),
181185 ...(process.env.FORGE_HOST === undefined
@@ -332,7 +336,8 @@ const checks =
332336 ? undefined
333337 : startChecks(app, {
334338 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"))),
336341 redHoldMs: RED_HOLD_MS,
337342 // A roll lets running checks finish this long before cutting them off.
338343 drainMs: positive(process.env.FORGE_CHECKS_DRAIN_S, 300) * 1000,
@@ -379,7 +384,7 @@ const crew = appsOn && !crewHeldOff
379384 forgeUrl,
380385 localUrl: servers.url,
381386 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"))),
383388 budgetUsd: Number(process.env.FORGE_CREW_BUDGET_USD) || 5,
384389 runTimeoutMs: crewRunMs,
385390 allowHosts: (process.env.FORGE_CREW_ALLOW_HOSTS ?? "")
@@ -478,6 +483,13 @@ const staleTimer = setInterval(() => void tickAsks(), 120_000);
478483 void tickAsks();
479484
480485 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 } : {}) }));
481493 // Only an agent is on a crew (the `repo_crew_agents_only` trigger refuses anyone else). A person
482494 // put on one before that is reported here for the owner to take off — the
483495 // 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 @@
44 * each take one line: the release is not kept, or its schema is behind the
55 * live database's (a down-migration is not something this forge has).
66 * 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).
910 */
1011
1112 import { Database } from "bun:sqlite";
src/settings/signup.ts +20 −2
@@ -1,7 +1,7 @@
11 /**
22 * Who may make an account: `open`, `invite` or `closed`. Stored in
33 * `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`).
55 * `/signup`, `POST /api/v1/users`, passkey signup and a first edge sign-in ask
66 * it (`signupClosed` in `actions/users.ts`); `invite` lets in only a person with
77 * an open invite link (`core/invites.ts`), spent with the account.
@@ -36,6 +36,22 @@ export function getSignup(db: Database): SignupMode {
3636 return parsed.ok ? parsed.value : SIGNUP_DEFAULT;
3737 }
3838
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
3955 /** Set the mode and keep the change; the same mode again keeps nothing. */
4056 export function setSignup(db: Database, value: SignupMode, actorId: string): void {
4157 const from = getSignup(db);
@@ -77,7 +93,9 @@ export function signupChanges(db: Database, limit = 20): SignupChange[] {
7793 .map((r) => ({
7894 from: r.from_value,
7995 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" : ""),
8199 at: r.at,
82100 }));
83101 }
src/snapshot/drill.ts +1 −1
@@ -56,7 +56,7 @@ class StepTimeout extends Error {}
5656 function within<T>(work: Promise<T>, ms: number): Promise<T> {
5757 let timer: ReturnType<typeof setTimeout>;
5858 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);
6060 });
6161 return Promise.race([work, late]).finally(() => clearTimeout(timer));
6262 }
src/snapshot/due.ts +23 −7
@@ -2,11 +2,21 @@
22 * When the weekly drill is owed: every Sunday 09:00 UTC is a slot, and a
33 * slot is kept by one drill at or after it, however it was asked for. A
44 * 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.
611 */
712
813 const DAY = 86_400_000;
914
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
1020 /** The latest Sunday 09:00 UTC at or before `now`, in ms. */
1121 export function lastSlot(now: number): number {
1222 const d = new Date(now);
@@ -17,14 +27,20 @@ export function lastSlot(now: number): number {
1727 }
1828
1929 /**
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`.
2334 */
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 {
2541 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);
2844 const began = Date.parse(since ?? "");
2945 return Number.isFinite(began) && began < slot;
3046 }
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 @@
1616 * with the same `S3_*` variables the forge runs under, or
1717 * `FORGE_SNAPSHOT_DIR` for a directory store.
1818 *
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 *
1923 * The key comes back only from its wrapped form and the operator's
2024 * passphrase (`FORGE_SNAPSHOT_PASSPHRASE`). A wrong passphrase refuses the
2125 * restore; none leaves the key out, and every sealed value that will not
@@ -28,6 +32,8 @@ import { runGit } from "../git/env.ts";
2832 import { installHooks } from "../git/hooks.ts";
2933 import { repoDir } from "../git/repos.ts";
3034 import {
35 IN_FLIGHT,
36 IN_FLIGHT_BYTES,
3137 isRunBundle,
3238 keyOf,
3339 listSnapshots,
@@ -36,6 +42,7 @@ import {
3642 type Manifest,
3743 } from "./snapshot.ts";
3844 import { unwrapKey, WRAPPED_KEY } from "./wrap.ts";
45 import { isPackedFile, partPaths, unpack, type PackedFile } from "./parts.ts";
3946 import { resolveDestination } from "./destination.ts";
4047 import type { Store } from "./store.ts";
4148
@@ -49,6 +56,9 @@ export interface Restored {
4956 readonly sealedMissing: readonly string[];
5057 }
5158
59 /** Repositories cloned at once while restoring. */
60 export const CLONES = 4;
61
5262 /** Names of every sealed value in a restored database; never the values. */
5363 async function sealedNames(dbFile: string): Promise<string[]> {
5464 const { Database } = await import("bun:sqlite");
@@ -74,11 +84,67 @@ async function sealedNames(dbFile: string): Promise<string[]> {
7484 }
7585 }
7686
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
77137 export async function restoreSnapshot(
78138 store: Store,
79139 name: string,
80140 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 } = {},
82148 ): Promise<Restored> {
83149 if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}-[0-9]{2}-[0-9]{2}Z$/.test(name))
84150 throw new Error(`not a snapshot name: ${name}`);
@@ -104,20 +170,29 @@ export async function restoreSnapshot(
104170 "the passphrase does not open this snapshot's wrapped key; check FORGE_SNAPSHOT_PASSPHRASE, or restore without it to leave sealed values behind",
105171 );
106172 }
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[]>();
107181 mkdirSync(dataDir, { recursive: true });
108182
109183 let files = 0;
110184 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 }
121196 // A day's run logs in one object come back as the logs they were.
122197 if (isRunBundle(entry.path)) {
123198 for (const run of unpackRuns(body)) {
@@ -126,7 +201,7 @@ export async function restoreSnapshot(
126201 await Bun.write(out, run.body);
127202 files++;
128203 }
129 continue;
204 return;
130205 }
131206 const bundle = entry.path.match(/^repos\/([^/]+)\/([^/]+)\.bundle$/);
132207 // A bundle is not a file of the forge's; it becomes a repository below.
@@ -141,23 +216,62 @@ export async function restoreSnapshot(
141216 chmodSync(target, 0o600);
142217 files++;
143218 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++;
160236 }
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);
161275 const { rmSync } = await import("node:fs");
162276 rmSync(join(dataDir, "restore-bundles"), { recursive: true, force: true });
163277 if (key !== undefined) {
src/snapshot/service.ts +81 −21
@@ -5,7 +5,9 @@
55 * snapshots and says so on its settings page, rather than pretending.
66 *
77 * 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.
911 */
1012
1113 import type { App } from "../app.ts";
@@ -20,15 +22,29 @@ import {
2022 type Progress,
2123 } from "./snapshot.ts";
2224 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";
2426 import { lastDrill, recordDrill } from "./drills.ts";
2527 import { lastMirror, mirrorPass, MIRROR_EVERY_MS, recordMirror, type MirrorState } from "./mirror.ts";
28 import { recordSnapshotOutcome } from "./outcome.ts";
2629 import type { Store } from "./store.ts";
2730
2831 const log = createLog("snapshots");
2932
3033 /** When the nightly snapshot is taken, in UTC: before the probe round. */
3134 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 }
3248
3349 export interface SnapshotsHandle {
3450 /** The store in effect now; undefined when the forge has been given none. */
@@ -45,7 +61,7 @@ export interface SnapshotsHandle {
4561 readonly last: () => { manifest?: Manifest; error?: string; at?: string };
4662 /** Drill the newest snapshot now and keep the result; waits for any snapshot in flight. */
4763 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. */
4965 readonly drillIfDue: () => Promise<boolean>;
5066 /** The off-box mirror's store; undefined when the forge has none. */
5167 readonly mirror: Store | undefined;
@@ -94,9 +110,15 @@ export function startSnapshots(
94110 /** Test seam: how often the mirror is looked at. */
95111 readonly mirrorEveryMs?: number | undefined;
96112 readonly nightly?: boolean;
113 /** Test seam: how soon a failed snapshot is tried again. */
114 readonly retryMs?: number | undefined;
97115 readonly passphrase?: string | undefined;
98116 /** Test seam: the drill itself. */
99117 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;
100122 } = {
101123 version: "0",
102124 },
@@ -122,12 +144,13 @@ export function startSnapshots(
122144 let last: { manifest?: Manifest; error?: string; at?: string } = {};
123145
124146 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 () => {
126150 // Resolved per run, so a destination saved on the Backups page is the
127151 // very next snapshot's.
128152 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();
131154 running = { objects: 0, bytes: 0, elapsedMs: 0 };
132155 const manifest = await takeSnapshot(
133156 {
@@ -153,11 +176,19 @@ export function startSnapshots(
153176 inFlight = run.then(
154177 (m) => {
155178 last = { manifest: m, at: m.finishedAt };
179 keep({ landed: m.name });
180 arm(untilNightly());
156181 return m;
157182 },
158183 (error) => {
159184 last = { error: String(error), at: new Date().toISOString() };
185 if (error instanceof Nowhere) {
186 arm(untilNightly());
187 throw error;
188 }
160189 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()));
161192 throw error;
162193 },
163194 );
@@ -217,20 +248,30 @@ export function startSnapshots(
217248 return result;
218249 })().finally(() => {
219250 drilling = undefined;
251 // However it was asked for, a red drill is tried again on its own.
252 armRedRetry();
220253 });
221254 return drilling;
222255 };
223256
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 }
232263 };
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());
234275
235276 // The board only reads; the time the forge first owed a drill is kept here.
236277 try {
@@ -241,22 +282,40 @@ export function startSnapshots(
241282
242283 let catchUp: ReturnType<typeof setTimeout> | undefined;
243284 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;
244288 const scheduleDrill = (): void => {
245289 if (options.nightly === false || stopped) return;
246290 drillTimer = setTimeout(() => {
247291 runIfDue().finally(scheduleDrill);
248292 }, untilDrill());
249293 };
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;
253308 await drill().catch((error) => log.error("restore drill failed to run", { error: String(error) }));
254309 return true;
255310 };
256311 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.
258314 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 );
260319 }
261320
262321 // Within the hour: look for snapshots the mirror lacks, soon after boot and then hourly.
@@ -279,7 +338,7 @@ export function startSnapshots(
279338 wraps: !!options.passphrase,
280339 now: take,
281340 drill,
282 drillIfDue: runIfDue,
341 drillIfDue: () => runIfDue(),
283342 list: async () => {
284343 const store = current();
285344 return store === undefined ? [] : listSnapshots(store);
@@ -301,6 +360,7 @@ export function startSnapshots(
301360 if (timer !== undefined) clearTimeout(timer);
302361 if (drillTimer !== undefined) clearTimeout(drillTimer);
303362 if (catchUp !== undefined) clearTimeout(catchUp);
363 if (redRetry !== undefined) clearTimeout(redRetry);
304364 if (mirrorTimer !== undefined) clearTimeout(mirrorTimer);
305365 if (mirroring !== undefined) await mirroring.catch(() => {});
306366 if (inFlight !== undefined) await inFlight.catch(() => {});
src/snapshot/snapshot.ts +31 −5
@@ -12,10 +12,13 @@
1212 * checks, previews, pictures.
1313 *
1414 * 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.
1922 *
2023 * Objects go up through a bounded queue, eight in flight; the manifest
2124 * lists them in the order they were handed over, not the order they landed.
@@ -32,6 +35,7 @@ import { runGit } from "../git/env.ts";
3235 import { repoDir } from "../git/repos.ts";
3336 import { createLog } from "../kernel/log.ts";
3437 import { countRows, type DrillCounts } from "./counts.ts";
38 import { pack, partPath, PART_BYTES, type PackedFile } from "./parts.ts";
3539 import type { Store } from "./store.ts";
3640 import { unwrapKey, wrapKey, WRAPPED_KEY } from "./wrap.ts";
3741
@@ -63,6 +67,8 @@ export interface Manifest {
6367 * `raw` in snapshots from before the key was wrapped.
6468 */
6569 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[];
6672 }
6773
6874 /** What a manifest says of the key, including manifests older than the field. */
@@ -162,6 +168,8 @@ export interface Snapshotter {
162168 readonly onLand?: ((p: Progress) => void) | undefined;
163169 /** Overrides of the progress cadence, for tests. */
164170 readonly progressEvery?: { objects?: number; ms?: number } | undefined;
171 /** Override of the database's part size, for tests. */
172 readonly partBytes?: number | undefined;
165173 }
166174
167175 /** `2026-09-22T09-00-00Z`: sortable, and a name a bucket allows. */
@@ -216,6 +224,7 @@ export async function takeSnapshot(
216224 const prefix = `${SNAPSHOT_PREFIX}${name}/`;
217225 const scratch = mkdtempSync(join(tmpdir(), "forge-snapshot-"));
218226 const files: SnapshotFile[] = [];
227 const packed: PackedFile[] = [];
219228 let dbCounts: DrillCounts | undefined;
220229 const started = Date.now();
221230 const everyObjects = forge.progressEvery?.objects ?? PROGRESS_OBJECTS;
@@ -305,7 +314,23 @@ export async function takeSnapshot(
305314 } finally {
306315 copy.close();
307316 }
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 });
309334 // The key travels only wrapped. Without a passphrase it stays home.
310335 let key: Manifest["key"] = "none";
311336 const keyFile = Bun.file(join(forge.dataDir, "secret.key"));
@@ -434,6 +459,7 @@ export async function takeSnapshot(
434459 bytes: files.reduce((n, f) => n + f.bytes, 0),
435460 ...(dbCounts === undefined ? {} : { counts: dbCounts }),
436461 key,
462 packed,
437463 };
438464 await store.put(
439465 `${prefix}manifest.json`,
src/snapshot/store.ts +43 −9
@@ -51,17 +51,40 @@ export function s3FromEnv(
5151 };
5252 }
5353
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
5471 /**
5572 * A store that tries again. Object stores answer a burst, a flake or a
5673 * moment of maintenance with a 5xx — Backblaze says so in its own docs —
5774 * and one such answer in a thousand puts used to fail the whole snapshot
5875 * with "S3Error: an unexpected error has occurred" and no key named. Every
5976 * 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
6279 * settles on its own, and once held a snapshot on its first object for an
6380 * hour with the network fine (2026-10-13), so the next attempt opens a
6481 * 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.
6588 */
6689 export function withRetries(
6790 inner: Store,
@@ -69,14 +92,23 @@ export function withRetries(
6992 attempts?: number;
7093 baseMs?: number;
7194 deadlineMs?: number;
95 floorBytesPerS?: number;
96 slackMs?: number;
7297 sleep?: (ms: number) => Promise<void>;
7398 } = {},
7499 ): Store {
75100 const attempts = Math.max(1, options.attempts ?? 5);
76101 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);
78106 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> {
80112 let last: unknown;
81113 for (let n = 0; n < attempts; n++) {
82114 try {
@@ -93,11 +125,13 @@ export function withRetries(
93125 return {
94126 where: inner.where,
95127 ...(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)),
101135 };
102136 }
103137
src/tui/app/app.ts +3 −2
@@ -63,6 +63,7 @@ import {
6363 type CursorPos,
6464 } from "../ui/widgets.ts";
6565 import { binding, find, keyFor, type Binding } from "./keys.ts";
66 import { message } from "../screens/common.ts";
6667 import { Palette, type Command, type PaletteSearch } from "./palette.ts";
6768 import { truncate } from "../term/width.ts";
6869
@@ -470,7 +471,7 @@ export class App {
470471 // Every failure becomes a toast; nothing throws into the loop, and no
471472 // single bad read may drop the session.
472473 if (!entry.abort.signal.aborted)
473 this.toast(e instanceof Error ? e.message : String(e), "danger");
474 this.toast(message(e), "danger");
474475 })
475476 .finally(() => {
476477 entry.loading = false;
@@ -876,7 +877,7 @@ export class App {
876877 private run(command: Command): void {
877878 this.overlay = "none";
878879 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"),
880881 );
881882 }
882883
src/tui/screens/about.ts +13
@@ -15,6 +15,7 @@
1515 * card is an OSC 8 hyperlink besides.
1616 */
1717
18 import { audienceFor } from "../../core/audience.ts";
1819 import { isPinned } from "../../core/pins.ts";
1920 import { parentReleases, releasesSentence } from "../../core/parent_releases.ts";
2021 import { running } from "../../kernel/version.ts";
@@ -128,6 +129,18 @@ export function aboutScreen(
128129 style(description === "" ? c.faint : c.fg, c.card),
129130 edge,
130131 );
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 );
131144 drawFacts(
132145 s,
133146 {
src/tui/screens/common.ts +3
@@ -7,6 +7,7 @@
77 * of them. Nothing here knows about repos or issues — that is the point.
88 */
99
10 import { refusalText } from "../../http/refusal.ts";
1011 import { NOT_A_MEMBER } from "../../core/roles.ts";
1112 import type { Ctx, Screen } from "../app/app.ts";
1213 import { binding, type Binding } from "../app/keys.ts";
@@ -996,6 +997,8 @@ export function errorText(code: string): string {
996997
997998 /** Anything thrown, as a string — the toast text of last resort. */
998999 export function message(error: unknown): string {
1000 const refused = refusalText(error);
1001 if (refused !== undefined) return refused;
9991002 return error instanceof Error ? error.message : String(error);
10001003 }
10011004
src/tui/screens/pull.ts +27 −3
@@ -39,7 +39,7 @@ import {
3939 submitReview,
4040 } from "../../actions/pulls.ts";
4141 import { openEnvUrl } from "../../actions/apps.ts";
42 import { listRepoRuns } from "../../actions/runs.ts";
42 import { listRepoRuns, mayReadRunLog } from "../../actions/runs.ts";
4343 import { previewEnv } from "../../apps/data.ts";
4444 import {
4545 appsRuntime,
@@ -49,7 +49,7 @@ import { proposedByText } from "../../core/proposal_token.ts";
4949 import { getAppByRepo } from "../../core/apps.ts";
5050 import { getIssue, type Issue } from "../../core/issues.ts";
5151 import { isProposed, pullMayRun } from "../../core/pull_trust.ts";
52 import { runningProgress } from "../../checks/progress.ts";
52 import { runningProgress, stepsLine } from "../../checks/progress.ts";
5353 import { askCheck } from "../../checks/again.ts";
5454 import { checkStale, checksHold } from "../../checks/stale.ts";
5555 import { checkWords } from "../../checks/summary.ts";
@@ -237,6 +237,8 @@ export function pullScreen(
237237 /** The pull's base branch, to name what the check was merged with. */
238238 let checkBase = "";
239239 let checkProgress = "";
240 /** The run page's three step rows as a line; empty when the log is hidden. */
241 let checkSteps = "";
240242 /** The check's green was against a base that has since moved. */
241243 let checkOld = false;
242244 /** Agent runs about this pull, newest first. */
@@ -288,6 +290,14 @@ export function pullScreen(
288290 : checkSummary(f.db, row.id, row.headOid || (facts?.headOid ?? ""));
289291 checkBase = row?.baseBranch ?? facts?.baseBranch ?? "";
290292 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 );
291301 checkOld =
292302 repo !== undefined &&
293303 row !== undefined &&
@@ -790,7 +800,7 @@ export function pullScreen(
790800 // The box is exactly as tall as what is in it: the state, then a row
791801 // for the verdicts when there are any, then a row for the checks when
792802 // 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);
794804 const boxH =
795805 3 +
796806 (standingText(standing(), requireReview) === "" ? 0 : 1) +
@@ -1251,6 +1261,20 @@ export function checksText(
12511261 stale = false,
12521262 /** `runningProgress` of the check's log; empty when none is known. */
12531263 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,
12541278 ): string {
12551279 if (check.state === "none")
12561280 return required
src/tui/screens/signin.ts +31 −14
@@ -8,9 +8,10 @@
88 * account while signup allows — not an admin; the operator makes the admin.
99 */
1010
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";
1212 import { SignupSlot, waitText } from "../../actions/signup-window.ts";
1313 import { hasAnyUser } from "../../core/users.ts";
14 import { getSignup } from "../../settings/signup.ts";
1415 import type { Ctx, Screen } from "../app/app.ts";
1516 import { binding } from "../app/keys.ts";
1617 import { style } from "../term/style.ts";
@@ -37,36 +38,51 @@ const CREATE: Field[] = [
3738 { label: "Password", secret: true, hint: "At least 10 characters." },
3839 ];
3940
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
4045 export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen {
4146 // "Is this forge empty?" is a existence question, not a count: `COUNT(*)`
4247 // walks the whole user table on the one screen every session paints.
4348 const first = !hasAnyUser(f.db);
4449 // 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 = ["", "", "", ""];
4856 let focus = 0;
4957 let error: string | undefined;
5058 let busy = false;
5159
52 const fields = (): Field[] => (mode === "create" ? CREATE : SIGN_IN);
60 const fields = (): Field[] => (mode === "create" ? (invited() ? [...CREATE, INVITE] : CREATE) : SIGN_IN);
5361
5462 const submit = (): void => {
5563 if (busy) return;
56 if (closed && mode === "create") {
64 if (closed() && mode === "create") {
5765 error = SIGNUP_CLOSED_MESSAGE;
5866 return;
5967 }
68 const needsInvite = mode === "create" && invited();
69 if (needsInvite && (values[3] ?? "").trim() === "") {
70 error = INVITE_BLANK_MESSAGE;
71 focus = 3;
72 return;
73 }
6074 busy = true;
6175 error = undefined;
6276 runAsync(ctx, async () => {
6377 try {
6478 if (mode === "create") {
65 const [username, email, password] = values as [
79 const [username, email, password, token] = values as [
80 string,
6681 string,
6782 string,
6883 string,
6984 ];
85 const invite = needsInvite ? token.trim() : "";
7086 const slot = SignupSlot.take(f.app, f.identity.remoteAddress);
7187 if (typeof slot === "number") {
7288 error = waitText(slot);
@@ -75,10 +91,11 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen {
7591 const created = await signUp(
7692 f.app,
7793 { username, email, password },
78 { slot, address: f.identity.remoteAddress },
94 { slot, address: f.identity.remoteAddress, ...(invite === "" ? {} : { invite }) },
7995 );
8096 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);
8299 return;
83100 }
84101 const adopted = f.adopt(created.value);
@@ -109,21 +126,21 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen {
109126 keys: [
110127 binding(["tab"], "next field"),
111128 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")]),
113130 binding(["esc"], "back"),
114131 ],
115132 render(area, s) {
116133 const c = ctx.theme.c;
117134 const [head, body] = split(area, "v", [len(2), fill(1)]);
118135 segmented(s, head!, c, {
119 items: closed ? ["Sign in"] : ["Sign in", "Create account"],
136 items: closed() ? ["Sign in"] : ["Sign in", "Create account"],
120137 active: mode === "create" ? 1 : 0,
121138 });
122139 s.text(
123140 head!.x,
124141 head!.y + 1,
125142 truncate(
126 closed
143 closed()
127144 ? SIGNUP_CLOSED_MESSAGE
128145 : first
129146 ? "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 {
162179 ctx.setCursor(cursor);
163180 },
164181 key(e) {
165 if (e.ctrl && e.key === "t" && !closed) {
182 if (e.ctrl && e.key === "t" && !closed()) {
166183 mode = mode === "create" ? "signin" : "create";
167 values = ["", "", ""];
184 values = ["", "", "", ""];
168185 focus = 0;
169186 error = undefined;
170187 return true;
src/web/chrome.ts +16
@@ -12,6 +12,7 @@ import { sshCommand } from "../ssh/command.ts";
1212 import { mayDirectCrew } from "../crew/direct.ts";
1313 import { mayReadRuns } from "../actions/runs.ts";
1414 import { getAppByRepo } from "../core/apps.ts";
15 import { audienceFacts, audienceOf } from "../core/audience.ts";
1516 import { edgeLoginUrl } from "../http/edge.ts";
1617 import {
1718 canAdmin,
@@ -310,6 +311,7 @@ export function scopeHeader(
310311 ${facts.visibility === "private"
311312 ? badge("Private", { icon: "lock", variant: "secondary" })
312313 : badge("Public", { variant: "secondary" })}
314 ${repo === undefined ? null : audienceLine(ctx, repo, admin, base)}
313315 ${actions.text.trim() !== ""
314316 ? html`<span class="repo-actions">${actions}</span>`
315317 : null}`;
@@ -347,6 +349,20 @@ export function scopeHeader(
347349 </header>`;
348350 }
349351
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
350366 export { hostOf };
351367
352368 /** 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 */ `
295295
296296 ${elapsedTickJs}
297297
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
298332 if (steer && window.fetch) {
299333 var text = steer.querySelector('textarea');
300334 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 */ `
341375 var text = event.data;
342376 try { var parsed = JSON.parse(text); if (typeof parsed === 'string') text = parsed; } catch (err) {}
343377 add(draw(text));
378 if (/^(\\$ |test-shards: |\\(fail\\) |\u2500\u2500 shard )/.test(text)) stepsSoon();
344379 });
345380 source.addEventListener('done', function (event) {
346381 source.close();
347382 var run;
348383 try { run = JSON.parse(event.data); } catch (err) { return; }
349384 paint(run);
385 if (stepsTimer) { window.clearTimeout(stepsTimer); stepsTimer = 0; }
386 steps();
350387 if (steer) {
351388 steer.setAttribute('data-running', '0');
352389 var n = document.getElementById('run-steer-note');
@@ -1930,6 +1967,10 @@ export const liveRegionsJs = /* js */ `
19301967 // post is in flight) is never pulled out from under the click; the next
19311968 // matching event tries the swap again.
19321969 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;
19331974 var active = document.activeElement;
19341975 if (active && mine.contains(active) && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return;
19351976 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";
3131 import { listKeys } from "../../core/keys.ts";
3232 import { endAccountSession } from "../../actions/signins.ts";
3333 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";
3535 import type { CommitEmail, User } from "../../core/users.ts";
3636 import { cookieValue, type Ctx } from "../../http/context.ts";
3737 import { sshCommand } from "../../ssh/command.ts";
@@ -539,7 +539,9 @@ function tokensView(ctx: Ctx): Html {
539539 options: [
540540 { value: "rw", label: "read + write — clone, push, open issues" },
541541 { 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 : []),
543545 ],
544546 hint: "A token can never mint another token, whatever its scope.",
545547 })}
@@ -594,6 +596,8 @@ export async function tokenCreate(ctx: Ctx): Promise<Response> {
594596 field(form, "name").trim() || "token",
595597 chosen,
596598 );
599 if (!minted.ok && minted.error === "forbidden")
600 return flashTo(ctx, "/settings/tokens", { tone: "danger", message: ADMIN_TOKEN_WORDS });
597601 if (!minted.ok) return signInFirst(ctx);
598602 // Post-redirect-get: the secret rides a one-shot flash, so a refresh shows
599603 // the list rather than minting a second token.
src/web/pages/accounts.ts +2 −2
@@ -20,7 +20,7 @@ import {
2020 revokeAccountToken,
2121 } from "../../actions/signins.ts";
2222 import { mailConfigured } from "../../actions/verify.ts";
23 import type { AccountRow } from "../../core/accounts.ts";
23 import { originOf, type AccountRow } from "../../core/accounts.ts";
2424 import {
2525 OWNERS_TEAM,
2626 ownerlessOrgs,
@@ -73,7 +73,7 @@ function standing(a: AccountRow): Html {
7373 function row(a: AccountRow, sole: readonly string[]): Html {
7474 const at = `/settings/accounts/${encodeURIComponent(a.username)}`;
7575 const how = [
76 a.via ?? (a.kind === "agent" ? "crew" : ""),
76 originOf(a),
7777 a.address ?? "",
7878 ].filter((s) => s !== "");
7979 const holds = [
src/web/pages/backups.ts +16 −6
@@ -20,6 +20,7 @@ import { resolveDestination, sealedSet } from "../../snapshot/destination.ts";
2020 import { keyOf } from "../../snapshot/snapshot.ts";
2121 import { lastDrill } from "../../snapshot/drills.ts";
2222 import { snapshotsOf } from "../../snapshot/service.ts";
23 import { lastSnapshotOutcome } from "../../snapshot/outcome.ts";
2324 import type { Ctx } from "../../http/context.ts";
2425 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
2526 import { field, readForm, type Form } from "../form.ts";
@@ -183,9 +184,18 @@ export async function backupsPage(ctx: Ctx): Promise<Response> {
183184 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.",
184185 })}${form}`,
185186 );
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;
186197 // The store is somewhere else; a listing that fails is a sentence on the
187198 // page, never a page that fails.
188 const last = svc.last();
189199 let snapshots: Awaited<ReturnType<typeof svc.list>> = [];
190200 let listing: string | undefined;
191201 try {
@@ -227,11 +237,11 @@ bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre>`
227237 </p>`,
228238 )}
229239 ${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}
235245 </p>`,
236246 )}
237247 ${when(
src/web/pages/site.ts +1 −5
@@ -15,7 +15,7 @@ import { clientAddress } from "../../http/peer.ts";
1515 import { verifyEmail } from "../../actions/verify.ts";
1616 import { STRANGER_MAX } from "../../http/limits.ts";
1717 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";
1919 import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts";
2020 import { listPins } from "../../core/pins.ts";
2121 import { getReposByIds } from "../../core/repos.ts";
@@ -419,10 +419,6 @@ export function inviteOf(ctx: Ctx): string | undefined {
419419 return token === "" ? undefined : token;
420420 }
421421
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
426422 /** The whole of `/signup` while the forge makes no new accounts: a sentence, no form. */
427423 function signupClosedPage(ctx: Ctx): Response {
428424 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 */ `
2727 /* Your apps: a narrow column on a desk, a tab away on a phone. */
2828 .home-tabs { display: none; margin-bottom: 12px; }
2929 .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); }
3033 .home-apps-verbs { flex-wrap: wrap; padding: 10px 14px; }
3134 @media (max-width: 899px) {
3235 .home-tabs { display: inline-flex; }
src/web/studio/home.ts +31 −4
@@ -60,7 +60,7 @@ import { passkeyJs } from "../passkey-client.ts";
6060 const ON_YOU_LIVE =
6161 "issue.assigned,pull.assigned,issue.closed,pull.closed,issue.reopened,pull.reopened,pull.merged,fleet.operated";
6262 /** 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";
6464 /** How many of your apps the rail names before "All apps". */
6565 const RAIL_APPS = 8;
6666
@@ -83,13 +83,23 @@ function yourApps(ctx: Ctx, user: User): Html {
8383 ? undefined
8484 : runtime.status(record).find((env) => env.env === "production")?.state;
8585 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 )}
8697 <a class="truncate" href="${repoBase(repo)}">${repo.ownerName}/${repo.name}</a>
8798 <span class="spacer"></span>
8899 ${state === undefined ? null : envStateBadge(state)}
89100 </li>`;
90101 });
91 return card(
92 html`${rows.length === 0
102 const list = html`${rows.length === 0
93103 ? html`<p class="muted small">No apps yet.</p>`
94104 : html`<ul class="list">
95105 ${rows}
@@ -108,7 +118,24 @@ function yourApps(ctx: Ctx, user: User): Html {
108118 ${when(user.isAdmin, () =>
109119 btn("Front page", { href: "/front", size: "sm", icon: "globe" }),
110120 )}
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,
112139 {
113140 head: html`${icon("rocket")}
114141 <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 @@
77 * deploy event refreshes (`appStateFragment`) share.
88 */
99
10 import { audienceFacts, audienceOf } from "../../core/audience.ts";
1011 import { sealedScope } from "../../core/sealed.ts";
1112 import { terminalFor } from "../../actions/apps.ts";
1213 import { envPull, previewEnv } from "../../apps/data.ts";
@@ -298,6 +299,10 @@ export function appPanels(
298299 // The state in one word, and under it the facts — why it is not up, and
299300 // the deploy whose log says the rest.
300301 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;
301306 const head = html`<div class="page-head studio-head">
302307 <div class="stack stack-tight">
303308 <h1>
@@ -320,6 +325,7 @@ export function appPanels(
320325 >`
321326 : html`· <span class="mono">${url.replace(/^https?:\/\//, "")}</span>`}
322327 </p>
328 <p class="muted small" id="app-audience">${audience}</p>
323329 ${when(production, (env) => envFacts(base, env))}
324330 ${when(writer, () =>
325331 terminalLine(
src/web/studio/run.ts +55 −1
@@ -8,6 +8,8 @@
88 * at `?all=1`.
99 */
1010
11 import { checkSteps, stepWord } from "../../checks/summary.ts";
12 import { parseTestsRow, testsRowWords } from "../../checks/tests-row.ts";
1113 import { directionNote } from "../../crew/direct.ts";
1214 import { appsRuntime } from "../../apps/runtime.ts";
1315 import { getAppByRepo } from "../../core/apps.ts";
@@ -24,7 +26,7 @@ import { repoDir } from "../../git/repos.ts";
2426 import type { Ctx } from "../../http/context.ts";
2527 import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts";
2628 import { studioRunJs } from "../client.ts";
27 import { html, when, type Html } from "../html.ts";
29 import { html, raw, when, type Html } from "../html.ts";
2830 import { runLine } from "../describe.ts";
2931 import { page } from "../layout.ts";
3032 import { btn, sha } from "../ui.ts";
@@ -244,6 +246,58 @@ export async function runView(
244246 Nothing has been logged yet.
245247 </p>`,
246248 )}
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 )}
247301 <div class="run-live" id="run-live" data-state="${run.state}">
248302 ${when(!logHidden, () => logBox({
249303 id: "run-log",
src/web/style/content.ts +25
@@ -25,6 +25,16 @@ export const content = /* css */ `
2525 }
2626 .repo-title .owner { font-weight: 400; color: var(--muted-fg); }
2727 .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 }
2838 .repo-desc { margin-top: 4px; color: var(--muted-fg); font-size: 13.5px; text-wrap: pretty; }
2939 .repo-lineage { margin-top: 3px; font-size: 12.5px; color: var(--faint); }
3040 .repo-head .tabs { margin-top: 12px; }
@@ -865,6 +875,21 @@ form.inline { display: inline; margin-left: auto; }
865875 .checks-line .ok { display: inline-flex; align-items: center; gap: 4px; color: var(--success); }
866876 .checks-line .bad { display: inline-flex; align-items: center; gap: 4px; color: var(--danger); }
867877 .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); }
868893 /* A run, live: a transcript with a speaker column, bounded so the page
869894 head stays reachable, and a composer that sits under it. */
870895 .run-live { position: relative; margin-top: 12px; }
test/accounts.test.ts +46 −2
@@ -13,6 +13,7 @@ import {
1313 import { queueAgentRun } from "../src/crew/dispatch.ts";
1414 import { fetchInternal } from "../src/git/hook-address.ts";
1515 import { addComment, openIssue } from "../src/actions/issues.ts";
16 import { newAccountLines } from "../src/actions/accounts.ts";
1617 import { createRepo } from "../src/actions/repos.ts";
1718 import { createOrg } from "../src/core/orgs.ts";
1819 import { getAgent } from "../src/core/agents.ts";
@@ -175,6 +176,7 @@ describe("accounts: the owner sees, suspends and removes", () => {
175176 items: {
176177 username: string;
177178 via: string | null;
179 signsInThrough: string | null;
178180 repos: number;
179181 spentUsd: number;
180182 suspendedAt: string | null;
@@ -186,6 +188,8 @@ describe("accounts: the owner sees, suspends and removes", () => {
186188 expect(ada.spentUsd).toBe(0);
187189 expect(ada.suspendedAt).toBeNull();
188190 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();
189193 const page = await fetch(`${base}/settings/accounts`, {
190194 headers: { authorization: `Bearer ${ownerToken}` },
191195 });
@@ -193,6 +197,12 @@ describe("accounts: the owner sees, suspends and removes", () => {
193197 const html = await page.text();
194198 expect(html).toContain("ada");
195199 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)");
196206 });
197207
198208 test("suspending stops every credential at once", async () => {
@@ -598,7 +608,7 @@ describe("accounts: the owner sees, suspends and removes", () => {
598608 test("a refusal the database raises answers 403 with its reason, not 500", async () => {
599609 // An operator's guard, the way production holds new accounts back.
600610 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;",
602612 );
603613 try {
604614 const res = await api("/api/v1/repos", {
@@ -609,7 +619,7 @@ describe("accounts: the owner sees, suspends and removes", () => {
609619 expect(res.status).toBe(403);
610620 expect(await res.json()).toEqual({
611621 error: "refused",
612 message: "new repositories are paused here",
622 message: "forge: new repositories are paused here",
613623 });
614624 // The refusal leaves nothing on disk.
615625 expect(existsSync(repoDir(app.reposRoot, "olive", "guarded"))).toBe(false);
@@ -618,6 +628,40 @@ describe("accounts: the owner sees, suspends and removes", () => {
618628 }
619629 });
620630
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
621665 test("the owner can verify a newcomer's email, and it can create at once", async () => {
622666 // Made first: the helper proves every account waiting on a link.
623667 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";
1717 import { serve, type Servers } from "../src/http/server.ts";
1818 import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts";
1919 import { seedAdmin } from "./helpers/admin.ts";
20 import { tokenFor } from "./helpers/tokens.ts";
2021
2122 /** Read from an SSE body until `predicate(buffer)` holds or we time out —
2223 * 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", () => {
455456 },
456457 null,
457458 );
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");
472460 expect((await api(env, { token: mallory })).status).toBe(404);
473461 expect((await put("GREETING", "x", mallory)).status).toBe(404);
474462 expect(
test/archive-mcp.test.ts +53 −2
@@ -18,6 +18,7 @@ let dataDir: string;
1818 let app: App;
1919 let servers: Servers;
2020 let repo: Repo;
21 let second: Repo;
2122 const tok: Record<string, string> = {};
2223
2324 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 =>
2627 };
2728 const archived = (): boolean => getRepoById(app.db, repo.id)!.archivedAt !== null;
2829
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 }> {
3031 const res = await fetch(`${servers.url}/mcp`, {
3132 method: "POST",
3233 headers: {
@@ -38,7 +39,7 @@ async function mcp(token: string, args: unknown): Promise<{ error?: string; valu
3839 jsonrpc: "2.0",
3940 id: 1,
4041 method: "tools/call",
41 params: { name: "archive", arguments: args },
42 params: { name: tool, arguments: args },
4243 }),
4344 });
4445 const body = (await res.json()) as any;
@@ -65,6 +66,9 @@ beforeAll(async () => {
6566 repo = ok(
6667 await createRepo(app.db, app.reposRoot, { ownerId: org.id, name: "svc", visibility: "public" }),
6768 );
69 second = ok(
70 await createRepo(app.db, app.reposRoot, { ownerId: org.id, name: "web", visibility: "public" }),
71 );
6872 const bot = ok(await createAgent(app, founder, { username: "bot" }));
6973 expect(addToCrew(app, founder, repo, "bot").ok).toBe(true);
7074 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 () => {
111115 expect(woke.value).toMatchObject({ archived: false, was: true });
112116 expect(archived()).toBe(false);
113117 });
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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";
2323 import {
2424 capacityChanges,
2525 capacityCount,
26 capacityDefault,
2627 noteBooted,
2728 capacityEnv,
2829 capacityValue,
@@ -291,14 +292,23 @@ describe("capacity: only a forge admin reads or changes it", () => {
291292 body: new URLSearchParams({ key: "checks.shards", reset: "1" }),
292293 });
293294 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");
295305
296306 const changes = capacityChanges(app.db);
297307 expect(changes.map((c) => [c.key, c.from, c.to, c.actorName])).toEqual([
298 ["checks.shards", "3", "", "alice"],
308 ["checks.shards", "3", shardsDefault, "alice"],
299309 ["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"],
302312 ]);
303313 const after = await (
304314 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";
4747 import type { Repo } from "../src/core/repos.ts";
4848 import { serve, type Servers } from "../src/http/server.ts";
4949 import { readRunLog } from "../src/runs/log.ts";
50 import { expectContained, probeSource } from "./helpers/cell-probe.ts";
5051 import { missing } from "./helpers/tools.ts";
5152 import { portInsteadOfSocket } from "../src/git/hook-address.ts";
5253
@@ -497,88 +498,6 @@ const noCells =
497498 process.platform !== "linux" ||
498499 portInsteadOfSocket();
499500
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
582501 describe.skipIf(noCells)(
583502 "cells: repository code cannot take over the forge",
584503 () => {
test/crew-held.test.ts +37
@@ -223,6 +223,43 @@ describe("the tick says why each ready issue cannot start", () => {
223223 expect(replannable(again).map((i) => i.number)).toEqual([done.number]);
224224 });
225225
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
226263 test("nothing startable with a slot free asks the planner once, with the breakdown, whatever the plate", async () => {
227264 const { repo, atlas, mason } = await crewed(app, alice, "stuck");
228265 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", () =
16151615 expect(alreadyDone(e.app.db, [work.id]).has(work.id)).toBe(false);
16161616 expect(alreadyDoneOids("no changes pushed: already done: " + oid)).toEqual([]);
16171617 });
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 });
16181648 });
test/cross-repo-pull.test.ts +2 −12
@@ -7,6 +7,7 @@ import { serve, type Servers } from "../src/http/server.ts";
77 import { gitClientEnv } from "./helpers/git-client.ts";
88 import { proveAll } from "./helpers/verified.ts";
99 import { promote } from "./helpers/members.ts";
10 import { tokenFor } from "./helpers/tokens.ts";
1011
1112 const gitEnv = gitClientEnv({
1213 GIT_AUTHOR_NAME: "X",
@@ -56,18 +57,7 @@ describe("cross-repo pull requests (fork → upstream)", () => {
5657 redirect: "manual",
5758 });
5859 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");
7161 return { token, cookie };
7262 }
7363
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
7676 expect(body.seen.health).toContain("Disk is nearly full: 97%");
7777 expect(body.seen.board).toContain("The runner is stuck");
7878 expect(body.backSeconds).toBe(3);
79 expect(Object.keys(body).sort()).toEqual(["backSeconds", "domain", "seen"]);
7980 expect(h.lines.filter((l) => l.includes("The runner is stuck"))).toHaveLength(1);
8081 expect(h.lines).toContain("GREEN: every phrase seen");
8182 });
8283
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
83106 test("stays down past the ceiling: posts nothing, exits 0, says so", async () => {
84107 const state: Stub = { down: false, posts: [], refuse: false };
85108 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
119142
120143 test("checkArgs: each missing piece and the cleartext refusal is an error; the good case is options", () => {
121144 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);
124147 expect(r.ok).toBe(false);
125148 return r.ok ? "" : r.error;
126149 };
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);
133155 expect(ok(["runner", "--forge", "http://forge.example", "--insecure"]).ok).toBe(true);
134156 for (const host of ["localhost:3000", "127.0.0.1:1", "[::1]:1"]) expect(ok(["runner", "--forge", `http://${host}/`]).ok).toBe(true);
135157 expect(ok(["runner", "--forge", "https://f.example/"])).toEqual({
@@ -161,3 +183,46 @@ test("control characters the forge sends are removed before printing or posting;
161183 expect(said).not.toContain("\x1b");
162184 expect(JSON.stringify(state.posts[0]!.body)).not.toContain("\\u001b");
163185 });
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";
1212 import { createUser } from "../src/core/users.ts";
1313 import { vitals } from "../src/kernel/vitals.ts";
1414 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";
1616 import { lastDrill, recordDrill } from "../src/snapshot/drills.ts";
1717 import { startSnapshots } from "../src/snapshot/service.ts";
1818 import { takeSnapshot } from "../src/snapshot/snapshot.ts";
@@ -20,6 +20,8 @@ import { dirStore } from "../src/snapshot/store.ts";
2020 import { recordVerdict } from "../src/snapshot/verdict.ts";
2121
2222 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 });
2325
2426 describe("when a weekly drill is owed", () => {
2527 // 2026-10-11 is a Sunday.
@@ -30,10 +32,23 @@ describe("when a weekly drill is owed", () => {
3032
3133 test("owed once: a drill since the slot keeps it, an older one does not", () => {
3234 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);
3537 // 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);
3752 });
3853
3954 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", () => {
8095 await rm(root, { recursive: true, force: true });
8196 }
8297 });
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 });
83244 });
84245
85246 describe("a count that differs is red", () => {
test/everyday.test.ts +21
@@ -32,6 +32,7 @@ import { createUser, getUserByName } from "../src/core/users.ts";
3232 import { serve, type Servers } from "../src/http/server.ts";
3333 import { quietLogs } from "./helpers/globals.ts";
3434 import { passkeyFields } from "./helpers/passkey.ts";
35 import { ADMIN_TOKEN_WORDS } from "../src/core/tokens.ts";
3536
3637 quietLogs();
3738
@@ -241,6 +242,26 @@ describe("the everyday account", () => {
241242 );
242243 });
243244
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
244265 test("from a page's session, the API refuses an admin's token without adminConfirm", async () => {
245266 const root = await signIn("ada");
246267 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.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs