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

Take updates from trevato/forge-upstream #2604

Merged opened this in answer to

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

Merged

mason/forge:mason/2603-take-updates-from-trevato-forge-upstream → main 1 commit fork 1 thread · 1 unresolved

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

Review

Sign in to review this change.
160 files changed +8364 −588
.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 +77 −5

Diff not shown — a line 4,879 characters wide (limit 2,000). +77 −5

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/PLANNING.md +22 −2
@@ -325,13 +325,33 @@ one undo.
325325 the ready order changes. It is one fleet row, the verb `place`; undo puts
326326 the place back. Through the API and the MCP `limb` tool (`action` raise or
327327 lower, `under` when the limb sits in several places), dry-run by default.
328 - **Re-plan** (#761) hands the limb to the planner to come back as fewer,
329 larger pieces. The preview lists every open issue the planner will be
330 given. Pressing it writes the hand-over (`limb_replan`, with the issues
331 it was given) and owes the planner one pass, even with a full map, even
332 after it declined the direction, and without waiting out the hour since
333 its last pass. Its task names the limb and lists the issues. Until the
334 planner has answered — a planning run begun after the hand-over has
335 ended — the limb reads _With the planner_ on its milestone and the crew
336 picks nothing beneath it (the `with_planner` hold). The planner may close
337 an issue beneath it only as `split` into open issues made since the
338 hand-over, as for a twice-parked issue; it may not drop one, and the
339 hand-over grants nothing outside the limb. A person may **take it back**
340 until a planning run has begun. Afterwards the milestone lists which old
341 issue closed into which. Not a fleet operation: it writes no issue row,
342 so there is no receipt to undo; taking it back is the undo.
343 In the terminal, `p` on the limbs screen (Milestones in a repository)
344 previews what the planner is given and hands the limb over on confirm; the
345 row then reads _with the planner_, and `p` takes it back while allowed.
346 Through the API and the MCP `limb` tool as `action` `replan` or
347 `takeback`, dry-run by default (the dry run is the list of issues).
328348
329349 An umbrella or the gate is the plan's own and is not filed. A leaf beneath
330350 a limb that is not deferred cannot be deferred alone: defer the limb. A
331 candidate to trim offers Defer first. Handing one to the planner is #761.
351 candidate to trim offers Defer first.
332352
333353 The same is `POST /api/v1/repos/:owner/:name/milestones/:number/limb`
334 `{ "action": "defer" | "resume" | "close" | "raise" | "lower", "reason"?, "pillar"?, "under"?, "dryRun"? }`
354 `{ "action": "defer" | "resume" | "close" | "raise" | "lower" | "replan" | "takeback", "reason"?, "pillar"?, "under"?, "dryRun"? }`
335355 and the MCP's `limb`, both a dry run unless `dryRun` is false. Applying and
336356 undoing take an admin token of a person who administers the repository; an
337357 agent may look and never apply.
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/issues.ts +8 −2
@@ -55,6 +55,7 @@ import {
5555 unadoptedAmong,
5656 } from "../crew/direct.ts";
5757 import { laterGate } from "../crew/later.ts";
58 import { replanSince } from "../core/limb_replan.ts";
5859 import { handedOver } from "../crew/parks.ts";
5960 import { alreadyDone, CLOSED_NOTE } from "../core/done_on_main.ts";
6061 import { listReviews } from "../core/reviews.ts";
@@ -511,17 +512,22 @@ function handedToPlanner(
511512 // Replacements must be newer than the hand-over when the crew handed the
512513 // issue over, and newer than the issue itself when the planner re-plans it
513514 // on its own pass.
514 const since = handedOver(app.db, repo, issue.id) ?? {
515 // A limb a person handed over (`core/limb_replan.ts`): newer than that
516 // hand-over, and only as a split — a person's issue beneath it is not dropped.
517 const limbSince = replanSince(app.db, repo, issue);
518 const own = handedOver(app.db, repo, issue.id) ?? {
515519 at: issue.createdAt,
516520 id: issue.id,
517521 };
522 const since = limbSince !== undefined && limbSince > own.at ? { at: limbSince, id: "" } : own;
518523 // Done on main: only when commits already name the issue, as the close
519524 // note keeps them.
520525 if (closing.reason === undefined || closing.reason === "done")
521526 return (alreadyDone(app.db, [issue.id]).get(issue.id) ?? []).length > 0;
522527 // Dropped only when no person's ask stands in it — not one the planner
523528 // wrote to replace a person's, however many times it was split since.
524 if (closing.reason === "dropped") return !standsForPerson(app.db, issue);
529 if (closing.reason === "dropped")
530 return limbSince === undefined && !standsForPerson(app.db, issue);
525531 if (closing.reason !== "split") return false;
526532 const into = closing.into ?? [];
527533 if (into.length === 0) return false;
src/actions/limb_replan.ts added +168
@@ -0,0 +1,168 @@
1 /**
2 * Hand a limb to the planner (#761): `replan` writes the hand-over, `takeback`
3 * withdraws it before the planner has been given it. Both are a person's
4 * who administers the repository; anyone who can read may see the preview,
5 * which lists every issue the planner will be given. The planner's own
6 * grant to close what is beneath is in `actions/issues.ts`.
7 */
8
9 import type { App } from "../app.ts";
10 import { listCrewRoles } from "../core/agents.ts";
11 import {
12 answeredFor,
13 given,
14 openBeneath,
15 pendingFor,
16 recordReplan,
17 recordTakenBack,
18 splitsOf,
19 type Split,
20 } from "../core/limb_replan.ts";
21 import { canAdmin, canRead, type Repo } from "../core/repos.ts";
22 import type { User } from "../core/users.ts";
23 import { err, ok, type Result } from "../kernel/result.ts";
24 import { getIssueById } from "../core/issues.ts";
25 import type { FleetError } from "./fleet.ts";
26 import type { LimbIssue } from "./limbs.ts";
27
28 export const REPLAN_ACTIONS = ["replan", "takeback"] as const;
29 export type ReplanAction = (typeof REPLAN_ACTIONS)[number];
30
31 export const isReplanAction = (value: unknown): value is ReplanAction =>
32 (REPLAN_ACTIONS as readonly unknown[]).includes(value);
33
34 export interface ReplanPreview {
35 readonly limb: LimbIssue;
36 /** Every open issue the planner will be given: the limb's own, the limb first. */
37 readonly issues: readonly LimbIssue[];
38 /** Whether it is with the planner already, and whether it can still be taken back. */
39 readonly withPlanner: boolean;
40 readonly takeBack: boolean;
41 /** Why it cannot be handed over now, when it cannot. */
42 readonly blocked?: string;
43 }
44
45 export interface ReplanDone {
46 readonly done: "replan" | "takeback";
47 readonly limb: LimbIssue;
48 readonly issues: readonly LimbIssue[];
49 }
50
51 const fail = (code: FleetError["code"], message: string) =>
52 err<FleetError>({ code, message });
53
54 const brief = (i: { number: number; title: string }): LimbIssue => ({
55 number: i.number,
56 title: i.title,
57 });
58
59 function find(
60 app: App,
61 actor: User | undefined,
62 repo: Repo,
63 number: number,
64 ): Result<ReplanPreview, FleetError> {
65 if (!actor) return fail("unauthorized", "sign in to hand a limb to the planner");
66 if (!canRead(app.db, repo, actor)) return fail("not_found", "no such milestone here");
67 const issues = openBeneath(app.db, repo, number);
68 const limb = issues.find((i) => i.number === number);
69 if (limb === undefined) return fail("not_found", "no such milestone here");
70 const pending = pendingFor(app.db, repo, limb.id);
71 const blocked =
72 mayAct(app, actor, repo)?.message ??
73 (pending !== undefined
74 ? "it is with the planner already"
75 : repo.autonomy === "off"
76 ? "the crew is not running here, so there is no planner to ask"
77 : !listCrewRoles(app.db, repo.id).some((c) => c.role === "planner")
78 ? "there is no planner on the crew"
79 : issues.length < 2
80 ? "nothing is beneath it to re-plan"
81 : undefined);
82 return ok({
83 limb: brief(limb),
84 issues: issues.map(brief),
85 withPlanner: pending !== undefined,
86 takeBack: pending !== undefined && !given(app.db, repo, pending.at),
87 ...(blocked === undefined ? {} : { blocked }),
88 });
89 }
90
91 /** The dry run: every issue the planner will be given, and nothing written. */
92 export function previewReplan(
93 app: App,
94 actor: User | undefined,
95 repo: Repo,
96 number: number,
97 ): Result<ReplanPreview, FleetError> {
98 return find(app, actor, repo, number);
99 }
100
101 function mayAct(app: App, actor: User, repo: Repo): FleetError | undefined {
102 if (actor.kind === "agent")
103 return { code: "forbidden", message: "handing a limb to the planner is a person's call" };
104 if (!canAdmin(app.db, repo, actor))
105 return { code: "forbidden", message: "only a person who administers this app hands a limb over" };
106 return undefined;
107 }
108
109 /** Hand the limb over: the planner's next pass is owed it, once. */
110 export function replanLimb(
111 app: App,
112 actor: User | undefined,
113 repo: Repo,
114 number: number,
115 ): Result<ReplanDone, FleetError> {
116 const seen = find(app, actor, repo, number);
117 if (!seen.ok) return seen;
118 const refused = mayAct(app, actor!, repo);
119 if (refused) return err(refused);
120 if (seen.value.blocked !== undefined) return fail("bad_action", seen.value.blocked);
121 const issues = openBeneath(app.db, repo, number);
122 const limb = issues.find((i) => i.number === number)!;
123 recordReplan(app.db, repo, limb, actor!.id, issues);
124 app.bus.append({
125 kind: "limb.replan",
126 repoId: repo.id,
127 actorId: actor!.id,
128 payload: { number, issues: issues.map((i) => i.number) },
129 });
130 return ok({ done: "replan", limb: brief(limb), issues: issues.map(brief) });
131 }
132
133 /** Take it back, before the planner has been given it. */
134 export function takeBackLimb(
135 app: App,
136 actor: User | undefined,
137 repo: Repo,
138 number: number,
139 ): Result<ReplanDone, FleetError> {
140 const seen = find(app, actor, repo, number);
141 if (!seen.ok) return seen;
142 const refused = mayAct(app, actor!, repo);
143 if (refused) return err(refused);
144 const limb = openBeneath(app.db, repo, number).find((i) => i.number === number)!;
145 const pending = pendingFor(app.db, repo, limb.id);
146 if (pending === undefined) return fail("bad_action", "it is not with the planner");
147 if (!seen.value.takeBack)
148 return fail("bad_action", "the planner has it in hand already");
149 recordTakenBack(app.db, pending.id);
150 app.bus.append({
151 kind: "limb.replan_taken_back",
152 repoId: repo.id,
153 actorId: actor!.id,
154 payload: { number },
155 });
156 return ok({ done: "takeback", limb: brief(limb), issues: seen.value.issues });
157 }
158
159 /** What the planner's last answer on this limb changed: each old issue and what replaced it. */
160 export function replanResult(
161 app: App,
162 repo: Repo,
163 limbId: string,
164 ): { readonly at: string; readonly splits: readonly Split[] } | undefined {
165 if (getIssueById(app.db, limbId) === undefined) return undefined;
166 const r = answeredFor(app.db, repo, limbId);
167 return r === undefined ? undefined : { at: r.at, splits: splitsOf(app.db, repo, r) };
168 }
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/limb_replan.ts added +230
@@ -0,0 +1,230 @@
1 /**
2 * A limb handed to the planner (#761). A person presses Re-plan on a
3 * milestone: the limb and every open issue beneath it are written down, and
4 * the planner's next pass is owed them, once. The hand-over is pending until
5 * a planning run that was given it has ended, or the person takes it back
6 * before any run starts. While it is pending nothing beneath the limb is
7 * picked by the crew (`crew/held.ts`), and the planner may close what is
8 * beneath it only as split into what it opens after the hand-over
9 * (`actions/issues.ts`). After, the rows say which issue became which.
10 *
11 * Plain reads and writes over two tables; who may is the action's.
12 */
13
14 import type { Database } from "bun:sqlite";
15 import { id } from "../kernel/id.ts";
16 import { transact } from "../kernel/db.ts";
17 import { getIssueById, type Issue } from "./issues.ts";
18 import { readPlan } from "./milestones.ts";
19 import type { Repo } from "./repos.ts";
20
21 export interface Replan {
22 readonly id: string;
23 readonly limbId: string;
24 readonly byUser: string;
25 readonly at: string;
26 readonly takenBackAt: string | null;
27 }
28
29 interface Row {
30 id: string;
31 limb_issue_id: string;
32 by_user: string;
33 at: string;
34 taken_back_at: string | null;
35 }
36
37 const of = (r: Row): Replan => ({
38 id: r.id,
39 limbId: r.limb_issue_id,
40 byUser: r.by_user,
41 at: r.at,
42 takenBackAt: r.taken_back_at,
43 });
44
45 /** Whether a planning run was started after `at`, a cancelled one aside: the planner has been given it. */
46 export function given(db: Database, repo: Repo, at: string): boolean {
47 if (repo.directionIssueId === null) return false;
48 return (
49 db
50 .query<{ n: number }, [string, string]>(
51 `SELECT COUNT(*) AS n FROM run
52 WHERE issue_id = ?1 AND kind = 'agent' AND state != 'cancelled'
53 AND created_at > ?2`,
54 )
55 .get(repo.directionIssueId, at)?.n ?? 0
56 ) > 0;
57 }
58
59 /**
60 * The start of the last finished planning run: a hand-over made after it has
61 * not been answered, one made before it has. One bound in SQL, no per-row query.
62 */
63 const LAST_ANSWER = `COALESCE((SELECT MAX(created_at) FROM run
64 WHERE issue_id = ?2 AND kind = 'agent' AND state IN ('ok','failed')), '')`;
65
66 /** The hand-overs the planner has not answered and nobody took back, newest first. */
67 export function pendingReplans(db: Database, repo: Repo): Replan[] {
68 return db
69 .query<Row, [string, string | null]>(
70 `SELECT * FROM limb_replan
71 WHERE repo_id = ?1 AND taken_back_at IS NULL AND at >= ${LAST_ANSWER}
72 ORDER BY at DESC, id DESC`,
73 )
74 .all(repo.id, repo.directionIssueId)
75 .map(of);
76 }
77
78 /** The pending hand-over of this limb, if there is one. */
79 export function pendingFor(
80 db: Database,
81 repo: Repo,
82 limbId: string,
83 ): Replan | undefined {
84 return pendingReplans(db, repo).find((r) => r.limbId === limbId);
85 }
86
87 /** The newest hand-over of this limb the planner answered. */
88 export function answeredFor(
89 db: Database,
90 repo: Repo,
91 limbId: string,
92 ): Replan | undefined {
93 const row = db
94 .query<Row, [string, string | null, string]>(
95 `SELECT * FROM limb_replan
96 WHERE repo_id = ?1 AND limb_issue_id = ?3 AND taken_back_at IS NULL
97 AND at < ${LAST_ANSWER}
98 ORDER BY at DESC, id DESC LIMIT 1`,
99 )
100 .get(repo.id, repo.directionIssueId, limbId);
101 return row === null ? undefined : of(row);
102 }
103
104 /** The issues a hand-over was given, as they were then. */
105 export function givenIssueIds(db: Database, replanId: string): string[] {
106 return db
107 .query<{ issue_id: string }, [string]>(
108 `SELECT issue_id FROM limb_replan_issue WHERE replan_id = ?1`,
109 )
110 .all(replanId)
111 .map((r) => r.issue_id);
112 }
113
114 /**
115 * The open issues of a limb now, the limb included, in plan order: what
116 * only it needs, so what something outside also waits for stays out.
117 */
118 export function openBeneath(
119 db: Database,
120 repo: Repo,
121 limbNumber: number,
122 ): Issue[] {
123 return (
124 readPlan(db, repo)
125 .limb(limbNumber)
126 ?.own.filter((i) => i.state === "open" && i.kind === "issue") ?? []
127 );
128 }
129
130 /** Write the hand-over down: the limb, and each issue it was given. */
131 export function recordReplan(
132 db: Database,
133 repo: Repo,
134 limb: Issue,
135 byUser: string,
136 issues: readonly Issue[],
137 ): Replan {
138 const rid = id();
139 transact(db, () => {
140 db.query(
141 `INSERT INTO limb_replan (id, repo_id, limb_issue_id, by_user) VALUES (?1, ?2, ?3, ?4)`,
142 ).run(rid, repo.id, limb.id, byUser);
143 for (const issue of issues)
144 db.query(
145 `INSERT INTO limb_replan_issue (replan_id, issue_id) VALUES (?1, ?2)`,
146 ).run(rid, issue.id);
147 });
148 return of(db.query<Row, [string]>(`SELECT * FROM limb_replan WHERE id = ?1`).get(rid)!);
149 }
150
151 /** Take it back: it is no longer pending. */
152 export function recordTakenBack(db: Database, replanId: string): void {
153 db.query(
154 `UPDATE limb_replan SET taken_back_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
155 WHERE id = ?1 AND taken_back_at IS NULL`,
156 ).run(replanId);
157 }
158
159 /** What a pending hand-over covers now: what it was given, and what is beneath the limb today. */
160 function covered(db: Database, repo: Repo, r: Replan): Set<string> {
161 const ids = new Set(givenIssueIds(db, r.id));
162 const limb = getIssueById(db, r.limbId);
163 if (limb !== undefined)
164 for (const i of openBeneath(db, repo, limb.number)) ids.add(i.id);
165 return ids;
166 }
167
168 /** Every issue the crew may not pick because its limb is with the planner. */
169 export function withPlanner(db: Database, repo: Repo): Set<string> {
170 const out = new Set<string>();
171 for (const r of pendingReplans(db, repo))
172 for (const i of covered(db, repo, r)) out.add(i);
173 return out;
174 }
175
176 /**
177 * When a limb this issue sits beneath was handed over, while that is
178 * pending: the planner may split the issue only into what is made after.
179 * Nothing for an issue no pending hand-over covers.
180 */
181 export function replanSince(
182 db: Database,
183 repo: Repo,
184 issue: Issue,
185 ): string | undefined {
186 let since: string | undefined;
187 for (const r of pendingReplans(db, repo)) {
188 if (!covered(db, repo, r).has(issue.id)) continue;
189 if (since === undefined || r.at > since) since = r.at;
190 }
191 return since;
192 }
193
194 /** An issue the planner closed as split, and what it became. */
195 export interface Split {
196 readonly number: number;
197 readonly title: string;
198 readonly into: readonly { readonly number: number; readonly title: string }[];
199 }
200
201 /** What a hand-over changed: each issue it was given that closed as split, into which. */
202 export function splitsOf(db: Database, repo: Repo, r: Replan): Split[] {
203 const out: Split[] = [];
204 for (const issueId of givenIssueIds(db, r.id)) {
205 const issue = getIssueById(db, issueId);
206 // A sealed issue is never named to a reader.
207 if (issue === undefined || issue.sealed || issue.state !== "closed" || issue.closeReason !== "split")
208 continue;
209 const into = db
210 .query<{ parts: string }, [string, number]>(
211 `SELECT json_extract(payload, '$.into') AS parts FROM event
212 WHERE repo_id = ?1 AND kind = 'issue.closed'
213 AND json_extract(payload, '$.reason') = 'split'
214 AND json_extract(payload, '$.number') = ?2
215 ORDER BY id DESC LIMIT 1`,
216 )
217 .get(repo.id, issue.number);
218 const numbers: unknown = into === null || into === undefined ? [] : JSON.parse(into.parts);
219 const parts = (Array.isArray(numbers) ? numbers : []).flatMap((n) => {
220 const part = typeof n === "number" ? db
221 .query<{ number: number; title: string }, [string, number]>(
222 `SELECT number, title FROM issue WHERE repo_id = ?1 AND number = ?2 AND sealed = 0`,
223 )
224 .get(repo.id, n) : null;
225 return part === null ? [] : [part];
226 });
227 out.push({ number: issue.number, title: issue.title, into: parts });
228 }
229 return out.sort((a, b) => a.number - b.number);
230 }
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/migrations/20261018110000-limb-replan.ts added +18
@@ -0,0 +1,18 @@
1 // A person hands a limb to the planner to re-plan as fewer, larger pieces
2 // (#761): one row for the hand-over, one for each open issue it was given.
3 export default `
4 CREATE TABLE limb_replan (
5 id TEXT PRIMARY KEY,
6 repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
7 limb_issue_id TEXT NOT NULL REFERENCES issue(id) ON DELETE CASCADE,
8 by_user TEXT NOT NULL,
9 at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
10 taken_back_at TEXT
11 );
12 CREATE INDEX limb_replan_repo ON limb_replan(repo_id, at);
13 CREATE TABLE limb_replan_issue (
14 replan_id TEXT NOT NULL REFERENCES limb_replan(id) ON DELETE CASCADE,
15 issue_id TEXT NOT NULL REFERENCES issue(id) ON DELETE CASCADE,
16 PRIMARY KEY (replan_id, issue_id)
17 );
18 `;
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/autonomy.ts +14 −5
@@ -89,6 +89,7 @@ import { reviewSummary } from "../core/reviews.ts";
8989 import { standing } from "../core/standing.ts";
9090 import { pace, type Pace } from "./pacing.ts";
9191 import { BREAKER_NOTE, breakerTripped } from "./breaker.ts";
92 import { given, pendingReplans } from "../core/limb_replan.ts";
9293 import { handedOverPulls, SKIP_SUMMARY, skipsReplan } from "./guard.ts";
9394 import { atRunCap, type Held, heldBy, heldWords, replannable } from "./held.ts";
9495 import { BY_CREW, DONE_LIKE } from "../core/done_on_main.ts";
@@ -562,7 +563,8 @@ async function planIfThin(
562563 stuck: Held | null = null,
563564 ): Promise<boolean> {
564565 const handed = [...parkedTwice(app.db, [repo]), ...handedOverPulls(app.db, repo)];
565 if (ready >= THIN_MAP && handed.length === 0) return false;
566 const limbs = pendingReplans(app.db, repo);
567 if (ready >= THIN_MAP && handed.length === 0 && limbs.length === 0) return false;
566568 const planner = crew.find((c) => c.role === "planner")?.agent;
567569 if (planner === undefined) return notPlanned(repo, "no planner on the crew");
568570 if (repo.autonomyDirection.trim() === "")
@@ -582,11 +584,14 @@ async function planIfThin(
582584 // The hand-overs the planner has not been given: newer than its last pass.
583585 const seen = lastPlan?.created_at ?? "";
584586 const owed = handed.filter((issue) => issue.at > seen);
585 if (ready >= THIN_MAP && owed.length === 0) return false;
587 // A limb a person handed over is owed one pass, whatever the map and
588 // whatever the planner said of the direction before (#761).
589 const limbsOwed = limbs.filter((r) => !given(app.db, repo, r.at));
590 if (ready >= THIN_MAP && owed.length === 0 && limbsOwed.length === 0) return false;
586591 // Nothing startable for issues held since its last pass that re-planning
587592 // lifts: owed one pass like a hand-over, once (#2244).
588593 const unstuck = stuck === null ? [] : replannable(stuck).filter((issue) => heldSince(app, issue, seen));
589 const news = owed.length + unstuck.length;
594 const news = owed.length + unstuck.length + limbsOwed.length;
590595 // The direction is the planner's standing issue, not one issue retried:
591596 // it is held to its own day's share, never the builders' cap (#2143).
592597 if (atRunCap(app, repo, direction))
@@ -606,9 +611,11 @@ async function planIfThin(
606611 return notPlanned(repo, "the planner declined; waiting for a person");
607612 if (lastPlan.state === "queued" || lastPlan.state === "running")
608613 return notPlanned(repo, "a planning run is in flight");
614 // A person's hand-over is not made to wait for the last pass's hour.
609615 if (
616 limbsOwed.length === 0 &&
610617 now.getTime() - new Date(lastPlan.created_at).getTime() <
611 REPLAN_AFTER_MS
618 REPLAN_AFTER_MS
612619 )
613620 return notPlanned(repo, "planned recently");
614621 // Nobody spoke and the map has not moved since the last pass: another
@@ -631,7 +638,9 @@ async function planIfThin(
631638 if (!assigned.ok)
632639 return notPlanned(repo, `could not assign: ${String(assigned.error)}`);
633640 log.info(
634 owed.length > 0
641 limbsOwed.length > 0
642 ? "a limb was handed over; planner asked"
643 : owed.length > 0
635644 ? "parked twice; planner asked"
636645 : unstuck.length > 0
637646 ? "nothing startable; planner asked"
src/crew/brief.ts +31 −1
@@ -186,6 +186,8 @@ export interface TaskInput extends BriefInput {
186186 readonly looks?: readonly string[] | undefined;
187187 /** For a planner: the issues the crew parked twice, with why. */
188188 readonly parkedTwice?: readonly HandedOver[] | undefined;
189 /** For a planner: the limbs a person handed over to re-plan as fewer, larger pieces. */
190 readonly limbs?: readonly HandedLimb[] | undefined;
189191 /** For a planner: the issue deferred work waits behind, if there is one. */
190192 readonly laterGate?: number | undefined;
191193 /** For a planner: nothing ready can start, and what holds it (`held.ts`). */
@@ -204,6 +206,13 @@ export interface Stuck {
204206 }[];
205207 }
206208
209 /** A limb a person handed to the planner, with every open issue beneath it. */
210 export interface HandedLimb {
211 readonly number: number;
212 readonly title: string;
213 readonly issues: readonly { readonly number: number; readonly title: string }[];
214 }
215
207216 /** An issue the crew parked twice, as the planner is told of it. */
208217 export interface HandedOver {
209218 readonly number: number;
@@ -234,7 +243,7 @@ function taskBody(input: TaskInput): string {
234243 const { issue } = input;
235244 const out: string[] = [];
236245 const isAgent = (name: string): boolean => name === input.agent.user.username;
237 const handed = [parkedTwiceLines(input), stuckLines(input)]
246 const handed = [parkedTwiceLines(input), limbLines(input), stuckLines(input)]
238247 .filter((lines) => lines.length > 0)
239248 .flatMap((lines, n) => (n === 0 ? lines : ["", ...lines]));
240249 const readers = input.readers ?? new Set<string>();
@@ -533,6 +542,27 @@ function parkedTwiceLines(input: TaskInput): string[] {
533542 ];
534543 }
535544
545 /**
546 * What a planner is told of a limb a person handed it: the limb and every
547 * open issue beneath it, to come back as fewer, larger issues. Each old
548 * issue is closed as split into the new ones, never dropped — the person
549 * asked for them. Nothing outside the limb is touched.
550 */
551 function limbLines(input: TaskInput): string[] {
552 const limbs = input.role === "planner" ? (input.limbs ?? []) : [];
553 if (limbs.length === 0) return [];
554 const issues = `$FORGE_URL/api/v1/repos/${input.repo.ownerName}/${input.repo.name}/issues`;
555 return [
556 "A person handed you these limbs to re-plan as fewer, larger pieces. Nothing beneath one is picked by the crew until you have answered:",
557 ...limbs.flatMap((limb) => [
558 `- Limb #${limb.number} ${limb.title}, ${limb.issues.length} open issue${limb.issues.length === 1 ? "" : "s"}:`,
559 ...limb.issues.map((i) => ` #${i.number} ${i.title}`),
560 ]),
561 `Open the larger issues first, each saying what done looks like. Then close every old issue of the limb as split into them — PATCH ${issues}/N with {"state":"closed","reason":"split","into":[A,B]}. Only open issues made since the hand-over are accepted, and nothing outside the limb is yours to close this way. Do not drop an issue of the limb: a person asked for it, so what it asked for must be in a larger piece.`,
562 `If other issues wait for one you close, make them wait for what replaces it: DELETE ${issues}/D/dependencies/N, then POST ${issues}/D/dependencies with {"blockedBy":M}. Make the new issues wait for the limb's issue, so they stay beneath it.`,
563 ];
564 }
565
536566 /**
537567 * What a planner is told when no ready issue can start while builders sit
538568 * free: the count per filter, and each stalled or capped issue, to re-plan
src/crew/handed_limbs.ts added +22
@@ -0,0 +1,22 @@
1 /**
2 * The limbs a person handed to the planner (#761), as its task is told of
3 * them: each with every open issue beneath it. Only those it has not yet
4 * answered (`core/limb_replan.ts`).
5 */
6
7 import type { App } from "../app.ts";
8 import { getIssueById } from "../core/issues.ts";
9 import { openBeneath, pendingReplans } from "../core/limb_replan.ts";
10 import type { Repo } from "../core/repos.ts";
11 import type { HandedLimb } from "./brief.ts";
12
13 export function handedLimbs(app: App, repo: Repo): HandedLimb[] {
14 return pendingReplans(app.db, repo).flatMap((r) => {
15 const limb = getIssueById(app.db, r.limbId);
16 if (limb === undefined) return [];
17 const beneath = openBeneath(app.db, repo, limb.number).filter((i) => i.id !== limb.id);
18 return beneath.length === 0
19 ? []
20 : [{ number: limb.number, title: limb.title, issues: beneath.map((i) => ({ number: i.number, title: i.title })) }];
21 });
22 }
src/crew/held.ts +6
@@ -9,6 +9,8 @@
99 * - `running`: a run is in flight on it.
1010 * - `open_pull`: an open pull already proposes it (`direct.ts`).
1111 * - `planner`: the planner or a reviewer holds it, not a builder.
12 * - `with_planner`: its limb was handed to the planner by a person and the
13 * planner has not answered (`core/limb_replan.ts`).
1214 * - `done_on_main`: the crew found it done on the base branch and waits for
1315 * a person to close it (`done_on_main.ts`); nothing is left to plan.
1416 * - `stalled`: a builder ran on it and left without a pull, and nobody
@@ -27,6 +29,7 @@ import { listCrewRoles, type Agent, type CrewRole } from "../core/agents.ts";
2729 import { readyNow, repoGraph, type Ranked } from "../core/dependencies.ts";
2830 import { alreadyDone, BY_CREW, DONE_LIKE } from "../core/done_on_main.ts";
2931 import type { Issue } from "../core/issues.ts";
32 import { withPlanner } from "../core/limb_replan.ts";
3033 import type { Repo } from "../core/repos.ts";
3134 import { listActive, type Run } from "../core/runs.ts";
3235 import { getUserById } from "../core/users.ts";
@@ -42,6 +45,7 @@ export const HOLDS = [
4245 "running",
4346 "open_pull",
4447 "planner",
48 "with_planner",
4549 "done_on_main",
4650 "stalled",
4751 "run_cap",
@@ -115,10 +119,12 @@ export function heldBy(
115119 const planners = new Set(
116120 crew.filter((c) => c.role !== "builder").map((c) => c.agent.user.id),
117121 );
122 const handed = withPlanner(app.db, repo);
118123 const holdOf = (issue: Issue): HoldName | null => {
119124 if (busyIssues.has(issue.id)) return "running";
120125 if (proposed.has(issue.number)) return "open_pull";
121126 if (issue.assigneeId !== null && planners.has(issue.assigneeId)) return "planner";
127 if (handed.has(issue.id)) return "with_planner";
122128 if (alreadyDone(app.db, [issue.id]).has(issue.id)) return "done_on_main";
123129 if (stalled(app, repo, issue)) return "stalled";
124130 if (atRunCap(app, repo, issue)) return "run_cap";
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/runner.ts +2
@@ -108,6 +108,7 @@ import {
108108 import { clearStaleLocks, lockedOut } from "./locks.ts";
109109 import { interruptedSummary } from "./interrupted.ts";
110110 import { laterGate } from "./later.ts";
111 import { handedLimbs } from "./handed_limbs.ts";
111112 import { parkedTwice } from "./parks.ts";
112113 import { heldNow, stuckOf } from "./held.ts";
113114 import { noteLimit } from "../core/standing.ts";
@@ -974,6 +975,7 @@ async function work(
974975 ...(brief.role === "planner" && issue.id === repo.directionIssueId
975976 ? {
976977 parkedTwice: [...parkedTwice(app.db, [repo]), ...handedOverPulls(app.db, repo)],
978 limbs: handedLimbs(app, repo),
977979 laterGate: laterGate(app.db, repo.id),
978980 stuck: stuckOf(heldNow(app, repo)),
979981 }
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 +28 −3
@@ -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,
@@ -312,6 +313,12 @@ import {
312313 milestonesJson,
313314 } from "../web/pages/milestones.ts";
314315 import { applyLimb, isLimbAction, previewLimb } from "../actions/limbs.ts";
316 import {
317 isReplanAction,
318 previewReplan,
319 replanLimb,
320 takeBackLimb,
321 } from "../actions/limb_replan.ts";
315322 import { appsRuntime, envsFor, type AppsRuntime } from "../apps/runtime.ts";
316323 import { isEnvName } from "../apps/data.ts";
317324 import { getAppByRepo } from "../core/apps.ts";
@@ -1854,6 +1861,8 @@ export function apiRoutes(router: Router<Handler>): void {
18541861 str(input.name) || "unnamed",
18551862 scope,
18561863 );
1864 if (!minted.ok && minted.error === "forbidden")
1865 return apiError("forbidden", 403, ADMIN_TOKEN_WORDS);
18571866 if (!minted.ok) return unauthorized();
18581867 return json(minted.value, 201);
18591868 });
@@ -2146,7 +2155,12 @@ export function apiRoutes(router: Router<Handler>): void {
21462155
21472156 router.add("GET", "/api/v1/repos/:owner/:name", (ctx) => {
21482157 const repo = loadRepo(ctx);
2149 return repo ? json(repoJson(ctx, repo)) : notFound();
2158 return repo
2159 ? json({
2160 ...repoJson(ctx, repo),
2161 audience: audienceFor(ctx.app.db, repo, ctx.auth.user),
2162 })
2163 : notFound();
21502164 });
21512165
21522166 router.add("PATCH", "/api/v1/repos/:owner/:name", async (ctx) => {
@@ -3149,8 +3163,19 @@ export function apiRoutes(router: Router<Handler>): void {
31493163 const repo = loadRepo(ctx);
31503164 const number = positiveInt(ctx.params.number);
31513165 if (!repo || number === undefined) return notFound();
3166 if (isReplanAction(input.action)) {
3167 if (dryRun) {
3168 const plan = previewReplan(ctx.app, ctx.auth.user, repo, number);
3169 return plan.ok ? json({ dryRun: true, ...plan.value }) : respondFleet(plan.error);
3170 }
3171 const done =
3172 input.action === "replan"
3173 ? replanLimb(ctx.app, ctx.auth.user, repo, number)
3174 : takeBackLimb(ctx.app, ctx.auth.user, repo, number);
3175 return done.ok ? json({ dryRun: false, ...done.value }, 201) : respondFleet(done.error);
3176 }
31523177 if (!isLimbAction(input.action))
3153 return apiError("bad_action", 422, "action is defer, resume, close, raise or lower");
3178 return apiError("bad_action", 422, "action is defer, resume, close, raise, lower, replan or takeback");
31543179 const request = {
31553180 action: input.action,
31563181 ...(typeof input.reason === "string" ? { reason: input.reason } : {}),
src/http/mcp.ts +118 −6
@@ -130,8 +130,14 @@ 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";
135 import {
136 isReplanAction,
137 previewReplan,
138 replanLimb,
139 takeBackLimb,
140 } from "../actions/limb_replan.ts";
135141 import {
136142 milestoneData,
137143 milestonesJson,
@@ -151,6 +157,7 @@ import {
151157 isAskedOrder,
152158 isAskedShow,
153159 } from "../core/asked.ts";
160 import { audienceFor } from "../core/audience.ts";
154161 import { linksFrom, linksTo } from "../core/thought_links.ts";
155162 import { streamJson, thoughtStream } from "../core/thought_stream.ts";
156163 import {
@@ -264,6 +271,67 @@ function repoArg(ctx: Ctx, user: User, args: Args): Repo | Answer {
264271 : bad("not_found", "no such app or repository (owner/name)");
265272 }
266273
274 const MAX_ARCHIVE_REPOS = 50;
275
276 /** `archive` over `repos`: the fleet's own operation, each row's outcome and the id to undo. */
277 async function archiveMany(ctx: Ctx, user: User, args: Args): Promise<Answer> {
278 const names = args["repos"];
279 if (
280 !Array.isArray(names) ||
281 names.length === 0 ||
282 names.length > MAX_ARCHIVE_REPOS ||
283 names.some((n) => typeof n !== "string")
284 )
285 return bad("invalid", `\`repos\` is one to ${MAX_ARCHIVE_REPOS} names, owner/name each`);
286 if (args["repo"] !== undefined)
287 return bad("invalid", "give `repo` or `repos`, not both");
288 if (args["archived"] !== undefined && typeof args["archived"] !== "boolean")
289 return bad("invalid", "`archived` is true or false");
290 const archived = args["archived"] ?? true;
291 const dryRun = args["dryRun"] !== false;
292 if (user.kind === "agent")
293 return bad("forbidden", "archiving or waking is a person's call, not an agent's");
294 if (!dryRun && !scopeAllows(ctx.auth.scope ?? "ro", "admin"))
295 return bad("forbidden", "archiving or waking needs an admin token");
296 const ids: string[] = [];
297 const missing: string[] = [];
298 for (const name of new Set(names as string[])) {
299 const repo = repoArg(ctx, user, { repo: name });
300 if (isAnswer(repo)) missing.push(name);
301 else ids.push(repo.id);
302 }
303 const query = `mcp: ${ids.length + missing.length} named`;
304 const action = { verb: "archive", value: archived };
305 const unknown = missing.map((repo) => ({
306 repo,
307 outcome: "refused",
308 reason: "no such app or repository (owner/name)",
309 }));
310 const rowsOf = (rows: readonly { label: string; outcome: string; reason: string }[]) => [
311 ...rows.map((r) => ({ repo: r.label, outcome: r.outcome, reason: r.reason })),
312 ...unknown,
313 ];
314 if (dryRun) {
315 const plan = previewFleetIds(ctx.app, user, "repo", ids, query, action);
316 if (!plan.ok) return bad(plan.error.code, plan.error.message);
317 return good({
318 dryRun: true,
319 archived,
320 rows: rowsOf(plan.value.rows),
321 says: "This is the plan; nothing changed. Send dryRun: false to do it.",
322 });
323 }
324 const op = await applyFleetIds(ctx.app, user, "repo", ids, query, action);
325 if (!op.ok) return bad(op.error.code, op.error.message);
326 return good({
327 dryRun: false,
328 archived,
329 operation: op.value.id,
330 rows: rowsOf(op.value.rows),
331 says: `Done. The \`limb\` tool with action undo and operation ${op.value.id} reverts it for 24 hours.`,
332 });
333 }
334
267335 const APP_ARG = {
268336 type: "string",
269337 description: "The app, as owner/name.",
@@ -343,6 +411,28 @@ const TOOLS: readonly Tool[] = [
343411 );
344412 },
345413 },
414 {
415 name: "repo",
416 description:
417 "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.",
418 inputSchema: {
419 type: "object",
420 properties: { repo: REPO_ARG },
421 required: ["repo"],
422 },
423 writes: false,
424 run(ctx, user, args) {
425 const repo = repoArg(ctx, user, args);
426 if (isAnswer(repo)) return repo;
427 return good({
428 repo: `${repo.ownerName}/${repo.name}`,
429 description: repo.description,
430 visibility: repo.visibility,
431 autonomy: repo.autonomy,
432 audience: audienceFor(ctx.app.db, repo, user),
433 });
434 },
435 },
346436 {
347437 name: "here",
348438 description:
@@ -1209,22 +1299,28 @@ const TOOLS: readonly Tool[] = [
12091299 {
12101300 name: "archive",
12111301 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.",
1302 "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.",
12131303 inputSchema: {
12141304 type: "object",
12151305 properties: {
12161306 repo: REPO_ARG,
1307 repos: {
1308 type: "array",
1309 items: { type: "string" },
1310 maxItems: MAX_ARCHIVE_REPOS,
1311 description: "Several repositories, as owner/name, to archive in one operation.",
1312 },
12171313 archived: { type: "boolean" },
12181314 dryRun: {
12191315 type: "boolean",
12201316 description: "Plan only (the default). Send false to do it.",
12211317 },
12221318 },
1223 required: ["repo"],
12241319 },
12251320 writes: true,
12261321 dryByDefault: true,
12271322 async run(ctx, user, args) {
1323 if (args["repos"] !== undefined) return archiveMany(ctx, user, args);
12281324 const repo = repoArg(ctx, user, args);
12291325 if (isAnswer(repo)) return repo;
12301326 const archived = args["archived"];
@@ -1986,7 +2082,7 @@ const TOOLS: readonly Tool[] = [
19862082 {
19872083 name: "limb",
19882084 description:
1989 "One action on a whole limb of the plan — a milestone and everything beneath it that only it needs. `defer` moves the limb behind the Later gate, so the crew picks none of it; `resume` brings it back, under `pillar` when you name one; `close` closes every open issue of the limb as not doing, with `reason` said on each; `raise` and `lower` move it one place among its siblings — `under` says whose limbs when it is a limb of several, or `top` — and the crew takes ready work from the higher limb first; the dry run answers with the new `order` and the top of the ready list `before` and `after`. `undo` reverses an `operation` by its id. A dry run unless `dryRun` is false: it answers with every row before and after, what the action takes along (`taken`) and what stays because something outside the limb still waits for it (`kept`), and changes nothing. Applying and undoing take an admin token of a person who administers the app, and answer with the receipt.",
2085 "One action on a whole limb of the plan — a milestone and everything beneath it that only it needs. `defer` moves the limb behind the Later gate, so the crew picks none of it; `resume` brings it back, under `pillar` when you name one; `close` closes every open issue of the limb as not doing, with `reason` said on each; `raise` and `lower` move it one place among its siblings — `under` says whose limbs when it is a limb of several, or `top` — and the crew takes ready work from the higher limb first; the dry run answers with the new `order` and the top of the ready list `before` and `after`. `replan` hands the limb to the planner to re-plan as fewer, larger pieces: the dry run lists every open issue it will be given, applying owes the planner's next pass once and holds the limb from the crew until it answers, and `takeback` withdraws it before the planner starts. `undo` reverses an `operation` by its id. A dry run unless `dryRun` is false: it answers with every row before and after, what the action takes along (`taken`) and what stays because something outside the limb still waits for it (`kept`), and changes nothing. Applying and undoing take an admin token of a person who administers the app, and answer with the receipt.",
19902086 inputSchema: {
19912087 type: "object",
19922088 properties: {
@@ -1996,7 +2092,7 @@ const TOOLS: readonly Tool[] = [
19962092 minimum: 1,
19972093 description: "The limb: the number of its issue.",
19982094 },
1999 action: { type: "string", enum: ["defer", "resume", "close", "raise", "lower", "undo"] },
2095 action: { type: "string", enum: ["defer", "resume", "close", "raise", "lower", "replan", "takeback", "undo"] },
20002096 reason: {
20012097 type: "string",
20022098 description: "For close: why it is not being done, in one sentence.",
@@ -2039,13 +2135,29 @@ const TOOLS: readonly Tool[] = [
20392135 return undone.ok ? good(undone.value) : refused(undone.error);
20402136 }
20412137 const number = args["issue"];
2138 if (
2139 isReplanAction(args["action"]) &&
2140 typeof number === "number" &&
2141 Number.isInteger(number) &&
2142 number > 0
2143 ) {
2144 if (dry) {
2145 const plan = previewReplan(ctx.app, user, repo, number);
2146 return plan.ok ? good({ dryRun: true, ...plan.value }) : refused(plan.error);
2147 }
2148 const done =
2149 args["action"] === "replan"
2150 ? replanLimb(ctx.app, user, repo, number)
2151 : takeBackLimb(ctx.app, user, repo, number);
2152 return done.ok ? good({ dryRun: false, ...done.value }) : refused(done.error);
2153 }
20422154 if (
20432155 !isLimbAction(args["action"]) ||
20442156 typeof number !== "number" ||
20452157 !Number.isInteger(number) ||
20462158 number < 1
20472159 )
2048 return bad("invalid", "give the limb's issue number and an action: defer, resume, close, raise, lower or undo");
2160 return bad("invalid", "give the limb's issue number and an action: defer, resume, close, raise, lower, replan, takeback or undo");
20492161 const request = {
20502162 action: args["action"],
20512163 ...(typeof args["reason"] === "string" ? { reason: args["reason"] } : {}),
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/plural.ts added +8
@@ -0,0 +1,8 @@
1 /**
2 * "1 comment", "0 comments". A count and its noun are one phrase, so they are
3 * formatted in one place — the alternative is `1 commits` in whichever screen
4 * was written last, which is precisely what happened before this existed.
5 */
6 export function plural(n: number, word: string, many = `${word}s`): string {
7 return `${n} ${n === 1 ? word : many}`;
8 }
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 +4 −8
@@ -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
@@ -1032,11 +1035,4 @@ export function ago(iso: string): string {
10321035 return rel === "now" ? "just now" : `${rel} ago`;
10331036 }
10341037
1035 /**
1036 * "1 comment", "0 comments". A count and its noun are one phrase, so they are
1037 * formatted in one place — the alternative is `1 commits` in whichever screen
1038 * was written last, which is precisely what happened before this existed.
1039 */
1040 export function plural(n: number, word: string, many = `${word}s`): string {
1041 return `${n} ${n === 1 ? word : many}`;
1042 }
1038 export { plural } from "../../kernel/plural.ts";
src/tui/screens/limbs.ts +65 −2
@@ -12,6 +12,11 @@
1212 * facts (`canSeeOwnerFacts`, as on the page) also gets, under the list, what
1313 * the limb under the cursor cost and landed, and its costliest issues.
1414 *
15 * `p` hands the limb under the cursor to the planner to re-plan as fewer,
16 * larger pieces (#761): `previewReplan` lists what it will be given, then
17 * `replanLimb` on confirm. The row reads "with the planner" until it
18 * answers, and `p` then takes it back while that is still allowed.
19 *
1520 * `enter` on a limb that has limbs of its own opens them, so a milestone's
1621 * sub-limbs can be moved among themselves (the `under` of the action).
1722 * Subscribed to the bus while on the stack, so a move made on the web, or in
@@ -19,6 +24,8 @@
1924 */
2025
2126 import { undoFleetOp } from "../../actions/fleet.ts";
27 import { previewReplan, replanLimb, takeBackLimb } from "../../actions/limb_replan.ts";
28 import { pendingReplans } from "../../core/limb_replan.ts";
2229 import { applyLimb, previewLimb, type LimbPreview } from "../../actions/limbs.ts";
2330 import { canAdmin, canRead, canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
2431 import { readPlan, type Limb, type Milestone } from "../../core/milestones.ts";
@@ -40,7 +47,7 @@ import {
4047 } from "./common.ts";
4148
4249 /** What reorders or reshapes the limbs: a move, or an issue opening or closing. */
43 const LIVE_KINDS = ["limb.moved", "issue.opened", "issue.closed", "issue.reopened"];
50 const LIVE_KINDS = ["limb.moved", "limb.replan", "limb.replan_taken_back", "issue.opened", "issue.closed", "issue.reopened"];
4451
4552 /** How many lines of the new order and of the ready list a preview shows. */
4653 const SHOWN = 6;
@@ -104,6 +111,8 @@ export function limbsScreen(
104111 let spends = new Map<number, NonNullable<Milestone["spend"]>>();
105112 /** The receipt of the last move made here, which `u` reverses once. */
106113 let last: string | undefined;
114 /** The limbs with the planner, by issue id (#761). */
115 let withPlanner = new Set<string>();
107116 // Read at use, not at open: a repository's admin can change under a session.
108117 const may = (): boolean =>
109118 f.user !== undefined && f.user.kind === "user" && canAdmin(f.db, repo, f.user);
@@ -117,6 +126,7 @@ export function limbsScreen(
117126 return;
118127 }
119128 limbs = limbsOf(f, repo, group) ?? [];
129 withPlanner = new Set(pendingReplans(f.db, repo).map((r) => r.limbId));
120130 spends = new Map();
121131 if (canSeeOwnerFacts(f.db, repo, f.user)) {
122132 const plan = readPlan(f.db, repo, { spend: true });
@@ -175,6 +185,57 @@ export function limbsScreen(
175185 );
176186 };
177187
188 /** Re-plan: hand the limb to the planner after reading what it is given; or take it back. */
189 const replan = (): void => {
190 const limb = current();
191 if (limb === undefined || !may()) return;
192 const number = limb.issue.number;
193 const seen = previewReplan(f.app, f.user, repo, number);
194 if (!seen.ok) {
195 ctx.toast(seen.error.message, "danger");
196 return;
197 }
198 const p = seen.value;
199 if (p.withPlanner && !p.takeBack) {
200 ctx.toast("the planner has it in hand already", "danger");
201 return;
202 }
203 if (!p.withPlanner && p.blocked !== undefined) {
204 ctx.toast(p.blocked, "danger");
205 return;
206 }
207 const taking = p.withPlanner;
208 const body = [
209 taking
210 ? "It is with the planner; nothing beneath it is picked. Take it back before the planner starts:"
211 : `The planner will be given ${plural(p.issues.length, "issue")}, to close as split into fewer, larger ones:`,
212 ...p.issues.slice(0, SHOWN).map((i) => ` ${line(i.number, i.title)}`),
213 ...(p.issues.length > SHOWN ? [` … ${p.issues.length - SHOWN} more`] : []),
214 ];
215 ctx.push(
216 confirmScreen(ctx, screen, {
217 title: `${taking ? "Take back" : "Re-plan"} “${limb.name}”?`,
218 body,
219 actions: [
220 { label: taking ? "Take it back" : "Hand to the planner", variant: "default" },
221 { label: "Cancel", variant: "ghost" },
222 ],
223 onChoose: (choice) => {
224 if (choice !== 0) return;
225 const done = taking
226 ? takeBackLimb(f.app, f.user, repo, number)
227 : replanLimb(f.app, f.user, repo, number);
228 if (!done.ok) {
229 ctx.toast(done.error.message, "danger");
230 return;
231 }
232 reload(number);
233 ctx.toast(taking ? "taken back" : "with the planner", "success");
234 },
235 }),
236 );
237 };
238
178239 const undo = (): void => {
179240 const id = last;
180241 if (id === undefined || !may()) return;
@@ -202,7 +263,7 @@ export function limbsScreen(
202263 const rows = (): ListRow[] =>
203264 limbs.map((l, n) => ({
204265 title: `${n + 1}. ${line(l.issue.number, l.name)}`,
205 secondary: l.deferred ? "deferred" : "",
266 secondary: withPlanner.has(l.issue.id) ? "with the planner" : l.deferred ? "deferred" : "",
206267 meta: `${l.weight.done}/${l.weight.total} done`,
207268 }));
208269
@@ -215,6 +276,7 @@ export function limbsScreen(
215276 binding(["enter"], "open"),
216277 binding(["r"], "raise", { disabled: !moves }),
217278 binding(["l"], "lower", { disabled: !moves }),
279 binding(["p"], withPlanner.has(current()?.issue.id ?? "") ? "take back" : "re-plan", { disabled: !may() || current() === undefined }),
218280 binding(["u"], "undo", { disabled: !may() || last === undefined }),
219281 ];
220282 },
@@ -246,6 +308,7 @@ export function limbsScreen(
246308 if (e.key === "enter") open();
247309 else if (e.key === "r") move("raise");
248310 else if (e.key === "l") move("lower");
311 else if (e.key === "p") replan();
249312 else if (e.key === "u") undo();
250313 else return false;
251314 return true;
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/live.ts +1 −1
@@ -302,7 +302,7 @@ export const liveClientJs = /* js */ `
302302 'label.created', 'label.updated', 'label.deleted',
303303 'run.queued', 'run.started', 'run.finished', 'app.serving',
304304 'app.created', 'app.updated', 'app.deleted', 'app.stopped',
305 'limb.moved', 'issue.assigned', 'pull.assigned', 'crew.added', 'crew.removed',
305 'limb.moved', 'limb.replan', 'limb.replan_taken_back', 'issue.assigned', 'pull.assigned', 'crew.added', 'crew.removed',
306306 ];
307307 var extra = (cfg.getAttribute('data-repos') || '').split(' ').filter(Boolean);
308308 var watching = [repo].concat(extra).filter(function (r, i, all) {
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/limb.ts +4
@@ -6,6 +6,7 @@
66 * take it back once.
77 */
88
9 import { isReplanAction } from "../../actions/limb_replan.ts";
910 import { getFleetOp, undoFleetOp } from "../../actions/fleet.ts";
1011 import {
1112 applyLimb,
@@ -32,6 +33,7 @@ import { field, readForm } from "../form.ts";
3233 import { html, when, type Html } from "../html.ts";
3334 import { page, redirect } from "../layout.ts";
3435 import { btn } from "../ui.ts";
36 import { replanPreviewPage, replanSubmit } from "./limb_replan.ts";
3537 import { milestoneHref, milestonesCss } from "./milestones.ts";
3638
3739 const WORDS: Record<LimbAction, { ask: string; does: string; go: string }> = {
@@ -166,6 +168,7 @@ export function limbPreviewPage(ctx: Ctx): Response {
166168 if (!ctx.auth.user) return signInFirst(ctx);
167169 const number = Number(ctx.params.number);
168170 const action = ctx.url.searchParams.get("action");
171 if (action === "replan") return replanPreviewPage(ctx, repo, number);
169172 if (!Number.isInteger(number) || !isLimbAction(action))
170173 return notFoundPage(ctx);
171174 const request = requestOf(action, (n) => ctx.url.searchParams.get(n) ?? "");
@@ -350,6 +353,7 @@ export async function limbSubmit(ctx: Ctx): Promise<Response> {
350353 if (form instanceof Response) return form;
351354 const number = Number(ctx.params.number);
352355 const action = field(form, "action");
356 if (isReplanAction(action)) return replanSubmit(ctx, repo, number, action);
353357 if (!Number.isInteger(number) || !isLimbAction(action))
354358 return notFoundPage(ctx);
355359 const base = repoBase(repo);
src/web/pages/limb_replan.ts added +106
@@ -0,0 +1,106 @@
1 /**
2 * Handing a limb to the planner from its milestone (#761): the preview that
3 * lists every issue the planner will be given, the confirm, and taking it
4 * back. Over `actions/limb_replan.ts`; the same pattern as `limb.ts`.
5 */
6
7 import {
8 previewReplan,
9 replanLimb,
10 takeBackLimb,
11 type ReplanAction,
12 } from "../../actions/limb_replan.ts";
13 import type { Repo } from "../../core/repos.ts";
14 import type { Ctx } from "../../http/context.ts";
15 import {
16 crumbsFor,
17 notFoundPage,
18 repoBase,
19 repoHeader,
20 repoPalette,
21 } from "../chrome.ts";
22 import { html } from "../html.ts";
23 import { page, redirect } from "../layout.ts";
24 import { plural } from "../../kernel/plural.ts";
25 import { btn } from "../ui.ts";
26 import { milestoneHref, milestonesCss } from "./milestones.ts";
27
28 /** GET /:owner/:name/milestones/:number/limb?action=replan — what the planner will be given. */
29 export function replanPreviewPage(ctx: Ctx, repo: Repo, number: number): Response {
30 const plan = previewReplan(ctx.app, ctx.auth.user, repo, number);
31 if (!plan.ok) return notFoundPage(ctx);
32 const base = repoBase(repo);
33 const p = plan.value;
34 const back = milestoneHref(base, number);
35 return page({
36 ctx,
37 title: `Re-plan ${p.limb.title} · ${repo.name}`,
38 crumbs: crumbsFor(repo),
39 palette: repoPalette(ctx, repo),
40 styleCss: milestonesCss,
41 body: html`${repoHeader(ctx, repo, "map")}
42 <div class="ms-top mt">
43 <p class="small muted"><a href="${back}">${p.limb.title}</a></p>
44 <h1>Re-plan “${p.limb.title}”?</h1>
45 <p>
46 The planner's next pass is given this limb to re-plan as fewer,
47 larger pieces. Each issue below is closed as split into what
48 replaces it; none is dropped. The crew picks nothing beneath the
49 limb until the planner has answered, and you can take it back
50 before it starts.
51 </p>
52 </div>
53 <section class="ms-section" id="replan-issues">
54 <h2>${plural(p.issues.length, "issue")} the planner will be given</h2>
55 <ul class="ms-rows">
56 ${p.issues.map(
57 (i) =>
58 html`<li>
59 <a href="${base}/issues/${i.number}">${i.title}</a>
60 <span class="muted small">#${i.number}</span>
61 </li>`,
62 )}
63 </ul>
64 </section>
65 ${p.blocked !== undefined
66 ? html`<p class="mt" id="replan-refused">
67 Not now: ${p.blocked}. <a href="${back}">Back</a>
68 </p>`
69 : html`<form method="post" action="${back}/limb" class="bar mt">
70 <input type="hidden" name="action" value="replan" />
71 ${btn("Hand to the planner", { variant: "primary" })}
72 ${btn("Back", { href: back, variant: "ghost" })}
73 </form>`}`,
74 });
75 }
76
77 /** POST /:owner/:name/milestones/:number/limb with action replan or takeback, its form already read. */
78 export function replanSubmit(
79 ctx: Ctx,
80 repo: Repo,
81 number: number,
82 action: ReplanAction,
83 ): Response {
84 if (!Number.isInteger(number)) return notFoundPage(ctx);
85 const done =
86 action === "replan"
87 ? replanLimb(ctx.app, ctx.auth.user, repo, number)
88 : takeBackLimb(ctx.app, ctx.auth.user, repo, number);
89 if (!done.ok && done.error.code === "not_found") return notFoundPage(ctx);
90 const back = milestoneHref(repoBase(repo), number);
91 if (done.ok) return redirect(back);
92 // Said, not hidden: the press did nothing, and why.
93 return page({
94 ctx,
95 title: `Not done · ${repo.name}`,
96 crumbs: crumbsFor(repo),
97 palette: repoPalette(ctx, repo),
98 styleCss: milestonesCss,
99 body: html`${repoHeader(ctx, repo, "map")}
100 <div class="ms-top mt">
101 <h1>${action === "replan" ? "Not handed over" : "Not taken back"}</h1>
102 <p id="replan-refused">Nothing changed: ${done.error.message}.</p>
103 <p><a href="${back}">Back</a></p>
104 </div>`,
105 }, 409);
106 }
src/web/pages/milestones.ts +65 −4
@@ -34,6 +34,8 @@ import {
3434 import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
3535 import { isWatching } from "../../core/watches.ts";
3636 import { watchMilestone } from "../../actions/watches.ts";
37 import { replanResult } from "../../actions/limb_replan.ts";
38 import { given, pendingFor } from "../../core/limb_replan.ts";
3739 import type { Ctx } from "../../http/context.ts";
3840 import { askToLiveFor } from "../ask_to_live.ts";
3941 import {
@@ -59,7 +61,7 @@ export const milestoneHref = (base: string, number: number): string =>
5961 export const limbHref = (
6062 base: string,
6163 number: number,
62 action: LimbAction,
64 action: LimbAction | "replan",
6365 under?: string,
6466 ): string =>
6567 `${milestoneHref(base, number)}/limb?action=${action}${under === undefined ? "" : `&under=${under}`}`;
@@ -120,11 +122,13 @@ function limbActions(
120122 furniture: boolean,
121123 lead: Html | null,
122124 move: Html | null,
125 replan: Html | null,
123126 ): Html {
124127 const number = m.issue.number;
125128 return html`<p class="bar ms-actions" id="limb-actions">
126129 ${lead}
127130 ${move}
131 ${replan}
128132 ${furniture
129133 ? null
130134 : m.deferred
@@ -242,6 +246,58 @@ function folded(rows: Html[], what: string, shown: number): Html {
242246 </details>`;
243247 }
244248
249 /**
250 * Where a limb stands with the planner (#761): "with the planner" while it
251 * waits, with a way to take it back until the planner has been given it;
252 * otherwise Re-plan, for whoever administers the repository.
253 */
254 function replanControl(
255 ctx: Ctx,
256 repo: Repo,
257 m: Milestone,
258 admin: boolean,
259 ): Html | null {
260 const base = repoBase(repo);
261 const pending = pendingFor(ctx.app.db, repo, m.issue.id);
262 if (pending === undefined)
263 return admin && m.weight.open > 1
264 ? btn("Re-plan", {
265 href: limbHref(base, m.issue.number, "replan"),
266 size: "sm",
267 title: "Hand this limb to the planner to re-plan as fewer, larger pieces",
268 })
269 : null;
270 return html`<span class="ms-planner" id="ms-with-planner"
271 ><b>With the planner</b>
272 <span class="muted small">Nothing beneath it is picked until it answers.</span></span
273 >${admin && !given(ctx.app.db, repo, pending.at)
274 ? html`<form method="post" action="${milestoneHref(base, m.issue.number)}/limb">
275 <input type="hidden" name="action" value="takeback" />
276 ${btn("Take it back", { size: "sm", variant: "ghost" })}
277 </form>`
278 : null}`;
279 }
280
281 /** What the planner changed on this limb, once it has answered: each old issue and what replaced it. */
282 function replanChanged(ctx: Ctx, repo: Repo, m: Milestone): Html | null {
283 const base = repoBase(repo);
284 const result = replanResult(ctx.app, repo, m.issue.id);
285 if (result === undefined || result.splits.length === 0) return null;
286 const link = (n: { number: number; title: string }): Html =>
287 html`<a href="${base}/issues/${n.number}">${n.title}</a>
288 <span class="muted small">#${n.number}</span>`;
289 return html`<section class="ms-section" id="ms-replanned">
290 <h2>Re-planned ${time(result.at)}</h2>
291 <ul class="ms-rows">
292 ${result.splits.map(
293 (s) =>
294 html`<li>${link(s)} <span class="muted small">closed as split into</span>
295 ${s.into.map((p, n) => html`${n === 0 ? "" : ", "}${link(p)}`)}</li>`,
296 )}
297 </ul>
298 </section>`;
299 }
300
245301 /**
246302 * Watch, or stop: one button, for a person who is signed in. Watching puts
247303 * the milestone's progress in their feed and tells their phone once, when
@@ -490,17 +546,20 @@ export function milestonePage(ctx: Ctx): Response {
490546 const move = moverFor(ctx.app.db, repo, viewer, plan.read);
491547 // Its own place: among the limbs of whichever milestone holds it first.
492548 const seat = move?.homeOf(milestone.issue);
493 const actions = canAdmin(ctx.app.db, repo, ctx.auth.user)
549 const admin = canAdmin(ctx.app.db, repo, ctx.auth.user);
550 const replan = replanControl(ctx, repo, milestone, admin);
551 const actions = admin
494552 ? limbActions(
495553 repoBase(repo),
496554 milestone,
497555 isStructural(pillarsOf(ctx.app.db, repo), milestone.issue.id),
498556 watch,
499557 seat === undefined ? null : (move?.(milestone.issue, seat) ?? null),
558 replan,
500559 )
501 : watch === null
560 : watch === null && replan === null
502561 ? null
503 : html`<p class="bar ms-actions">${watch}</p>`;
562 : html`<p class="bar ms-actions">${watch}${replan}</p>`;
504563 return page({
505564 ctx,
506565 title: `${milestone.name} · ${repo.name}`,
@@ -508,6 +567,7 @@ export function milestonePage(ctx: Ctx): Response {
508567 palette: repoPalette(ctx, repo),
509568 body: html`${repoHeader(ctx, repo, "map")}
510569 ${milestoneBody(repo, milestone, owner, actions, move)}
570 ${replanChanged(ctx, repo, milestone)}
511571 ${liveMount({ repo: repoBase(repo) })}`,
512572 styleCss: milestonesCss,
513573 scriptJs: liveClientJs,
@@ -621,6 +681,7 @@ export const milestonesCss = /* css */ `
621681 .ms-top h1, .ms-top p { margin: 0; }
622682 .ms-head { display: grid; grid-template-columns: minmax(0, 1fr); gap: 4px; }
623683 .ms-actions { margin-top: 4px; }
684 .ms-planner { display: inline-flex; flex-wrap: wrap; gap: 4px 8px; align-items: baseline; }
624685 .ms-move { grid-column: 1 / -1; display: flex; gap: 14px; }
625686 .ms-section { margin-top: 22px; }
626687 .ms-section h2 { font-size: 13px; margin: 0 0 6px; }
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 });

60 more files not shown.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs