Public: anyone can read this; the crew can change it.
Take updates from trevato/forge-upstream #2604
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/2603-take-updates-from-trevato-forge-upstream → main 1 commit fork 1 thread · 1 unresolved
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. | ||
| 254 | 254 | whose default export is the SQL — never the next number: `0001`–`0091` |
| 255 | 255 | are the numbered history, and a shared next number is what parallel pulls |
| 256 | 256 | collided on (#1837). Files run in name order, once each, recognised by a |
| 257 | hash of their text in `schema_migration` (`kernel/migrate.ts`), so two | |
| 258 | pulls' migrations land in either order. Never edit an existing migration | |
| 259 | — an edited one is a new one and runs again. It must only add | |
| 257 | hash of their text in `schema_migration` (`kernel/migrate.ts`, which also | |
| 258 | records each name), so two pulls' migrations land in either order, and a | |
| 259 | database with one this build does not know boots and names it. Never edit | |
| 260 | an existing migration — an edited one is a new one and runs again; once | |
| 261 | pinned in `core/migration-pins.json` (`bun scripts/pin-migrations.ts`, run | |
| 262 | by the release agent, never by a pull) the schema test fails on an edit. It must only add | |
| 260 | 263 | (`test/schema-additive.test.ts`); the release agent lists its id in |
| 261 | 264 | `core/releases.json`. |
| 262 | 265 | - **New git read:** put it in `git/read.ts`; reuse the long-lived `cat-file` |
README.md +77 −5
Diff not shown — a line 4,879 characters wide (limit 2,000). +77 −5
View filedocs/APPS.md +1 −1
| @@ -1114,7 +1114,7 @@ a link to the milestone (`docs/PLANNING.md`); its progress never does. | ||
| 1114 | 1114 | | `FORGE_APP_DOMAIN` | the host in `FORGE_URL`/`_HOST` | what app hostnames attach to (`example` or `--x`) | |
| 1115 | 1115 | | `FORGE_APPS_CONCURRENCY` | `2` | deploys running their heavy steps at once | |
| 1116 | 1116 | | `FORGE_PREVIEW_IDLE_DAYS` | `3` | days a pull may sit untouched before its preview sleeps; `0` = never | |
| 1117 | | `FORGE_CREW_CONCURRENCY` | `2` | agent runs at once (live: Settings → Capacity) | | |
| 1117 | | `FORGE_CREW_CONCURRENCY` | 1 per 3 GiB above 2 GiB, 2–16 | agent runs at once (live: Settings → Capacity) | | |
| 1118 | 1118 | | `FORGE_CHECKS_CONCURRENCY` | `1` | checks at once (live: Settings → Capacity) | |
| 1119 | 1119 | | `FORGE_CHECKS_PLACEMENT` | `process` | `job` runs a check's commands as a cluster Job (see Placement above) | |
| 1120 | 1120 | | `FORGE_CHECKS_DRAIN_S` | `300` | seconds a roll lets running checks finish first | |
docs/ARCHITECTURE.md +3 −2
| @@ -176,8 +176,9 @@ NOT NULL column without a default, and `test/schema-additive.test.ts` | ||
| 176 | 176 | refuses one in any migration not shipped by both of the two newest releases |
| 177 | 177 | recorded in `src/core/releases.json` (by id: a count, and the stamped ids), |
| 178 | 178 | which the release agent bumps when it cuts one. |
| 179 | An old process that restarts mid-roll refuses a database ahead of it | |
| 180 | (`user_version`), as it always has. | |
| 179 | An old process that restarts mid-roll boots on a database ahead of it and | |
| 180 | logs, by name, the migrations it does not know (`schema_migration` records | |
| 181 | each name, #783) — they only add, so it serves as it did. | |
| 181 | 182 | |
| 182 | 183 | State transitions use compare-and-swap (`UPDATE … WHERE state = ?`), and every |
| 183 | 184 | transition writes its event row in the same transaction. On boot, a |
docs/DOMAINS.md +3 −2
| @@ -35,13 +35,14 @@ two devices backed by the same physical disk, are not. | ||
| 35 | 35 | ## Running a drill |
| 36 | 36 | |
| 37 | 37 | `scripts/drills/run.ts` walks you through one. It refuses a domain whose drill |
| 38 | is none, prints the fault to cause, and waits for Enter. It then polls | |
| 38 | is none, prints the fault to cause, and waits for Enter (a line on stdin, so piping one works; closing stdin aborts). It then polls | |
| 39 | 39 | `/api/v1/health` and `/` once a second, keeping each distinct line the forge |
| 40 | 40 | says while unhealthy; when the forge is healthy again it posts what it saw and |
| 41 | 41 | the seconds it took to `POST /api/v1/drills` and prints the verdict. If the |
| 42 | 42 | forge is still down after an hour it posts nothing, says so, and exits 0. A |
| 43 | 43 | refused post exits non-zero with the forge's message. `FORGE_TOKEN` must be a |
| 44 | site admin's token. | |
| 44 | site admin's token; without it the script still watches, prints the result as | |
| 45 | JSON and says that nothing was posted. | |
| 45 | 46 | |
| 46 | 47 | |
| 47 | 48 | FORGE_TOKEN=… bun scripts/drills/run.ts runner --forge https://forge.open-platform.sh |
docs/MCP.md +2 −1
| @@ -28,6 +28,7 @@ for a tunnel and a forge that sleeps. | ||
| 28 | 28 | | tool | does | writes | |
| 29 | 29 | | -------------- | ------------------------------------------------------------------------------------------------- | ------ | |
| 30 | 30 | | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | | |
| 31 | | `repo` | `{ repo }`: one repository's description, visibility, autonomy and `audience { level, sentence }`, the same as `GET /api/v1/repos/:owner/:name` | | | |
| 31 | 32 | | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes | |
| 32 | 33 | | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes | |
| 33 | 34 | | `pull` | `{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks` | | |
| @@ -39,7 +40,7 @@ for a tunnel and a forge that sleeps. | ||
| 39 | 40 | | `audit` | who can do what, per app, read from its contract.json in git: yes, own or no per door, and whether outsiders may direct its crew (`crewOutsiders`) | | |
| 40 | 41 | | `outsiders` | `{ repo, on?, dryRun? }`: whether people who cannot write may direct the crew; with `on`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes | |
| 41 | 42 | | `decisions_by` | `{ repo, by?, dryRun? }`: the standing rule for a repository's hand-overs (`me`, `decider`, `decider_tell`); with `by`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes | |
| 42 | | `archive` | `{ repo, archived?, dryRun? }`: archive (true) or wake (false) a repository, by name; planned first (`dryRun` is true unless `false`); only the repository's admin with an admin token, never an agent | yes | | |
| 43 | | `archive` | `{ repo | repos, archived?, dryRun? }`: archive (true) or wake (false) a repository, by name; planned first (`dryRun` is true unless `false`); only the repository's admin with an admin token, never an agent. `repos` (at most 50) runs the fleet `archive` operation over them, answers each one's outcome and the `operation` id; `limb` with `action: "undo"`, that `operation` and any one of the repositories as `app` reverts it for a day | yes | | |
| 43 | 44 | | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | | |
| 44 | 45 | | `following` | where a fork stands against what it follows, and what the crew is doing about it | | |
| 45 | 46 | | `spawn` | `{ name, apps?, crew?, dryRun? }`: a child forge, planned first (`dryRun` is true unless `false`); an admin's, and an `ro` token may plan but not make | yes | |
docs/PLANNING.md +22 −2
| @@ -325,13 +325,33 @@ one undo. | ||
| 325 | 325 | the ready order changes. It is one fleet row, the verb `place`; undo puts |
| 326 | 326 | the place back. Through the API and the MCP `limb` tool (`action` raise or |
| 327 | 327 | 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). | |
| 328 | 348 | |
| 329 | 349 | An umbrella or the gate is the plan's own and is not filed. A leaf beneath |
| 330 | 350 | 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. | |
| 332 | 352 | |
| 333 | 353 | 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"? }` | |
| 335 | 355 | and the MCP's `limb`, both a dry run unless `dryRun` is false. Applying and |
| 336 | 356 | undoing take an admin token of a person who administers the repository; an |
| 337 | 357 | 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 | ||
| 16 | 16 | on a side port and reads `/api/v1/health`. One comment lands on the recovery |
| 17 | 17 | issue (`FORGE_RECOVERY_ISSUE` in `FORGE_RECOVERY_REPO`): snapshot, restore |
| 18 | 18 | time, health, counts, pass or fail. A failed drill is an inbox card naming |
| 19 | the step (fetch, restore, open, count, unseal, health) until one passes. No | |
| 20 | comment this week means the backups are unproven. | |
| 19 | the step (fetch, restore, open, count, unseal, health), run again at boot and | |
| 20 | every three hours until one passes. No comment this week: backups unproven. | |
| 21 | 21 | |
| 22 | 22 | ## What a restore needs |
| 23 | 23 |
docs/SNAPSHOT.md +51 −4
| @@ -8,7 +8,23 @@ empty data directory. `src/snapshot/`. | ||
| 8 | 8 | ## What a snapshot holds |
| 9 | 9 | |
| 10 | 10 | - `forge.db`, copied consistently while the forge runs (`VACUUM INTO`, |
| 11 | never a raw copy of a file in WAL mode). | |
| 11 | never a raw copy of a file in WAL mode), then zstd-compressed and cut in | |
| 12 | 16 MB parts: `forge.db.zst.0000`, `forge.db.zst.0001`, … Each part is its | |
| 13 | own put with its own deadline and retries, so a slow uplink carries it | |
| 14 | and a dropped connection costs one part, not the database. The manifest's | |
| 15 | `packed` says what the parts make and how big that is; a restore joins | |
| 16 | them, unpacks, and refuses a size that does not match. The log line | |
| 17 | `database packed` gives each night's ratio. Snapshots from before this | |
| 18 | hold a plain `forge.db` and still restore. The parts go up through the | |
| 19 | snapshot's upload queue like every other object: at most eight objects | |
| 20 | or 64 MB in flight, so four 16 MB parts at a time. | |
| 21 | ||
| 22 | Packing and restoring both hold the database in memory: the copy, its | |
| 23 | compressed form (the parts are views of it, not copies), and on restore | |
| 24 | the joined parts and the unpacked file, about three times the database | |
| 25 | at the peak (a 334 MB database at about 1 GB). A database that outgrows | |
| 26 | the app's memory limit needs a streaming pack; until then that limit, | |
| 27 | not the uplink, is the ceiling. | |
| 12 | 28 | - The key the database's sealed values need, **only wrapped**: |
| 13 | 29 | `secret.key.wrapped`, AES-256-GCM under a key scrypt-derived from the |
| 14 | 30 | operator's passphrase. The raw `secret.key` is never written to the store, |
| @@ -24,8 +40,8 @@ empty data directory. `src/snapshot/`. | ||
| 24 | 40 | stay out. |
| 25 | 41 | - The crew's run logs (`runs/`). |
| 26 | 42 | - `manifest.json`: the name, when, the forge's version, counts, every file |
| 27 | with its size, and `key`: `wrapped` or `none` (older snapshots: `raw`). A | |
| 28 | restore checks each size. | |
| 43 | with its size, `packed` (the files that went up in parts), and `key`: | |
| 44 | `wrapped` or `none` (older snapshots: `raw`). A restore checks each size. | |
| 29 | 45 | |
| 30 | 46 | What stays out is what a boot or a deploy rebuilds: clones, checks, |
| 31 | 47 | pictures, previews. |
| @@ -86,7 +102,24 @@ mirror is copied from the primary store, not written in parallel, so a snapshot | ||
| 86 | 102 | that never landed on the primary is not seen by it. |
| 87 | 103 | |
| 88 | 104 | The store is `Bun.S3Client` or a directory behind one small interface |
| 89 | (`src/snapshot/store.ts`): put, get, list, delete. Nothing else. | |
| 105 | (`src/snapshot/store.ts`): put, get, list, delete. Nothing else. Every call | |
| 106 | is tried five times; an attempt that has not answered by its deadline counts | |
| 107 | as failed. A put's deadline follows its size: its bytes at a floor of | |
| 108 | 5 Mbit/s, plus 60 s. A fixed 180 s once failed every attempt at a 334 MB | |
| 109 | database behind a 15 Mbit/s uplink (#2536). | |
| 110 | ||
| 111 | ## When a snapshot fails | |
| 112 | ||
| 113 | Its outcome is kept in `snapshot/state.json` under the data directory, so a | |
| 114 | restart forgets nothing. A failure shows straight away as a card on the board | |
| 115 | (`snapshot-failed`, pointing at Backups), in the terminal's dashboard among | |
| 116 | the forge's needs, and as a red line on the Backups page with the reason and | |
| 117 | since when. The forge tries again within the hour, not the next night. The | |
| 118 | next snapshot that lands, nightly or on demand, clears both. | |
| 119 | ||
| 120 | A forge given nowhere to put a snapshot has not failed: nothing is kept, no | |
| 121 | card is raised, and nothing is retried sooner than the nightly. The Backups | |
| 122 | page says there is no destination. | |
| 90 | 123 | |
| 91 | 124 | ## The restore drill |
| 92 | 125 | |
| @@ -96,12 +129,26 @@ scratch directory under the data directory, opens the database read-only, | ||
| 96 | 129 | counts repositories and issues, opens one sealed value with the restored key, |
| 97 | 130 | and removes the scratch whatever happened. The page waits until it finishes, |
| 98 | 131 | for up to 10 minutes; a step that hangs past that is red and names the step. |
| 132 | The restore reads several objects at a time, under the same limits as the | |
| 133 | upload (`IN_FLIGHT` gets, `IN_FLIGHT_BYTES` held): read one after another, a | |
| 134 | snapshot of thousands of objects spent the drill's whole 10 minutes in the | |
| 135 | restore step (#2539). | |
| 136 | Repositories come back four clones at a time, not one after another (#2543). | |
| 99 | 137 | One drill runs at a time: a second click, or a retry after a proxy timeout, |
| 100 | 138 | joins the drill already running rather than starting another restore. Every |
| 101 | 139 | run is a row in the `drill` table, and the last one shows as a line on the |
| 102 | 140 | Backups page: when, which snapshot, how long, the counts, and green or the |
| 103 | 141 | step that failed. |
| 104 | 142 | |
| 143 | The drill runs on its own every Sunday 09:00 UTC, and a forge that was down | |
| 144 | then catches up five minutes after boot. A red drill does not keep the | |
| 145 | week's slot: it is owed again `RED_RETRY_MS` (three hours) after it ran, | |
| 146 | and at a boot once it is `BOOT_RED_FLOOR_MS` (30 minutes) old, until one | |
| 147 | passes (`src/snapshot/due.ts`). The floor keeps a crash loop with a broken | |
| 148 | restore from running a restore on every boot. Kept for the | |
| 149 | week, a drill red at 09:11 stood as a card long after the fix deployed at | |
| 150 | 10:18 (#2541). | |
| 151 | ||
| 105 | 152 | ## Restore |
| 106 | 153 | |
| 107 | 154 | The printable card, seven lines, is [](RESTORE.md); a test runs it. |
scripts/drills/run.ts +45 −79
| @@ -4,11 +4,13 @@ | ||
| 4 | 4 | * polls `/api/v1/health` and `/` once a second keeping each distinct line the |
| 5 | 5 | * forge says while unhealthy, and when the forge is healthy again posts what |
| 6 | 6 | * it saw and how long it took to `POST /api/v1/drills` (`FORGE_TOKEN` from |
| 7 | * the environment). If the forge never comes back within the ceiling it posts | |
| 8 | * nothing. Zero dependencies; it never shells out. | |
| 7 | * the environment). Without a token it prints the result and says nothing was | |
| 8 | * posted. If the forge never comes back within the ceiling it posts nothing. | |
| 9 | * Zero dependencies; it never shells out. | |
| 9 | 10 | */ |
| 10 | 11 | |
| 11 | 12 | import { DOMAINS } from "../../src/kernel/domains.ts"; |
| 13 | import { UNREACHABLE, clean, linesOf, watchPolls, type Poll } from "./watch.ts"; | |
| 12 | 14 | |
| 13 | 15 | export interface DrillRun { |
| 14 | 16 | domain: string; |
| @@ -27,50 +29,17 @@ export interface DrillRun { | ||
| 27 | 29 | const SEEN_MAX = 8_000; |
| 28 | 30 | const DEFAULT_CEILING_MS = 3_600_000; |
| 29 | 31 | |
| 30 | /** C0 and C1 control characters out (tab stays): the forge's words never drive the terminal. */ | |
| 31 | export const clean = (s: string): string => s.replace(/[\x00-\x08\x0a-\x1f\x7f-\x9f]/g, ""); | |
| 32 | export { clean }; | |
| 32 | 33 | |
| 33 | /** Every string in a JSON value, or the text's own lines when it is not JSON. */ | |
| 34 | function linesOf(text: string, contentType: string): string[] { | |
| 35 | return rawLinesOf(text, contentType).map(clean).filter((l) => l.trim() !== ""); | |
| 36 | } | |
| 37 | ||
| 38 | function rawLinesOf(text: string, contentType: string): string[] { | |
| 39 | if (contentType.includes("json")) { | |
| 40 | try { | |
| 41 | const out: string[] = []; | |
| 42 | const walk = (v: unknown): void => { | |
| 43 | if (typeof v === "string") out.push(v); | |
| 44 | else if (Array.isArray(v)) v.forEach(walk); | |
| 45 | else if (v && typeof v === "object") Object.values(v).forEach(walk); | |
| 46 | }; | |
| 47 | walk(JSON.parse(text)); | |
| 48 | return out; | |
| 49 | } catch { | |
| 50 | /* fall through to plain lines */ | |
| 51 | } | |
| 52 | } | |
| 53 | return text | |
| 54 | .replace(/<(script|style)[\s\S]*?<\/\1>/gi, " ") | |
| 55 | .replace(/<[^>]*>/g, "\n") | |
| 56 | .replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") | |
| 57 | .replace(/"/g, '"').replace(/�?39;/g, "'") | |
| 58 | .split("\n") | |
| 59 | .map((l) => l.trim()) | |
| 60 | .filter((l) => l !== ""); | |
| 61 | } | |
| 62 | ||
| 63 | interface Look { | |
| 64 | healthy: boolean; | |
| 65 | health: string[]; | |
| 66 | board: string[]; | |
| 67 | } | |
| 68 | ||
| 69 | async function look(forge: string, token: string): Promise<Look> { | |
| 34 | /** | |
| 35 | * One look at the forge: healthy only when `/api/v1/health` reports ok and `/` | |
| 36 | * answers; what each said is kept under `health` and `board`. | |
| 37 | */ | |
| 38 | export function forgePoll(forge: string, token: string, fetchIt: typeof fetch = fetch): () => Promise<Poll> { | |
| 70 | 39 | const get = async (path: string): Promise<{ ok: boolean; lines: string[]; json: unknown }> => { |
| 71 | 40 | try { |
| 72 | const res = await fetch(`${forge}${path}`, { | |
| 73 | headers: { authorization: `Bearer ${token}` }, | |
| 41 | const res = await fetchIt(`${forge}${path}`, { | |
| 42 | headers: token === "" ? {} : { authorization: `Bearer ${token}` }, | |
| 74 | 43 | redirect: "manual", |
| 75 | 44 | signal: AbortSignal.timeout(5_000), |
| 76 | 45 | }); |
| @@ -82,17 +51,16 @@ async function look(forge: string, token: string): Promise<Look> { | ||
| 82 | 51 | } |
| 83 | 52 | return { ok: res.status < 400, lines: linesOf(text, type), json }; |
| 84 | 53 | } catch { |
| 85 | return { ok: false, lines: [], json: null }; | |
| 54 | return { ok: false, lines: [UNREACHABLE], json: null }; | |
| 86 | 55 | } |
| 87 | 56 | }; |
| 88 | const [h, b] = await Promise.all([get("/api/v1/health"), get("/")]); | |
| 89 | const reported = (h.json as { ok?: unknown } | null)?.ok === true; | |
| 90 | return { healthy: h.ok && b.ok && reported, health: h.lines, board: b.lines }; | |
| 57 | return async () => { | |
| 58 | const [h, b] = await Promise.all([get("/api/v1/health"), get("/")]); | |
| 59 | const reported = (h.json as { ok?: unknown } | null)?.ok === true; | |
| 60 | return { healthy: h.ok && b.ok && reported, said: { health: h.lines, board: b.lines } }; | |
| 61 | }; | |
| 91 | 62 | } |
| 92 | 63 | |
| 93 | /** A line with its numbers blanked, so a clock or a counter is not a new line. */ | |
| 94 | const shape = (l: string): string => l.replace(/\d+/g, "#"); | |
| 95 | ||
| 96 | 64 | const cap = (lines: Iterable<string>): string => [...lines].join("\n").slice(0, SEEN_MAX); |
| 97 | 65 | |
| 98 | 66 | /** Runs the drill; resolves to the process exit code. */ |
| @@ -116,29 +84,29 @@ export async function runDrill(run: DrillRun): Promise<number> { | ||
| 116 | 84 | run.say(`The forge should be back within ${domain.backMinutes} minutes. Press Enter when you have caused it.`); |
| 117 | 85 | await run.enter(); |
| 118 | 86 | |
| 119 | const started = now(); | |
| 120 | const health = new Map<string, string>(); | |
| 121 | const board = new Map<string, string>(); | |
| 122 | let sawDown = false; | |
| 123 | for (;;) { | |
| 124 | const seen = await look(run.forge, run.token); | |
| 125 | if (seen.healthy && sawDown) break; | |
| 126 | if (!seen.healthy) { | |
| 127 | sawDown = true; | |
| 128 | for (const l of seen.health) if (!health.has(shape(l))) { health.set(shape(l), l); run.say(`health: ${l}`); } | |
| 129 | for (const l of seen.board) if (!board.has(shape(l))) { board.set(shape(l), l); run.say(`board: ${l}`); } | |
| 130 | } | |
| 131 | if (now() - started >= ceilingMs) { | |
| 132 | const ceilingS = Math.round(ceilingMs / 1000); | |
| 133 | run.say(`The forge did not come back within the ceiling of ${ceilingS}s; nothing was posted.`); | |
| 134 | run.say(JSON.stringify({ domain: domain.key, backSeconds: null, posted: false, ceilingSeconds: ceilingS })); | |
| 135 | return 0; | |
| 136 | } | |
| 137 | await sleep(intervalMs); | |
| 87 | const watched = await watchPolls({ | |
| 88 | poll: forgePoll(run.forge, run.token), | |
| 89 | sleep, | |
| 90 | limit: Infinity, | |
| 91 | ceilingMs, | |
| 92 | intervalMs, | |
| 93 | now, | |
| 94 | onLine: (l, source) => run.say(`${source}: ${l}`), | |
| 95 | }); | |
| 96 | if (watched.timeout) { | |
| 97 | const ceilingS = Math.round(ceilingMs / 1000); | |
| 98 | run.say(`The forge did not come back within the ceiling of ${ceilingS}s; nothing was posted.`); | |
| 99 | run.say(JSON.stringify({ domain: domain.key, backSeconds: null, posted: false, ceilingSeconds: ceilingS })); | |
| 100 | return 0; | |
| 101 | } | |
| 102 | const { health = [], board = [] } = watched.said; | |
| 103 | const backSeconds = Math.max(0, Math.round(watched.healthyAt / 1000)); | |
| 104 | const body = { domain: domain.key, seen: { health: cap(health), board: cap(board) }, backSeconds }; | |
| 105 | if (run.token === "") { | |
| 106 | run.say("FORGE_TOKEN is not set; nothing was posted. The result:"); | |
| 107 | run.say(JSON.stringify({ domain: domain.key, backSeconds, posted: false, body })); | |
| 108 | return 0; | |
| 138 | 109 | } |
| 139 | ||
| 140 | const backSeconds = Math.max(0, Math.round((now() - started) / 1000)); | |
| 141 | const body = { domain: domain.key, seen: { health: cap(health.values()), board: cap(board.values()) }, backSeconds }; | |
| 142 | 110 | let res: Response; |
| 143 | 111 | try { |
| 144 | 112 | res = await fetch(`${run.forge}/api/v1/drills`, { |
| @@ -172,14 +140,13 @@ export interface DrillArgs { | ||
| 172 | 140 | token: string; |
| 173 | 141 | } |
| 174 | 142 | |
| 175 | const USAGE = "usage: FORGE_TOKEN=… bun scripts/drills/run.ts <domain> --forge <url> [--insecure]"; | |
| 143 | const USAGE = "usage: [FORGE_TOKEN=…] bun scripts/drills/run.ts <domain> --forge <url> [--insecure]"; | |
| 176 | 144 | const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); |
| 177 | 145 | |
| 178 | 146 | /** The options, or the one plain line to print before exiting 2. */ |
| 179 | 147 | export function checkArgs( |
| 180 | 148 | argv: string[], |
| 181 | 149 | env: Record<string, string | undefined>, |
| 182 | isTTY: boolean, | |
| 183 | 150 | ): { ok: true; args: DrillArgs } | { ok: false; error: string } { |
| 184 | 151 | const at = argv.indexOf("--forge"); |
| 185 | 152 | const forge = at >= 0 ? argv[at + 1] : undefined; |
| @@ -193,16 +160,14 @@ export function checkArgs( | ||
| 193 | 160 | return { ok: false, error: `--forge must be an http(s) URL; ${USAGE}` }; |
| 194 | 161 | } |
| 195 | 162 | const token = env.FORGE_TOKEN ?? ""; |
| 196 | if (token === "") return { ok: false, error: `FORGE_TOKEN is empty; ${USAGE}` }; | |
| 197 | if (!isTTY) return { ok: false, error: "stdin is not a terminal; run the drill from an interactive shell" }; | |
| 198 | if (url.protocol === "http:" && !LOOPBACK.has(url.hostname) && !insecure) { | |
| 163 | if (token !== "" && url.protocol === "http:" && !LOOPBACK.has(url.hostname) && !insecure) { | |
| 199 | 164 | return { ok: false, error: `refusing to send the admin token over plain http to ${url.hostname}; use https, or pass --insecure` }; |
| 200 | 165 | } |
| 201 | 166 | return { ok: true, args: { domain, forge: forge.replace(/\/+$/, ""), token } }; |
| 202 | 167 | } |
| 203 | 168 | |
| 204 | 169 | if (import.meta.main) { |
| 205 | const checked = checkArgs(process.argv.slice(2), process.env, process.stdin.isTTY === true); | |
| 170 | const checked = checkArgs(process.argv.slice(2), process.env); | |
| 206 | 171 | if (!checked.ok) { |
| 207 | 172 | console.error(checked.error); |
| 208 | 173 | process.exit(2); |
| @@ -211,7 +176,8 @@ if (import.meta.main) { | ||
| 211 | 176 | ...checked.args, |
| 212 | 177 | say: (l) => console.log(clean(l)), |
| 213 | 178 | enter: async () => { |
| 214 | for await (const _ of console) break; | |
| 179 | for await (const _ of console) return; | |
| 180 | throw new Error("stdin closed before Enter; nothing was caused or watched"); | |
| 215 | 181 | }, |
| 216 | 182 | }); |
| 217 | 183 | process.exit(code); |
scripts/drills/watch.ts added +140
| @@ -0,0 +1,140 @@ | ||
| 1 | /** | |
| 2 | * `watchDrill`: polls one URL until the forge is healthy again after having | |
| 3 | * been unhealthy, and returns each distinct line it said meanwhile. Pure: the | |
| 4 | * fetch and the sleep come in as arguments, nothing is read, posted or global. | |
| 5 | * Times are milliseconds: the clock you pass, or else polls made times the | |
| 6 | * interval. `watchPolls` is the loop for any poll that says healthy or not and | |
| 7 | * what each of its sources said; `watchDrill` is it over one URL. | |
| 8 | */ | |
| 9 | ||
| 10 | /** C0 and C1 control characters out (tab stays): the forge's words never drive the terminal. */ | |
| 11 | export const clean = (s: string): string => s.replace(/[\x00-\x08\x0a-\x1f\x7f-\x9f]/g, ""); | |
| 12 | ||
| 13 | /** Every string in a JSON value, or the text's own lines when it is not JSON. */ | |
| 14 | export function linesOf(text: string, contentType: string): string[] { | |
| 15 | return rawLinesOf(text, contentType).map(clean).filter((l) => l.trim() !== ""); | |
| 16 | } | |
| 17 | ||
| 18 | function rawLinesOf(text: string, contentType: string): string[] { | |
| 19 | if (contentType.includes("json")) { | |
| 20 | try { | |
| 21 | const out: string[] = []; | |
| 22 | const walk = (v: unknown): void => { | |
| 23 | if (typeof v === "string") out.push(v); | |
| 24 | else if (Array.isArray(v)) v.forEach(walk); | |
| 25 | else if (v && typeof v === "object") Object.values(v).forEach(walk); | |
| 26 | }; | |
| 27 | walk(JSON.parse(text)); | |
| 28 | return out; | |
| 29 | } catch { | |
| 30 | /* fall through to plain lines */ | |
| 31 | } | |
| 32 | } | |
| 33 | return text | |
| 34 | .replace(/<(script|style)[\s\S]*?<\/\1>/gi, " ") | |
| 35 | .replace(/<[^>]*>/g, "\n") | |
| 36 | .replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") | |
| 37 | .replace(/"/g, '"').replace(/�?39;/g, "'") | |
| 38 | .split("\n") | |
| 39 | .map((l) => l.trim()) | |
| 40 | .filter((l) => l !== ""); | |
| 41 | } | |
| 42 | ||
| 43 | /** A line with its numbers blanked, so a clock or a counter is not a new line. */ | |
| 44 | const shape = (l: string): string => l.replace(/\d+/g, "#"); | |
| 45 | ||
| 46 | /** The part of a Response that a watch reads. */ | |
| 47 | export interface Reply { | |
| 48 | status: number; | |
| 49 | text: () => Promise<string>; | |
| 50 | headers: { get: (name: string) => string | null }; | |
| 51 | } | |
| 52 | ||
| 53 | /** What a fetch that threw is recorded as saying. */ | |
| 54 | export const UNREACHABLE = "unreachable"; | |
| 55 | ||
| 56 | /** One look at the forge: is it healthy, and what each source said (named by source). */ | |
| 57 | export interface Poll { | |
| 58 | healthy: boolean; | |
| 59 | said: Record<string, string[]>; | |
| 60 | } | |
| 61 | ||
| 62 | export interface Watch { | |
| 63 | poll: () => Promise<Poll>; | |
| 64 | /** Waits one interval. */ | |
| 65 | sleep: (ms: number) => Promise<unknown>; | |
| 66 | /** The most polls to make before giving up. */ | |
| 67 | limit: number; | |
| 68 | intervalMs?: number; | |
| 69 | /** Milliseconds on any clock; without it, time is the polls made times `intervalMs`. */ | |
| 70 | now?: () => number; | |
| 71 | /** Gives up once this many milliseconds have passed, however few polls that took. */ | |
| 72 | ceilingMs?: number; | |
| 73 | /** Called once with each new distinct line and its source, as it is first said. */ | |
| 74 | onLine?: (line: string, source: string) => void; | |
| 75 | } | |
| 76 | ||
| 77 | export type Watched = | |
| 78 | | { timeout: false; startedAt: number; healthyAt: number; said: Record<string, string[]> } | |
| 79 | | { timeout: true; startedAt: number | null; said: Record<string, string[]> }; | |
| 80 | ||
| 81 | /** Polls until healthy again after having been unhealthy; keeps each distinct line said while unhealthy. */ | |
| 82 | export async function watchPolls(w: Watch): Promise<Watched> { | |
| 83 | const intervalMs = w.intervalMs ?? 1_000; | |
| 84 | const seen = new Map<string, Map<string, string>>(); | |
| 85 | const said = (): Record<string, string[]> => | |
| 86 | Object.fromEntries([...seen].map(([source, m]) => [source, [...m.values()]])); | |
| 87 | const began = w.now?.() ?? 0; | |
| 88 | let startedAt: number | null = null; | |
| 89 | for (let n = 0; n < w.limit; n++) { | |
| 90 | const look = await w.poll(); | |
| 91 | const at = w.now ? w.now() - began : n * intervalMs; | |
| 92 | if (look.healthy && startedAt !== null) return { timeout: false, startedAt, healthyAt: at, said: said() }; | |
| 93 | if (!look.healthy) { | |
| 94 | startedAt ??= at; | |
| 95 | for (const [source, lines] of Object.entries(look.said)) { | |
| 96 | const m = seen.get(source) ?? new Map<string, string>(); | |
| 97 | seen.set(source, m); | |
| 98 | for (const l of lines) if (!m.has(shape(l))) { m.set(shape(l), l); w.onLine?.(l, source); } | |
| 99 | } | |
| 100 | } | |
| 101 | if (w.ceilingMs !== undefined && at >= w.ceilingMs) break; | |
| 102 | await w.sleep(intervalMs); | |
| 103 | } | |
| 104 | return { timeout: true, startedAt, said: said() }; | |
| 105 | } | |
| 106 | ||
| 107 | export interface WatchDrill extends Omit<Watch, "poll" | "onLine"> { | |
| 108 | url: string; | |
| 109 | fetch: (url: string) => Promise<Reply>; | |
| 110 | onLine?: (line: string) => void; | |
| 111 | } | |
| 112 | ||
| 113 | export type WatchedDrill = | |
| 114 | | { timeout: false; startedAt: number; healthyAt: number; lines: string[] } | |
| 115 | | { timeout: true; startedAt: number | null; lines: string[] }; | |
| 116 | ||
| 117 | /** `watchPolls` over one URL: healthy is a status under 400, and the lines are its body's. */ | |
| 118 | export async function watchDrill(w: WatchDrill): Promise<WatchedDrill> { | |
| 119 | const poll = async (): Promise<Poll> => { | |
| 120 | try { | |
| 121 | const res = await w.fetch(w.url); | |
| 122 | if (res.status < 400) return { healthy: true, said: {} }; | |
| 123 | let lines: string[] = []; | |
| 124 | try { | |
| 125 | lines = linesOf(await res.text(), res.headers.get("content-type") ?? ""); | |
| 126 | } catch { | |
| 127 | /* an unreadable body is unhealthy and says nothing */ | |
| 128 | } | |
| 129 | return { healthy: false, said: { "": lines } }; | |
| 130 | } catch { | |
| 131 | return { healthy: false, said: { "": [UNREACHABLE] } }; | |
| 132 | } | |
| 133 | }; | |
| 134 | const { url: _url, fetch: _fetch, onLine, ...rest } = w; | |
| 135 | const r = await watchPolls({ ...rest, poll, ...(onLine && { onLine: (l: string) => onLine(l) }) }); | |
| 136 | const lines = r.said[""] ?? []; | |
| 137 | return r.timeout | |
| 138 | ? { timeout: true, startedAt: r.startedAt, lines } | |
| 139 | : { timeout: false, startedAt: r.startedAt, healthyAt: r.healthyAt, lines }; | |
| 140 | } |
scripts/flake-hunt.ts +112 −6
| @@ -9,6 +9,12 @@ | ||
| 9 | 9 | * `--file-issues` each gets an issue carrying the failure text, the |
| 10 | 10 | * seed and the run (FORGE_API, FORGE_REPO, FORGE_TOKEN; FORGE_RUN_URL |
| 11 | 11 | * names the run). Exits 1 when it found one. |
| 12 | * bun scripts/flake-hunt.ts outside [--file-issues] | |
| 13 | * Every file in EXCLUDED once, one at a time, with the gate's timeout: | |
| 14 | * the schedule for what the gate does not run (#812). With | |
| 15 | * `--file-issues` a newly red file opens one `Red: test/….test.ts` | |
| 16 | * issue naming its first failing test, a file red on another test | |
| 17 | * updates it, and a file green again closes it. | |
| 12 | 18 | * bun scripts/flake-hunt.ts quarantine |
| 13 | 19 | * Puts every file an open `Flaky: test/….test.ts` issue the hunt filed |
| 14 | 20 | * (author is the token's user, not untrusted) names into |
| @@ -30,12 +36,16 @@ import { | ||
| 30 | 36 | huntedFile, |
| 31 | 37 | HUNT_RUNS, |
| 32 | 38 | issueFor, |
| 39 | redActions, | |
| 40 | redFile, | |
| 41 | redIssueFor, | |
| 42 | type OpenRed, | |
| 33 | 43 | quarantineFlaky, |
| 34 | 44 | readRun, |
| 35 | 45 | shuffled, |
| 36 | 46 | type FileRun, |
| 37 | 47 | } from "./flake.ts"; |
| 38 | import { gateFiles, presentTests } from "./gate.ts"; | |
| 48 | import { gateFiles, outsideFiles, presentTests } from "./gate.ts"; | |
| 39 | 49 | import { parseShardSpec, sliceFiles } from "./shards.ts"; |
| 40 | 50 | |
| 41 | 51 | const GATE_FILE = new URL("./gate.ts", import.meta.url).pathname; |
| @@ -66,7 +76,7 @@ function runsOf(fallback: number): number { | ||
| 66 | 76 | |
| 67 | 77 | const freshSeed = (): number => crypto.getRandomValues(new Uint32Array(1))[0]!; |
| 68 | 78 | |
| 69 | /** One file, one seed, one `bun test`: the gate's own unit. */ | |
| 79 | /** One file, one `bun test`: the gate's own unit (random order when seeded). */ | |
| 70 | 80 | async function runFile(file: string, seed: number): Promise<FileRun> { |
| 71 | 81 | const env = { ...process.env }; |
| 72 | 82 | // The gate hands its children none of these (scripts/test-shards.ts). |
| @@ -84,8 +94,7 @@ async function runFile(file: string, seed: number): Promise<FileRun> { | ||
| 84 | 94 | "test", |
| 85 | 95 | "--timeout", |
| 86 | 96 | "60000", |
| 87 | "--randomize", | |
| 88 | `--seed=${seed}`, | |
| 97 | ...(seed === 0 ? [] : ["--randomize", `--seed=${seed}`]), | |
| 89 | 98 | `./${file}`, |
| 90 | 99 | ], |
| 91 | 100 | { stdin: "ignore", stdout: "pipe", stderr: "pipe", env }, |
| @@ -171,6 +180,102 @@ async function openFlakyIssues(): Promise<Map<string, number>> { | ||
| 171 | 180 | return found; |
| 172 | 181 | } |
| 173 | 182 | |
| 183 | async function forgeJson( | |
| 184 | method: string, | |
| 185 | path: string, | |
| 186 | body: unknown, | |
| 187 | ): Promise<Response> { | |
| 188 | const { api, repo, token } = forgeApi(); | |
| 189 | return fetch(`${api}/repos/${repo}${path}`, { | |
| 190 | method, | |
| 191 | headers: { | |
| 192 | authorization: `Bearer ${token}`, | |
| 193 | "content-type": "application/json", | |
| 194 | }, | |
| 195 | body: JSON.stringify(body), | |
| 196 | }); | |
| 197 | } | |
| 198 | ||
| 199 | /** Open `Red:` issues the scheduled run filed: file → issue. */ | |
| 200 | async function openRedIssues(): Promise<Map<string, OpenRed>> { | |
| 201 | const { api, repo, token } = forgeApi(); | |
| 202 | const self = await huntUserId(api, token); | |
| 203 | const found = new Map<string, OpenRed>(); | |
| 204 | let cursor: string | number | null = ""; | |
| 205 | for (let page = 0; page < 100 && cursor !== null; page++) { | |
| 206 | const query = cursor === "" ? "" : `&cursor=${encodeURIComponent(cursor)}`; | |
| 207 | const res = await fetch( | |
| 208 | `${api}/repos/${repo}/issues?state=open&limit=100${query}`, | |
| 209 | { headers: { authorization: `Bearer ${token}` } }, | |
| 210 | ); | |
| 211 | if (!res.ok) fail(`listing issues: ${res.status}`); | |
| 212 | const body = (await res.json()) as { | |
| 213 | items?: { | |
| 214 | number?: unknown; | |
| 215 | title?: unknown; | |
| 216 | body?: unknown; | |
| 217 | authorId?: unknown; | |
| 218 | untrusted?: unknown; | |
| 219 | }[]; | |
| 220 | next_cursor?: string | number | null; | |
| 221 | }; | |
| 222 | for (const issue of body.items ?? []) { | |
| 223 | if (issue.authorId !== self || (issue.untrusted ?? false) !== false) | |
| 224 | continue; | |
| 225 | const file = | |
| 226 | typeof issue.title === "string" ? redFile(issue.title) : undefined; | |
| 227 | if (file !== undefined && Number.isInteger(issue.number)) | |
| 228 | found.set(file, { | |
| 229 | number: issue.number as number, | |
| 230 | ...(typeof issue.body === "string" ? { body: issue.body } : {}), | |
| 231 | }); | |
| 232 | } | |
| 233 | cursor = body.next_cursor ?? null; | |
| 234 | } | |
| 235 | return found; | |
| 236 | } | |
| 237 | ||
| 238 | /** The files outside the gate, one at a time; red ones reported to the forge. */ | |
| 239 | async function outside(): Promise<number> { | |
| 240 | const files = outsideFiles(presentTests()); | |
| 241 | const fileIssues = rest.includes("--file-issues"); | |
| 242 | const open = fileIssues ? await openRedIssues() : new Map<string, OpenRed>(); | |
| 243 | const runs: FileRun[] = []; | |
| 244 | for (const file of files) { | |
| 245 | const run = await runFile(file, 0); | |
| 246 | console.log(`${run.green ? "green" : "red "}: ${file}`); | |
| 247 | runs.push(run); | |
| 248 | } | |
| 249 | const where = process.env["FORGE_RUN_URL"] ?? ""; | |
| 250 | for (const action of redActions(runs, open)) { | |
| 251 | if (!fileIssues) continue; | |
| 252 | if (action.kind === "open") { | |
| 253 | const res = await forgeJson("POST", "/issues", redIssueFor(action.run, where)); | |
| 254 | const made = (await res.json().catch(() => ({}))) as { number?: unknown }; | |
| 255 | if (!res.ok) fail(`filing the issue for ${action.run.file}: ${res.status}`); | |
| 256 | console.log(` filed #${made.number} for ${action.run.file}`); | |
| 257 | } else if (action.kind === "update") { | |
| 258 | const { body } = redIssueFor(action.run, where); | |
| 259 | const res = await forgeJson("PATCH", `/issues/${action.number}`, { body }); | |
| 260 | if (!res.ok) { | |
| 261 | const note = await forgeJson("POST", `/issues/${action.number}/comments`, { body }); | |
| 262 | if (!note.ok) fail(`updating #${action.number}: ${note.status}`); | |
| 263 | } | |
| 264 | console.log(` updated #${action.number} for ${action.run.file}`); | |
| 265 | } else { | |
| 266 | await forgeJson("POST", `/issues/${action.number}/comments`, { | |
| 267 | body: `\`${action.file}\` passed in the scheduled run${where ? ` (${where})` : ""}.`, | |
| 268 | }); | |
| 269 | const res = await forgeJson("PATCH", `/issues/${action.number}`, { state: "closed" }); | |
| 270 | if (!res.ok) fail(`closing #${action.number}: ${res.status}`); | |
| 271 | console.log(` closed #${action.number} for ${action.file}`); | |
| 272 | } | |
| 273 | } | |
| 274 | const red = runs.filter((run) => !run.green).length; | |
| 275 | console.log(`${files.length} files outside the gate, ${red} red`); | |
| 276 | return 0; | |
| 277 | } | |
| 278 | ||
| 174 | 279 | async function hunt(): Promise<number> { |
| 175 | 280 | const runs = runsOf(HUNT_RUNS); |
| 176 | 281 | const spec = parseShardSpec(valueOf("--shard")); |
| @@ -263,10 +368,11 @@ async function admit(): Promise<number> { | ||
| 263 | 368 | return admitted(results) ? 0 : 1; |
| 264 | 369 | } |
| 265 | 370 | |
| 266 | if (mode === "hunt") process.exit(await hunt()); | |
| 371 | if (mode === "outside") process.exit(await outside()); | |
| 372 | else if (mode === "hunt") process.exit(await hunt()); | |
| 267 | 373 | else if (mode === "quarantine") process.exit(await quarantineAll()); |
| 268 | 374 | else if (mode === "admit") process.exit(await admit()); |
| 269 | 375 | else |
| 270 | 376 | fail( |
| 271 | "usage: flake-hunt.ts hunt [--runs N] [--shard N/M] [--file-issues] | quarantine | admit test/x.test.ts [--runs N]", | |
| 377 | "usage: flake-hunt.ts hunt [--runs N] [--shard N/M] [--file-issues] | outside [--file-issues] | quarantine | admit test/x.test.ts [--runs N]", | |
| 272 | 378 | ); |
scripts/flake.ts +72
| @@ -21,6 +21,9 @@ export const HUNT_RUNS = 5; | ||
| 21 | 21 | /** The title prefix of the issue the hunt files; quarantine reads it back. */ |
| 22 | 22 | export const FLAKY_TITLE = "Flaky: "; |
| 23 | 23 | |
| 24 | /** The title prefix of the issue the scheduled run files for a red file. */ | |
| 25 | export const RED_TITLE = "Red: "; | |
| 26 | ||
| 24 | 27 | /** |
| 25 | 28 | * The least a test may bound a request or a spawn by, in wall-clock ms. A |
| 26 | 29 | * loopback fetch on a loaded shared runner takes tens of milliseconds; a |
| @@ -321,3 +324,72 @@ export function shuffled(files: readonly string[], seed: number): string[] { | ||
| 321 | 324 | } |
| 322 | 325 | return out; |
| 323 | 326 | } |
| 327 | ||
| 328 | /** The first failing test's name as the issue words it. */ | |
| 329 | export function firstFailing(run: FileRun): string { | |
| 330 | return run.failing[0] !== undefined | |
| 331 | ? `\`${run.failing[0]}\`` | |
| 332 | : "none named; the file failed outside a test"; | |
| 333 | } | |
| 334 | ||
| 335 | /** The issue for a file outside the gate that is red. */ | |
| 336 | export function redIssueFor( | |
| 337 | run: FileRun, | |
| 338 | where: string, | |
| 339 | ): { title: string; body: string } { | |
| 340 | const fence = "```"; | |
| 341 | return { | |
| 342 | title: `${RED_TITLE}${run.file}`, | |
| 343 | body: [ | |
| 344 | `\`${run.file}\` is outside the gate and was red in the scheduled run. This issue closes itself when the file passes again.`, | |
| 345 | "", | |
| 346 | `- first failing test: ${firstFailing(run)}`, | |
| 347 | `- run: ${where || "(a local run)"}`, | |
| 348 | `- reproduce: \`bun test --timeout 60000 ./${run.file}\``, | |
| 349 | "", | |
| 350 | fence, | |
| 351 | run.text || "(no failure text captured)", | |
| 352 | fence, | |
| 353 | ].join("\n"), | |
| 354 | }; | |
| 355 | } | |
| 356 | ||
| 357 | /** The file an issue titled `Red: …` names, or undefined. */ | |
| 358 | export function redFile(title: string): string | undefined { | |
| 359 | if (!title.startsWith(RED_TITLE)) return undefined; | |
| 360 | return flakyFile(`${FLAKY_TITLE}${title.slice(RED_TITLE.length)}`); | |
| 361 | } | |
| 362 | ||
| 363 | /** An open `Red:` issue as the scheduled run sees it. */ | |
| 364 | export type OpenRed = { number: number; body?: string }; | |
| 365 | ||
| 366 | export type RedAction = | |
| 367 | | { kind: "open"; run: FileRun } | |
| 368 | | { kind: "update"; run: FileRun; number: number } | |
| 369 | | { kind: "close"; file: string; number: number }; | |
| 370 | ||
| 371 | /** | |
| 372 | * What the forge must be told after a scheduled run: a newly red file opens | |
| 373 | * one issue; a file still red whose first failing test is not in its issue | |
| 374 | * updates that issue; a file green again closes it. A file that stays red | |
| 375 | * on the same test says nothing, so the asks show it once. | |
| 376 | */ | |
| 377 | export function redActions( | |
| 378 | runs: readonly FileRun[], | |
| 379 | open: ReadonlyMap<string, OpenRed>, | |
| 380 | ): RedAction[] { | |
| 381 | const actions: RedAction[] = []; | |
| 382 | for (const run of runs) { | |
| 383 | const issue = open.get(run.file); | |
| 384 | if (run.green) { | |
| 385 | if (issue) | |
| 386 | actions.push({ kind: "close", file: run.file, number: issue.number }); | |
| 387 | } else if (!issue) actions.push({ kind: "open", run }); | |
| 388 | else if ( | |
| 389 | typeof issue.body === "string" && | |
| 390 | !issue.body.includes(firstFailing(run)) | |
| 391 | ) | |
| 392 | actions.push({ kind: "update", run, number: issue.number }); | |
| 393 | } | |
| 394 | return actions; | |
| 395 | } |
scripts/gate.ts +17
| @@ -15,6 +15,9 @@ | ||
| 15 | 15 | * order under load (`bun scripts/flake-hunt.ts admit <file>`). A flaky file |
| 16 | 16 | * leaves it only through `bun scripts/flake-hunt.ts quarantine`, under the |
| 17 | 17 | * issue the nightly hunt filed (`.forgejo/workflows/flake-hunt.yml`). |
| 18 | * | |
| 19 | * The excluded files are not unseen: `.forgejo/workflows/outside-gate.yml` | |
| 20 | * runs `outsideFiles` nightly and files a `Red:` issue for each that fails (#812). | |
| 18 | 21 | */ |
| 19 | 22 | |
| 20 | 23 | import { readdirSync } from "node:fs"; |
| @@ -135,6 +138,20 @@ export function gateFiles( | ||
| 135 | 138 | return present.filter((file) => !out.has(file)); |
| 136 | 139 | } |
| 137 | 140 | |
| 141 | /** | |
| 142 | * What the scheduled run runs (`bun scripts/flake-hunt.ts outside`): the | |
| 143 | * present files `EXCLUDED` names, in name order. Derived from the same list | |
| 144 | * as `gateFiles`, so a file is in exactly one of the two: moving it into | |
| 145 | * the gate stops the schedule running it. | |
| 146 | */ | |
| 147 | export function outsideFiles( | |
| 148 | present: readonly string[] = presentTests(), | |
| 149 | excluded: readonly Exclusion[] = EXCLUDED, | |
| 150 | ): string[] { | |
| 151 | const gated = new Set(gateFiles(present, excluded)); | |
| 152 | return present.filter((file) => !gated.has(file)); | |
| 153 | } | |
| 154 | ||
| 138 | 155 | /** |
| 139 | 156 | * The gate a checked-out tree's own `scripts/gate.ts` names, whichever shape |
| 140 | 157 | * it has: a tree from before #2071 lists it in `GATE`; a later one derives |
scripts/pin-migrations.ts added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | /** | |
| 2 | * `bun scripts/pin-migrations.ts`: pin every migration in | |
| 3 | * `src/core/migrations/` that `src/core/migration-pins.json` does not | |
| 4 | * name yet, by its id (`migrationId`). An existing pin is never rewritten: | |
| 5 | * once pinned, a migration's text is history, and | |
| 6 | * `test/schema-migrations.test.ts` fails if it changes (#783). | |
| 7 | * | |
| 8 | * The release agent runs it when it cuts a release, beside bumping | |
| 9 | * `src/core/releases.json`, so pulls never touch the file and two of them | |
| 10 | * adding a migration each never meet in it. | |
| 11 | */ | |
| 12 | ||
| 13 | import { readFileSync, writeFileSync } from "node:fs"; | |
| 14 | import { join } from "node:path"; | |
| 15 | import { migrationFiles } from "../src/core/schema.ts"; | |
| 16 | import { type MigrationPins, pinBreaks, pinned } from "../src/kernel/migrate.ts"; | |
| 17 | ||
| 18 | const file = join(import.meta.dir, "../src/core/migration-pins.json"); | |
| 19 | const pins = JSON.parse(readFileSync(file, "utf8")) as MigrationPins; | |
| 20 | const breaks = pinBreaks(migrationFiles, pins); | |
| 21 | if (breaks.length > 0) { | |
| 22 | for (const line of breaks) console.error(`pin-migrations: ${line}`); | |
| 23 | process.exit(1); | |
| 24 | } | |
| 25 | const next = pinned(migrationFiles, pins); | |
| 26 | const added = Object.keys(next).filter((name) => !(name in pins)); | |
| 27 | writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`); | |
| 28 | // CLI answer for the person at the terminal. | |
| 29 | console.log( | |
| 30 | added.length === 0 | |
| 31 | ? "pin-migrations: every migration is already pinned" | |
| 32 | : `pin-migrations: pinned ${added.join(", ")}`, | |
| 33 | ); |
scripts/shards.ts +34
| @@ -495,3 +495,37 @@ export function besideOthers( | ||
| 495 | 495 | const line = `fails only beside others: ${cleared.join(", ")} (shard-mates: ${mates})`; |
| 496 | 496 | return { red: stillRed, besideOthers: cleared, line }; |
| 497 | 497 | } |
| 498 | ||
| 499 | const HEADER = /^(\S+\.test\.[cm]?[jt]sx?):$/; | |
| 500 | const FAILED = /^\(fail\) /; | |
| 501 | ||
| 502 | /** | |
| 503 | * The files whose tests failed in one `bun test` report, in the order the | |
| 504 | * report reached them: bun prints `path:` before a file's tests and | |
| 505 | * `(fail) name` for each one that failed. A report that names no failing | |
| 506 | * test (a crash, a file that would not load) yields none. | |
| 507 | */ | |
| 508 | export function failingFiles(output: string): string[] { | |
| 509 | const found: string[] = []; | |
| 510 | let current: string | undefined; | |
| 511 | for (const line of output.split("\n")) { | |
| 512 | const header = HEADER.exec(line); | |
| 513 | if (header) current = header[1]!; | |
| 514 | else if (FAILED.test(line) && current !== undefined && !found.includes(current)) | |
| 515 | found.push(current); | |
| 516 | } | |
| 517 | return found; | |
| 518 | } | |
| 519 | ||
| 520 | /** | |
| 521 | * Whether every red in one `bun test` report is a failed test: the report | |
| 522 | * reached its summary, counts no errors, and has no unhandled error between | |
| 523 | * tests. A file that threw while loading, an error outside any test, or a | |
| 524 | * crash before the summary is red for a reason a second run alone does not | |
| 525 | * explain away, so nothing in such a report may be cleared. | |
| 526 | */ | |
| 527 | export function onlyTestFailures(output: string): boolean { | |
| 528 | if (!/^Ran \d+ tests? across \d+ files?/m.test(output)) return false; | |
| 529 | if (/^# Unhandled error/m.test(output)) return false; | |
| 530 | return !/^\s*[1-9][0-9]* errors?\s*$/m.test(output); | |
| 531 | } |
scripts/smoke.ts +83 −6
| @@ -4,6 +4,11 @@ | ||
| 4 | 4 | * `/healthz` is fetched once per user agent and must answer 200; the |
| 5 | 5 | * stylesheet linked from `/` must be a non-empty `text/css` 200. Exit 1 and |
| 6 | 6 | * one line per failure (url, user agent, status, content type) otherwise. |
| 7 | * | |
| 8 | * `--version <v>` first polls `/healthz` until `X-Forge-Version` is `<v>`, for | |
| 9 | * at most `--wait <seconds>` (default 120), and fails naming the last version | |
| 10 | * seen. `/sw.js` must be a 200 JavaScript response whose navigation branch | |
| 11 | * never answers with `Response.error()`. | |
| 7 | 12 | */ |
| 8 | 13 | |
| 9 | 14 | export const AGENTS: Record<string, string> = { |
| @@ -32,11 +37,61 @@ export function stylesheetHref(html: string): string | null { | ||
| 32 | 37 | return null; |
| 33 | 38 | } |
| 34 | 39 | |
| 40 | /** The part of a service worker that answers navigations, comments removed. */ | |
| 41 | export function navigationBranch(source: string): string | null { | |
| 42 | const at = source.search(/mode\s*===?\s*["']navigate["']/); | |
| 43 | if (at < 0) return null; | |
| 44 | const rest = source.slice(at); | |
| 45 | const end = rest.search(/\breturn\s*;/); | |
| 46 | return (end < 0 ? rest : rest.slice(0, end)).replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); | |
| 47 | } | |
| 48 | ||
| 49 | export interface Options { | |
| 50 | /** Wait for `/healthz` to report this `X-Forge-Version`. */ | |
| 51 | version?: string | undefined; | |
| 52 | /** Longest wait for it, in milliseconds. */ | |
| 53 | waitMs?: number; | |
| 54 | /** Pause between polls, in milliseconds. */ | |
| 55 | pollMs?: number; | |
| 56 | /** The fetch to use; the global one unless a caller (the forge proving itself, a test) gives its own. */ | |
| 57 | fetch?: typeof fetch; | |
| 58 | /** Accept a redirect from `/board` as the sign-in it is, for a caller holding no token. */ | |
| 59 | anonymousBoard?: boolean; | |
| 60 | } | |
| 61 | ||
| 62 | /** Polls `/healthz` until it reports `version`; null when it does, else a failure. */ | |
| 63 | async function waitForVersion(root: URL, version: string, waitMs: number, pollMs: number, fetch: typeof globalThis.fetch): Promise<Failure | null> { | |
| 64 | const url = new URL("/healthz", root).href; | |
| 65 | const deadline = Date.now() + waitMs; | |
| 66 | let seen = "none"; | |
| 67 | let status: number | string = "error"; | |
| 68 | for (;;) { | |
| 69 | try { | |
| 70 | const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(5_000) }); | |
| 71 | await res.body?.cancel(); | |
| 72 | status = res.status; | |
| 73 | seen = res.headers.get("x-forge-version") ?? "none"; | |
| 74 | if (seen === version) return null; | |
| 75 | } catch (error) { | |
| 76 | status = "error"; | |
| 77 | seen = `none (${error})`; | |
| 78 | } | |
| 79 | if (Date.now() + pollMs > deadline) break; | |
| 80 | await Bun.sleep(pollMs); | |
| 81 | } | |
| 82 | return { url, agent: "-", status, contentType: "", why: `wanted X-Forge-Version ${version}, last saw ${seen}` }; | |
| 83 | } | |
| 84 | ||
| 35 | 85 | /** Runs every check against `base`; returns the failures (empty is green). */ |
| 36 | export async function smoke(base: string, token = ""): Promise<Failure[]> { | |
| 86 | export async function smoke(base: string, token = "", options: Options = {}): Promise<Failure[]> { | |
| 37 | 87 | const root = new URL(base); |
| 38 | 88 | if (root.protocol !== "http:" && root.protocol !== "https:") throw new Error("url must be http(s)"); |
| 39 | 89 | const failures: Failure[] = []; |
| 90 | const fetch = options.fetch ?? globalThis.fetch; | |
| 91 | if (options.version) { | |
| 92 | const late = await waitForVersion(root, options.version, options.waitMs ?? 120_000, options.pollMs ?? 1_000, fetch); | |
| 93 | if (late) return [late]; | |
| 94 | } | |
| 40 | 95 | |
| 41 | 96 | const get = async (path: string, agent: string, headers: Record<string, string> = {}) => { |
| 42 | 97 | const url = new URL(path, root).href; |
| @@ -75,20 +130,42 @@ export async function smoke(base: string, token = ""): Promise<Failure[]> { | ||
| 75 | 130 | } |
| 76 | 131 | } |
| 77 | 132 | const board = await get("/board", agent, auth); |
| 78 | if (board && board.res.status !== 200) fail(board.url, agent, board.res, "expected 200 (a 302 is sign-in)"); | |
| 133 | const signIn = options.anonymousBoard && !token && board?.res.status === 302; | |
| 134 | if (board && board.res.status !== 200 && !signIn) fail(board.url, agent, board.res, "expected 200 (a 302 is sign-in)"); | |
| 79 | 135 | const health = await get("/healthz", agent); |
| 80 | 136 | if (health && health.res.status !== 200) fail(health.url, agent, health.res, "expected 200"); |
| 81 | 137 | } |
| 138 | ||
| 139 | const sw = await get("/sw.js", "desktop"); | |
| 140 | if (sw) { | |
| 141 | const type = sw.res.headers.get("content-type") ?? ""; | |
| 142 | if (sw.res.status !== 200) fail(sw.url, "desktop", sw.res, "expected 200"); | |
| 143 | else if (!/^(text|application)\/(x-)?javascript\b/i.test(type)) fail(sw.url, "desktop", sw.res, "expected javascript"); | |
| 144 | else { | |
| 145 | const nav = navigationBranch(sw.body); | |
| 146 | if (nav === null) fail(sw.url, "desktop", sw.res, "no navigation branch"); | |
| 147 | else if (/Response\s*\.\s*error\s*\(/.test(nav)) fail(sw.url, "desktop", sw.res, "a navigation can be answered with Response.error()"); | |
| 148 | } | |
| 149 | } | |
| 82 | 150 | return failures; |
| 83 | 151 | } |
| 84 | 152 | |
| 85 | 153 | if (import.meta.main) { |
| 86 | const base = process.argv[2]; | |
| 87 | if (!base) { | |
| 88 | console.error("usage: bun scripts/smoke.ts <url>"); | |
| 154 | const args = process.argv.slice(2); | |
| 155 | const flag = (name: string) => { | |
| 156 | const i = args.indexOf(name); | |
| 157 | if (i < 0) return undefined; | |
| 158 | return args.splice(i, 2)[1]; | |
| 159 | }; | |
| 160 | const version = flag("--version"); | |
| 161 | const wait = flag("--wait"); | |
| 162 | const base = args[0]; | |
| 163 | const seconds = wait === undefined ? 120 : Number(wait); | |
| 164 | if (!base || !Number.isFinite(seconds) || seconds < 0 || args.length > 1) { | |
| 165 | console.error("usage: bun scripts/smoke.ts <url> [--version <v>] [--wait <seconds>]"); | |
| 89 | 166 | process.exit(2); |
| 90 | 167 | } |
| 91 | const failures = await smoke(base, process.env["FORGE_TOKEN"] ?? ""); | |
| 168 | const failures = await smoke(base, process.env["FORGE_TOKEN"] ?? "", { version, waitMs: seconds * 1000 }); | |
| 92 | 169 | for (const f of failures) |
| 93 | 170 | console.error(`FAIL ${f.url} agent=${f.agent} status=${f.status} content-type=${f.contentType || "-"} (${f.why})`); |
| 94 | 171 | if (failures.length) process.exit(1); |
scripts/test-shards.ts +59 −18
| @@ -58,6 +58,9 @@ import { | ||
| 58 | 58 | splitCapped, |
| 59 | 59 | tally, |
| 60 | 60 | weigher, |
| 61 | besideOthers, | |
| 62 | failingFiles, | |
| 63 | onlyTestFailures, | |
| 61 | 64 | type ShardResult, |
| 62 | 65 | } from "./shards.ts"; |
| 63 | 66 | import { gateFiles, gateRefusal, presentTests } from "./gate.ts"; |
| @@ -425,6 +428,12 @@ async function drain( | ||
| 425 | 428 | const measured = new Map<string, string[]>(); |
| 426 | 429 | const unmeasured = new Set<string>(); |
| 427 | 430 | |
| 431 | // Failing files a red shard re-runs alone. | |
| 432 | const RETRY_CAP = 3; | |
| 433 | // Set once any shard has gone red for good: no retries then. | |
| 434 | let halted = false; | |
| 435 | const stopping = (): boolean => halted; | |
| 436 | ||
| 428 | 437 | type Timed = ShardResult & { seconds: number }; |
| 429 | 438 | |
| 430 | 439 | /** |
| @@ -463,29 +472,60 @@ async function runShard( | ||
| 463 | 472 | const cover = covDir |
| 464 | 473 | ? ["--coverage", "--coverage-reporter=lcov", `--coverage-dir=${covDir}`] |
| 465 | 474 | : []; |
| 466 | const child = Bun.spawn(["bun", "test", ...flags, ...cover, ...paths], { | |
| 467 | stdin: "ignore", | |
| 468 | stdout: "pipe", | |
| 469 | stderr: "pipe", | |
| 470 | // Bun keeps shell-inherited vars after `delete process.env.X`; pass a | |
| 471 | // cleaned env so capacityDefault does not see the gate's shard count. | |
| 472 | env: childEnv, | |
| 473 | }); | |
| 474 | children.add(child); | |
| 475 | // One buffer, in arrival order: `bun test` splits its report across | |
| 476 | // stdout and stderr. | |
| 477 | const output: string[] = []; | |
| 475 | const run = async ( | |
| 476 | targets: string[], | |
| 477 | extra: string[], | |
| 478 | through?: (text: string) => void, | |
| 479 | ): Promise<{ code: number; output: string[] }> => { | |
| 480 | const child = Bun.spawn(["bun", "test", ...flags, ...extra, ...targets], { | |
| 481 | stdin: "ignore", | |
| 482 | stdout: "pipe", | |
| 483 | stderr: "pipe", | |
| 484 | // Bun keeps shell-inherited vars after `delete process.env.X`; pass a | |
| 485 | // cleaned env so capacityDefault does not see the gate's shard count. | |
| 486 | env: childEnv, | |
| 487 | }); | |
| 488 | children.add(child); | |
| 489 | // One buffer, in arrival order: `bun test` splits its report across | |
| 490 | // stdout and stderr. | |
| 491 | const output: string[] = []; | |
| 492 | await Promise.all([ | |
| 493 | drain(child.stdout, output, through), | |
| 494 | drain(child.stderr, output, through), | |
| 495 | ]); | |
| 496 | const code = await child.exited; | |
| 497 | children.delete(child); | |
| 498 | return { code, output }; | |
| 499 | }; | |
| 478 | 500 | const through = live |
| 479 | 501 | ? (text: string): void => { |
| 480 | 502 | process.stdout.write(text); |
| 481 | 503 | } |
| 482 | 504 | : undefined; |
| 483 | await Promise.all([ | |
| 484 | drain(child.stdout, output, through), | |
| 485 | drain(child.stderr, output, through), | |
| 486 | ]); | |
| 487 | const code = await child.exited; | |
| 488 | children.delete(child); | |
| 505 | const first = await run(paths, cover, through); | |
| 506 | let code = first.code; | |
| 507 | const output = first.output; | |
| 508 | // A sliced shard that went red: each failing file (the first few) is run | |
| 509 | // once alone, same env and flags. One that passes alone was red only | |
| 510 | // beside others, and does not fail the check; the log names it. | |
| 511 | if (code !== 0 && slice.length > 1 && !covDir && !stopping()) { | |
| 512 | const report = output.join(""); | |
| 513 | // A file that errored (not a failed test) keeps the whole shard red. | |
| 514 | const red = onlyTestFailures(report) ? failingFiles(report) : []; | |
| 515 | const alone = new Map<string, boolean>(); | |
| 516 | for (const file of red.slice(0, RETRY_CAP)) { | |
| 517 | if (stopping()) break; | |
| 518 | const again = await run([file.startsWith("/") || file.startsWith("./") ? file : `./${file}`], []); | |
| 519 | alone.set(file, again.code === 0); | |
| 520 | } | |
| 521 | const verdict = besideOthers(red, alone, RETRY_CAP); | |
| 522 | if (verdict.line !== null) { | |
| 523 | const note = `${output.join("").endsWith("\n") ? "" : "\n"}test-shards: ${verdict.line}\n`; | |
| 524 | output.push(note); | |
| 525 | through?.(note); | |
| 526 | if (verdict.red.length === 0) code = 0; | |
| 527 | } | |
| 528 | } | |
| 489 | 529 | const text = output.join(""); |
| 490 | 530 | const seconds = ((performance.now() - started) / 1000).toFixed(1); |
| 491 | 531 | process.stdout.write( |
| @@ -524,6 +564,7 @@ async function pool(width: number): Promise<Timed[]> { | ||
| 524 | 564 | results[index] = await runShard(slices[index]!, index, width === 1); |
| 525 | 565 | if (results[index]!.code !== 0) { |
| 526 | 566 | stop = true; |
| 567 | halted = true; | |
| 527 | 568 | for (const child of children) child.kill("SIGTERM"); |
| 528 | 569 | } |
| 529 | 570 | } |
src/actions/accounts.ts +2 −1
| @@ -18,6 +18,7 @@ import { appsRuntime } from "../apps/runtime.ts"; | ||
| 18 | 18 | import { |
| 19 | 19 | dropCredentials, |
| 20 | 20 | listAccounts as readAccounts, |
| 21 | originOf, | |
| 21 | 22 | scrubAccount, |
| 22 | 23 | setSuspended, |
| 23 | 24 | type AccountRow, |
| @@ -331,7 +332,7 @@ export function newAccountLines( | ||
| 331 | 332 | const id = `account:${a.username}`; |
| 332 | 333 | return { |
| 333 | 334 | id, |
| 334 | line: `${a.username} joined the forge${a.via === null ? "" : ` (${a.via})`}`, | |
| 335 | line: `${a.username} joined the forge${originOf(a) === "" ? "" : ` (${originOf(a)})`}`, | |
| 335 | 336 | items: [id], |
| 336 | 337 | }; |
| 337 | 338 | }); |
src/actions/issues.ts +8 −2
| @@ -55,6 +55,7 @@ import { | ||
| 55 | 55 | unadoptedAmong, |
| 56 | 56 | } from "../crew/direct.ts"; |
| 57 | 57 | import { laterGate } from "../crew/later.ts"; |
| 58 | import { replanSince } from "../core/limb_replan.ts"; | |
| 58 | 59 | import { handedOver } from "../crew/parks.ts"; |
| 59 | 60 | import { alreadyDone, CLOSED_NOTE } from "../core/done_on_main.ts"; |
| 60 | 61 | import { listReviews } from "../core/reviews.ts"; |
| @@ -511,17 +512,22 @@ function handedToPlanner( | ||
| 511 | 512 | // Replacements must be newer than the hand-over when the crew handed the |
| 512 | 513 | // issue over, and newer than the issue itself when the planner re-plans it |
| 513 | 514 | // 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) ?? { | |
| 515 | 519 | at: issue.createdAt, |
| 516 | 520 | id: issue.id, |
| 517 | 521 | }; |
| 522 | const since = limbSince !== undefined && limbSince > own.at ? { at: limbSince, id: "" } : own; | |
| 518 | 523 | // Done on main: only when commits already name the issue, as the close |
| 519 | 524 | // note keeps them. |
| 520 | 525 | if (closing.reason === undefined || closing.reason === "done") |
| 521 | 526 | return (alreadyDone(app.db, [issue.id]).get(issue.id) ?? []).length > 0; |
| 522 | 527 | // Dropped only when no person's ask stands in it — not one the planner |
| 523 | 528 | // 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); | |
| 525 | 531 | if (closing.reason !== "split") return false; |
| 526 | 532 | const into = closing.into ?? []; |
| 527 | 533 | 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 { | ||
| 21 | 21 | deleteToken as revokeToken, |
| 22 | 22 | type TokenScope, |
| 23 | 23 | } from "../core/tokens.ts"; |
| 24 | import { inviteSpentBy } from "../core/invites.ts"; | |
| 24 | 25 | import { |
| 25 | 26 | changePassword as rehash, |
| 26 | 27 | cleanDisplayName, |
| @@ -65,6 +66,10 @@ export function signupClosed(app: App, invite?: string): boolean { | ||
| 65 | 66 | return newAccountsClosed(app.db, invite); |
| 66 | 67 | } |
| 67 | 68 | |
| 69 | /** What a person with an invite link that no longer opens the door is told. */ | |
| 70 | export const INVITE_SPENT_MESSAGE = | |
| 71 | "This invite link has been used, revoked or has expired. Ask whoever sent it for a new one."; | |
| 72 | ||
| 68 | 73 | /** What anyone is told at a door that still offers a password-only sign-up. */ |
| 69 | 74 | export const PASSKEY_REQUIRED_MESSAGE = |
| 70 | 75 | "New accounts sign up with a passkey and an email address. Open the sign-up page in a browser and choose \u201cCreate account with a passkey\u201d."; |
| @@ -122,6 +127,7 @@ export async function signUp( | ||
| 122 | 127 | ...(from.address === undefined ? {} : { address: from.address }), |
| 123 | 128 | }, |
| 124 | 129 | }); |
| 130 | auditInvite(app, created.value.id); | |
| 125 | 131 | // The address is proven by the link it is sent; until then nothing it |
| 126 | 132 | // does may create or cost. |
| 127 | 133 | await welcome(app, created.value); |
| @@ -178,10 +184,18 @@ export async function signUpWithPasskey( | ||
| 178 | 184 | ...(from.address === undefined ? {} : { address: from.address }), |
| 179 | 185 | }, |
| 180 | 186 | }); |
| 187 | auditInvite(app, created.value.id); | |
| 181 | 188 | await welcome(app, created.value); |
| 182 | 189 | return created; |
| 183 | 190 | } |
| 184 | 191 | |
| 192 | /** Using an invite link is an event: the invite's id and who it let in, never its token. */ | |
| 193 | function auditInvite(app: App, userId: string): void { | |
| 194 | const inviteId = inviteSpentBy(app.db, userId); | |
| 195 | if (inviteId !== undefined) | |
| 196 | app.bus.append({ kind: "invite.used", actorId: userId, payload: { id: inviteId } }); | |
| 197 | } | |
| 198 | ||
| 185 | 199 | /** |
| 186 | 200 | * Mint a personal access token. Callers decide what credential may mint one |
| 187 | 201 | * (a session or the password, never another token — no privilege |
| @@ -193,9 +207,11 @@ export function createToken( | ||
| 193 | 207 | name: string, |
| 194 | 208 | scope: TokenScope, |
| 195 | 209 | options: { expiresAt?: string } = {}, |
| 196 | ): Result<{ id: string; token: string }, "unauthorized"> { | |
| 210 | ): Result<{ id: string; token: string }, "unauthorized" | "forbidden"> { | |
| 197 | 211 | const user = requireUser(actor); |
| 198 | 212 | if (!user.ok) return user; |
| 213 | // A token that acts as admin is for an admin to hold; ro and rw are anyone's. | |
| 214 | if (scope === "admin" && !user.value.isAdmin) return err("forbidden"); | |
| 199 | 215 | const minted = mintToken(app.db, user.value.id, name, scope, options); |
| 200 | 216 | app.bus.append({ |
| 201 | 217 | kind: "token.created", |
src/app.ts +13 −3
| @@ -9,13 +9,14 @@ import type { Database } from "bun:sqlite"; | ||
| 9 | 9 | import type { Decider } from "./crew/decide.ts"; |
| 10 | 10 | import type { VerifyHuman } from "./core/turnstile.ts"; |
| 11 | 11 | import { randomBytes } from "node:crypto"; |
| 12 | import { mkdirSync } from "node:fs"; | |
| 12 | import { existsSync, mkdirSync } from "node:fs"; | |
| 13 | 13 | import { join, resolve } from "node:path"; |
| 14 | import { migrations } from "./core/schema.ts"; | |
| 14 | import { migrationFiles } from "./core/schema.ts"; | |
| 15 | 15 | import { dropLegacyNewAccountGuards } from "./core/schema.ts"; |
| 16 | 16 | import { EventBus } from "./core/events.ts"; |
| 17 | 17 | import { follow } from "./core/following.ts"; |
| 18 | 18 | import { enqueueForEvent } from "./core/webhooks.ts"; |
| 19 | import { seedSignup } from "./settings/signup.ts"; | |
| 19 | 20 | import { openDb } from "./kernel/db.ts"; |
| 20 | 21 | import { id } from "./kernel/id.ts"; |
| 21 | 22 | import { |
| @@ -31,6 +32,12 @@ import { closeReadersUnder } from "./git/read.ts"; | ||
| 31 | 32 | export interface ForgeConfig { |
| 32 | 33 | /** Everything lives here: index db, bare repos, the internal socket. */ |
| 33 | 34 | dataDir: string; |
| 35 | /** | |
| 36 | * A database made by this call starts with signup = invite (`seedSignup`); | |
| 37 | * one that already exists is left as it is. Off by default so a test's | |
| 38 | * forge reads the setting's `open`; `main.ts` turns it on. | |
| 39 | */ | |
| 40 | seedSignup?: boolean; | |
| 34 | 41 | /** The disk probe vitals reads; the volume under `dataDir` when unset. Tests give it a reading. */ |
| 35 | 42 | disk?: (dir: string) => { freeBytes: number; totalBytes: number }; |
| 36 | 43 | /** @default 3000 */ |
| @@ -212,7 +219,10 @@ export function createApp(config: ForgeConfig): App { | ||
| 212 | 219 | mkdirSync(reposRoot, { recursive: true }); |
| 213 | 220 | const socketPath = join(dataDir, `forge-${id()}.sock`); |
| 214 | 221 | |
| 215 | const db = openDb(join(dataDir, "forge.db"), migrations); | |
| 222 | const dbPath = join(dataDir, "forge.db"); | |
| 223 | const fresh = !existsSync(dbPath); | |
| 224 | const db = openDb(dbPath, migrationFiles); | |
| 225 | if (config.seedSignup === true) seedSignup(db, fresh); | |
| 216 | 226 | // Hand-installed new-account guards become the `guard_new_accounts` |
| 217 | 227 | // setting (default off): drop the legacy triggers once, here, on boot. |
| 218 | 228 | // Idempotent — a forge without them changes nothing. |
src/chaos/worker.ts +2 −2
| @@ -4,12 +4,12 @@ | ||
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | 6 | import { openDb } from "../kernel/db.ts"; |
| 7 | import { migrations } from "../core/schema.ts"; | |
| 7 | import { migrationFiles } from "../core/schema.ts"; | |
| 8 | 8 | import { claimRun, createRun, heartbeatRuns } from "../core/runs.ts"; |
| 9 | 9 | |
| 10 | 10 | const [dbPath, repoId] = process.argv.slice(2); |
| 11 | 11 | if (dbPath === undefined || repoId === undefined) process.exit(2); |
| 12 | const db = openDb(dbPath, migrations); | |
| 12 | const db = openDb(dbPath, migrationFiles); | |
| 13 | 13 | console.log("ready"); |
| 14 | 14 | for (;;) { |
| 15 | 15 | const run = createRun(db, { repoId, kind: "check" }); |
src/checks/progress.ts +21
| @@ -8,6 +8,8 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import type { CheckSummary } from "../core/runs.ts"; |
| 11 | import { checkSteps, stepsWords } from "./summary.ts"; | |
| 12 | import { parseTestsRow, testsRowLine } from "./tests-row.ts"; | |
| 11 | 13 | import { readRunLog } from "../runs/log.ts"; |
| 12 | 14 | |
| 13 | 15 | export type CheckProgress = { |
| @@ -90,3 +92,22 @@ export function runningProgress(dataDir: string, check: CheckSummary): string { | ||
| 90 | 92 | return ""; |
| 91 | 93 | return progressWords(parseShardProgress(readRunLog(dataDir, run.id).lines)); |
| 92 | 94 | } |
| 95 | ||
| 96 | /** | |
| 97 | * The run page's three step rows as one line, for the terminal: the same | |
| 98 | * `checkSteps` over the same log, and nothing at all when the run is not a | |
| 99 | * check or when the viewer may not read its log (`mayReadRunLog`), as the | |
| 100 | * page withholds the rows then. | |
| 101 | */ | |
| 102 | export function stepsLine( | |
| 103 | dataDir: string, | |
| 104 | check: CheckSummary, | |
| 105 | mayReadLog: boolean, | |
| 106 | ): string { | |
| 107 | const run = check.run; | |
| 108 | if (!mayReadLog || run === null || run.kind !== "check") return ""; | |
| 109 | const { lines, truncated } = readRunLog(dataDir, run.id); | |
| 110 | const steps = stepsWords(checkSteps({ ...run, state: check.state === "none" ? run.state : check.state }, lines, truncated)); | |
| 111 | const tests = testsRowLine(parseTestsRow(lines)); | |
| 112 | return tests === "" ? steps : `${steps} · ${tests}`; | |
| 113 | } |
src/checks/service.ts +10 −5
| @@ -61,6 +61,7 @@ import { appendRunLog } from "../runs/log.ts"; | ||
| 61 | 61 | import { runningLimitSentence, runningRuns } from "../settings/quotas.ts"; |
| 62 | 62 | import { exportTree, readFileAt } from "../apps/checkout.ts"; |
| 63 | 63 | import { parseCheck } from "../apps/manifest.ts"; |
| 64 | import { workerCpus } from "../../scripts/shards.ts"; | |
| 64 | 65 | import { accountActive } from "../core/accounts.ts"; |
| 65 | 66 | import { childEnv, runStep } from "../apps/process.ts"; |
| 66 | 67 | import { noteReached } from "../apps/egress_report.ts"; |
| @@ -68,6 +69,7 @@ import { | ||
| 68 | 69 | capacityCount, |
| 69 | 70 | noteBooted, |
| 70 | 71 | capacityEnv, |
| 72 | cpuLimit, | |
| 71 | 73 | egressSetting, |
| 72 | 74 | } from "../core/capacity.ts"; |
| 73 | 75 | import { |
| @@ -245,9 +247,10 @@ export function provideChecksForTest(app: App, handle: ChecksHandle): void { | ||
| 245 | 247 | export function startChecks(app: App, options: ChecksOptions): ChecksHandle { |
| 246 | 248 | // Read on every pick (`core/capacity.ts`): an admin's change is honoured |
| 247 | 249 | // by the next check, not the next boot. `concurrency` is the boot default. |
| 248 | noteBooted(app.db, "checks.concurrency", String(options.concurrency ?? 1)); | |
| 250 | const booted = options.concurrency ?? 1; | |
| 251 | noteBooted(app.db, "checks.concurrency", String(booted)); | |
| 249 | 252 | const capacity = (): number => |
| 250 | capacityCount(app.db, "checks.concurrency", options.concurrency ?? 1); | |
| 253 | capacityCount(app.db, "checks.concurrency", booted); | |
| 251 | 254 | const gate = options.gate ?? new Gate(capacity()); |
| 252 | 255 | const placement = options.placement ?? placementFromEnv(); |
| 253 | 256 | /** Full gates on land in flight: not slots in the queue, but stopped with it. */ |
| @@ -792,9 +795,11 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 792 | 795 | } |
| 793 | 796 | : undefined; |
| 794 | 797 | try { |
| 798 | const cellCpus = resolveLimits("step", manifest.limits).cpus; | |
| 795 | 799 | const env = childEnv({ |
| 796 | // The shard count an admin set (`core/capacity.ts`), never the pod's. | |
| 797 | ...capacityEnv(app.db), | |
| 800 | // The shard count an admin set (`core/capacity.ts`), else the | |
| 801 | // default sized to what this check's cell holds, never the pod's. | |
| 802 | ...capacityEnv(app.db, workerCpus(cpuLimit(), cellCpus)), | |
| 798 | 803 | CI: "1", |
| 799 | 804 | FORGE_URL: options.forgeUrl, |
| 800 | 805 | FORGE_CHECK: run.id, |
| @@ -802,7 +807,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 802 | 807 | FORGE_CHECK_IMPACT: IMPACT_FILE, |
| 803 | 808 | // The cell's CPU limit, which `availableParallelism()` may not |
| 804 | 809 | // report inside it; the shard count follows this (#2257). |
| 805 | FORGE_CELL_CPUS: String(resolveLimits("step", manifest.limits).cpus), | |
| 810 | FORGE_CELL_CPUS: String(cellCpus), | |
| 806 | 811 | ...(base === undefined |
| 807 | 812 | ? {} |
| 808 | 813 | : { |
src/checks/summary.ts +88
| @@ -111,3 +111,91 @@ export function checkWords( | ||
| 111 | 111 | } |
| 112 | 112 | return undefined; |
| 113 | 113 | } |
| 114 | ||
| 115 | export type StepName = "install" | "typecheck" | "tests"; | |
| 116 | export type StepState = | |
| 117 | | "pending" | |
| 118 | | "running" | |
| 119 | | "ok" | |
| 120 | | "failed" | |
| 121 | | "stopped" | |
| 122 | | "unknown"; | |
| 123 | ||
| 124 | export interface CheckStep { | |
| 125 | readonly name: StepName; | |
| 126 | readonly state: StepState; | |
| 127 | } | |
| 128 | ||
| 129 | /** `$ <command>` is what `runStep` and the job runner echo before each command. */ | |
| 130 | const ECHO = /^\$ /; | |
| 131 | /** The package manager's own install, never a script that merely has the word in its name. */ | |
| 132 | const INSTALL_ECHO = /^\$ (?:bun|npm|pnpm|yarn)(?: -\S+)* (?:install|ci)\b/; | |
| 133 | /** The shard runner's banner and shard headers: the tests have begun. */ | |
| 134 | const TESTS_BEGUN = /^test-shards: |^── shard /; | |
| 135 | ||
| 136 | /** | |
| 137 | * The three rows of a check's run page — install, typecheck, tests — read | |
| 138 | * from the run's state and the lines the check wrote, never from a new | |
| 139 | * record. The log carries no step boundaries of its own, so each is | |
| 140 | * inferred from the furthest sign of progress: an echoed command, then the | |
| 141 | * shard runner's banner (typecheck comes before it in the check command). | |
| 142 | * A repository without an install command has its install row `ok` once | |
| 143 | * the check begins. A job-placed check echoes every command up front, so | |
| 144 | * its install is only done once output follows the echoes. Always three | |
| 145 | * rows, so the page never shifts when a state changes. `lines` may be only | |
| 146 | * the tail of a long log (`truncated`): the echoes are then gone, so unless | |
| 147 | * the tests are seen to have begun, or the run passed, the rows are | |
| 148 | * `unknown` rather than read wrong. | |
| 149 | */ | |
| 150 | export function checkSteps( | |
| 151 | run: Pick<Run, "state" | "placement">, | |
| 152 | lines: readonly string[], | |
| 153 | truncated = false, | |
| 154 | ): readonly CheckStep[] { | |
| 155 | const echoes = lines.flatMap((line, i) => (ECHO.test(line) ? [i] : [])); | |
| 156 | const hasInstall = lines.some((line) => INSTALL_ECHO.test(line)); | |
| 157 | const needed = hasInstall ? 2 : 1; | |
| 158 | const lastEcho = echoes.at(-1); | |
| 159 | const output = lastEcho !== undefined && lastEcho < lines.length - 1; | |
| 160 | const checkBegan = | |
| 161 | echoes.length >= needed && (run.placement !== "job" || output); | |
| 162 | const testsBegan = lines.some((line) => TESTS_BEGUN.test(line)); | |
| 163 | if (truncated && run.state !== "ok" && !testsBegan) | |
| 164 | return (["install", "typecheck", "tests"] as const).map((name) => ({ | |
| 165 | name, | |
| 166 | state: "unknown", | |
| 167 | })); | |
| 168 | // A tail that begins after the echoes still shows the tests by their own banner. | |
| 169 | const live = run.state === "running"; | |
| 170 | const dead = run.state === "failed" || run.state === "cancelled"; | |
| 171 | const after = run.state === "cancelled" ? "stopped" : "failed"; | |
| 172 | ||
| 173 | // The first step not yet done is where the run is, or where it ended. | |
| 174 | const reached = testsBegan ? 2 : checkBegan ? 1 : 0; | |
| 175 | const names: StepName[] = ["install", "typecheck", "tests"]; | |
| 176 | return names.map((name, i): CheckStep => { | |
| 177 | if (run.state === "ok") return { name, state: "ok" }; | |
| 178 | if (i < reached) return { name, state: "ok" }; | |
| 179 | if (i === reached) { | |
| 180 | if (live) return { name, state: "running" }; | |
| 181 | if (dead) return { name, state: after }; | |
| 182 | } | |
| 183 | return { name, state: "pending" }; | |
| 184 | }); | |
| 185 | } | |
| 186 | ||
| 187 | const STEP_WORDS: Record<StepState, string> = { | |
| 188 | pending: "waiting", | |
| 189 | running: "running", | |
| 190 | ok: "passed", | |
| 191 | failed: "failed", | |
| 192 | stopped: "stopped", | |
| 193 | unknown: "unknown", | |
| 194 | }; | |
| 195 | ||
| 196 | /** `install passed · typecheck passed · tests running`: the page's rows, in a line. */ | |
| 197 | export const stepWord = (state: StepState): string => STEP_WORDS[state]; | |
| 198 | ||
| 199 | export function stepsWords(steps: readonly CheckStep[]): string { | |
| 200 | return steps.map((s) => `${s.name} ${stepWord(s.state)}`).join(" · "); | |
| 201 | } |
src/checks/tests-row.ts added +99
| @@ -0,0 +1,99 @@ | ||
| 1 | /** | |
| 2 | * What the tests row of a running check's run page adds to its name: how | |
| 3 | * many files the shard runner has started out of the run's count, the file | |
| 4 | * (or files) in the shard running now, and every failure so far with the | |
| 5 | * first lines `bun test` printed for it. Read only from the shard runner's | |
| 6 | * own lines and `bun test`'s `(fail)` markers; a figure the log does not | |
| 7 | * give is left out, never guessed. | |
| 8 | */ | |
| 9 | ||
| 10 | import { parseShardProgress, type CheckProgress } from "./progress.ts"; | |
| 11 | ||
| 12 | export type PinnedFailure = { | |
| 13 | readonly name: string; | |
| 14 | /** The first lines of what was printed above the `(fail)` marker. */ | |
| 15 | readonly lines: readonly string[]; | |
| 16 | }; | |
| 17 | ||
| 18 | export type TestsRow = CheckProgress & { | |
| 19 | /** Files in shards that have begun, finished or not. */ | |
| 20 | started?: number; | |
| 21 | /** The files of the live shard that has not finished, if one is open. */ | |
| 22 | running?: readonly string[]; | |
| 23 | failures: readonly PinnedFailure[]; | |
| 24 | }; | |
| 25 | ||
| 26 | const SHARD_LIVE_OPEN = /^── shard \d+\/\d+: (.+)$/; | |
| 27 | const SHARD_BATCHED = /^── shard \d+\/\d+ \(exit \d+, [0-9.]+s\): (.+)$/; | |
| 28 | const SHARD_DONE = /^── shard \d+\/\d+ done \(exit \d+, [0-9.]+s\)$/; | |
| 29 | const FAIL_LINE = /^\(fail\) (.+?)(?: \[[0-9.]+m?s\])?(?: \[shard [0-9]+\])?$/; | |
| 30 | /** Lines that end one test's output and so bound the next one's. */ | |
| 31 | const BOUNDARY = /^(?:\(pass\) |\(skip\) |\(todo\) |── |test-shards: |\$ )|\.test\.[cm]?[jt]sx?:$/; | |
| 32 | ||
| 33 | export const MAX_PINNED = 5; | |
| 34 | export const PIN_LINES = 3; | |
| 35 | const LINE_MAX = 200; | |
| 36 | ||
| 37 | const names = (text: string): string[] => text.split(" ").filter((n) => n !== ""); | |
| 38 | const bound = (text: string): string => | |
| 39 | text.length > LINE_MAX ? `${text.slice(0, LINE_MAX - 1)}…` : text; | |
| 40 | ||
| 41 | export function parseTestsRow(lines: readonly string[]): TestsRow { | |
| 42 | const base = parseShardProgress(lines); | |
| 43 | let started: number | undefined; | |
| 44 | let running: string[] | undefined; | |
| 45 | const failures: PinnedFailure[] = []; | |
| 46 | // Lines since the last boundary: the failing test's own output. | |
| 47 | let block: string[] = []; | |
| 48 | for (const raw of lines) { | |
| 49 | const line = raw.trimEnd(); | |
| 50 | const live = SHARD_LIVE_OPEN.exec(line); | |
| 51 | const batched = SHARD_BATCHED.exec(line); | |
| 52 | if (live) { | |
| 53 | running = names(live[1]!); | |
| 54 | started = (started ?? 0) + running.length; | |
| 55 | } else if (batched) { | |
| 56 | started = (started ?? 0) + names(batched[1]!).length; | |
| 57 | } else if (SHARD_DONE.test(line)) { | |
| 58 | running = undefined; | |
| 59 | } | |
| 60 | const fail = FAIL_LINE.exec(line); | |
| 61 | if (fail) { | |
| 62 | const name = bound(fail[1]!); | |
| 63 | if (failures.length < MAX_PINNED && !failures.some((f) => f.name === name)) | |
| 64 | failures.push({ name, lines: block.slice(0, PIN_LINES).map(bound) }); | |
| 65 | block = []; | |
| 66 | continue; | |
| 67 | } | |
| 68 | if (BOUNDARY.test(line)) { | |
| 69 | block = []; | |
| 70 | continue; | |
| 71 | } | |
| 72 | if (line.trim() !== "") block.push(line.trim()); | |
| 73 | } | |
| 74 | const row: TestsRow = { ...base, failures }; | |
| 75 | if (started !== undefined) row.started = started; | |
| 76 | if (running !== undefined) row.running = running; | |
| 77 | return row; | |
| 78 | } | |
| 79 | ||
| 80 | /** `3 of 9 files started`, `812 passed, 1 failed`: the parts that are known. */ | |
| 81 | export function testsRowWords(row: TestsRow): string { | |
| 82 | const parts: string[] = []; | |
| 83 | if (row.started !== undefined && row.of !== undefined && row.started <= row.of) | |
| 84 | parts.push(`${row.started} of ${row.of} files started`); | |
| 85 | if (row.passed !== undefined && row.failed !== undefined) | |
| 86 | parts.push(`${row.passed} passed, ${row.failed} failed`); | |
| 87 | return parts.join(" · "); | |
| 88 | } | |
| 89 | ||
| 90 | /** | |
| 91 | * The terminal's words for the same row: `tests 2 of 9 files started · 812 | |
| 92 | * passed, 1 failed · failing: a, b`, empty when the log gave nothing. | |
| 93 | */ | |
| 94 | export function testsRowLine(row: TestsRow): string { | |
| 95 | const words = testsRowWords(row); | |
| 96 | const pins = row.failures.map((f) => f.name).join(", "); | |
| 97 | const parts = [words, pins === "" ? "" : `failing: ${pins}`].filter((p) => p !== ""); | |
| 98 | return parts.length === 0 ? "" : `tests ${parts.join(" · ")}`; | |
| 99 | } |
src/core/accounts.ts +12
| @@ -21,6 +21,8 @@ export interface AccountRow { | ||
| 21 | 21 | readonly createdAt: string; |
| 22 | 22 | /** How the account came: "signup", "edge", or null for one older than the record. */ |
| 23 | 23 | readonly via: string | null; |
| 24 | /** The linked sign-in method it came through ("open-platform.sh" for an edge-made account), else null. */ | |
| 25 | readonly signsInThrough: string | null; | |
| 24 | 26 | /** The address it signed up from, when one was recorded. */ |
| 25 | 27 | readonly address: string | null; |
| 26 | 28 | readonly repos: number; |
| @@ -33,6 +35,15 @@ export interface AccountRow { | ||
| 33 | 35 | readonly verified: boolean; |
| 34 | 36 | } |
| 35 | 37 | |
| 38 | /** What the platform edge's sign-in is called to the owner. */ | |
| 39 | export const EDGE_METHOD = "open-platform.sh"; | |
| 40 | ||
| 41 | /** How an account came, in words: its sign-in method, else its origin, else "crew" for an agent, else "". */ | |
| 42 | export function originOf(a: Pick<AccountRow, "kind" | "via" | "signsInThrough">): string { | |
| 43 | if (a.signsInThrough !== null) return `signs in through ${a.signsInThrough}`; | |
| 44 | return a.via ?? (a.kind === "agent" ? "crew" : ""); | |
| 45 | } | |
| 46 | ||
| 36 | 47 | /** Every person and agent, newest first. Organizations are not accounts. */ |
| 37 | 48 | export function listAccounts(db: Database): AccountRow[] { |
| 38 | 49 | const rows = db |
| @@ -78,6 +89,7 @@ export function listAccounts(db: Database): AccountRow[] { | ||
| 78 | 89 | isAdmin: r.is_admin === 1, |
| 79 | 90 | createdAt: r.created_at, |
| 80 | 91 | via: origin.via, |
| 92 | signsInThrough: origin.via === "edge" ? EDGE_METHOD : null, | |
| 81 | 93 | address: origin.address, |
| 82 | 94 | repos: r.repos, |
| 83 | 95 | apps: r.apps, |
src/core/audience.ts added +223
| @@ -0,0 +1,223 @@ | ||
| 1 | /** | |
| 2 | * Who can see a repository or an app, in one sentence. | |
| 3 | * | |
| 4 | * `audienceOf` is pure: plain facts in, a level and a sentence out, so every | |
| 5 | * wording is a test with no database. `audienceFacts` reads the facts, and | |
| 6 | * asks the same two rules the forge enforces — `REPO_VISIBLE` and | |
| 7 | * `REPO_WRITABLE` — once per candidate, so the sentence cannot drift from | |
| 8 | * what the forge lets through. Site admins are not counted: their reach is | |
| 9 | * the forge's, not the repository's. Nor are suspended or removed accounts. | |
| 10 | */ | |
| 11 | ||
| 12 | import type { Database } from "bun:sqlite"; | |
| 13 | import type { Repo } from "./repos.ts"; | |
| 14 | import type { User } from "./users.ts"; | |
| 15 | import { REPO_VISIBLE, REPO_WRITABLE } from "./visibility.ts"; | |
| 16 | ||
| 17 | export interface AudienceFacts { | |
| 18 | readonly visibility: "public" | "private"; | |
| 19 | /** Who is looking: nobody signed in, a person, or a site admin. */ | |
| 20 | readonly viewer: "signed-out" | "person" | "admin"; | |
| 21 | readonly ownerKind: "user" | "org"; | |
| 22 | readonly ownerName: string; | |
| 23 | /** People (not the crew) other than the viewer who may read it. */ | |
| 24 | readonly readers: number; | |
| 25 | /** People (not the crew) who may change it, the viewer among them. */ | |
| 26 | readonly writers: number; | |
| 27 | readonly viewerWrites: boolean; | |
| 28 | /** Whether any of the crew works here. */ | |
| 29 | readonly crew: boolean; | |
| 30 | /** | |
| 31 | * The app's access, when the repository has an app. People are counted | |
| 32 | * once, never the viewer: `writers` may change the repository, `named` are | |
| 33 | * named by the app and cannot. | |
| 34 | */ | |
| 35 | readonly app?: { | |
| 36 | readonly access: "public" | "private"; | |
| 37 | readonly writers: number; | |
| 38 | readonly named: number; | |
| 39 | readonly viewerNamed: boolean; | |
| 40 | }; | |
| 41 | } | |
| 42 | ||
| 43 | export interface Audience { | |
| 44 | /** `split`: public code, private app. */ | |
| 45 | readonly level: "public" | "private" | "split"; | |
| 46 | readonly sentence: string; | |
| 47 | } | |
| 48 | ||
| 49 | const people = (n: number): string => (n === 1 ? "1 person" : `${n} people`); | |
| 50 | ||
| 51 | /** "a", "a and b", "a, b, and c". */ | |
| 52 | function list(parts: readonly string[]): string { | |
| 53 | if (parts.length <= 2) return parts.join(" and "); | |
| 54 | return `${parts.slice(0, -1).join(", ")}, and ${parts[parts.length - 1]}`; | |
| 55 | } | |
| 56 | ||
| 57 | export function audienceOf(facts: AudienceFacts): Audience { | |
| 58 | const crew = facts.crew ? ["the crew"] : []; | |
| 59 | // "you" is only said to someone signed in. | |
| 60 | const you = facts.viewer === "signed-out" ? [] : ["you"]; | |
| 61 | if (facts.visibility === "public") { | |
| 62 | if (facts.app?.access === "private") { | |
| 63 | // Only someone who can change it, or is named, is told "you". | |
| 64 | const youToo = | |
| 65 | you.length > 0 && (facts.viewerWrites || facts.app.viewerNamed) | |
| 66 | ? you | |
| 67 | : []; | |
| 68 | const parts = [ | |
| 69 | ...youToo, | |
| 70 | ...(facts.app.writers > 0 | |
| 71 | ? [`${people(facts.app.writers)} who can change it`] | |
| 72 | : []), | |
| 73 | ...(facts.app.named > 0 ? [`${people(facts.app.named)} it names`] : []), | |
| 74 | ...crew, | |
| 75 | ]; | |
| 76 | return { | |
| 77 | level: "split", | |
| 78 | sentence: `The code is public; the app answers only to ${ | |
| 79 | parts.length === 0 ? "nobody" : list(parts) | |
| 80 | }.`, | |
| 81 | }; | |
| 82 | } | |
| 83 | const changers = [ | |
| 84 | ...(facts.writers > 0 ? [people(facts.writers)] : []), | |
| 85 | ...crew, | |
| 86 | ]; | |
| 87 | return { | |
| 88 | level: "public", | |
| 89 | sentence: `Public: anyone can read this; ${ | |
| 90 | changers.length === 0 ? "nobody" : list(changers) | |
| 91 | } can change it.`, | |
| 92 | }; | |
| 93 | } | |
| 94 | const parts = [...you]; | |
| 95 | if (facts.readers > 0) { | |
| 96 | parts.push( | |
| 97 | facts.ownerKind === "org" | |
| 98 | ? `${people(facts.readers)} in ${facts.ownerName}` | |
| 99 | : facts.ownerName, | |
| 100 | ); | |
| 101 | } | |
| 102 | const alone = parts.length === 1 && you.length === 1; | |
| 103 | parts.push(...crew); | |
| 104 | return { | |
| 105 | level: "private", | |
| 106 | sentence: `Private: ${alone ? "only " : ""}${ | |
| 107 | parts.length === 0 ? "nobody" : list(parts) | |
| 108 | }.`, | |
| 109 | }; | |
| 110 | } | |
| 111 | ||
| 112 | /** How many active, non-admin people satisfy `rule` (over `?1` = repo id). */ | |
| 113 | function countPeople( | |
| 114 | db: Database, | |
| 115 | repoId: string, | |
| 116 | rule: string, | |
| 117 | exceptId: string | undefined, | |
| 118 | ): number { | |
| 119 | // The rules take the person as `?1`; here each candidate is that person. | |
| 120 | const asCandidate = rule.replaceAll("?1", "cand.id"); | |
| 121 | return db | |
| 122 | .query<{ n: number }, [string, string]>( | |
| 123 | `SELECT COUNT(*) AS n FROM user AS cand | |
| 124 | WHERE cand.kind = 'user' AND cand.is_agent = 0 AND cand.is_admin = 0 | |
| 125 | AND cand.suspended_at IS NULL AND cand.removed_at IS NULL | |
| 126 | AND cand.id != ?2 | |
| 127 | AND EXISTS (SELECT 1 FROM repo WHERE repo.id = ?1 AND ${asCandidate})`, | |
| 128 | ) | |
| 129 | .get(repoId, exceptId ?? "")!.n; | |
| 130 | } | |
| 131 | ||
| 132 | /** | |
| 133 | * `REPO_WRITABLE` without its site-admin clause: an admin's reach is the | |
| 134 | * forge's, so only membership of the repository itself earns them "you". | |
| 135 | * Throws if the rule is reshaped, so a drift cannot go unnoticed. | |
| 136 | */ | |
| 137 | const MEMBER_WRITABLE = REPO_WRITABLE.replace( | |
| 138 | /\n\s*OR EXISTS \(SELECT 1 FROM user AS me WHERE me\.id = \?1 AND me\.is_admin = 1\)/, | |
| 139 | "", | |
| 140 | ); | |
| 141 | if (MEMBER_WRITABLE === REPO_WRITABLE) { | |
| 142 | throw new Error("audience: REPO_WRITABLE lost its admin clause; update audience.ts"); | |
| 143 | } | |
| 144 | ||
| 145 | export function audienceFacts( | |
| 146 | db: Database, | |
| 147 | repo: Repo, | |
| 148 | viewer: User | undefined, | |
| 149 | ): AudienceFacts { | |
| 150 | const owner = db | |
| 151 | .query<{ kind: "user" | "org" }, [string]>( | |
| 152 | "SELECT kind FROM user WHERE id = ?1", | |
| 153 | ) | |
| 154 | .get(repo.ownerId); | |
| 155 | const viewerId = viewer?.id; | |
| 156 | const writes = | |
| 157 | viewerId !== undefined && | |
| 158 | db | |
| 159 | .query<{ n: number }, [string, string]>( | |
| 160 | `SELECT 1 AS n FROM repo WHERE repo.id = ?1 AND ${MEMBER_WRITABLE.replaceAll("?1", "?2")}`, | |
| 161 | ) | |
| 162 | .get(repo.id, viewerId) !== null; | |
| 163 | const app = db | |
| 164 | .query<{ id: string; access: "public" | "private" }, [string]>( | |
| 165 | "SELECT id, access FROM app WHERE repo_id = ?1", | |
| 166 | ) | |
| 167 | .get(repo.id); | |
| 168 | const namedRule = `EXISTS (SELECT 1 FROM app_member AS m WHERE m.app_id = ?1 AND m.user_id = cand.id)`; | |
| 169 | const activePerson = `cand.kind = 'user' AND cand.is_agent = 0 AND cand.is_admin = 0 | |
| 170 | AND cand.suspended_at IS NULL AND cand.removed_at IS NULL`; | |
| 171 | const named = app | |
| 172 | ? db | |
| 173 | .query<{ n: number }, [string, string, string]>( | |
| 174 | `SELECT COUNT(*) AS n FROM user AS cand | |
| 175 | WHERE ${activePerson} AND cand.id != ?3 AND ${namedRule} | |
| 176 | AND NOT EXISTS (SELECT 1 FROM repo WHERE repo.id = ?2 AND ${REPO_WRITABLE.replaceAll("?1", "cand.id")})`, | |
| 177 | ) | |
| 178 | .get(app.id, repo.id, viewerId ?? "")!.n | |
| 179 | : 0; | |
| 180 | const viewerNamed = | |
| 181 | app !== null && | |
| 182 | viewerId !== undefined && | |
| 183 | db | |
| 184 | .query<{ n: number }, [string, string]>( | |
| 185 | "SELECT 1 AS n FROM app_member WHERE app_id = ?1 AND user_id = ?2", | |
| 186 | ) | |
| 187 | .get(app.id, viewerId) !== null; | |
| 188 | return { | |
| 189 | visibility: repo.visibility, | |
| 190 | viewer: | |
| 191 | viewer === undefined ? "signed-out" : viewer.isAdmin ? "admin" : "person", | |
| 192 | ownerKind: owner?.kind ?? "user", | |
| 193 | ownerName: repo.ownerName, | |
| 194 | readers: countPeople(db, repo.id, REPO_VISIBLE, viewerId), | |
| 195 | writers: countPeople(db, repo.id, REPO_WRITABLE, undefined), | |
| 196 | viewerWrites: writes, | |
| 197 | crew: | |
| 198 | db | |
| 199 | .query<{ n: number }, [string]>( | |
| 200 | "SELECT 1 AS n FROM repo_crew WHERE repo_id = ?1", | |
| 201 | ) | |
| 202 | .get(repo.id) !== null, | |
| 203 | ...(app | |
| 204 | ? { | |
| 205 | app: { | |
| 206 | access: app.access, | |
| 207 | writers: countPeople(db, repo.id, REPO_WRITABLE, viewerId), | |
| 208 | named, | |
| 209 | viewerNamed, | |
| 210 | }, | |
| 211 | } | |
| 212 | : {}), | |
| 213 | }; | |
| 214 | } | |
| 215 | ||
| 216 | /** The audience of `repo` for `viewer`: the one call the API and the MCP share. */ | |
| 217 | export function audienceFor( | |
| 218 | db: Database, | |
| 219 | repo: Repo, | |
| 220 | viewer: User | undefined, | |
| 221 | ): Audience { | |
| 222 | return audienceOf(audienceFacts(db, repo, viewer)); | |
| 223 | } |
src/core/capacity.ts +96 −12
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | |
| 14 | 14 | import type { Database } from "bun:sqlite"; |
| 15 | 15 | import { readFileSync } from "node:fs"; |
| 16 | import { totalmem } from "node:os"; | |
| 16 | import { availableParallelism, totalmem } from "node:os"; | |
| 17 | 17 | |
| 18 | 18 | export type CapacityKey = |
| 19 | 19 | | "crew.concurrency" |
| @@ -34,6 +34,8 @@ interface Definition { | ||
| 34 | 34 | | { readonly type: "choice"; readonly values: readonly string[] }; |
| 35 | 35 | /** What it is when neither the store nor the environment says. */ |
| 36 | 36 | readonly fallback: string; |
| 37 | /** The default from the pod's CPUs, in place of `fallback` (#2549). */ | |
| 38 | readonly sized?: (cpus: number) => number; | |
| 37 | 39 | readonly help: string; |
| 38 | 40 | } |
| 39 | 41 | |
| @@ -43,21 +45,23 @@ export const CAPACITY: Record<CapacityKey, Definition> = { | ||
| 43 | 45 | env: "FORGE_CREW_CONCURRENCY", |
| 44 | 46 | kind: { type: "count", min: 1, max: 16 }, |
| 45 | 47 | fallback: "2", |
| 46 | help: "How many agent runs go at once. Lowering it lets running ones finish; the next one waits.", | |
| 48 | help: "How many agent runs go at once. Empty: one per 3 GiB of memory above the forge's own 2 GiB, from the pod's limit, at least 2 and at most 16. Lowering it lets running ones finish; the next one waits.", | |
| 47 | 49 | }, |
| 48 | 50 | "checks.concurrency": { |
| 49 | 51 | label: "Checks at once", |
| 50 | 52 | env: "FORGE_CHECKS_CONCURRENCY", |
| 51 | 53 | kind: { type: "count", min: 1, max: 16 }, |
| 52 | 54 | fallback: "1", |
| 53 | help: "How many repository checks run at once. Lowering it lets running ones finish; the next one waits.", | |
| 55 | sized: (cpus) => checksForCpus(cpus), | |
| 56 | help: "How many repository checks run at once. Lowering it lets running ones finish; the next one waits. Empty: the pod's CPUs over three per shard (shards × 3), from 1 to 16 — 80 CPUs give 6.", | |
| 54 | 57 | }, |
| 55 | 58 | "checks.shards": { |
| 56 | 59 | label: "Test shards per check", |
| 57 | 60 | env: "FORGE_CHECK_SHARDS", |
| 58 | 61 | kind: { type: "count", min: 1, max: 32 }, |
| 59 | fallback: "", | |
| 60 | help: "How many parallel shards a check's test run uses (handed to checks and crew shells as FORGE_CHECK_SHARDS). Empty: as many as the machine has cores.", | |
| 62 | fallback: "1", | |
| 63 | sized: (cpus) => shardsForCpus(cpus), | |
| 64 | help: "How many parallel shards a check's test run uses (handed to checks and crew shells as FORGE_CHECK_SHARDS). Empty: the pod's CPUs, at most 4 — and, in a check, no more than its cell holds.", | |
| 61 | 65 | }, |
| 62 | 66 | "children.max": { |
| 63 | 67 | label: "Forges this one may send", |
| @@ -106,10 +110,70 @@ export function validCapacity(key: CapacityKey, value: string): boolean { | ||
| 106 | 110 | } |
| 107 | 111 | |
| 108 | 112 | /** The boot default: the environment, when it holds a valid value. */ |
| 109 | export function capacityDefault(key: CapacityKey): string { | |
| 113 | export function capacityDefault(key: CapacityKey, cpus?: number): string { | |
| 114 | const env = explicitDefault(key); | |
| 115 | if (env !== undefined) return env; | |
| 116 | const sized = CAPACITY[key].sized; | |
| 117 | if (sized !== undefined) return String(sized(cpus ?? cpuLimit())); | |
| 118 | if (key === "children.max") return String(childrenForMemory(memoryLimitBytes())); | |
| 119 | if (key === "crew.concurrency") return String(crewForMemory(memoryLimitBytes())); | |
| 120 | return CAPACITY[key].fallback; | |
| 121 | } | |
| 122 | ||
| 123 | /** The environment's value for `key`, when it holds a valid one. */ | |
| 124 | function explicitDefault(key: CapacityKey): string | undefined { | |
| 110 | 125 | const env = process.env[CAPACITY[key].env]?.trim() ?? ""; |
| 111 | if (env !== "" && validCapacity(key, env)) return env; | |
| 112 | return key === "children.max" ? String(childrenForMemory(memoryLimitBytes())) : CAPACITY[key].fallback; | |
| 126 | return env !== "" && validCapacity(key, env) ? env : undefined; | |
| 127 | } | |
| 128 | ||
| 129 | /** Most shards a check takes by default: past four, a test run waits on git, not CPU. */ | |
| 130 | const SHARDS_DEFAULT_MAX = 4; | |
| 131 | /** CPUs a shard is given by default: its bun test, the git children it starts, and the forge it boots. */ | |
| 132 | const CPUS_PER_SHARD = 3; | |
| 133 | ||
| 134 | /** Shards per check by default: `min(4, cpus)`, at least one. 80 CPUs give 4; 2 give 2. */ | |
| 135 | export function shardsForCpus(cpus: number): number { | |
| 136 | const n = Math.floor(cpus); | |
| 137 | return Number.isFinite(n) ? Math.max(1, Math.min(SHARDS_DEFAULT_MAX, n)) : 1; | |
| 138 | } | |
| 139 | ||
| 140 | /** | |
| 141 | * Checks at once by default: `floor(cpus / (shards × 3))`, held to 1–16, so | |
| 142 | * every check's shards have three CPUs each. 80 CPUs: 4 shards, 80 / 12 = 6 | |
| 143 | * checks; 16 CPUs: 1; 4 CPUs: 4 shards, 4 / 12 → 1 (#2549). | |
| 144 | */ | |
| 145 | export function checksForCpus(cpus: number, shards = shardsForCpus(cpus)): number { | |
| 146 | const n = Math.floor(cpus / (shards * CPUS_PER_SHARD)); | |
| 147 | return Number.isFinite(n) ? Math.max(1, Math.min(16, n)) : 1; | |
| 148 | } | |
| 149 | ||
| 150 | let cpusForTest: number | undefined; | |
| 151 | ||
| 152 | /** | |
| 153 | * A test's CPU count in place of the pod's, so a default sized from it is | |
| 154 | * the same on a laptop and an 80-core runner; `undefined` reads the pod again. | |
| 155 | */ | |
| 156 | export function provideCpusForTest(cpus: number | undefined): void { | |
| 157 | cpusForTest = cpus; | |
| 158 | } | |
| 159 | ||
| 160 | /** | |
| 161 | * The pod's CPU limit: cgroup v2 `cpu.max` ("quota period", in µs), rounded | |
| 162 | * down and never below one, else `os.availableParallelism()` — "max" (no | |
| 163 | * quota), an unreadable file or anything not two numbers falls back. Never | |
| 164 | * more than the machine has. | |
| 165 | */ | |
| 166 | export function cpuLimit(file = "/sys/fs/cgroup/cpu.max"): number { | |
| 167 | if (cpusForTest !== undefined) return cpusForTest; | |
| 168 | const machine = availableParallelism(); | |
| 169 | try { | |
| 170 | const [quota, period] = readFileSync(file, "utf8").trim().split(/\s+/).map(Number); | |
| 171 | if (quota !== undefined && period !== undefined && quota > 0 && period > 0) | |
| 172 | return Math.max(1, Math.min(machine, Math.floor(quota / period))); | |
| 173 | } catch { | |
| 174 | // No cgroup v2 CPU controller here: the machine's count. | |
| 175 | } | |
| 176 | return machine; | |
| 113 | 177 | } |
| 114 | 178 | |
| 115 | 179 | const GIB = 1024 ** 3; |
| @@ -126,6 +190,18 @@ export function childrenForMemory(bytes: number): number { | ||
| 126 | 190 | } |
| 127 | 191 | const MIN_CHILDREN = 5; |
| 128 | 192 | |
| 193 | /** | |
| 194 | * One crew run per 3 GiB of room above the forge's own 2 GiB (32 GiB gives | |
| 195 | * 10, 64 GiB the ceiling of 16), never fewer than two: a big machine runs | |
| 196 | * the crew it can hold without an override the sentinel then flags (#2548). | |
| 197 | */ | |
| 198 | export function crewForMemory(bytes: number): number { | |
| 199 | const n = Math.floor((bytes - 2 * GIB) / (3 * GIB)); | |
| 200 | return Math.min(MAX_CREW, Math.max(MIN_CREW, Number.isFinite(n) ? n : MIN_CREW)); | |
| 201 | } | |
| 202 | const MIN_CREW = 2; | |
| 203 | const MAX_CREW = 16; | |
| 204 | ||
| 129 | 205 | /** The pod's memory limit (cgroup v2, then v1), else the machine's memory. */ |
| 130 | 206 | export function memoryLimitBytes(): number { |
| 131 | 207 | for (const f of ["/sys/fs/cgroup/memory.max", "/sys/fs/cgroup/memory/memory.limit_in_bytes"]) { |
| @@ -201,10 +277,18 @@ export function egressSetting(db: Database): "enforce" | "report" { | ||
| 201 | 277 | return capacityValue(db, "cells.egress") === "report" ? "report" : "enforce"; |
| 202 | 278 | } |
| 203 | 279 | |
| 204 | /** What a check's commands and a crew shell are told: the shard count, when one is set. */ | |
| 205 | export function capacityEnv(db: Database): Record<string, string> { | |
| 206 | const shards = capacityValue(db, "checks.shards"); | |
| 207 | return shards === "" ? {} : { FORGE_CHECK_SHARDS: shards }; | |
| 280 | /** | |
| 281 | * What a check's commands and a crew shell are told: the shard count in | |
| 282 | * force. An admin's value or the environment's is passed as it is; the | |
| 283 | * default is sized from `cpus` — a check passes what its cell holds, so a | |
| 284 | * small cell is never told the pod's count. | |
| 285 | */ | |
| 286 | export function capacityEnv(db: Database, cpus?: number): Record<string, string> { | |
| 287 | const shards = | |
| 288 | storedCapacity(db, "checks.shards") ?? | |
| 289 | explicitDefault("checks.shards") ?? | |
| 290 | String(shardsForCpus(cpus ?? cpuLimit())); | |
| 291 | return { FORGE_CHECK_SHARDS: shards }; | |
| 208 | 292 | } |
| 209 | 293 | |
| 210 | 294 | export interface CapacityChange { |
src/core/invites.ts +5
| @@ -120,3 +120,8 @@ export function revokeInvite( | ||
| 120 | 120 | if (changed === 0) return err("not_found"); |
| 121 | 121 | return ok(view(db.query<Row, [string]>("SELECT * FROM invite WHERE id = ?1").get(inviteId)!, now)); |
| 122 | 122 | } |
| 123 | ||
| 124 | /** The id of the invite an account spent, if one let it in. */ | |
| 125 | export function inviteSpentBy(db: Database, userId: string): string | undefined { | |
| 126 | return db.query<{ id: string }, [string]>("SELECT id FROM invite WHERE used_by = ?1").get(userId)?.id; | |
| 127 | } |
src/core/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 @@ | ||
| 4 | 4 | // check (`REPO_WRITABLE`'s crew clause) and `onCrew` can never drift. |
| 5 | 5 | // A person already there is reported at boot (`peopleOnCrews`), never |
| 6 | 6 | // removed by a migration. |
| 7 | // The RAISE messages below are shown to any caller, anonymous ones included | |
| 8 | // (#723); they are written for anyone, and are known by name in | |
| 9 | // src/http/refusal.ts because the numbered migrations' text is frozen. | |
| 7 | 10 | export default ` |
| 8 | 11 | CREATE TRIGGER repo_crew_agents_only BEFORE INSERT ON repo_crew |
| 9 | 12 | WHEN NOT EXISTS (SELECT 1 FROM user WHERE id = NEW.user_id AND is_agent = 1) |
src/core/migrations/0070-a-sealed-repository-is-never.ts +3
| @@ -1,6 +1,9 @@ | ||
| 1 | 1 | // 70 — a sealed repository is never public, said by the database (#865's |
| 2 | 2 | // review): the rule lived in `updateRepoSettings`; these triggers hold |
| 3 | 3 | // it for every writer of `repo.visibility`, now and later. |
| 4 | // The RAISE messages below are shown to any caller, anonymous ones included | |
| 5 | // (#723); they are written for anyone, and are known by name in | |
| 6 | // src/http/refusal.ts because the numbered migrations' text is frozen. | |
| 4 | 7 | export default ` |
| 5 | 8 | CREATE TRIGGER repo_sealed_never_public BEFORE UPDATE OF visibility, sealed_for ON repo |
| 6 | 9 | WHEN NEW.sealed_for IS NOT NULL AND NEW.visibility = 'public' |
src/core/migrations/0081-thoughts.ts +3
| @@ -2,6 +2,9 @@ | ||
| 2 | 2 | // row per thought, appended and never changed: the triggers refuse an |
| 3 | 3 | // UPDATE or DELETE, and there is no foreign key so no cascade can remove |
| 4 | 4 | // one when an account goes. The author is an id, not a reference. |
| 5 | // The RAISE messages below are shown to any caller, anonymous ones included | |
| 6 | // (#723); they are written for anyone, and are known by name in | |
| 7 | // src/http/refusal.ts because the numbered migrations' text is frozen. | |
| 5 | 8 | export default ` |
| 6 | 9 | CREATE TABLE thought ( |
| 7 | 10 | id TEXT PRIMARY KEY, |
src/core/migrations/20261018100000-incident-remembered.ts added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | // A degraded finding remembers the incident it was filed for (#2553): the | |
| 2 | // time the cause first opened, and when its closed issue was last told the | |
| 3 | // incident goes on. Both are null for every other finding. | |
| 4 | export default ` | |
| 5 | ALTER TABLE sentinel_finding ADD COLUMN incident TEXT; | |
| 6 | ALTER TABLE sentinel_finding ADD COLUMN commented_at TEXT; | |
| 7 | `; |
src/core/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 @@ | ||
| 1 | 1 | { |
| 2 | "note": "The migrations each release applied, newest first. The release agent adds the new release here when it cuts one: `migrations` counts the migration ids it applied (every file in src/core/migrations/), and `ids` names the stamped ones among them — every file past 0090, its name without .ts (`ls src/core/migrations | grep -v ^0 | sed s/.ts$//`). A release from before #1837 has a count only: the first that many numbered files. Every migration that is not in both of the two newest releases must be additive (test/schema-additive.test.ts, notAdditive in src/kernel/migrate.ts): the old process keeps serving on the same database while the new one rolls in.", | |
| 2 | "note": "The migrations each release applied, newest first. The release agent adds the new release here when it cuts one: `migrations` counts the migration ids it applied (every file in src/core/migrations/), and `ids` names the stamped ones among them — every file past 0090, its name without .ts (`ls src/core/migrations | grep -v ^0 | sed s/.ts$//`) — and runs `bun scripts/pin-migrations.ts`, which pins each new migration's text in src/core/migration-pins.json. A release from before #1837 has a count only: the first that many numbered files. Every migration that is not in both of the two newest releases must be additive (test/schema-additive.test.ts, notAdditive in src/kernel/migrate.ts): the old process keeps serving on the same database while the new one rolls in.", | |
| 3 | 3 | "releases": [ |
| 4 | 4 | { |
| 5 | 5 | "version": "0.7.165", |
src/core/reserved.ts +1
| @@ -72,6 +72,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 72 | 72 | "flow", |
| 73 | 73 | "front", |
| 74 | 74 | "go", |
| 75 | "home", | |
| 75 | 76 | "icons", |
| 76 | 77 | "live", |
| 77 | 78 | "login", |
src/core/schema.ts +3 −1
| @@ -9,7 +9,9 @@ | ||
| 9 | 9 | * new one is named by the UTC second it was written |
| 10 | 10 | * (`date -u +%Y%m%d%H%M%S`, e.g. `20261015093000-deploy-notes.ts`), so two |
| 11 | 11 | * pulls that each add one add two files and never touch the same line |
| 12 | * (#1837). Each file is one `export default` of SQL. | |
| 12 | * (#1837). Each file is one `export default` of SQL. The database records | |
| 13 | * each applied one by name (#783); `migration-pins.json` holds what each | |
| 14 | * said when it was released, and a test fails if one says something else. | |
| 13 | 15 | */ |
| 14 | 16 | |
| 15 | 17 | import type { Database } from "bun:sqlite"; |
src/core/tokens.ts +3
| @@ -13,6 +13,9 @@ import { getUserById, isActive, type User } from "./users.ts"; | ||
| 13 | 13 | |
| 14 | 14 | export type TokenScope = "ro" | "rw" | "admin"; |
| 15 | 15 | |
| 16 | /** What a non-admin is told when they ask for a token that acts as admin. */ | |
| 17 | export const ADMIN_TOKEN_WORDS = "Only an admin can make a token with admin scope; ro and rw are open to you."; | |
| 18 | ||
| 16 | 19 | const rank: Record<TokenScope, number> = { ro: 0, rw: 1, admin: 2 }; |
| 17 | 20 | |
| 18 | 21 | export function scopeAllows(held: TokenScope, needed: TokenScope): boolean { |
src/crew/autonomy.ts +14 −5
| @@ -89,6 +89,7 @@ import { reviewSummary } from "../core/reviews.ts"; | ||
| 89 | 89 | import { standing } from "../core/standing.ts"; |
| 90 | 90 | import { pace, type Pace } from "./pacing.ts"; |
| 91 | 91 | import { BREAKER_NOTE, breakerTripped } from "./breaker.ts"; |
| 92 | import { given, pendingReplans } from "../core/limb_replan.ts"; | |
| 92 | 93 | import { handedOverPulls, SKIP_SUMMARY, skipsReplan } from "./guard.ts"; |
| 93 | 94 | import { atRunCap, type Held, heldBy, heldWords, replannable } from "./held.ts"; |
| 94 | 95 | import { BY_CREW, DONE_LIKE } from "../core/done_on_main.ts"; |
| @@ -562,7 +563,8 @@ async function planIfThin( | ||
| 562 | 563 | stuck: Held | null = null, |
| 563 | 564 | ): Promise<boolean> { |
| 564 | 565 | 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; | |
| 566 | 568 | const planner = crew.find((c) => c.role === "planner")?.agent; |
| 567 | 569 | if (planner === undefined) return notPlanned(repo, "no planner on the crew"); |
| 568 | 570 | if (repo.autonomyDirection.trim() === "") |
| @@ -582,11 +584,14 @@ async function planIfThin( | ||
| 582 | 584 | // The hand-overs the planner has not been given: newer than its last pass. |
| 583 | 585 | const seen = lastPlan?.created_at ?? ""; |
| 584 | 586 | 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; | |
| 586 | 591 | // Nothing startable for issues held since its last pass that re-planning |
| 587 | 592 | // lifts: owed one pass like a hand-over, once (#2244). |
| 588 | 593 | 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; | |
| 590 | 595 | // The direction is the planner's standing issue, not one issue retried: |
| 591 | 596 | // it is held to its own day's share, never the builders' cap (#2143). |
| 592 | 597 | if (atRunCap(app, repo, direction)) |
| @@ -606,9 +611,11 @@ async function planIfThin( | ||
| 606 | 611 | return notPlanned(repo, "the planner declined; waiting for a person"); |
| 607 | 612 | if (lastPlan.state === "queued" || lastPlan.state === "running") |
| 608 | 613 | 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. | |
| 609 | 615 | if ( |
| 616 | limbsOwed.length === 0 && | |
| 610 | 617 | now.getTime() - new Date(lastPlan.created_at).getTime() < |
| 611 | REPLAN_AFTER_MS | |
| 618 | REPLAN_AFTER_MS | |
| 612 | 619 | ) |
| 613 | 620 | return notPlanned(repo, "planned recently"); |
| 614 | 621 | // Nobody spoke and the map has not moved since the last pass: another |
| @@ -631,7 +638,9 @@ async function planIfThin( | ||
| 631 | 638 | if (!assigned.ok) |
| 632 | 639 | return notPlanned(repo, `could not assign: ${String(assigned.error)}`); |
| 633 | 640 | log.info( |
| 634 | owed.length > 0 | |
| 641 | limbsOwed.length > 0 | |
| 642 | ? "a limb was handed over; planner asked" | |
| 643 | : owed.length > 0 | |
| 635 | 644 | ? "parked twice; planner asked" |
| 636 | 645 | : unstuck.length > 0 |
| 637 | 646 | ? "nothing startable; planner asked" |
src/crew/brief.ts +31 −1
| @@ -186,6 +186,8 @@ export interface TaskInput extends BriefInput { | ||
| 186 | 186 | readonly looks?: readonly string[] | undefined; |
| 187 | 187 | /** For a planner: the issues the crew parked twice, with why. */ |
| 188 | 188 | 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; | |
| 189 | 191 | /** For a planner: the issue deferred work waits behind, if there is one. */ |
| 190 | 192 | readonly laterGate?: number | undefined; |
| 191 | 193 | /** For a planner: nothing ready can start, and what holds it (`held.ts`). */ |
| @@ -204,6 +206,13 @@ export interface Stuck { | ||
| 204 | 206 | }[]; |
| 205 | 207 | } |
| 206 | 208 | |
| 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 | ||
| 207 | 216 | /** An issue the crew parked twice, as the planner is told of it. */ |
| 208 | 217 | export interface HandedOver { |
| 209 | 218 | readonly number: number; |
| @@ -234,7 +243,7 @@ function taskBody(input: TaskInput): string { | ||
| 234 | 243 | const { issue } = input; |
| 235 | 244 | const out: string[] = []; |
| 236 | 245 | 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)] | |
| 238 | 247 | .filter((lines) => lines.length > 0) |
| 239 | 248 | .flatMap((lines, n) => (n === 0 ? lines : ["", ...lines])); |
| 240 | 249 | const readers = input.readers ?? new Set<string>(); |
| @@ -533,6 +542,27 @@ function parkedTwiceLines(input: TaskInput): string[] { | ||
| 533 | 542 | ]; |
| 534 | 543 | } |
| 535 | 544 | |
| 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 | ||
| 536 | 566 | /** |
| 537 | 567 | * What a planner is told when no ready issue can start while builders sit |
| 538 | 568 | * 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 @@ | ||
| 9 | 9 | * - `running`: a run is in flight on it. |
| 10 | 10 | * - `open_pull`: an open pull already proposes it (`direct.ts`). |
| 11 | 11 | * - `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`). | |
| 12 | 14 | * - `done_on_main`: the crew found it done on the base branch and waits for |
| 13 | 15 | * a person to close it (`done_on_main.ts`); nothing is left to plan. |
| 14 | 16 | * - `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"; | ||
| 27 | 29 | import { readyNow, repoGraph, type Ranked } from "../core/dependencies.ts"; |
| 28 | 30 | import { alreadyDone, BY_CREW, DONE_LIKE } from "../core/done_on_main.ts"; |
| 29 | 31 | import type { Issue } from "../core/issues.ts"; |
| 32 | import { withPlanner } from "../core/limb_replan.ts"; | |
| 30 | 33 | import type { Repo } from "../core/repos.ts"; |
| 31 | 34 | import { listActive, type Run } from "../core/runs.ts"; |
| 32 | 35 | import { getUserById } from "../core/users.ts"; |
| @@ -42,6 +45,7 @@ export const HOLDS = [ | ||
| 42 | 45 | "running", |
| 43 | 46 | "open_pull", |
| 44 | 47 | "planner", |
| 48 | "with_planner", | |
| 45 | 49 | "done_on_main", |
| 46 | 50 | "stalled", |
| 47 | 51 | "run_cap", |
| @@ -115,10 +119,12 @@ export function heldBy( | ||
| 115 | 119 | const planners = new Set( |
| 116 | 120 | crew.filter((c) => c.role !== "builder").map((c) => c.agent.user.id), |
| 117 | 121 | ); |
| 122 | const handed = withPlanner(app.db, repo); | |
| 118 | 123 | const holdOf = (issue: Issue): HoldName | null => { |
| 119 | 124 | if (busyIssues.has(issue.id)) return "running"; |
| 120 | 125 | if (proposed.has(issue.number)) return "open_pull"; |
| 121 | 126 | if (issue.assigneeId !== null && planners.has(issue.assigneeId)) return "planner"; |
| 127 | if (handed.has(issue.id)) return "with_planner"; | |
| 122 | 128 | if (alreadyDone(app.db, [issue.id]).has(issue.id)) return "done_on_main"; |
| 123 | 129 | if (stalled(app, repo, issue)) return "stalled"; |
| 124 | 130 | 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; | ||
| 48 | 48 | |
| 49 | 49 | /** The vitals causes this pager owns; the rest are paged elsewhere or are settings. */ |
| 50 | 50 | export function owned(cause: string): boolean { |
| 51 | return cause === "errors" || cause === "internal" || cause === "disk" || cause.startsWith("queue:"); | |
| 51 | return cause === "errors" || cause === "internal" || cause === "disk" || cause === "release" || cause.startsWith("queue:"); | |
| 52 | 52 | } |
| 53 | 53 | |
| 54 | 54 | /** One owned cause being watched. */ |
src/crew/parks.ts +7 −2
| @@ -23,6 +23,7 @@ import type { Database } from "bun:sqlite"; | ||
| 23 | 23 | import { getIssueById, standsForPerson } from "../core/issues.ts"; |
| 24 | 24 | import type { Repo } from "../core/repos.ts"; |
| 25 | 25 | import { |
| 26 | alreadyDone, | |
| 26 | 27 | BY_CREW, |
| 27 | 28 | DONE_LIKE, |
| 28 | 29 | LATER, |
| @@ -195,7 +196,7 @@ export interface ParkedTwice { | ||
| 195 | 196 | * nothing open. So an issue leaves the list when it is closed (split, or |
| 196 | 197 | * replaced by a fresh one), when it is put behind something open (its |
| 197 | 198 | * parts, or the Later gate), or when someone who directs the crew says what |
| 198 | * to try. One read for every repository, and two more for each that has | |
| 199 | * to try, or when commits on main already name it (`alreadyDone`). One read for every repository, and two more for each that has | |
| 199 | 200 | * any. |
| 200 | 201 | */ |
| 201 | 202 | export function parkedTwice( |
| @@ -288,9 +289,13 @@ export function parkedTwice( | ||
| 288 | 289 | : issue; |
| 289 | 290 | }); |
| 290 | 291 | if (struck.length === 0) continue; |
| 292 | // Finished work is the asker's to close, not the planner's to split. | |
| 293 | const done = alreadyDone(db, struck.map((issue) => issue.issueId)); | |
| 294 | const open = struck.filter((issue) => !done.has(issue.issueId)); | |
| 295 | if (open.length === 0) continue; | |
| 291 | 296 | // An open pull that would close it is someone's answer already. |
| 292 | 297 | const proposed = proposedWork(db, repo); |
| 293 | out.push(...struck.filter((issue) => !proposed.has(issue.number))); | |
| 298 | out.push(...open.filter((issue) => !proposed.has(issue.number))); | |
| 294 | 299 | } |
| 295 | 300 | return out; |
| 296 | 301 | } |
src/crew/runner.ts +2
| @@ -108,6 +108,7 @@ import { | ||
| 108 | 108 | import { clearStaleLocks, lockedOut } from "./locks.ts"; |
| 109 | 109 | import { interruptedSummary } from "./interrupted.ts"; |
| 110 | 110 | import { laterGate } from "./later.ts"; |
| 111 | import { handedLimbs } from "./handed_limbs.ts"; | |
| 111 | 112 | import { parkedTwice } from "./parks.ts"; |
| 112 | 113 | import { heldNow, stuckOf } from "./held.ts"; |
| 113 | 114 | import { noteLimit } from "../core/standing.ts"; |
| @@ -974,6 +975,7 @@ async function work( | ||
| 974 | 975 | ...(brief.role === "planner" && issue.id === repo.directionIssueId |
| 975 | 976 | ? { |
| 976 | 977 | parkedTwice: [...parkedTwice(app.db, [repo]), ...handedOverPulls(app.db, repo)], |
| 978 | limbs: handedLimbs(app, repo), | |
| 977 | 979 | laterGate: laterGate(app.db, repo.id), |
| 978 | 980 | stuck: stuckOf(heldNow(app, repo)), |
| 979 | 981 | } |
src/crew/sentinel.ts +48 −3
| @@ -64,6 +64,8 @@ export interface Finding { | ||
| 64 | 64 | readonly fix?: string; |
| 65 | 65 | /** A setting finding: the value and `updated_at` a person may have kept (#2001). */ |
| 66 | 66 | readonly kept?: { readonly value: string; readonly updatedAt: string }; |
| 67 | /** A degraded finding: when its cause first opened. Same cause and time, same incident (#2553). */ | |
| 68 | readonly incident?: string; | |
| 67 | 69 | } |
| 68 | 70 | |
| 69 | 71 | const clip = (text: string, n = 200): string => |
| @@ -386,7 +388,7 @@ export function repeatedErrors(now: number): Finding[] { | ||
| 386 | 388 | */ |
| 387 | 389 | export function degradedCauses(db: Database, now: number): Finding[] { |
| 388 | 390 | const from = now - DAY_MS; |
| 389 | const byCause = new Map<string, { sentence: string; href: string; first: number; ms: number }>(); | |
| 391 | const byCause = new Map<string, { sentence: string; href: string; first: number; opened: number; ms: number }>(); | |
| 390 | 392 | for (const s of spans(db, from, now)) { |
| 391 | 393 | const start = Math.max(s.openedAt, from); |
| 392 | 394 | const held = Math.min(s.closedAt ?? now, now) - start; |
| @@ -396,6 +398,7 @@ export function degradedCauses(db: Database, now: number): Finding[] { | ||
| 396 | 398 | sentence: s.sentence, |
| 397 | 399 | href: s.href, |
| 398 | 400 | first: seen?.first ?? start, |
| 401 | opened: seen?.opened ?? s.openedAt, | |
| 399 | 402 | ms: (seen?.ms ?? 0) + Math.max(0, held), |
| 400 | 403 | }); |
| 401 | 404 | } |
| @@ -415,6 +418,7 @@ export function degradedCauses(db: Database, now: number): Finding[] { | ||
| 415 | 418 | ], |
| 416 | 419 | pillar: "P0 Trust: the forge says when it is not well, and is well again", |
| 417 | 420 | lookFor: `\`${cause}\` stays out of the degraded list for a day, or holds under ${DEGRADED_DAY_MS / 60_000} minutes.`, |
| 421 | incident: new Date(d.opened).toISOString(), | |
| 418 | 422 | }); |
| 419 | 423 | } |
| 420 | 424 | return out; |
| @@ -626,6 +630,42 @@ export function recordKept(db: Database, key: string, value: string, updatedAt: | ||
| 626 | 630 | ).run(key, value, updatedAt, at); |
| 627 | 631 | } |
| 628 | 632 | |
| 633 | /** | |
| 634 | * A degraded incident whose issue was closed as done and which goes on (same | |
| 635 | * cause, same first-open time) is told to that issue, not filed again: the | |
| 636 | * fix is likely merged but not live. At most one comment a day. True when | |
| 637 | * the incident is that one: the comments made (0 or 1), else undefined. | |
| 638 | */ | |
| 639 | function continuesDone(app: App, repo: Repo, actor: User, f: Finding, evidence: string, at: string, now: number): 0 | 1 | undefined { | |
| 640 | const prior = app.db | |
| 641 | .query<{ issue_id: string; commented_at: string | null }, [string, string]>( | |
| 642 | `SELECT t.issue_id, t.commented_at FROM sentinel_finding t JOIN issue i ON i.id = t.issue_id | |
| 643 | WHERE t.key = ?1 AND t.incident = ?2 AND i.state = 'closed' | |
| 644 | ORDER BY t.first_seen DESC LIMIT 1`, | |
| 645 | ) | |
| 646 | .get(f.key, f.incident ?? ""); | |
| 647 | if (prior === null) return undefined; | |
| 648 | const issue = getIssueById(app.db, prior.issue_id); | |
| 649 | if (issue === undefined || issue.closeReason !== "done") return undefined; | |
| 650 | if (prior.commented_at !== null && now - Date.parse(prior.commented_at) < DAY_MS) return 0; | |
| 651 | const minutes = f.evidence.find((e) => e.startsWith("total: ")); | |
| 652 | const said = addComment( | |
| 653 | app, | |
| 654 | actor, | |
| 655 | repo, | |
| 656 | issue, | |
| 657 | `Still degraded after the fix; the fix may not be live yet. At ${at}${minutes === undefined ? "" : `, ${minutes.slice(7)}`}; evidence now:\n${f.evidence.map((e) => `- ${e}`).join("\n")}`, | |
| 658 | ); | |
| 659 | if (!said.ok) { | |
| 660 | log.warn("sentinel could not comment", { key: f.key, error: String(said.error) }); | |
| 661 | return 0; | |
| 662 | } | |
| 663 | app.db | |
| 664 | .query("UPDATE sentinel_finding SET commented_at = ?1, last_seen = ?1, evidence = ?2 WHERE key = ?3 AND issue_id = ?4") | |
| 665 | .run(at, evidence, f.key, prior.issue_id); | |
| 666 | return 1; | |
| 667 | } | |
| 668 | ||
| 629 | 669 | /** |
| 630 | 670 | * Read, file, record. One open issue per finding: a tracked finding whose |
| 631 | 671 | * issue is open is commented on only when its evidence changed; one whose |
| @@ -674,6 +714,11 @@ export function hunt( | ||
| 674 | 714 | .run(at, evidence, f.key, tracked.issue_id); |
| 675 | 715 | continue; |
| 676 | 716 | } |
| 717 | const told = f.incident === undefined ? undefined : continuesDone(app, repo, actor, f, evidence, at, now); | |
| 718 | if (told !== undefined) { | |
| 719 | commented += told; | |
| 720 | continue; | |
| 721 | } | |
| 677 | 722 | const title = `Sentinel: ${clip(f.title, 200)}`; |
| 678 | 723 | // An issue already filed under this title whose record was lost |
| 679 | 724 | // (the insert failed last hunt) is adopted, not filed twice. |
| @@ -694,8 +739,8 @@ export function hunt( | ||
| 694 | 739 | if (f.urgent && f.kept === undefined) handOver(app, repo, actor, made.value); |
| 695 | 740 | } |
| 696 | 741 | app.db |
| 697 | .query("INSERT INTO sentinel_finding (key, issue_id, evidence, first_seen, last_seen) VALUES (?1, ?2, ?3, ?4, ?4)") | |
| 698 | .run(f.key, issueId, evidence, at); | |
| 742 | .query("INSERT INTO sentinel_finding (key, issue_id, evidence, first_seen, last_seen, incident) VALUES (?1, ?2, ?3, ?4, ?4, ?5)") | |
| 743 | .run(f.key, issueId, evidence, at, f.incident ?? null); | |
| 699 | 744 | } catch (error) { |
| 700 | 745 | log.error("sentinel filing failed", { key: f.key, error: String(error) }); |
| 701 | 746 | } |
src/http/api.ts +28 −3
| @@ -160,6 +160,7 @@ import { | ||
| 160 | 160 | updateProfile, |
| 161 | 161 | } from "../actions/users.ts"; |
| 162 | 162 | import { listEvents } from "../core/events.ts"; |
| 163 | import { audienceFor } from "../core/audience.ts"; | |
| 163 | 164 | import { audienceFilter } from "../core/event_audience.ts"; |
| 164 | 165 | import { |
| 165 | 166 | forkParentFor, |
| @@ -221,7 +222,7 @@ import { | ||
| 221 | 222 | } from "../core/reviews.ts"; |
| 222 | 223 | import { createSession } from "../core/sessions.ts"; |
| 223 | 224 | import { mintProposal } from "../actions/proposal_tokens.ts"; |
| 224 | import { listTokens } from "../core/tokens.ts"; | |
| 225 | import { ADMIN_TOKEN_WORDS, listTokens } from "../core/tokens.ts"; | |
| 225 | 226 | import { |
| 226 | 227 | headInNamespace, |
| 227 | 228 | PROPOSAL_BODY_MAX, |
| @@ -312,6 +313,12 @@ import { | ||
| 312 | 313 | milestonesJson, |
| 313 | 314 | } from "../web/pages/milestones.ts"; |
| 314 | 315 | import { applyLimb, isLimbAction, previewLimb } from "../actions/limbs.ts"; |
| 316 | import { | |
| 317 | isReplanAction, | |
| 318 | previewReplan, | |
| 319 | replanLimb, | |
| 320 | takeBackLimb, | |
| 321 | } from "../actions/limb_replan.ts"; | |
| 315 | 322 | import { appsRuntime, envsFor, type AppsRuntime } from "../apps/runtime.ts"; |
| 316 | 323 | import { isEnvName } from "../apps/data.ts"; |
| 317 | 324 | import { getAppByRepo } from "../core/apps.ts"; |
| @@ -1854,6 +1861,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1854 | 1861 | str(input.name) || "unnamed", |
| 1855 | 1862 | scope, |
| 1856 | 1863 | ); |
| 1864 | if (!minted.ok && minted.error === "forbidden") | |
| 1865 | return apiError("forbidden", 403, ADMIN_TOKEN_WORDS); | |
| 1857 | 1866 | if (!minted.ok) return unauthorized(); |
| 1858 | 1867 | return json(minted.value, 201); |
| 1859 | 1868 | }); |
| @@ -2146,7 +2155,12 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2146 | 2155 | |
| 2147 | 2156 | router.add("GET", "/api/v1/repos/:owner/:name", (ctx) => { |
| 2148 | 2157 | 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(); | |
| 2150 | 2164 | }); |
| 2151 | 2165 | |
| 2152 | 2166 | router.add("PATCH", "/api/v1/repos/:owner/:name", async (ctx) => { |
| @@ -3149,8 +3163,19 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3149 | 3163 | const repo = loadRepo(ctx); |
| 3150 | 3164 | const number = positiveInt(ctx.params.number); |
| 3151 | 3165 | 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 | } | |
| 3152 | 3177 | 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"); | |
| 3154 | 3179 | const request = { |
| 3155 | 3180 | action: input.action, |
| 3156 | 3181 | ...(typeof input.reason === "string" ? { reason: input.reason } : {}), |
src/http/mcp.ts +118 −6
| @@ -130,8 +130,14 @@ import { | ||
| 130 | 130 | } from "../crew/direct.ts"; |
| 131 | 131 | import { setArchived, setCrewOutsiders, setDecisionsBy } from "../actions/repos.ts"; |
| 132 | 132 | import { mapJson, marksFor } from "../web/pages/map.ts"; |
| 133 | import { undoFleetOp } from "../actions/fleet.ts"; | |
| 133 | import { applyFleetIds, previewFleetIds, undoFleetOp } from "../actions/fleet.ts"; | |
| 134 | 134 | import { applyLimb, isLimbAction, previewLimb } from "../actions/limbs.ts"; |
| 135 | import { | |
| 136 | isReplanAction, | |
| 137 | previewReplan, | |
| 138 | replanLimb, | |
| 139 | takeBackLimb, | |
| 140 | } from "../actions/limb_replan.ts"; | |
| 135 | 141 | import { |
| 136 | 142 | milestoneData, |
| 137 | 143 | milestonesJson, |
| @@ -151,6 +157,7 @@ import { | ||
| 151 | 157 | isAskedOrder, |
| 152 | 158 | isAskedShow, |
| 153 | 159 | } from "../core/asked.ts"; |
| 160 | import { audienceFor } from "../core/audience.ts"; | |
| 154 | 161 | import { linksFrom, linksTo } from "../core/thought_links.ts"; |
| 155 | 162 | import { streamJson, thoughtStream } from "../core/thought_stream.ts"; |
| 156 | 163 | import { |
| @@ -264,6 +271,67 @@ function repoArg(ctx: Ctx, user: User, args: Args): Repo | Answer { | ||
| 264 | 271 | : bad("not_found", "no such app or repository (owner/name)"); |
| 265 | 272 | } |
| 266 | 273 | |
| 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 | ||
| 267 | 335 | const APP_ARG = { |
| 268 | 336 | type: "string", |
| 269 | 337 | description: "The app, as owner/name.", |
| @@ -343,6 +411,28 @@ const TOOLS: readonly Tool[] = [ | ||
| 343 | 411 | ); |
| 344 | 412 | }, |
| 345 | 413 | }, |
| 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 | }, | |
| 346 | 436 | { |
| 347 | 437 | name: "here", |
| 348 | 438 | description: |
| @@ -1209,22 +1299,28 @@ const TOOLS: readonly Tool[] = [ | ||
| 1209 | 1299 | { |
| 1210 | 1300 | name: "archive", |
| 1211 | 1301 | 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.", | |
| 1213 | 1303 | inputSchema: { |
| 1214 | 1304 | type: "object", |
| 1215 | 1305 | properties: { |
| 1216 | 1306 | 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 | }, | |
| 1217 | 1313 | archived: { type: "boolean" }, |
| 1218 | 1314 | dryRun: { |
| 1219 | 1315 | type: "boolean", |
| 1220 | 1316 | description: "Plan only (the default). Send false to do it.", |
| 1221 | 1317 | }, |
| 1222 | 1318 | }, |
| 1223 | required: ["repo"], | |
| 1224 | 1319 | }, |
| 1225 | 1320 | writes: true, |
| 1226 | 1321 | dryByDefault: true, |
| 1227 | 1322 | async run(ctx, user, args) { |
| 1323 | if (args["repos"] !== undefined) return archiveMany(ctx, user, args); | |
| 1228 | 1324 | const repo = repoArg(ctx, user, args); |
| 1229 | 1325 | if (isAnswer(repo)) return repo; |
| 1230 | 1326 | const archived = args["archived"]; |
| @@ -1986,7 +2082,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1986 | 2082 | { |
| 1987 | 2083 | name: "limb", |
| 1988 | 2084 | 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.", | |
| 1990 | 2086 | inputSchema: { |
| 1991 | 2087 | type: "object", |
| 1992 | 2088 | properties: { |
| @@ -1996,7 +2092,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1996 | 2092 | minimum: 1, |
| 1997 | 2093 | description: "The limb: the number of its issue.", |
| 1998 | 2094 | }, |
| 1999 | action: { type: "string", enum: ["defer", "resume", "close", "raise", "lower", "undo"] }, | |
| 2095 | action: { type: "string", enum: ["defer", "resume", "close", "raise", "lower", "replan", "takeback", "undo"] }, | |
| 2000 | 2096 | reason: { |
| 2001 | 2097 | type: "string", |
| 2002 | 2098 | description: "For close: why it is not being done, in one sentence.", |
| @@ -2039,13 +2135,29 @@ const TOOLS: readonly Tool[] = [ | ||
| 2039 | 2135 | return undone.ok ? good(undone.value) : refused(undone.error); |
| 2040 | 2136 | } |
| 2041 | 2137 | 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 | } | |
| 2042 | 2154 | if ( |
| 2043 | 2155 | !isLimbAction(args["action"]) || |
| 2044 | 2156 | typeof number !== "number" || |
| 2045 | 2157 | !Number.isInteger(number) || |
| 2046 | 2158 | number < 1 |
| 2047 | 2159 | ) |
| 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"); | |
| 2049 | 2161 | const request = { |
| 2050 | 2162 | action: args["action"], |
| 2051 | 2163 | ...(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 @@ | ||
| 5 | 5 | * verdict. Auth is resolved once per request and handed to handlers as data. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { refusedByDatabase, shownRefusal } from "./refusal.ts"; | |
| 8 | 9 | import { NOT_A_MEMBER } from "../core/roles.ts"; |
| 9 | 10 | import { signupOptions, signupPasskey } from "../web/pages/signup-passkey.ts"; |
| 10 | 11 | import { toolbarComment, toolbarPage } from "../web/studio/toolbar.ts"; |
| @@ -248,6 +249,7 @@ import { | ||
| 248 | 249 | asksMute, |
| 249 | 250 | asksPage, |
| 250 | 251 | } from "../web/studio/asks_page.ts"; |
| 252 | import { homeArchive, homeArchived, homeArchiveUndo } from "../web/studio/home_archive.ts"; | |
| 251 | 253 | import { liveFeedFragment } from "../web/studio/feed.ts"; |
| 252 | 254 | import { serviceWorker } from "../web/sw.ts"; |
| 253 | 255 | import { explorePage } from "../web/studio/explore.ts"; |
| @@ -654,6 +656,9 @@ export function buildRouter(): Router<Handler> { | ||
| 654 | 656 | // Apps and people ranked by real numbers, narrowed in one line (#607). |
| 655 | 657 | router.add("GET", "/explore", explorePage); |
| 656 | 658 | router.add("POST", "/front", frontUpdate); |
| 659 | router.add("POST", "/home/archive", homeArchive); | |
| 660 | router.add("GET", "/home/archived/:op", homeArchived); | |
| 661 | router.add("POST", "/home/archived/:op/undo", homeArchiveUndo); | |
| 657 | 662 | router.add("GET", "/signin", signinPage); |
| 658 | 663 | router.add("POST", "/signin", signinSubmit); |
| 659 | 664 | router.add("GET", "/signin/parent", parentStart); |
| @@ -1853,25 +1858,16 @@ export interface Servers { | ||
| 1853 | 1858 | } |
| 1854 | 1859 | |
| 1855 | 1860 | /** |
| 1856 | * A refusal a database trigger raised (`RAISE(ABORT, '…')`): an operator's | |
| 1857 | * guard saying no, in its own words. It is the answer, not a fault, so the | |
| 1858 | * person sees the reason and a 403 rather than "internal". | |
| 1859 | */ | |
| 1860 | function refusedByDatabase(error: unknown): string | undefined { | |
| 1861 | if (!(error instanceof Error)) return undefined; | |
| 1862 | const code = (error as { code?: unknown }).code; | |
| 1863 | return code === "SQLITE_CONSTRAINT_TRIGGER" ? error.message : undefined; | |
| 1864 | } | |
| 1865 | ||
| 1866 | /** | |
| 1867 | * What a thrown handler is answered with: the guard's own words and a 403 | |
| 1861 | * What a thrown handler is answered with: the guard's words (or a generic | |
| 1862 | * "refused") and a 403 | |
| 1868 | 1863 | * when a database trigger refused, else a 500 that says nothing of the fault. |
| 1869 | 1864 | */ |
| 1870 | 1865 | function failure(app: App, error: unknown): Response { |
| 1871 | 1866 | const refused = refusedByDatabase(error); |
| 1872 | 1867 | if (refused !== undefined) { |
| 1873 | app.log.warn("refused by the database", { reason: refused }); | |
| 1874 | return securityFloor(json({ error: "refused", message: refused }, 403)); | |
| 1868 | const shown = shownRefusal(refused); | |
| 1869 | app.log.warn("refused by the database", { reason: refused, shown }); | |
| 1870 | return securityFloor(json({ error: "refused", message: shown }, 403)); | |
| 1875 | 1871 | } |
| 1876 | 1872 | app.log.error("unhandled request error", { error: String(error) }); |
| 1877 | 1873 | return securityFloor(json({ error: "internal" }, 500)); |
src/kernel/db.ts +14 −5
| @@ -7,7 +7,10 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { Database } from "bun:sqlite"; |
| 9 | 9 | import { existsSync, writeFileSync } from "node:fs"; |
| 10 | import { migrate } from "./migrate.ts"; | |
| 10 | import { createLog } from "./log.ts"; | |
| 11 | import { type Migration, migrate } from "./migrate.ts"; | |
| 12 | ||
| 13 | const log = createLog("db"); | |
| 11 | 14 | |
| 12 | 15 | /** |
| 13 | 16 | * How long a writer waits for another writer before giving up. Long by |
| @@ -34,7 +37,7 @@ const CACHE_KIB = 64 * 1024; | ||
| 34 | 37 | */ |
| 35 | 38 | const MMAP_BYTES = 512 * 1024 * 1024; |
| 36 | 39 | |
| 37 | export function openDb(path: string, migrations: readonly string[]): Database { | |
| 40 | export function openDb(path: string, migrations: readonly Migration[]): Database { | |
| 38 | 41 | const memory = path === ":memory:"; |
| 39 | 42 | const fresh = memory || !existsSync(path); |
| 40 | 43 | const image = fresh ? migratedEmpty.get(migrations) : undefined; |
| @@ -47,8 +50,14 @@ export function openDb(path: string, migrations: readonly string[]): Database { | ||
| 47 | 50 | } |
| 48 | 51 | applyPragmas(db); |
| 49 | 52 | // On a copy every migration is already recorded, so this is a no-op — |
| 50 | // and a list that grew since the copy was taken still runs what is new. | |
| 51 | migrate(db, migrations); | |
| 53 | // and a list that grew since the copy was taken still runs what is new; | |
| 54 | // one behind the copy boots and says what it does not know (#783). | |
| 55 | const { unknown } = migrate(db, migrations); | |
| 56 | if (unknown.length > 0) | |
| 57 | log.warn("database has migrations this build does not know; left as they are", { | |
| 58 | count: unknown.length, | |
| 59 | unknown: unknown.join(","), | |
| 60 | }); | |
| 52 | 61 | if (fresh && image === undefined) migratedEmpty.set(migrations, db.serialize()); |
| 53 | 62 | return db; |
| 54 | 63 | } |
| @@ -63,7 +72,7 @@ export function openDb(path: string, migrations: readonly string[]): Database { | ||
| 63 | 72 | * bytes however it was made: the migrations that draw randomness only |
| 64 | 73 | * rewrite rows an empty database does not have. |
| 65 | 74 | */ |
| 66 | const migratedEmpty = new WeakMap<readonly string[], Uint8Array>(); | |
| 75 | const migratedEmpty = new WeakMap<readonly Migration[], Uint8Array>(); | |
| 67 | 76 | |
| 68 | 77 | /** |
| 69 | 78 | * The image with its header saying "rollback journal" (bytes 18 and 19 of a |
src/kernel/migrate.ts +123 −15
| @@ -11,7 +11,8 @@ | ||
| 11 | 11 | * migration is a row in `schema_migration`, keyed by a hash of its text, and |
| 12 | 12 | * a boot runs exactly the ones whose text it has not seen. A second boot is |
| 13 | 13 | * a no-op; a migration that moved is still recognised; a sibling's migration |
| 14 | * this build does not know is left alone. | |
| 14 | * this build does not know is left alone and named, never a refusal to boot | |
| 15 | * (#783) — each row also carries its migration's file name. | |
| 15 | 16 | * |
| 16 | 17 | * Each statement is also run so that meeting its own result is a no-op — |
| 17 | 18 | * `CREATE` gains `IF NOT EXISTS`, an `ADD COLUMN` whose column is there is |
| @@ -77,10 +78,73 @@ export function migrationId(sql: string): string { | ||
| 77 | 78 | return sha256(sql.replace(/\s+/g, " ").trim()); |
| 78 | 79 | } |
| 79 | 80 | |
| 80 | export function migrate(db: Database, migrations: readonly string[]): void { | |
| 81 | /** Each pinned migration's id, by name: what it said when it was pinned. */ | |
| 82 | export type MigrationPins = Readonly<Record<string, string>>; | |
| 83 | ||
| 84 | /** | |
| 85 | * How `files` differ from what was pinned: one line for each pinned | |
| 86 | * migration whose text says something else now, or that is gone. A | |
| 87 | * migration on main is history — an edited one is a new one, and a | |
| 88 | * database that ran the old text runs the new one again on top of it. | |
| 89 | */ | |
| 90 | export function pinBreaks( | |
| 91 | files: readonly MigrationFile[], | |
| 92 | pins: MigrationPins, | |
| 93 | ): string[] { | |
| 94 | const now = new Map(files.map((m) => [m.name, migrationId(m.sql)])); | |
| 95 | const breaks: string[] = []; | |
| 96 | for (const [name, id] of Object.entries(pins)) { | |
| 97 | const current = now.get(name); | |
| 98 | if (current === undefined) breaks.push(`${name} was pinned and is gone`); | |
| 99 | else if (current !== id) | |
| 100 | breaks.push(`${name} was edited after it was pinned — add a new migration instead`); | |
| 101 | } | |
| 102 | return breaks; | |
| 103 | } | |
| 104 | ||
| 105 | /** | |
| 106 | * `pins` with every migration in `files` it lacks added; a pin already | |
| 107 | * there is never rewritten. Sorted by name, so the file it is written to | |
| 108 | * changes only where a migration was added. | |
| 109 | */ | |
| 110 | export function pinned( | |
| 111 | files: readonly MigrationFile[], | |
| 112 | pins: MigrationPins, | |
| 113 | ): MigrationPins { | |
| 114 | const next: Record<string, string> = { ...pins }; | |
| 115 | for (const m of files) next[m.name] ??= migrationId(m.sql); | |
| 116 | return Object.fromEntries( | |
| 117 | Object.entries(next).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), | |
| 118 | ); | |
| 119 | } | |
| 120 | ||
| 121 | /** | |
| 122 | * A migration as `migrate` takes it: its file, which names it, or bare SQL — | |
| 123 | * a test's own list — which is known by its position alone. | |
| 124 | */ | |
| 125 | export type Migration = string | MigrationFile; | |
| 126 | ||
| 127 | /** What a boot did to the database, by migration name. */ | |
| 128 | export interface Migrated { | |
| 129 | /** The migrations this boot applied, in the order it applied them. */ | |
| 130 | readonly applied: string[]; | |
| 131 | /** | |
| 132 | * What the database has applied that this build does not know: a newer | |
| 133 | * build's or a sibling branch's, on data copied for a preview. Each by | |
| 134 | * the name its build recorded; one recorded with none by its position and | |
| 135 | * the start of its id. Left alone, never undone. | |
| 136 | */ | |
| 137 | readonly unknown: string[]; | |
| 138 | } | |
| 139 | ||
| 140 | export function migrate(db: Database, list: readonly Migration[]): Migrated { | |
| 141 | const migrations = list.map((m) => (typeof m === "string" ? m : m.sql)); | |
| 142 | const names = list.map((m, i) => | |
| 143 | typeof m === "string" ? `migration ${i + 1}` : `migration ${m.name}`, | |
| 144 | ); | |
| 81 | 145 | const ids = migrations.map((sql, i) => { |
| 82 | 146 | if (sql.trim() === "") |
| 83 | defect("db.migration_missing", `migration ${i + 1} is empty`); | |
| 147 | defect("db.migration_missing", `${names[i]} is empty`); | |
| 84 | 148 | return migrationId(sql); |
| 85 | 149 | }); |
| 86 | 150 | const seen = new Set<string>(); |
| @@ -88,22 +152,19 @@ export function migrate(db: Database, migrations: readonly string[]): void { | ||
| 88 | 152 | if (seen.has(id)) |
| 89 | 153 | defect( |
| 90 | 154 | "db.migration_duplicate", |
| 91 | `migration ${i + 1} repeats an earlier one word for word — it would never run`, | |
| 155 | `${names[i]} repeats an earlier one word for word — it would never run`, | |
| 92 | 156 | ); |
| 93 | 157 | seen.add(id); |
| 94 | 158 | }); |
| 95 | 159 | |
| 96 | 160 | const applied = appliedIds(db, migrations, ids); |
| 97 | if (applied.size > migrations.length) { | |
| 98 | defect( | |
| 99 | "db.future_schema", | |
| 100 | `database has applied ${applied.size} migrations, this build only knows ${migrations.length} — refusing to run`, | |
| 101 | ); | |
| 102 | } | |
| 161 | recordNames(db, list, ids); | |
| 162 | const ran: string[] = []; | |
| 103 | 163 | for (let i = 0; i < migrations.length; i++) { |
| 104 | 164 | const id = ids[i]!; |
| 105 | 165 | if (applied.has(id)) continue; |
| 106 | 166 | const sql = migrations[i]!; |
| 167 | const entry = list[i]!; | |
| 107 | 168 | db.transaction(() => { |
| 108 | 169 | for (const statement of statements(sql)) { |
| 109 | 170 | try { |
| @@ -111,19 +172,63 @@ export function migrate(db: Database, migrations: readonly string[]): void { | ||
| 111 | 172 | } catch (error) { |
| 112 | 173 | defect( |
| 113 | 174 | "db.migration_failed", |
| 114 | `migration ${i + 1} failed at ${quote(statement)}: ${messageOf(error)}`, | |
| 175 | `${names[i]} failed at ${quote(statement)}: ${messageOf(error)}`, | |
| 115 | 176 | error, |
| 116 | 177 | ); |
| 117 | 178 | } |
| 118 | 179 | } |
| 119 | 180 | db.query( |
| 120 | "INSERT INTO schema_migration (id, position, applied_at) VALUES (?1, ?2, ?3)", | |
| 121 | ).run(id, i + 1, new Date().toISOString()); | |
| 181 | "INSERT INTO schema_migration (id, position, applied_at, name) VALUES (?1, ?2, ?3, ?4)", | |
| 182 | ).run( | |
| 183 | id, | |
| 184 | i + 1, | |
| 185 | new Date().toISOString(), | |
| 186 | typeof entry === "string" ? null : entry.name, | |
| 187 | ); | |
| 122 | 188 | applied.add(id); |
| 123 | 189 | // PRAGMA can't be parameterized; the value is a count, not input. |
| 124 | 190 | db.exec(`PRAGMA user_version = ${applied.size}`); |
| 125 | 191 | })(); |
| 192 | ran.push(typeof entry === "string" ? String(i + 1) : entry.name); | |
| 126 | 193 | } |
| 194 | const known = new Set(ids); | |
| 195 | const unknown = db | |
| 196 | .query<{ id: string; position: number; name: string | null }, []>( | |
| 197 | "SELECT id, position, name FROM schema_migration ORDER BY position, id", | |
| 198 | ) | |
| 199 | .all() | |
| 200 | .filter((row) => !known.has(row.id)) | |
| 201 | .map((row) => row.name ?? `#${row.position} (${row.id.slice(0, 12)})`); | |
| 202 | return { applied: ran, unknown }; | |
| 203 | } | |
| 204 | ||
| 205 | /** | |
| 206 | * Write each known migration's name beside its id, where a build from | |
| 207 | * before names were recorded left none — so the database says which | |
| 208 | * migrations it has by name, and a later build that does not know one can | |
| 209 | * say which. A database written by test lists keeps its blanks. | |
| 210 | */ | |
| 211 | function recordNames( | |
| 212 | db: Database, | |
| 213 | list: readonly Migration[], | |
| 214 | ids: readonly string[], | |
| 215 | ): void { | |
| 216 | if (!hasColumn(db, "schema_migration", "name")) | |
| 217 | db.exec("ALTER TABLE schema_migration ADD COLUMN name TEXT"); | |
| 218 | const blank = db | |
| 219 | .query<{ n: number }, []>( | |
| 220 | "SELECT COUNT(*) AS n FROM schema_migration WHERE name IS NULL", | |
| 221 | ) | |
| 222 | .get()!.n; | |
| 223 | if (blank === 0) return; | |
| 224 | const name = db.query( | |
| 225 | "UPDATE schema_migration SET name = ?2 WHERE id = ?1 AND name IS NULL", | |
| 226 | ); | |
| 227 | db.transaction(() => { | |
| 228 | list.forEach((m, i) => { | |
| 229 | if (typeof m !== "string") name.run(ids[i]!, m.name); | |
| 230 | }); | |
| 231 | })(); | |
| 127 | 232 | } |
| 128 | 233 | |
| 129 | 234 | /** |
| @@ -132,7 +237,9 @@ export function migrate(db: Database, migrations: readonly string[]): void { | ||
| 132 | 237 | * taken as applied — what that number always meant — unless one plainly |
| 133 | 238 | * did not run (a table it creates or a column it adds is missing: a |
| 134 | 239 | * branch's migration sat at that number instead). They are written down |
| 135 | * once, after which positions never matter again. | |
| 240 | * once, after which positions never matter again. Such a database ahead of | |
| 241 | * this build is still refused: a count names nothing, so there is no | |
| 242 | * telling which of its migrations this build lacks. | |
| 136 | 243 | */ |
| 137 | 244 | function appliedIds( |
| 138 | 245 | db: Database, |
| @@ -153,7 +260,8 @@ function appliedIds( | ||
| 153 | 260 | db.exec(`CREATE TABLE schema_migration ( |
| 154 | 261 | id TEXT PRIMARY KEY, |
| 155 | 262 | position INTEGER NOT NULL, |
| 156 | applied_at TEXT NOT NULL | |
| 263 | applied_at TEXT NOT NULL, | |
| 264 | name TEXT | |
| 157 | 265 | )`); |
| 158 | 266 | const insert = db.query( |
| 159 | 267 | "INSERT INTO schema_migration (id, position, applied_at) VALUES (?1, ?2, ?3)", |
src/kernel/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"; | ||
| 24 | 24 | import { parseCidrs } from "./cidr.ts"; |
| 25 | 25 | import { dataHomeRefusal } from "./data_home.ts"; |
| 26 | 26 | import { applyPragmas } from "./db.ts"; |
| 27 | import { migrate } from "./migrate.ts"; | |
| 27 | import { type Migration, migrate } from "./migrate.ts"; | |
| 28 | 28 | |
| 29 | 29 | /** A scratch directory older than this was left by a killed run. */ |
| 30 | 30 | const STALE_MS = 60 * 60 * 1000; |
| @@ -81,13 +81,14 @@ export function configProblem( | ||
| 81 | 81 | export function preflight( |
| 82 | 82 | env: Record<string, string | undefined>, |
| 83 | 83 | treeDir: string, |
| 84 | migrations: readonly string[], | |
| 84 | migrations: readonly Migration[], | |
| 85 | 85 | ): Preflight { |
| 86 | 86 | const bad = configProblem(env, treeDir); |
| 87 | 87 | if (bad !== null) return { ok: false, line: `preflight failed: ${bad}` }; |
| 88 | 88 | |
| 89 | 89 | const dataDir = dataDirOf(env); |
| 90 | 90 | let scratch: string | undefined; |
| 91 | let unknown: string[] = []; | |
| 91 | 92 | try { |
| 92 | 93 | mkdirSync(dataDir, { recursive: true }); |
| 93 | 94 | sweepStale(dataDir); |
| @@ -101,13 +102,17 @@ export function preflight( | ||
| 101 | 102 | const db = new Database(copy, { create: true, strict: true }); |
| 102 | 103 | try { |
| 103 | 104 | applyPragmas(db); |
| 104 | migrate(db, migrations); | |
| 105 | unknown = migrate(db, migrations).unknown; | |
| 105 | 106 | } finally { |
| 106 | 107 | db.close(); |
| 107 | 108 | } |
| 108 | 109 | return { |
| 109 | 110 | ok: true, |
| 110 | line: `preflight ok: ${migrations.length} migrations apply to a copy of the database`, | |
| 111 | line: | |
| 112 | `preflight ok: ${migrations.length} migrations apply to a copy of the database` + | |
| 113 | (unknown.length > 0 | |
| 114 | ? `; it also has ${unknown.length} this build does not know (${unknown.join(", ")})` | |
| 115 | : ""), | |
| 111 | 116 | }; |
| 112 | 117 | } catch (error) { |
| 113 | 118 | const message = (error instanceof Error ? error.message : String(error)) |
src/kernel/vitals.ts +11 −1
| @@ -23,7 +23,9 @@ import { | ||
| 23 | 23 | import { STALE_AFTER_MS } from "../runs/lease.ts"; |
| 24 | 24 | import { BUDGETS } from "./budgets.ts"; |
| 25 | 25 | import { lastMirror, mirrorNeed } from "../snapshot/mirror.ts"; |
| 26 | import { lastSnapshotOutcome, snapshotNeed } from "../snapshot/outcome.ts"; | |
| 26 | 27 | import { drillNeed, firstSeen as drillFirstSeen, lastVerdict } from "../snapshot/verdict.ts"; |
| 28 | import { proofNeed } from "../release/prove.ts"; | |
| 27 | 29 | import { newestDrills } from "../core/domain_drills.ts"; |
| 28 | 30 | import { DOMAINS } from "./domains.ts"; |
| 29 | 31 | import { domainDrillNeeds } from "./domain_drill.ts"; |
| @@ -50,7 +52,7 @@ export interface EnvCount { | ||
| 50 | 52 | /** |
| 51 | 53 | * One cause of the forge being off: the `needs` sentence, a `cause` key that |
| 52 | 54 | * holds for as long as its condition does (`disk`, `errors`, `internal`, |
| 53 | * `queue:<kind>`, `stuck:<runId>`, `red:<repoId>`, `shared:<repoId>:<test>`), when it began by the forge's own evidence | |
| 55 | * `queue:<kind>`, `release`, `stuck:<runId>`, `red:<repoId>`, `shared:<repoId>:<test>`), when it began by the forge's own evidence | |
| 54 | 56 | * (the oldest evidence the forge holds, else the first time this process saw |
| 55 | 57 | * it, held until the cause clears; `at` is never the answer to a repeat |
| 56 | 58 | * call), and where to look. |
| @@ -384,10 +386,18 @@ export function vitals(input: VitalsInput): Vitals { | ||
| 384 | 386 | )) |
| 385 | 387 | need(owed.cause, owed.sentence, owed.since, "/"); |
| 386 | 388 | |
| 389 | // A snapshot that failed is a card from that morning until one lands. | |
| 390 | const failing = snapshotNeed(lastSnapshotOutcome(input.dataDir)); | |
| 391 | if (failing !== undefined) need("snapshot-failed", failing.sentence, failing.since, "/settings/backups"); | |
| 392 | ||
| 387 | 393 | // A snapshot that did not land off the box, or a mirror a day behind, is a card until it does. |
| 388 | 394 | const behind = mirrorNeed(lastMirror(input.dataDir), now); |
| 389 | 395 | if (behind !== undefined) need("mirror", behind.sentence, behind.since, "/settings/backups"); |
| 390 | 396 | |
| 397 | // A release the forge could not prove against its own public URL is a card until a release does. | |
| 398 | const unproven = proofNeed(input.dataDir); | |
| 399 | if (unproven !== undefined) need("release", unproven.sentence, unproven.since, "/board"); | |
| 400 | ||
| 391 | 401 | const apps = { serving: 0, asleep: 0, previews: 0 }; |
| 392 | 402 | for (const e of input.envs ?? []) { |
| 393 | 403 | if (e.env !== "production") apps.previews++; |
src/main.ts +16 −4
| @@ -54,7 +54,9 @@ import { configProblem, dataDirOf, preflight } from "./kernel/preflight.ts"; | ||
| 54 | 54 | import { running } from "./kernel/version.ts"; |
| 55 | 55 | import { record as recordDegraded } from "./kernel/degraded_history.ts"; |
| 56 | 56 | import { vitals } from "./kernel/vitals.ts"; |
| 57 | import { proveRelease } from "./release/prove.ts"; | |
| 57 | 58 | import { snapshotPlace } from "./actions/health.ts"; |
| 59 | import { capacityDefault } from "./core/capacity.ts"; | |
| 58 | 60 | |
| 59 | 61 | // `forge hook <name>` re-enters here from a git hook stub. It relays to the |
| 60 | 62 | // running forge over its socket and exits — it must never boot a server. |
| @@ -72,8 +74,8 @@ if (process.argv[2] === "hook") { | ||
| 72 | 74 | // config parsed, migrations run on a copy of the database. No socket, no |
| 73 | 75 | // timers, and the live database is never opened. |
| 74 | 76 | if (process.argv.includes("--preflight")) { |
| 75 | const { migrations } = await import("./core/schema.ts"); | |
| 76 | const result = preflight(process.env, import.meta.dir, migrations); | |
| 77 | const { migrationFiles } = await import("./core/schema.ts"); | |
| 78 | const result = preflight(process.env, import.meta.dir, migrationFiles); | |
| 77 | 79 | // CLI answer on the stream a person or a script reads. |
| 78 | 80 | (result.ok ? console.log : console.error)(result.line); |
| 79 | 81 | process.exit(result.ok ? 0 : 1); |
| @@ -176,6 +178,8 @@ if (parentProblem !== null) { | ||
| 176 | 178 | |
| 177 | 179 | const app = createApp({ |
| 178 | 180 | dataDir, |
| 181 | // A new forge starts with signup = invite (FORGE_SIGNUP below still wins). | |
| 182 | seedSignup: true, | |
| 179 | 183 | ...(parentUrl === "" ? {} : { parentUrl }), |
| 180 | 184 | port: Number(process.env.PORT ?? 3000), |
| 181 | 185 | ...(process.env.FORGE_HOST === undefined |
| @@ -332,7 +336,8 @@ const checks = | ||
| 332 | 336 | ? undefined |
| 333 | 337 | : startChecks(app, { |
| 334 | 338 | forgeUrl, |
| 335 | concurrency: positive(process.env.FORGE_CHECKS_CONCURRENCY, 1), | |
| 339 | // Sized to the pod's CPUs unless the environment says (#2549). | |
| 340 | concurrency: positive(process.env.FORGE_CHECKS_CONCURRENCY, Number(capacityDefault("checks.concurrency"))), | |
| 336 | 341 | redHoldMs: RED_HOLD_MS, |
| 337 | 342 | // A roll lets running checks finish this long before cutting them off. |
| 338 | 343 | drainMs: positive(process.env.FORGE_CHECKS_DRAIN_S, 300) * 1000, |
| @@ -379,7 +384,7 @@ const crew = appsOn && !crewHeldOff | ||
| 379 | 384 | forgeUrl, |
| 380 | 385 | localUrl: servers.url, |
| 381 | 386 | binary: process.env.FORGE_CLAUDE ?? "claude", |
| 382 | concurrency: positive(process.env.FORGE_CREW_CONCURRENCY, hosted ? 1 : 2), | |
| 387 | concurrency: positive(process.env.FORGE_CREW_CONCURRENCY, hosted ? 1 : Number(capacityDefault("crew.concurrency"))), | |
| 383 | 388 | budgetUsd: Number(process.env.FORGE_CREW_BUDGET_USD) || 5, |
| 384 | 389 | runTimeoutMs: crewRunMs, |
| 385 | 390 | allowHosts: (process.env.FORGE_CREW_ALLOW_HOSTS ?? "") |
| @@ -478,6 +483,13 @@ const staleTimer = setInterval(() => void tickAsks(), 120_000); | ||
| 478 | 483 | void tickAsks(); |
| 479 | 484 | |
| 480 | 485 | app.log.info("forge is up", { url: servers.url, data: app.config.dataDir }); |
| 486 | // A new version proves itself against the public URL once (release/prove.ts). | |
| 487 | void proveRelease({ | |
| 488 | dataDir: app.config.dataDir, | |
| 489 | version: running().version, | |
| 490 | url: forgeUrl, | |
| 491 | token: process.env["FORGE_TOKEN"], | |
| 492 | }).then((p) => p && app.log.info("release proof", { version: p.version, ok: String(p.ok), ...(p.failure ? { failure: p.failure } : {}) })); | |
| 481 | 493 | // Only an agent is on a crew (the `repo_crew_agents_only` trigger refuses anyone else). A person |
| 482 | 494 | // put on one before that is reported here for the owner to take off — the |
| 483 | 495 | // forge never removes it on its own. |
src/release/prove.ts added +109
| @@ -0,0 +1,109 @@ | ||
| 1 | /** | |
| 2 | * Is this release so: once per version, after a boot that changed it, the | |
| 3 | * forge waits for its own public URL to answer `/healthz` as that version, | |
| 4 | * then runs the smoke checks (`scripts/smoke.ts`) against it. The verdict is | |
| 5 | * one small file under the data directory (`release/proof.json`), so a | |
| 6 | * restart of the same version never runs it again and vitals reads it | |
| 7 | * without the network. A failure is a `release` card, paged once by the | |
| 8 | * cause pager, until a later version passes. It gives up after ten minutes. | |
| 9 | */ | |
| 10 | ||
| 11 | import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { smoke, type Failure } from "../../scripts/smoke.ts"; | |
| 14 | import { UNSTAMPED } from "../kernel/version.ts"; | |
| 15 | ||
| 16 | /** How long the public URL has to answer as the new version. */ | |
| 17 | export const GIVE_UP_MS = 10 * 60_000; | |
| 18 | const POLL_MS = 2_000; | |
| 19 | ||
| 20 | export interface Proof { | |
| 21 | readonly version: string; | |
| 22 | readonly at: string; | |
| 23 | readonly ok: boolean; | |
| 24 | /** The first failing check, or the give-up, when `ok` is false. */ | |
| 25 | readonly failure?: string; | |
| 26 | } | |
| 27 | ||
| 28 | const proofFile = (dataDir: string): string => join(dataDir, "release", "proof.json"); | |
| 29 | ||
| 30 | /** The last verdict kept, or undefined when none is or the file is not one. */ | |
| 31 | export function lastProof(dataDir: string): Proof | undefined { | |
| 32 | try { | |
| 33 | const p = JSON.parse(readFileSync(proofFile(dataDir), "utf8")) as Proof; | |
| 34 | if (typeof p.version !== "string" || typeof p.at !== "string" || typeof p.ok !== "boolean") return undefined; | |
| 35 | return p; | |
| 36 | } catch { | |
| 37 | return undefined; | |
| 38 | } | |
| 39 | } | |
| 40 | ||
| 41 | function keep(dataDir: string, proof: Proof): Proof { | |
| 42 | mkdirSync(join(dataDir, "release"), { recursive: true }); | |
| 43 | const tmp = `${proofFile(dataDir)}.tmp`; | |
| 44 | writeFileSync(tmp, JSON.stringify(proof)); | |
| 45 | renameSync(tmp, proofFile(dataDir)); | |
| 46 | return proof; | |
| 47 | } | |
| 48 | ||
| 49 | /** The card an unproven release is, or undefined while the last verdict is a pass. */ | |
| 50 | export function proofNeed(dataDir: string): { sentence: string; since: string } | undefined { | |
| 51 | const p = lastProof(dataDir); | |
| 52 | if (p === undefined || p.ok) return undefined; | |
| 53 | return { sentence: `Release ${p.version} is unproven: ${p.failure ?? "a smoke check failed"}.`, since: p.at }; | |
| 54 | } | |
| 55 | ||
| 56 | /** A failing check as one line: where, as whom, and why. */ | |
| 57 | export function checkLine(f: Failure): string { | |
| 58 | return `${f.url}${f.agent === "-" ? "" : ` as ${f.agent}`} ${f.why} (status ${f.status})`; | |
| 59 | } | |
| 60 | ||
| 61 | export interface ProveOptions { | |
| 62 | readonly dataDir: string; | |
| 63 | readonly version: string; | |
| 64 | /** The forge's public URL. */ | |
| 65 | readonly url: string; | |
| 66 | /** Signs `/board` in; without it a redirect to sign-in passes. */ | |
| 67 | readonly token?: string | undefined; | |
| 68 | readonly fetch?: typeof fetch; | |
| 69 | readonly now?: () => number; | |
| 70 | readonly sleep?: (ms: number) => Promise<void>; | |
| 71 | readonly giveUpMs?: number; | |
| 72 | } | |
| 73 | ||
| 74 | /** | |
| 75 | * Proves `version` unless it was proven (or failed) before. Returns what was | |
| 76 | * kept, or undefined when nothing ran. Never throws. | |
| 77 | */ | |
| 78 | export async function proveRelease(o: ProveOptions): Promise<Proof | undefined> { | |
| 79 | if (o.version === UNSTAMPED || lastProof(o.dataDir)?.version === o.version) return undefined; | |
| 80 | const now = o.now ?? Date.now; | |
| 81 | const sleep = o.sleep ?? Bun.sleep; | |
| 82 | const doFetch = o.fetch ?? fetch; | |
| 83 | const finish = (failure?: string): Proof => | |
| 84 | keep(o.dataDir, { | |
| 85 | version: o.version, | |
| 86 | at: new Date(now()).toISOString(), | |
| 87 | ok: failure === undefined, | |
| 88 | ...(failure === undefined ? {} : { failure }), | |
| 89 | }); | |
| 90 | try { | |
| 91 | const health = new URL("/healthz", o.url).href; | |
| 92 | const deadline = now() + (o.giveUpMs ?? GIVE_UP_MS); | |
| 93 | for (;;) { | |
| 94 | try { | |
| 95 | const res = await doFetch(health, { redirect: "manual", signal: AbortSignal.timeout(5_000) }); | |
| 96 | await res.body?.cancel(); | |
| 97 | if (res.headers.get("x-forge-version") === o.version) break; | |
| 98 | } catch { | |
| 99 | // not up yet at its public address | |
| 100 | } | |
| 101 | if (now() + POLL_MS > deadline) return finish(`the public URL never answered as ${o.version}`); | |
| 102 | await sleep(POLL_MS); | |
| 103 | } | |
| 104 | const failures = await smoke(o.url, o.token ?? "", { fetch: doFetch, anonymousBoard: true }); | |
| 105 | return finish(failures[0] === undefined ? undefined : checkLine(failures[0])); | |
| 106 | } catch (error) { | |
| 107 | return finish(`the smoke checks could not run: ${String(error)}`); | |
| 108 | } | |
| 109 | } |
src/release/rollback.ts +3 −2
| @@ -4,8 +4,9 @@ | ||
| 4 | 4 | * each take one line: the release is not kept, or its schema is behind the |
| 5 | 5 | * live database's (a down-migration is not something this forge has). |
| 6 | 6 | * A release whose schema is ahead of the live database is fine: it migrates |
| 7 | * forward on boot, and the database's own refusal of a future schema is | |
| 8 | * untouched. | |
| 7 | * forward on boot. A release behind it is refused here, not by the | |
| 8 | * database: a build boots on migrations it does not know and only names | |
| 9 | * them (#783). | |
| 9 | 10 | */ |
| 10 | 11 | |
| 11 | 12 | import { Database } from "bun:sqlite"; |
src/settings/signup.ts +20 −2
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Who may make an account: `open`, `invite` or `closed`. Stored in |
| 3 | 3 | * `forge_setting` under `signup`, and every change is kept in |
| 4 | * `forge_setting_change` (who, when, from what to what). Default `open`. | |
| 4 | * `forge_setting_change` (who, when, from what to what). Default `open` when nothing is stored; a new forge stores `invite` (`seedSignup`). | |
| 5 | 5 | * `/signup`, `POST /api/v1/users`, passkey signup and a first edge sign-in ask |
| 6 | 6 | * it (`signupClosed` in `actions/users.ts`); `invite` lets in only a person with |
| 7 | 7 | * an open invite link (`core/invites.ts`), spent with the account. |
| @@ -36,6 +36,22 @@ export function getSignup(db: Database): SignupMode { | ||
| 36 | 36 | return parsed.ok ? parsed.value : SIGNUP_DEFAULT; |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | /** The actor kept for the value a new forge starts with. */ | |
| 40 | export const FRESH_ACTOR = "fresh"; | |
| 41 | ||
| 42 | /** | |
| 43 | * What a new forge starts with: `invite`, stored so it is the forge's own | |
| 44 | * value from then on. Only a forge that made its database just now is new | |
| 45 | * (`fresh`); one that stored a value keeps it, and one that never stored any | |
| 46 | * keeps the `open` it has always read as. A spawned child is a fresh data | |
| 47 | * directory, so it starts here too. | |
| 48 | */ | |
| 49 | export function seedSignup(db: Database, fresh: boolean): void { | |
| 50 | if (!fresh) return; | |
| 51 | const stored = db.query("SELECT 1 FROM forge_setting WHERE key = ?1").get(STORE_KEY); | |
| 52 | if (stored === null) writeSetting(db, STORE_KEY, "invite", SIGNUP_DEFAULT, "invite", FRESH_ACTOR); | |
| 53 | } | |
| 54 | ||
| 39 | 55 | /** Set the mode and keep the change; the same mode again keeps nothing. */ |
| 40 | 56 | export function setSignup(db: Database, value: SignupMode, actorId: string): void { |
| 41 | 57 | const from = getSignup(db); |
| @@ -77,7 +93,9 @@ export function signupChanges(db: Database, limit = 20): SignupChange[] { | ||
| 77 | 93 | .map((r) => ({ |
| 78 | 94 | from: r.from_value, |
| 79 | 95 | to: r.to_value, |
| 80 | actorName: r.username ?? (r.actor_id === BOOT_ACTOR ? "FORGE_SIGNUP at boot" : ""), | |
| 96 | actorName: | |
| 97 | r.username ?? | |
| 98 | (r.actor_id === BOOT_ACTOR ? "FORGE_SIGNUP at boot" : r.actor_id === FRESH_ACTOR ? "new forge" : ""), | |
| 81 | 99 | at: r.at, |
| 82 | 100 | })); |
| 83 | 101 | } |
src/snapshot/drill.ts +1 −1
| @@ -56,7 +56,7 @@ class StepTimeout extends Error {} | ||
| 56 | 56 | function within<T>(work: Promise<T>, ms: number): Promise<T> { |
| 57 | 57 | let timer: ReturnType<typeof setTimeout>; |
| 58 | 58 | const late = new Promise<never>((_, reject) => { |
| 59 | timer = setTimeout(() => reject(new StepTimeout(`no answer in ${ms} ms`)), ms); | |
| 59 | timer = setTimeout(() => reject(new StepTimeout(`no answer in ${Math.round(ms)} ms`)), ms); | |
| 60 | 60 | }); |
| 61 | 61 | return Promise.race([work, late]).finally(() => clearTimeout(timer)); |
| 62 | 62 | } |
src/snapshot/due.ts +23 −7
| @@ -2,11 +2,21 @@ | ||
| 2 | 2 | * When the weekly drill is owed: every Sunday 09:00 UTC is a slot, and a |
| 3 | 3 | * slot is kept by one drill at or after it, however it was asked for. A |
| 4 | 4 | * forge that was down at the slot runs it on waking; one that restarts |
| 5 | * after the drill ran does not run it twice. Plain data in, plain data out. | |
| 5 | * after the drill ran does not run it twice. A red drill keeps no slot for | |
| 6 | * long: it is owed again `RED_RETRY_MS` after it ran, and on a boot once it | |
| 7 | * is `BOOT_RED_FLOOR_MS` old, since a restart is most often the deploy of | |
| 8 | * its fix (#2541: a drill red at 09:11 stood as a card for the week after | |
| 9 | * the fix landed at 10:18). The floor keeps a crash loop with a broken | |
| 10 | * restore from running a restore on every boot. Plain data in, plain data out. | |
| 6 | 11 | */ |
| 7 | 12 | |
| 8 | 13 | const DAY = 86_400_000; |
| 9 | 14 | |
| 15 | /** A red drill is owed again this long after it ran. */ | |
| 16 | export const RED_RETRY_MS = 3 * 3_600_000; | |
| 17 | /** On a boot, a red drill at least this old is owed again. */ | |
| 18 | export const BOOT_RED_FLOOR_MS = 30 * 60_000; | |
| 19 | ||
| 10 | 20 | /** The latest Sunday 09:00 UTC at or before `now`, in ms. */ |
| 11 | 21 | export function lastSlot(now: number): number { |
| 12 | 22 | const d = new Date(now); |
| @@ -17,14 +27,20 @@ export function lastSlot(now: number): number { | ||
| 17 | 27 | } |
| 18 | 28 | |
| 19 | 29 | /** |
| 20 | * Whether a drill is owed at `now`. `lastAt` is the newest drill's time, | |
| 21 | * `since` when the forge first owed one; a forge younger than the slot | |
| 22 | * owes nothing for it. | |
| 30 | * Whether a drill is owed at `now`. `last` is the newest drill, `since` | |
| 31 | * when the forge first owed one; a forge younger than the slot owes | |
| 32 | * nothing for it. `redRetryMs` is how long a red drill stands before it is | |
| 33 | * owed again; a boot passes `BOOT_RED_FLOOR_MS`. | |
| 23 | 34 | */ |
| 24 | export function drillDue(lastAt: string | undefined, since: string | undefined, now: number): boolean { | |
| 35 | export function drillDue( | |
| 36 | last: { readonly at: string; readonly verdict: "green" | "red" } | undefined, | |
| 37 | since: string | undefined, | |
| 38 | now: number, | |
| 39 | redRetryMs: number = RED_RETRY_MS, | |
| 40 | ): boolean { | |
| 25 | 41 | const slot = lastSlot(now); |
| 26 | const last = Date.parse(lastAt ?? ""); | |
| 27 | if (Number.isFinite(last)) return last < slot; | |
| 42 | const at = Date.parse(last?.at ?? ""); | |
| 43 | if (Number.isFinite(at)) return at < slot || (last?.verdict === "red" && now - at >= redRetryMs); | |
| 28 | 44 | const began = Date.parse(since ?? ""); |
| 29 | 45 | return Number.isFinite(began) && began < slot; |
| 30 | 46 | } |
src/snapshot/outcome.ts added +70
| @@ -0,0 +1,70 @@ | ||
| 1 | /** | |
| 2 | * How the last snapshot went, kept where the board can read it. A nightly | |
| 3 | * that failed used to be one line in the log and nothing else: two nights | |
| 4 | * of no off-box backup went by unseen (2026-10-17, #2536). Each attempt's | |
| 5 | * outcome is one small file under the data directory | |
| 6 | * (`snapshot/state.json`), written when it ends, so vitals and Backups read | |
| 7 | * it without the network and a restart forgets nothing. A failure is a | |
| 8 | * card from the moment it is written until a snapshot lands. | |
| 9 | */ | |
| 10 | ||
| 11 | import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; | |
| 12 | import { join } from "node:path"; | |
| 13 | ||
| 14 | export interface SnapshotOutcome { | |
| 15 | /** When the last attempt ended. */ | |
| 16 | readonly at: string; | |
| 17 | /** The newest snapshot that landed, and when; absent until one has. */ | |
| 18 | readonly landed?: string; | |
| 19 | readonly landedAt?: string; | |
| 20 | /** Set while attempts are failing: since when, and the last one's reason. */ | |
| 21 | readonly failingSince?: string; | |
| 22 | readonly error?: string; | |
| 23 | } | |
| 24 | ||
| 25 | const stateFile = (dataDir: string): string => join(dataDir, "snapshot", "state.json"); | |
| 26 | ||
| 27 | /** The outcome kept, or undefined when none is or the file is not one. */ | |
| 28 | export function lastSnapshotOutcome(dataDir: string): SnapshotOutcome | undefined { | |
| 29 | try { | |
| 30 | const s = JSON.parse(readFileSync(stateFile(dataDir), "utf8")) as SnapshotOutcome; | |
| 31 | if (typeof s.at !== "string") return undefined; | |
| 32 | return s; | |
| 33 | } catch { | |
| 34 | return undefined; | |
| 35 | } | |
| 36 | } | |
| 37 | ||
| 38 | /** Keep an attempt's end: a name that landed, or the error it failed with. Returns what was kept. */ | |
| 39 | export function recordSnapshotOutcome( | |
| 40 | dataDir: string, | |
| 41 | result: { readonly landed: string } | { readonly error: string }, | |
| 42 | at: string = new Date().toISOString(), | |
| 43 | ): SnapshotOutcome { | |
| 44 | const prev = lastSnapshotOutcome(dataDir); | |
| 45 | const kept = { | |
| 46 | ...(prev?.landed === undefined ? {} : { landed: prev.landed }), | |
| 47 | ...(prev?.landedAt === undefined ? {} : { landedAt: prev.landedAt }), | |
| 48 | }; | |
| 49 | const next: SnapshotOutcome = | |
| 50 | "landed" in result | |
| 51 | ? { at, landed: result.landed, landedAt: at } | |
| 52 | : { at, ...kept, failingSince: prev?.failingSince ?? at, error: result.error }; | |
| 53 | mkdirSync(join(dataDir, "snapshot"), { recursive: true }); | |
| 54 | const tmp = `${stateFile(dataDir)}.tmp`; | |
| 55 | writeFileSync(tmp, JSON.stringify(next)); | |
| 56 | renameSync(tmp, stateFile(dataDir)); | |
| 57 | return next; | |
| 58 | } | |
| 59 | ||
| 60 | /** The card a failing snapshot is, or undefined while the last one landed. */ | |
| 61 | export function snapshotNeed( | |
| 62 | s: SnapshotOutcome | undefined, | |
| 63 | ): { sentence: string; since: string } | undefined { | |
| 64 | if (s?.failingSince === undefined) return undefined; | |
| 65 | const last = s.landedAt ? ` The newest that landed is from ${s.landedAt.slice(0, 10)}.` : ""; | |
| 66 | return { | |
| 67 | sentence: `The last snapshot failed: ${(s.error ?? "unknown reason").replace(/\.$/, "")}.${last} The forge tries again within the hour.`, | |
| 68 | since: s.failingSince, | |
| 69 | }; | |
| 70 | } |
src/snapshot/parts.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | /** | |
| 2 | * A large file packed for a slow uplink: zstd over the whole of it, then | |
| 3 | * cut into parts of `PART_BYTES`, each its own object. Each part is one put | |
| 4 | * with its own deadline and its own retries, so a link that drops halfway | |
| 5 | * loses one part's progress, never the file's; and the mirror, which copies | |
| 6 | * object by object, moves parts the same way. | |
| 7 | * | |
| 8 | * The parts of `forge.db` are `forge.db.zst.0000`, `forge.db.zst.0001`, …; | |
| 9 | * the manifest lists them as files, like any other, and says once in | |
| 10 | * `packed` what they make together and how big that is. A restore reads | |
| 11 | * the parts back in order, joins them, and unpacks to exactly the bytes | |
| 12 | * that went in, or refuses. | |
| 13 | * | |
| 14 | * Plain data in, plain data out: no store, no files. | |
| 15 | */ | |
| 16 | ||
| 17 | /** A part's size: one put carries it within its own deadline at the floor rate (27 s at 5 Mbit/s). */ | |
| 18 | export const PART_BYTES = 16 * 1_048_576; | |
| 19 | ||
| 20 | /** What the manifest says of a file that went up in parts. */ | |
| 21 | export interface PackedFile { | |
| 22 | /** The file's own path, as a restore writes it back. */ | |
| 23 | readonly path: string; | |
| 24 | /** Its size as it was, before compression. */ | |
| 25 | readonly bytes: number; | |
| 26 | /** Its size compressed: the parts' sizes added. */ | |
| 27 | readonly packedBytes: number; | |
| 28 | readonly parts: number; | |
| 29 | } | |
| 30 | ||
| 31 | /** The key of part `n` of `path`, under a snapshot's prefix. */ | |
| 32 | export const partPath = (path: string, n: number): string => | |
| 33 | `${path}.zst.${String(n).padStart(4, "0")}`; | |
| 34 | ||
| 35 | /** Every part path of a packed file, in order. */ | |
| 36 | export const partPaths = (p: PackedFile): string[] => | |
| 37 | Array.from({ length: p.parts }, (_, n) => partPath(p.path, n)); | |
| 38 | ||
| 39 | /** A path a restore may write under its data directory: relative, and never climbing out. */ | |
| 40 | export const safePath = (path: string): boolean => | |
| 41 | path !== "" && | |
| 42 | !path.startsWith("/") && | |
| 43 | !path.includes("\\") && | |
| 44 | !path.split("/").some((s) => s === "" || s === "." || s === ".."); | |
| 45 | ||
| 46 | /** | |
| 47 | * `body` compressed and cut in parts. The parts are views of one buffer, | |
| 48 | * not copies; an empty body is one empty-content part, so every packed file | |
| 49 | * has at least one. | |
| 50 | */ | |
| 51 | export async function pack( | |
| 52 | path: string, | |
| 53 | body: Uint8Array, | |
| 54 | partBytes = PART_BYTES, | |
| 55 | ): Promise<{ packed: PackedFile; parts: Uint8Array[] }> { | |
| 56 | if (!safePath(path)) throw new Error(`refusing to pack ${path}`); | |
| 57 | if (!(partBytes > 0)) throw new Error(`a part of ${partBytes} bytes`); | |
| 58 | const zst = new Uint8Array(await Bun.zstdCompress(body)); | |
| 59 | const parts: Uint8Array[] = []; | |
| 60 | for (let at = 0; at < zst.byteLength || parts.length === 0; at += partBytes) | |
| 61 | parts.push(zst.subarray(at, at + partBytes)); | |
| 62 | return { | |
| 63 | packed: { | |
| 64 | path, | |
| 65 | bytes: body.byteLength, | |
| 66 | packedBytes: zst.byteLength, | |
| 67 | parts: parts.length, | |
| 68 | }, | |
| 69 | parts, | |
| 70 | }; | |
| 71 | } | |
| 72 | ||
| 73 | /** The file the parts make, checked against what the manifest says it was. */ | |
| 74 | export async function unpack( | |
| 75 | p: PackedFile, | |
| 76 | parts: readonly Uint8Array[], | |
| 77 | ): Promise<Uint8Array> { | |
| 78 | if (parts.length !== p.parts) | |
| 79 | throw new Error(`${p.path} came back in ${parts.length} parts, not ${p.parts}`); | |
| 80 | const total = parts.reduce((n, b) => n + b.byteLength, 0); | |
| 81 | if (total !== p.packedBytes) | |
| 82 | throw new Error(`${p.path}'s parts are ${total} bytes, not ${p.packedBytes}`); | |
| 83 | const joined = new Uint8Array(total); | |
| 84 | let at = 0; | |
| 85 | for (const b of parts) { | |
| 86 | joined.set(b, at); | |
| 87 | at += b.byteLength; | |
| 88 | } | |
| 89 | const body = new Uint8Array(await Bun.zstdDecompress(joined)); | |
| 90 | if (body.byteLength !== p.bytes) | |
| 91 | throw new Error(`${p.path} unpacks to ${body.byteLength} bytes, not ${p.bytes}`); | |
| 92 | return body; | |
| 93 | } | |
| 94 | ||
| 95 | /** Whether a manifest's `packed` entry reads whole: the restore trusts nothing else. */ | |
| 96 | export function isPackedFile(v: unknown): v is PackedFile { | |
| 97 | if (typeof v !== "object" || v === null) return false; | |
| 98 | const p = v as Record<string, unknown>; | |
| 99 | const count = (n: unknown): boolean => Number.isSafeInteger(n) && (n as number) >= 0; | |
| 100 | return ( | |
| 101 | typeof p["path"] === "string" && | |
| 102 | safePath(p["path"]) && | |
| 103 | count(p["bytes"]) && | |
| 104 | count(p["packedBytes"]) && | |
| 105 | count(p["parts"]) && | |
| 106 | (p["parts"] as number) >= 1 | |
| 107 | ); | |
| 108 | } |
src/snapshot/restore.ts +142 −28
| @@ -16,6 +16,10 @@ | ||
| 16 | 16 | * with the same `S3_*` variables the forge runs under, or |
| 17 | 17 | * `FORGE_SNAPSHOT_DIR` for a directory store. |
| 18 | 18 | * |
| 19 | * A file that went up compressed in parts (the database, `parts.ts`) | |
| 20 | * comes back whole: its parts read in order, joined, unpacked, and checked | |
| 21 | * against the size the manifest gives it. | |
| 22 | * | |
| 19 | 23 | * The key comes back only from its wrapped form and the operator's |
| 20 | 24 | * passphrase (`FORGE_SNAPSHOT_PASSPHRASE`). A wrong passphrase refuses the |
| 21 | 25 | * restore; none leaves the key out, and every sealed value that will not |
| @@ -28,6 +32,8 @@ import { runGit } from "../git/env.ts"; | ||
| 28 | 32 | import { installHooks } from "../git/hooks.ts"; |
| 29 | 33 | import { repoDir } from "../git/repos.ts"; |
| 30 | 34 | import { |
| 35 | IN_FLIGHT, | |
| 36 | IN_FLIGHT_BYTES, | |
| 31 | 37 | isRunBundle, |
| 32 | 38 | keyOf, |
| 33 | 39 | listSnapshots, |
| @@ -36,6 +42,7 @@ import { | ||
| 36 | 42 | type Manifest, |
| 37 | 43 | } from "./snapshot.ts"; |
| 38 | 44 | import { unwrapKey, WRAPPED_KEY } from "./wrap.ts"; |
| 45 | import { isPackedFile, partPaths, unpack, type PackedFile } from "./parts.ts"; | |
| 39 | 46 | import { resolveDestination } from "./destination.ts"; |
| 40 | 47 | import type { Store } from "./store.ts"; |
| 41 | 48 | |
| @@ -49,6 +56,9 @@ export interface Restored { | ||
| 49 | 56 | readonly sealedMissing: readonly string[]; |
| 50 | 57 | } |
| 51 | 58 | |
| 59 | /** Repositories cloned at once while restoring. */ | |
| 60 | export const CLONES = 4; | |
| 61 | ||
| 52 | 62 | /** Names of every sealed value in a restored database; never the values. */ |
| 53 | 63 | async function sealedNames(dbFile: string): Promise<string[]> { |
| 54 | 64 | const { Database } = await import("bun:sqlite"); |
| @@ -74,11 +84,67 @@ async function sealedNames(dbFile: string): Promise<string[]> { | ||
| 74 | 84 | } |
| 75 | 85 | } |
| 76 | 86 | |
| 87 | /** | |
| 88 | * Every file the manifest lists, read with several gets in flight at once | |
| 89 | * and checked against its size, each handed to `each` as it lands. Read | |
| 90 | * one after another, the restore drill of a snapshot with thousands of | |
| 91 | * objects ran out of its ten minutes in the restore step (2026-10-18), as | |
| 92 | * the upload once ran an hour on latency alone; the same limits bound both. | |
| 93 | */ | |
| 94 | async function fetchAll( | |
| 95 | store: Store, | |
| 96 | prefix: string, | |
| 97 | entries: readonly { readonly path: string; readonly bytes: number }[], | |
| 98 | each: (entry: { readonly path: string; readonly bytes: number }, body: Uint8Array, at: number) => Promise<void>, | |
| 99 | name: string, | |
| 100 | maxBytes: number, | |
| 101 | ): Promise<void> { | |
| 102 | const pending = new Set<Promise<void>>(); | |
| 103 | let failure: unknown; | |
| 104 | let held = 0; | |
| 105 | for (const [at, entry] of entries.entries()) { | |
| 106 | while ( | |
| 107 | (pending.size >= IN_FLIGHT || | |
| 108 | (pending.size > 0 && held + entry.bytes > maxBytes)) && | |
| 109 | failure === undefined | |
| 110 | ) | |
| 111 | await Promise.race(pending); | |
| 112 | if (failure !== undefined) break; | |
| 113 | held += entry.bytes; | |
| 114 | const run: Promise<void> = (async () => { | |
| 115 | const body = await store.get(prefix + entry.path); | |
| 116 | if (body === undefined) | |
| 117 | throw new Error(`snapshot ${name} is missing ${entry.path}`); | |
| 118 | if (body.byteLength !== entry.bytes) | |
| 119 | throw new Error( | |
| 120 | `snapshot ${name}: ${entry.path} is ${body.byteLength} bytes, not ${entry.bytes}`, | |
| 121 | ); | |
| 122 | await each(entry, body, at); | |
| 123 | })() | |
| 124 | .catch((error: unknown) => { | |
| 125 | failure ??= error; | |
| 126 | }) | |
| 127 | .finally(() => { | |
| 128 | held -= entry.bytes; | |
| 129 | pending.delete(run); | |
| 130 | }); | |
| 131 | pending.add(run); | |
| 132 | } | |
| 133 | await Promise.all([...pending]); | |
| 134 | if (failure !== undefined) throw failure; | |
| 135 | } | |
| 136 | ||
| 77 | 137 | export async function restoreSnapshot( |
| 78 | 138 | store: Store, |
| 79 | 139 | name: string, |
| 80 | 140 | dataDir: string, |
| 81 | options: { readonly passphrase?: string | undefined } = {}, | |
| 141 | options: { | |
| 142 | readonly passphrase?: string | undefined; | |
| 143 | /** Bytes held by reads in flight; `IN_FLIGHT_BYTES` unless a test shrinks it. */ | |
| 144 | readonly inFlightBytes?: number | undefined; | |
| 145 | /** Test seam: the git runner the clones go through. */ | |
| 146 | readonly git?: typeof runGit | undefined; | |
| 147 | } = {}, | |
| 82 | 148 | ): Promise<Restored> { |
| 83 | 149 | if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}-[0-9]{2}-[0-9]{2}Z$/.test(name)) |
| 84 | 150 | throw new Error(`not a snapshot name: ${name}`); |
| @@ -104,20 +170,29 @@ export async function restoreSnapshot( | ||
| 104 | 170 | "the passphrase does not open this snapshot's wrapped key; check FORGE_SNAPSHOT_PASSPHRASE, or restore without it to leave sealed values behind", |
| 105 | 171 | ); |
| 106 | 172 | } |
| 173 | const packed: readonly unknown[] = manifest.packed ?? []; | |
| 174 | if (!packed.every(isPackedFile)) | |
| 175 | throw new Error(`snapshot ${name}: a packed file in its manifest does not read whole`); | |
| 176 | const partOf = new Map<string, { file: PackedFile; n: number }>(); | |
| 177 | for (const file of packed) | |
| 178 | for (const [n, path] of partPaths(file).entries()) | |
| 179 | partOf.set(path, { file, n }); | |
| 180 | const gathered = new Map<string, Uint8Array[]>(); | |
| 107 | 181 | mkdirSync(dataDir, { recursive: true }); |
| 108 | 182 | |
| 109 | 183 | let files = 0; |
| 110 | 184 | let repos = 0; |
| 111 | const bundles: { owner: string; name: string; file: string }[] = []; | |
| 112 | for (const entry of manifest.files) { | |
| 113 | const body = await store.get(prefix + entry.path); | |
| 114 | if (body === undefined) | |
| 115 | throw new Error(`snapshot ${name} is missing ${entry.path}`); | |
| 116 | if (body.byteLength !== entry.bytes) | |
| 117 | throw new Error( | |
| 118 | `snapshot ${name}: ${entry.path} is ${body.byteLength} bytes, not ${entry.bytes}`, | |
| 119 | ); | |
| 120 | if (entry.path === WRAPPED_KEY) continue; | |
| 185 | const bundles: { at: number; owner: string; name: string; file: string }[] = []; | |
| 186 | await fetchAll(store, prefix, manifest.files, async (entry, body, at) => { | |
| 187 | if (entry.path === WRAPPED_KEY) return; | |
| 188 | // A part waits for the rest of its file. | |
| 189 | const part = partOf.get(entry.path); | |
| 190 | if (part !== undefined) { | |
| 191 | const got = gathered.get(part.file.path) ?? []; | |
| 192 | got[part.n] = body; | |
| 193 | gathered.set(part.file.path, got); | |
| 194 | return; | |
| 195 | } | |
| 121 | 196 | // A day's run logs in one object come back as the logs they were. |
| 122 | 197 | if (isRunBundle(entry.path)) { |
| 123 | 198 | for (const run of unpackRuns(body)) { |
| @@ -126,7 +201,7 @@ export async function restoreSnapshot( | ||
| 126 | 201 | await Bun.write(out, run.body); |
| 127 | 202 | files++; |
| 128 | 203 | } |
| 129 | continue; | |
| 204 | return; | |
| 130 | 205 | } |
| 131 | 206 | const bundle = entry.path.match(/^repos\/([^/]+)\/([^/]+)\.bundle$/); |
| 132 | 207 | // A bundle is not a file of the forge's; it becomes a repository below. |
| @@ -141,23 +216,62 @@ export async function restoreSnapshot( | ||
| 141 | 216 | chmodSync(target, 0o600); |
| 142 | 217 | files++; |
| 143 | 218 | if (bundle) |
| 144 | bundles.push({ owner: bundle[1]!, name: bundle[2]!, file: target }); | |
| 145 | } | |
| 146 | for (const b of bundles) { | |
| 147 | const dir = repoDir(join(dataDir, "repos"), b.owner, b.name); | |
| 148 | mkdirSync(dirname(dir), { recursive: true }); | |
| 149 | const cloned = await runGit(["clone", "--quiet", "--mirror", b.file, dir]); | |
| 150 | if (!cloned.ok) | |
| 151 | throw new Error( | |
| 152 | `could not restore ${b.owner}/${b.name}: ${cloned.error.stderr}`, | |
| 153 | ); | |
| 154 | // A mirror clone remembers the bundle as its origin; the forge's | |
| 155 | // repositories have no origin of their own. | |
| 156 | await runGit(["remote", "remove", "origin"], { cwd: dir }); | |
| 157 | await runGit(["config", "core.logAllRefUpdates", "true"], { cwd: dir }); | |
| 158 | await installHooks(dir); | |
| 159 | repos++; | |
| 219 | bundles.push({ at, owner: bundle[1]!, name: bundle[2]!, file: target }); | |
| 220 | }, name, options.inFlightBytes ?? IN_FLIGHT_BYTES); | |
| 221 | // Reads land in any order; repositories come back in the manifest's. | |
| 222 | bundles.sort((a, b) => a.at - b.at); | |
| 223 | for (const file of packed) { | |
| 224 | const got = gathered.get(file.path) ?? []; | |
| 225 | const parts = partPaths(file).map((path, n) => { | |
| 226 | const body = got[n]; | |
| 227 | if (body === undefined) | |
| 228 | throw new Error(`snapshot ${name} is missing ${path}`); | |
| 229 | return body; | |
| 230 | }); | |
| 231 | gathered.delete(file.path); | |
| 232 | const target = join(dataDir, file.path); | |
| 233 | mkdirSync(dirname(target), { recursive: true }); | |
| 234 | await Bun.write(target, await unpack(file, parts)); | |
| 235 | files++; | |
| 160 | 236 | } |
| 237 | // Each repository is its own clone, so several at once: a forge of | |
| 238 | // hundreds of them spent the drill's minutes here, one after another. | |
| 239 | const git = options.git ?? runGit; | |
| 240 | let next = 0; | |
| 241 | // Clones finish in any order; a refusal names the earliest bundle in the | |
| 242 | // manifest that failed, so it reads the same however the clones raced. | |
| 243 | const failed: { at: number; error: string }[] = []; | |
| 244 | await Promise.all( | |
| 245 | Array.from({ length: Math.min(CLONES, bundles.length) }, async () => { | |
| 246 | for (let b = bundles[next++]; b !== undefined; b = bundles[next++]) { | |
| 247 | const dir = repoDir(join(dataDir, "repos"), b.owner, b.name); | |
| 248 | mkdirSync(dirname(dir), { recursive: true }); | |
| 249 | const cloned = await git([ | |
| 250 | "clone", | |
| 251 | "--quiet", | |
| 252 | "--mirror", | |
| 253 | b.file, | |
| 254 | dir, | |
| 255 | ]); | |
| 256 | if (!cloned.ok) { | |
| 257 | next = bundles.length; | |
| 258 | failed.push({ | |
| 259 | at: b.at, | |
| 260 | error: `could not restore ${b.owner}/${b.name}: ${cloned.error.stderr}`, | |
| 261 | }); | |
| 262 | return; | |
| 263 | } | |
| 264 | // A mirror clone remembers the bundle as its origin; the forge's | |
| 265 | // repositories have no origin of their own. | |
| 266 | await git(["remote", "remove", "origin"], { cwd: dir }); | |
| 267 | await git(["config", "core.logAllRefUpdates", "true"], { cwd: dir }); | |
| 268 | await installHooks(dir); | |
| 269 | repos++; | |
| 270 | } | |
| 271 | }), | |
| 272 | ); | |
| 273 | if (failed.length > 0) | |
| 274 | throw new Error(failed.sort((a, b) => a.at - b.at)[0]!.error); | |
| 161 | 275 | const { rmSync } = await import("node:fs"); |
| 162 | 276 | rmSync(join(dataDir, "restore-bundles"), { recursive: true, force: true }); |
| 163 | 277 | if (key !== undefined) { |
src/snapshot/service.ts +81 −21
| @@ -5,7 +5,9 @@ | ||
| 5 | 5 | * snapshots and says so on its settings page, rather than pretending. |
| 6 | 6 | * |
| 7 | 7 | * Runs are serialized: a nightly that meets an on-demand one waits its |
| 8 | * turn, and a forge stopping waits for the one in flight. | |
| 8 | * turn, and a forge stopping waits for the one in flight. A run that fails | |
| 9 | * is kept (`outcome.ts`) and shows on the board at once, and the next try | |
| 10 | * is within the hour, not the next night. | |
| 9 | 11 | */ |
| 10 | 12 | |
| 11 | 13 | import type { App } from "../app.ts"; |
| @@ -20,15 +22,29 @@ import { | ||
| 20 | 22 | type Progress, |
| 21 | 23 | } from "./snapshot.ts"; |
| 22 | 24 | import { runDrill, type DrillDeps, type DrillResult } from "./drill.ts"; |
| 23 | import { drillDue } from "./due.ts"; | |
| 25 | import { BOOT_RED_FLOOR_MS, drillDue, RED_RETRY_MS } from "./due.ts"; | |
| 24 | 26 | import { lastDrill, recordDrill } from "./drills.ts"; |
| 25 | 27 | import { lastMirror, mirrorPass, MIRROR_EVERY_MS, recordMirror, type MirrorState } from "./mirror.ts"; |
| 28 | import { recordSnapshotOutcome } from "./outcome.ts"; | |
| 26 | 29 | import type { Store } from "./store.ts"; |
| 27 | 30 | |
| 28 | 31 | const log = createLog("snapshots"); |
| 29 | 32 | |
| 30 | 33 | /** When the nightly snapshot is taken, in UTC: before the probe round. */ |
| 31 | 34 | const NIGHTLY_HOUR_UTC = 8; |
| 35 | /** After a failed snapshot, the next try is this soon (or the nightly, if sooner). */ | |
| 36 | export const RETRY_MS = 3_600_000; | |
| 37 | ||
| 38 | /** | |
| 39 | * A forge given nowhere to put a snapshot. Not a failure: the Backups page | |
| 40 | * says so calmly, so it is never kept as an outcome, never a card, and | |
| 41 | * never retried sooner than the nightly. | |
| 42 | */ | |
| 43 | class Nowhere extends Error { | |
| 44 | constructor() { | |
| 45 | super("this forge has nowhere to put a snapshot"); | |
| 46 | } | |
| 47 | } | |
| 32 | 48 | |
| 33 | 49 | export interface SnapshotsHandle { |
| 34 | 50 | /** The store in effect now; undefined when the forge has been given none. */ |
| @@ -45,7 +61,7 @@ export interface SnapshotsHandle { | ||
| 45 | 61 | readonly last: () => { manifest?: Manifest; error?: string; at?: string }; |
| 46 | 62 | /** Drill the newest snapshot now and keep the result; waits for any snapshot in flight. */ |
| 47 | 63 | readonly drill: () => Promise<DrillResult>; |
| 48 | /** Drill if this week's slot has no drill yet; says whether it did. */ | |
| 64 | /** Drill if this week's slot has no drill yet, or the last one has stood red long enough; says whether it did. */ | |
| 49 | 65 | readonly drillIfDue: () => Promise<boolean>; |
| 50 | 66 | /** The off-box mirror's store; undefined when the forge has none. */ |
| 51 | 67 | readonly mirror: Store | undefined; |
| @@ -94,9 +110,15 @@ export function startSnapshots( | ||
| 94 | 110 | /** Test seam: how often the mirror is looked at. */ |
| 95 | 111 | readonly mirrorEveryMs?: number | undefined; |
| 96 | 112 | readonly nightly?: boolean; |
| 113 | /** Test seam: how soon a failed snapshot is tried again. */ | |
| 114 | readonly retryMs?: number | undefined; | |
| 97 | 115 | readonly passphrase?: string | undefined; |
| 98 | 116 | /** Test seam: the drill itself. */ |
| 99 | 117 | readonly drill?: ((deps: DrillDeps) => Promise<DrillResult>) | undefined; |
| 118 | /** Test seams: how soon after boot an owed drill runs, and when a red one is owed again. */ | |
| 119 | readonly catchUpMs?: number | undefined; | |
| 120 | readonly redRetryMs?: number | undefined; | |
| 121 | readonly bootRedFloorMs?: number | undefined; | |
| 100 | 122 | } = { |
| 101 | 123 | version: "0", |
| 102 | 124 | }, |
| @@ -122,12 +144,13 @@ export function startSnapshots( | ||
| 122 | 144 | let last: { manifest?: Manifest; error?: string; at?: string } = {}; |
| 123 | 145 | |
| 124 | 146 | const take = (): Promise<Manifest> => { |
| 125 | const run = (inFlight ?? Promise.resolve()).then(async () => { | |
| 147 | // After the one before it, however that ended: chained on a failure as it | |
| 148 | // was, every later run failed at once with the old error until a restart. | |
| 149 | const run = (inFlight ?? Promise.resolve()).catch(() => {}).then(async () => { | |
| 126 | 150 | // Resolved per run, so a destination saved on the Backups page is the |
| 127 | 151 | // very next snapshot's. |
| 128 | 152 | const store = current(); |
| 129 | if (store === undefined) | |
| 130 | throw new Error("this forge has nowhere to put a snapshot"); | |
| 153 | if (store === undefined) throw new Nowhere(); | |
| 131 | 154 | running = { objects: 0, bytes: 0, elapsedMs: 0 }; |
| 132 | 155 | const manifest = await takeSnapshot( |
| 133 | 156 | { |
| @@ -153,11 +176,19 @@ export function startSnapshots( | ||
| 153 | 176 | inFlight = run.then( |
| 154 | 177 | (m) => { |
| 155 | 178 | last = { manifest: m, at: m.finishedAt }; |
| 179 | keep({ landed: m.name }); | |
| 180 | arm(untilNightly()); | |
| 156 | 181 | return m; |
| 157 | 182 | }, |
| 158 | 183 | (error) => { |
| 159 | 184 | last = { error: String(error), at: new Date().toISOString() }; |
| 185 | if (error instanceof Nowhere) { | |
| 186 | arm(untilNightly()); | |
| 187 | throw error; | |
| 188 | } | |
| 160 | 189 | log.error("snapshot failed", { error: String(error) }); |
| 190 | keep({ error: error instanceof Error ? error.message : String(error) }); | |
| 191 | arm(Math.min(options.retryMs ?? RETRY_MS, untilNightly())); | |
| 161 | 192 | throw error; |
| 162 | 193 | }, |
| 163 | 194 | ); |
| @@ -217,20 +248,30 @@ export function startSnapshots( | ||
| 217 | 248 | return result; |
| 218 | 249 | })().finally(() => { |
| 219 | 250 | drilling = undefined; |
| 251 | // However it was asked for, a red drill is tried again on its own. | |
| 252 | armRedRetry(); | |
| 220 | 253 | }); |
| 221 | 254 | return drilling; |
| 222 | 255 | }; |
| 223 | 256 | |
| 224 | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 225 | const schedule = (): void => { | |
| 226 | if (options.nightly === false || stopped) return; | |
| 227 | timer = setTimeout(() => { | |
| 228 | take() | |
| 229 | .catch(() => {}) | |
| 230 | .finally(schedule); | |
| 231 | }, untilNightly()); | |
| 257 | const keep = (result: { landed: string } | { error: string }): void => { | |
| 258 | try { | |
| 259 | recordSnapshotOutcome(app.config.dataDir, result); | |
| 260 | } catch (error) { | |
| 261 | log.warn("snapshot outcome not kept; the board will not show it", { error: String(error) }); | |
| 262 | } | |
| 232 | 263 | }; |
| 233 | schedule(); | |
| 264 | ||
| 265 | // One timer for the next snapshot: the nightly, or sooner after a failure. | |
| 266 | // Every run that ends sets it again, whoever asked for the run. | |
| 267 | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 268 | function arm(ms: number): void { | |
| 269 | if (timer !== undefined) clearTimeout(timer); | |
| 270 | timer = undefined; | |
| 271 | if (options.nightly === false || stopped) return; | |
| 272 | timer = setTimeout(() => void take().catch(() => {}), ms); | |
| 273 | } | |
| 274 | arm(untilNightly()); | |
| 234 | 275 | |
| 235 | 276 | // The board only reads; the time the forge first owed a drill is kept here. |
| 236 | 277 | try { |
| @@ -241,22 +282,40 @@ export function startSnapshots( | ||
| 241 | 282 | |
| 242 | 283 | let catchUp: ReturnType<typeof setTimeout> | undefined; |
| 243 | 284 | let drillTimer: ReturnType<typeof setTimeout> | undefined; |
| 285 | let redRetry: ReturnType<typeof setTimeout> | undefined; | |
| 286 | const catchUpMs = options.catchUpMs ?? CATCH_UP_MS; | |
| 287 | const redRetryMs = options.redRetryMs ?? RED_RETRY_MS; | |
| 244 | 288 | const scheduleDrill = (): void => { |
| 245 | 289 | if (options.nightly === false || stopped) return; |
| 246 | 290 | drillTimer = setTimeout(() => { |
| 247 | 291 | runIfDue().finally(scheduleDrill); |
| 248 | 292 | }, untilDrill()); |
| 249 | 293 | }; |
| 250 | // Owed and not yet kept: run it once. A restart after the drill ran finds it kept. | |
| 251 | const runIfDue = async (): Promise<boolean> => { | |
| 252 | if (!drillDue(lastDrill(app.db)?.at, firstSeen(app.config.dataDir), Date.now())) return false; | |
| 294 | // While the newest drill is red, look again when it is owed again. | |
| 295 | const armRedRetry = (): void => { | |
| 296 | if (redRetry !== undefined) clearTimeout(redRetry); | |
| 297 | redRetry = undefined; | |
| 298 | if (options.nightly === false || stopped) return; | |
| 299 | const newest = lastDrill(app.db); | |
| 300 | if (newest?.verdict !== "red") return; | |
| 301 | const wait = Math.max(catchUpMs, Date.parse(newest.at) + redRetryMs - Date.now()); | |
| 302 | redRetry = setTimeout(() => void runIfDue().finally(armRedRetry), wait); | |
| 303 | }; | |
| 304 | // Owed and not yet kept: run it once. A restart after a green drill finds it | |
| 305 | // kept; one after a red drill runs it again, once it is past the boot floor. | |
| 306 | const runIfDue = async (redAfterMs: number = redRetryMs): Promise<boolean> => { | |
| 307 | if (!drillDue(lastDrill(app.db), firstSeen(app.config.dataDir), Date.now(), redAfterMs)) return false; | |
| 253 | 308 | await drill().catch((error) => log.error("restore drill failed to run", { error: String(error) })); |
| 254 | 309 | return true; |
| 255 | 310 | }; |
| 256 | 311 | scheduleDrill(); |
| 257 | // A forge that was down at the slot catches up shortly after boot. | |
| 312 | // A forge that was down at the slot, or whose last drill was red, drills | |
| 313 | // shortly after boot; a red one is looked at again until one passes. | |
| 258 | 314 | if (options.nightly !== false) { |
| 259 | catchUp = setTimeout(() => void runIfDue(), CATCH_UP_MS); | |
| 315 | catchUp = setTimeout( | |
| 316 | () => void runIfDue(options.bootRedFloorMs ?? BOOT_RED_FLOOR_MS).finally(armRedRetry), | |
| 317 | catchUpMs, | |
| 318 | ); | |
| 260 | 319 | } |
| 261 | 320 | |
| 262 | 321 | // Within the hour: look for snapshots the mirror lacks, soon after boot and then hourly. |
| @@ -279,7 +338,7 @@ export function startSnapshots( | ||
| 279 | 338 | wraps: !!options.passphrase, |
| 280 | 339 | now: take, |
| 281 | 340 | drill, |
| 282 | drillIfDue: runIfDue, | |
| 341 | drillIfDue: () => runIfDue(), | |
| 283 | 342 | list: async () => { |
| 284 | 343 | const store = current(); |
| 285 | 344 | return store === undefined ? [] : listSnapshots(store); |
| @@ -301,6 +360,7 @@ export function startSnapshots( | ||
| 301 | 360 | if (timer !== undefined) clearTimeout(timer); |
| 302 | 361 | if (drillTimer !== undefined) clearTimeout(drillTimer); |
| 303 | 362 | if (catchUp !== undefined) clearTimeout(catchUp); |
| 363 | if (redRetry !== undefined) clearTimeout(redRetry); | |
| 304 | 364 | if (mirrorTimer !== undefined) clearTimeout(mirrorTimer); |
| 305 | 365 | if (mirroring !== undefined) await mirroring.catch(() => {}); |
| 306 | 366 | if (inFlight !== undefined) await inFlight.catch(() => {}); |
src/snapshot/snapshot.ts +31 −5
| @@ -12,10 +12,13 @@ | ||
| 12 | 12 | * checks, previews, pictures. |
| 13 | 13 | * |
| 14 | 14 | * A snapshot is named by the moment it began, in UTC, so a listing sorts |
| 15 | * itself. Nothing is compressed or bundled beyond git's own bundles and | |
| 16 | * the crew's older run logs, one compressed bundle per day: a bucket costs | |
| 17 | * a round trip per object, so thousands of tiny logs are one object a day. | |
| 18 | * A restore is reading files back, and a person can read the manifest. | |
| 15 | * itself. Little is compressed or bundled beyond git's own bundles: the | |
| 16 | * crew's older run logs, one compressed bundle per day, because a bucket | |
| 17 | * costs a round trip per object and thousands of tiny logs are one object | |
| 18 | * a day; and the database, compressed and cut in parts (`parts.ts`), | |
| 19 | * because whole it was more than a home uplink carries in one put's | |
| 20 | * deadline. A restore is reading files back, and a person can read the | |
| 21 | * manifest. | |
| 19 | 22 | * |
| 20 | 23 | * Objects go up through a bounded queue, eight in flight; the manifest |
| 21 | 24 | * lists them in the order they were handed over, not the order they landed. |
| @@ -32,6 +35,7 @@ import { runGit } from "../git/env.ts"; | ||
| 32 | 35 | import { repoDir } from "../git/repos.ts"; |
| 33 | 36 | import { createLog } from "../kernel/log.ts"; |
| 34 | 37 | import { countRows, type DrillCounts } from "./counts.ts"; |
| 38 | import { pack, partPath, PART_BYTES, type PackedFile } from "./parts.ts"; | |
| 35 | 39 | import type { Store } from "./store.ts"; |
| 36 | 40 | import { unwrapKey, wrapKey, WRAPPED_KEY } from "./wrap.ts"; |
| 37 | 41 | |
| @@ -63,6 +67,8 @@ export interface Manifest { | ||
| 63 | 67 | * `raw` in snapshots from before the key was wrapped. |
| 64 | 68 | */ |
| 65 | 69 | readonly key?: "wrapped" | "none" | "raw"; |
| 70 | /** Files that went up compressed in parts, each listed in `files` by its parts. Absent before the database was packed. */ | |
| 71 | readonly packed?: readonly PackedFile[]; | |
| 66 | 72 | } |
| 67 | 73 | |
| 68 | 74 | /** What a manifest says of the key, including manifests older than the field. */ |
| @@ -162,6 +168,8 @@ export interface Snapshotter { | ||
| 162 | 168 | readonly onLand?: ((p: Progress) => void) | undefined; |
| 163 | 169 | /** Overrides of the progress cadence, for tests. */ |
| 164 | 170 | readonly progressEvery?: { objects?: number; ms?: number } | undefined; |
| 171 | /** Override of the database's part size, for tests. */ | |
| 172 | readonly partBytes?: number | undefined; | |
| 165 | 173 | } |
| 166 | 174 | |
| 167 | 175 | /** `2026-09-22T09-00-00Z`: sortable, and a name a bucket allows. */ |
| @@ -216,6 +224,7 @@ export async function takeSnapshot( | ||
| 216 | 224 | const prefix = `${SNAPSHOT_PREFIX}${name}/`; |
| 217 | 225 | const scratch = mkdtempSync(join(tmpdir(), "forge-snapshot-")); |
| 218 | 226 | const files: SnapshotFile[] = []; |
| 227 | const packed: PackedFile[] = []; | |
| 219 | 228 | let dbCounts: DrillCounts | undefined; |
| 220 | 229 | const started = Date.now(); |
| 221 | 230 | const everyObjects = forge.progressEvery?.objects ?? PROGRESS_OBJECTS; |
| @@ -305,7 +314,23 @@ export async function takeSnapshot( | ||
| 305 | 314 | } finally { |
| 306 | 315 | copy.close(); |
| 307 | 316 | } |
| 308 | await putFile("forge.db", dbCopy); | |
| 317 | // Compressed and in parts: each part one put, with its own deadline and | |
| 318 | // retries, so a slow uplink carries it and a dropped one costs a part. | |
| 319 | const database = await pack( | |
| 320 | "forge.db", | |
| 321 | new Uint8Array(await Bun.file(dbCopy).arrayBuffer()), | |
| 322 | forge.partBytes ?? PART_BYTES, | |
| 323 | ); | |
| 324 | packed.push(database.packed); | |
| 325 | log.info("database packed", { | |
| 326 | mb: (database.packed.bytes / 1_048_576).toFixed(1), | |
| 327 | packedMb: (database.packed.packedBytes / 1_048_576).toFixed(1), | |
| 328 | ratio: (database.packed.bytes / Math.max(1, database.packed.packedBytes)).toFixed(2), | |
| 329 | parts: database.packed.parts, | |
| 330 | }); | |
| 331 | for (const [n, part] of database.parts.entries()) | |
| 332 | await put(partPath("forge.db", n), part); | |
| 333 | rmSync(dbCopy, { force: true }); | |
| 309 | 334 | // The key travels only wrapped. Without a passphrase it stays home. |
| 310 | 335 | let key: Manifest["key"] = "none"; |
| 311 | 336 | const keyFile = Bun.file(join(forge.dataDir, "secret.key")); |
| @@ -434,6 +459,7 @@ export async function takeSnapshot( | ||
| 434 | 459 | bytes: files.reduce((n, f) => n + f.bytes, 0), |
| 435 | 460 | ...(dbCounts === undefined ? {} : { counts: dbCounts }), |
| 436 | 461 | key, |
| 462 | packed, | |
| 437 | 463 | }; |
| 438 | 464 | await store.put( |
| 439 | 465 | `${prefix}manifest.json`, |
src/snapshot/store.ts +43 −9
| @@ -51,17 +51,40 @@ export function s3FromEnv( | ||
| 51 | 51 | }; |
| 52 | 52 | } |
| 53 | 53 | |
| 54 | /** The slowest uplink a put may assume: 5 Mbit/s, a third of the home line it runs behind. */ | |
| 55 | export const FLOOR_BYTES_PER_S = 5_000_000 / 8; | |
| 56 | /** What every attempt gets on top of its bytes' time: the handshake, the server's thought, a queue ahead of it. */ | |
| 57 | export const SLACK_MS = 60_000; | |
| 58 | /** How long a read, a listing or a delete may go unanswered. */ | |
| 59 | export const CALL_DEADLINE_MS = 180_000; | |
| 60 | ||
| 61 | /** How long a put of `bytes` may take before it counts as failed: its bytes at the floor rate, plus slack. */ | |
| 62 | export const deadlineFor = ( | |
| 63 | bytes: number, | |
| 64 | floorBytesPerS = FLOOR_BYTES_PER_S, | |
| 65 | slackMs = SLACK_MS, | |
| 66 | ): number => slackMs + Math.ceil((bytes / floorBytesPerS) * 1000); | |
| 67 | ||
| 68 | const sizeOf = (body: Uint8Array | string): number => | |
| 69 | typeof body === "string" ? Buffer.byteLength(body) : body.byteLength; | |
| 70 | ||
| 54 | 71 | /** |
| 55 | 72 | * A store that tries again. Object stores answer a burst, a flake or a |
| 56 | 73 | * moment of maintenance with a 5xx — Backblaze says so in its own docs — |
| 57 | 74 | * and one such answer in a thousand puts used to fail the whole snapshot |
| 58 | 75 | * with "S3Error: an unexpected error has occurred" and no key named. Every |
| 59 | 76 | * call is tried `attempts` times with doubling waits, and what finally |
| 60 | * fails says which key it was. An attempt that has not answered within | |
| 61 | * `deadlineMs` counts as failed too: a connection that died quietly never | |
| 77 | * fails says which key it was. An attempt that has not answered within its | |
| 78 | * deadline counts as failed too: a connection that died quietly never | |
| 62 | 79 | * settles on its own, and once held a snapshot on its first object for an |
| 63 | 80 | * hour with the network fine (2026-10-13), so the next attempt opens a |
| 64 | 81 | * fresh request instead. |
| 82 | * | |
| 83 | * A put's deadline follows its size (`deadlineFor`): a fixed 180 s once | |
| 84 | * failed every attempt at a 334 MB database behind a 15 Mbit/s uplink | |
| 85 | * (2026-10-17). `deadlineMs` fixes every deadline instead, for tests; | |
| 86 | * `floorBytesPerS` and `slackMs` scale the size rule, for tests that run a | |
| 87 | * slow link at a thousand times its speed. | |
| 65 | 88 | */ |
| 66 | 89 | export function withRetries( |
| 67 | 90 | inner: Store, |
| @@ -69,14 +92,23 @@ export function withRetries( | ||
| 69 | 92 | attempts?: number; |
| 70 | 93 | baseMs?: number; |
| 71 | 94 | deadlineMs?: number; |
| 95 | floorBytesPerS?: number; | |
| 96 | slackMs?: number; | |
| 72 | 97 | sleep?: (ms: number) => Promise<void>; |
| 73 | 98 | } = {}, |
| 74 | 99 | ): Store { |
| 75 | 100 | const attempts = Math.max(1, options.attempts ?? 5); |
| 76 | 101 | const baseMs = options.baseMs ?? 500; |
| 77 | const deadlineMs = options.deadlineMs ?? 180_000; | |
| 102 | const fixed = options.deadlineMs; | |
| 103 | const callMs = fixed ?? CALL_DEADLINE_MS; | |
| 104 | const putMs = (body: Uint8Array | string): number => | |
| 105 | fixed ?? deadlineFor(sizeOf(body), options.floorBytesPerS, options.slackMs); | |
| 78 | 106 | const sleep = options.sleep ?? ((ms) => Bun.sleep(ms)); |
| 79 | async function tried<T>(what: string, fn: () => Promise<T>): Promise<T> { | |
| 107 | async function tried<T>( | |
| 108 | what: string, | |
| 109 | deadlineMs: number, | |
| 110 | fn: () => Promise<T>, | |
| 111 | ): Promise<T> { | |
| 80 | 112 | let last: unknown; |
| 81 | 113 | for (let n = 0; n < attempts; n++) { |
| 82 | 114 | try { |
| @@ -93,11 +125,13 @@ export function withRetries( | ||
| 93 | 125 | return { |
| 94 | 126 | where: inner.where, |
| 95 | 127 | ...(inner.dir === undefined ? {} : { dir: inner.dir }), |
| 96 | put: (key, body) => tried(`put ${key}`, () => inner.put(key, body)), | |
| 97 | get: (key) => tried(`get ${key}`, () => inner.get(key)), | |
| 98 | list: (prefix) => tried(`list ${prefix}`, () => inner.list(prefix)), | |
| 99 | names: (prefix) => tried(`names ${prefix}`, () => inner.names(prefix)), | |
| 100 | delete: (key) => tried(`delete ${key}`, () => inner.delete(key)), | |
| 128 | put: (key, body) => | |
| 129 | tried(`put ${key}`, putMs(body), () => inner.put(key, body)), | |
| 130 | get: (key) => tried(`get ${key}`, callMs, () => inner.get(key)), | |
| 131 | list: (prefix) => tried(`list ${prefix}`, callMs, () => inner.list(prefix)), | |
| 132 | names: (prefix) => | |
| 133 | tried(`names ${prefix}`, callMs, () => inner.names(prefix)), | |
| 134 | delete: (key) => tried(`delete ${key}`, callMs, () => inner.delete(key)), | |
| 101 | 135 | }; |
| 102 | 136 | } |
| 103 | 137 |
src/tui/app/app.ts +3 −2
| @@ -63,6 +63,7 @@ import { | ||
| 63 | 63 | type CursorPos, |
| 64 | 64 | } from "../ui/widgets.ts"; |
| 65 | 65 | import { binding, find, keyFor, type Binding } from "./keys.ts"; |
| 66 | import { message } from "../screens/common.ts"; | |
| 66 | 67 | import { Palette, type Command, type PaletteSearch } from "./palette.ts"; |
| 67 | 68 | import { truncate } from "../term/width.ts"; |
| 68 | 69 | |
| @@ -470,7 +471,7 @@ export class App { | ||
| 470 | 471 | // Every failure becomes a toast; nothing throws into the loop, and no |
| 471 | 472 | // single bad read may drop the session. |
| 472 | 473 | if (!entry.abort.signal.aborted) |
| 473 | this.toast(e instanceof Error ? e.message : String(e), "danger"); | |
| 474 | this.toast(message(e), "danger"); | |
| 474 | 475 | }) |
| 475 | 476 | .finally(() => { |
| 476 | 477 | entry.loading = false; |
| @@ -876,7 +877,7 @@ export class App { | ||
| 876 | 877 | private run(command: Command): void { |
| 877 | 878 | this.overlay = "none"; |
| 878 | 879 | void Promise.resolve(command.run()).catch((err: unknown) => |
| 879 | this.toast(err instanceof Error ? err.message : String(err), "danger"), | |
| 880 | this.toast(message(err), "danger"), | |
| 880 | 881 | ); |
| 881 | 882 | } |
| 882 | 883 |
src/tui/screens/about.ts +13
| @@ -15,6 +15,7 @@ | ||
| 15 | 15 | * card is an OSC 8 hyperlink besides. |
| 16 | 16 | */ |
| 17 | 17 | |
| 18 | import { audienceFor } from "../../core/audience.ts"; | |
| 18 | 19 | import { isPinned } from "../../core/pins.ts"; |
| 19 | 20 | import { parentReleases, releasesSentence } from "../../core/parent_releases.ts"; |
| 20 | 21 | import { running } from "../../kernel/version.ts"; |
| @@ -128,6 +129,18 @@ export function aboutScreen( | ||
| 128 | 129 | style(description === "" ? c.faint : c.fg, c.card), |
| 129 | 130 | edge, |
| 130 | 131 | ); |
| 132 | // Who can see it, in the web page's words (#809). | |
| 133 | if (repo !== undefined) | |
| 134 | s.text( | |
| 135 | lead, | |
| 136 | inner.y + 2, | |
| 137 | truncate( | |
| 138 | audienceFor(f.db, repo, f.user).sentence, | |
| 139 | Math.max(0, edge - lead), | |
| 140 | ), | |
| 141 | style(c.mutedFg, c.card), | |
| 142 | edge, | |
| 143 | ); | |
| 131 | 144 | drawFacts( |
| 132 | 145 | s, |
| 133 | 146 | { |
src/tui/screens/common.ts +4 −8
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * of them. Nothing here knows about repos or issues — that is the point. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { refusalText } from "../../http/refusal.ts"; | |
| 10 | 11 | import { NOT_A_MEMBER } from "../../core/roles.ts"; |
| 11 | 12 | import type { Ctx, Screen } from "../app/app.ts"; |
| 12 | 13 | import { binding, type Binding } from "../app/keys.ts"; |
| @@ -996,6 +997,8 @@ export function errorText(code: string): string { | ||
| 996 | 997 | |
| 997 | 998 | /** Anything thrown, as a string — the toast text of last resort. */ |
| 998 | 999 | export function message(error: unknown): string { |
| 1000 | const refused = refusalText(error); | |
| 1001 | if (refused !== undefined) return refused; | |
| 999 | 1002 | return error instanceof Error ? error.message : String(error); |
| 1000 | 1003 | } |
| 1001 | 1004 | |
| @@ -1032,11 +1035,4 @@ export function ago(iso: string): string { | ||
| 1032 | 1035 | return rel === "now" ? "just now" : `${rel} ago`; |
| 1033 | 1036 | } |
| 1034 | 1037 | |
| 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 @@ | ||
| 12 | 12 | * facts (`canSeeOwnerFacts`, as on the page) also gets, under the list, what |
| 13 | 13 | * the limb under the cursor cost and landed, and its costliest issues. |
| 14 | 14 | * |
| 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 | * | |
| 15 | 20 | * `enter` on a limb that has limbs of its own opens them, so a milestone's |
| 16 | 21 | * sub-limbs can be moved among themselves (the `under` of the action). |
| 17 | 22 | * Subscribed to the bus while on the stack, so a move made on the web, or in |
| @@ -19,6 +24,8 @@ | ||
| 19 | 24 | */ |
| 20 | 25 | |
| 21 | 26 | import { undoFleetOp } from "../../actions/fleet.ts"; |
| 27 | import { previewReplan, replanLimb, takeBackLimb } from "../../actions/limb_replan.ts"; | |
| 28 | import { pendingReplans } from "../../core/limb_replan.ts"; | |
| 22 | 29 | import { applyLimb, previewLimb, type LimbPreview } from "../../actions/limbs.ts"; |
| 23 | 30 | import { canAdmin, canRead, canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 24 | 31 | import { readPlan, type Limb, type Milestone } from "../../core/milestones.ts"; |
| @@ -40,7 +47,7 @@ import { | ||
| 40 | 47 | } from "./common.ts"; |
| 41 | 48 | |
| 42 | 49 | /** 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"]; | |
| 44 | 51 | |
| 45 | 52 | /** How many lines of the new order and of the ready list a preview shows. */ |
| 46 | 53 | const SHOWN = 6; |
| @@ -104,6 +111,8 @@ export function limbsScreen( | ||
| 104 | 111 | let spends = new Map<number, NonNullable<Milestone["spend"]>>(); |
| 105 | 112 | /** The receipt of the last move made here, which `u` reverses once. */ |
| 106 | 113 | let last: string | undefined; |
| 114 | /** The limbs with the planner, by issue id (#761). */ | |
| 115 | let withPlanner = new Set<string>(); | |
| 107 | 116 | // Read at use, not at open: a repository's admin can change under a session. |
| 108 | 117 | const may = (): boolean => |
| 109 | 118 | f.user !== undefined && f.user.kind === "user" && canAdmin(f.db, repo, f.user); |
| @@ -117,6 +126,7 @@ export function limbsScreen( | ||
| 117 | 126 | return; |
| 118 | 127 | } |
| 119 | 128 | limbs = limbsOf(f, repo, group) ?? []; |
| 129 | withPlanner = new Set(pendingReplans(f.db, repo).map((r) => r.limbId)); | |
| 120 | 130 | spends = new Map(); |
| 121 | 131 | if (canSeeOwnerFacts(f.db, repo, f.user)) { |
| 122 | 132 | const plan = readPlan(f.db, repo, { spend: true }); |
| @@ -175,6 +185,57 @@ export function limbsScreen( | ||
| 175 | 185 | ); |
| 176 | 186 | }; |
| 177 | 187 | |
| 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 | ||
| 178 | 239 | const undo = (): void => { |
| 179 | 240 | const id = last; |
| 180 | 241 | if (id === undefined || !may()) return; |
| @@ -202,7 +263,7 @@ export function limbsScreen( | ||
| 202 | 263 | const rows = (): ListRow[] => |
| 203 | 264 | limbs.map((l, n) => ({ |
| 204 | 265 | 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" : "", | |
| 206 | 267 | meta: `${l.weight.done}/${l.weight.total} done`, |
| 207 | 268 | })); |
| 208 | 269 | |
| @@ -215,6 +276,7 @@ export function limbsScreen( | ||
| 215 | 276 | binding(["enter"], "open"), |
| 216 | 277 | binding(["r"], "raise", { disabled: !moves }), |
| 217 | 278 | binding(["l"], "lower", { disabled: !moves }), |
| 279 | binding(["p"], withPlanner.has(current()?.issue.id ?? "") ? "take back" : "re-plan", { disabled: !may() || current() === undefined }), | |
| 218 | 280 | binding(["u"], "undo", { disabled: !may() || last === undefined }), |
| 219 | 281 | ]; |
| 220 | 282 | }, |
| @@ -246,6 +308,7 @@ export function limbsScreen( | ||
| 246 | 308 | if (e.key === "enter") open(); |
| 247 | 309 | else if (e.key === "r") move("raise"); |
| 248 | 310 | else if (e.key === "l") move("lower"); |
| 311 | else if (e.key === "p") replan(); | |
| 249 | 312 | else if (e.key === "u") undo(); |
| 250 | 313 | else return false; |
| 251 | 314 | return true; |
src/tui/screens/pull.ts +27 −3
| @@ -39,7 +39,7 @@ import { | ||
| 39 | 39 | submitReview, |
| 40 | 40 | } from "../../actions/pulls.ts"; |
| 41 | 41 | import { openEnvUrl } from "../../actions/apps.ts"; |
| 42 | import { listRepoRuns } from "../../actions/runs.ts"; | |
| 42 | import { listRepoRuns, mayReadRunLog } from "../../actions/runs.ts"; | |
| 43 | 43 | import { previewEnv } from "../../apps/data.ts"; |
| 44 | 44 | import { |
| 45 | 45 | appsRuntime, |
| @@ -49,7 +49,7 @@ import { proposedByText } from "../../core/proposal_token.ts"; | ||
| 49 | 49 | import { getAppByRepo } from "../../core/apps.ts"; |
| 50 | 50 | import { getIssue, type Issue } from "../../core/issues.ts"; |
| 51 | 51 | import { isProposed, pullMayRun } from "../../core/pull_trust.ts"; |
| 52 | import { runningProgress } from "../../checks/progress.ts"; | |
| 52 | import { runningProgress, stepsLine } from "../../checks/progress.ts"; | |
| 53 | 53 | import { askCheck } from "../../checks/again.ts"; |
| 54 | 54 | import { checkStale, checksHold } from "../../checks/stale.ts"; |
| 55 | 55 | import { checkWords } from "../../checks/summary.ts"; |
| @@ -237,6 +237,8 @@ export function pullScreen( | ||
| 237 | 237 | /** The pull's base branch, to name what the check was merged with. */ |
| 238 | 238 | let checkBase = ""; |
| 239 | 239 | let checkProgress = ""; |
| 240 | /** The run page's three step rows as a line; empty when the log is hidden. */ | |
| 241 | let checkSteps = ""; | |
| 240 | 242 | /** The check's green was against a base that has since moved. */ |
| 241 | 243 | let checkOld = false; |
| 242 | 244 | /** Agent runs about this pull, newest first. */ |
| @@ -288,6 +290,14 @@ export function pullScreen( | ||
| 288 | 290 | : checkSummary(f.db, row.id, row.headOid || (facts?.headOid ?? "")); |
| 289 | 291 | checkBase = row?.baseBranch ?? facts?.baseBranch ?? ""; |
| 290 | 292 | checkProgress = runningProgress(f.app.config.dataDir, check); |
| 293 | checkSteps = | |
| 294 | settings === undefined | |
| 295 | ? "" | |
| 296 | : stepsLine( | |
| 297 | f.app.config.dataDir, | |
| 298 | check, | |
| 299 | mayReadRunLog(f.app, f.user, settings), | |
| 300 | ); | |
| 291 | 301 | checkOld = |
| 292 | 302 | repo !== undefined && |
| 293 | 303 | row !== undefined && |
| @@ -790,7 +800,7 @@ export function pullScreen( | ||
| 790 | 800 | // The box is exactly as tall as what is in it: the state, then a row |
| 791 | 801 | // for the verdicts when there are any, then a row for the checks when |
| 792 | 802 | // a deploy has run or the gate demands one. |
| 793 | const checksLine = checksText(check, requireChecks, isApp, checkBase, checkOld, checkProgress); | |
| 803 | const checksLine = checksText(check, requireChecks, isApp, checkBase, checkOld, checkProgress, checkSteps); | |
| 794 | 804 | const boxH = |
| 795 | 805 | 3 + |
| 796 | 806 | (standingText(standing(), requireReview) === "" ? 0 : 1) + |
| @@ -1251,6 +1261,20 @@ export function checksText( | ||
| 1251 | 1261 | stale = false, |
| 1252 | 1262 | /** `runningProgress` of the check's log; empty when none is known. */ |
| 1253 | 1263 | progress = "", |
| 1264 | /** `stepsLine` of the check's log; empty when none is shown. */ | |
| 1265 | steps = "", | |
| 1266 | ): string { | |
| 1267 | const line = checksLine(check, required, isApp, baseBranch, stale, progress); | |
| 1268 | return line === "" || steps === "" ? line : `${line} · ${steps}`; | |
| 1269 | } | |
| 1270 | ||
| 1271 | function checksLine( | |
| 1272 | check: CheckSummary, | |
| 1273 | required: boolean, | |
| 1274 | isApp: boolean, | |
| 1275 | baseBranch: string, | |
| 1276 | stale: boolean, | |
| 1277 | progress: string, | |
| 1254 | 1278 | ): string { |
| 1255 | 1279 | if (check.state === "none") |
| 1256 | 1280 | return required |
src/tui/screens/signin.ts +31 −14
| @@ -8,9 +8,10 @@ | ||
| 8 | 8 | * account while signup allows — not an admin; the operator makes the admin. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts"; | |
| 11 | import { INVITE_SPENT_MESSAGE, SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts"; | |
| 12 | 12 | import { SignupSlot, waitText } from "../../actions/signup-window.ts"; |
| 13 | 13 | import { hasAnyUser } from "../../core/users.ts"; |
| 14 | import { getSignup } from "../../settings/signup.ts"; | |
| 14 | 15 | import type { Ctx, Screen } from "../app/app.ts"; |
| 15 | 16 | import { binding } from "../app/keys.ts"; |
| 16 | 17 | import { style } from "../term/style.ts"; |
| @@ -37,36 +38,51 @@ const CREATE: Field[] = [ | ||
| 37 | 38 | { label: "Password", secret: true, hint: "At least 10 characters." }, |
| 38 | 39 | ]; |
| 39 | 40 | |
| 41 | /** While signup is `invite` the create form also asks for the link's token. */ | |
| 42 | const INVITE_BLANK_MESSAGE = "Paste the token from your invite link."; | |
| 43 | const INVITE: Field = { label: "Invite", secret: true, hint: "The token from your invite link." }; | |
| 44 | ||
| 40 | 45 | export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen { |
| 41 | 46 | // "Is this forge empty?" is a existence question, not a count: `COUNT(*)` |
| 42 | 47 | // walks the whole user table on the one screen every session paints. |
| 43 | 48 | const first = !hasAnyUser(f.db); |
| 44 | 49 | // Closed signup offers no create mode at all, as the page offers no form. |
| 45 | const closed = signupClosed(f.app); | |
| 46 | let mode: "signin" | "create" = first && !closed ? "create" : "signin"; | |
| 47 | let values = ["", "", ""]; | |
| 50 | // With signup = invite the form is offered, and asks for the invite. | |
| 51 | // Both are read where they are used, so the screen follows the live setting. | |
| 52 | const invited = (): boolean => getSignup(f.db) === "invite"; | |
| 53 | const closed = (): boolean => !invited() && signupClosed(f.app); | |
| 54 | let mode: "signin" | "create" = first && !closed() ? "create" : "signin"; | |
| 55 | let values = ["", "", "", ""]; | |
| 48 | 56 | let focus = 0; |
| 49 | 57 | let error: string | undefined; |
| 50 | 58 | let busy = false; |
| 51 | 59 | |
| 52 | const fields = (): Field[] => (mode === "create" ? CREATE : SIGN_IN); | |
| 60 | const fields = (): Field[] => (mode === "create" ? (invited() ? [...CREATE, INVITE] : CREATE) : SIGN_IN); | |
| 53 | 61 | |
| 54 | 62 | const submit = (): void => { |
| 55 | 63 | if (busy) return; |
| 56 | if (closed && mode === "create") { | |
| 64 | if (closed() && mode === "create") { | |
| 57 | 65 | error = SIGNUP_CLOSED_MESSAGE; |
| 58 | 66 | return; |
| 59 | 67 | } |
| 68 | const needsInvite = mode === "create" && invited(); | |
| 69 | if (needsInvite && (values[3] ?? "").trim() === "") { | |
| 70 | error = INVITE_BLANK_MESSAGE; | |
| 71 | focus = 3; | |
| 72 | return; | |
| 73 | } | |
| 60 | 74 | busy = true; |
| 61 | 75 | error = undefined; |
| 62 | 76 | runAsync(ctx, async () => { |
| 63 | 77 | try { |
| 64 | 78 | if (mode === "create") { |
| 65 | const [username, email, password] = values as [ | |
| 79 | const [username, email, password, token] = values as [ | |
| 80 | string, | |
| 66 | 81 | string, |
| 67 | 82 | string, |
| 68 | 83 | string, |
| 69 | 84 | ]; |
| 85 | const invite = needsInvite ? token.trim() : ""; | |
| 70 | 86 | const slot = SignupSlot.take(f.app, f.identity.remoteAddress); |
| 71 | 87 | if (typeof slot === "number") { |
| 72 | 88 | error = waitText(slot); |
| @@ -75,10 +91,11 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen { | ||
| 75 | 91 | const created = await signUp( |
| 76 | 92 | f.app, |
| 77 | 93 | { username, email, password }, |
| 78 | { slot, address: f.identity.remoteAddress }, | |
| 94 | { slot, address: f.identity.remoteAddress, ...(invite === "" ? {} : { invite }) }, | |
| 79 | 95 | ); |
| 80 | 96 | if (!created.ok) { |
| 81 | error = errorText(created.error); | |
| 97 | // With signup = invite a refusal means the token did not open the door. | |
| 98 | error = needsInvite && created.error === "signup_closed" ? INVITE_SPENT_MESSAGE : errorText(created.error); | |
| 82 | 99 | return; |
| 83 | 100 | } |
| 84 | 101 | const adopted = f.adopt(created.value); |
| @@ -109,21 +126,21 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen { | ||
| 109 | 126 | keys: [ |
| 110 | 127 | binding(["tab"], "next field"), |
| 111 | 128 | binding(["enter"], mode === "create" ? "create" : "sign in"), |
| 112 | ...(closed ? [] : [binding(["ctrl+t"], first ? "sign in instead" : "create account")]), | |
| 129 | ...(closed() ? [] : [binding(["ctrl+t"], first ? "sign in instead" : "create account")]), | |
| 113 | 130 | binding(["esc"], "back"), |
| 114 | 131 | ], |
| 115 | 132 | render(area, s) { |
| 116 | 133 | const c = ctx.theme.c; |
| 117 | 134 | const [head, body] = split(area, "v", [len(2), fill(1)]); |
| 118 | 135 | segmented(s, head!, c, { |
| 119 | items: closed ? ["Sign in"] : ["Sign in", "Create account"], | |
| 136 | items: closed() ? ["Sign in"] : ["Sign in", "Create account"], | |
| 120 | 137 | active: mode === "create" ? 1 : 0, |
| 121 | 138 | }); |
| 122 | 139 | s.text( |
| 123 | 140 | head!.x, |
| 124 | 141 | head!.y + 1, |
| 125 | 142 | truncate( |
| 126 | closed | |
| 143 | closed() | |
| 127 | 144 | ? SIGNUP_CLOSED_MESSAGE |
| 128 | 145 | : first |
| 129 | 146 | ? "This forge has no accounts yet. The operator makes the admin." |
| @@ -162,9 +179,9 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen { | ||
| 162 | 179 | ctx.setCursor(cursor); |
| 163 | 180 | }, |
| 164 | 181 | key(e) { |
| 165 | if (e.ctrl && e.key === "t" && !closed) { | |
| 182 | if (e.ctrl && e.key === "t" && !closed()) { | |
| 166 | 183 | mode = mode === "create" ? "signin" : "create"; |
| 167 | values = ["", "", ""]; | |
| 184 | values = ["", "", "", ""]; | |
| 168 | 185 | focus = 0; |
| 169 | 186 | error = undefined; |
| 170 | 187 | return true; |
src/web/chrome.ts +16
| @@ -12,6 +12,7 @@ import { sshCommand } from "../ssh/command.ts"; | ||
| 12 | 12 | import { mayDirectCrew } from "../crew/direct.ts"; |
| 13 | 13 | import { mayReadRuns } from "../actions/runs.ts"; |
| 14 | 14 | import { getAppByRepo } from "../core/apps.ts"; |
| 15 | import { audienceFacts, audienceOf } from "../core/audience.ts"; | |
| 15 | 16 | import { edgeLoginUrl } from "../http/edge.ts"; |
| 16 | 17 | import { |
| 17 | 18 | canAdmin, |
| @@ -310,6 +311,7 @@ export function scopeHeader( | ||
| 310 | 311 | ${facts.visibility === "private" |
| 311 | 312 | ? badge("Private", { icon: "lock", variant: "secondary" }) |
| 312 | 313 | : badge("Public", { variant: "secondary" })} |
| 314 | ${repo === undefined ? null : audienceLine(ctx, repo, admin, base)} | |
| 313 | 315 | ${actions.text.trim() !== "" |
| 314 | 316 | ? html`<span class="repo-actions">${actions}</span>` |
| 315 | 317 | : null}`; |
| @@ -347,6 +349,20 @@ export function scopeHeader( | ||
| 347 | 349 | </header>`; |
| 348 | 350 | } |
| 349 | 351 | |
| 352 | /** | |
| 353 | * Who can see this repository, in the one sentence `audienceOf` gives, beside | |
| 354 | * the badge. A link to the settings for whoever can open them, plain text for | |
| 355 | * everyone else. One line: CSS truncates it, `title` keeps all of it. | |
| 356 | */ | |
| 357 | function audienceLine(ctx: Ctx, repo: Repo, admin: boolean, base: string): Html { | |
| 358 | const { sentence } = audienceOf(audienceFacts(ctx.app.db, repo, ctx.auth.user)); | |
| 359 | return admin | |
| 360 | ? html`<a class="repo-audience" href="${base}/settings" title="${sentence}" | |
| 361 | >${sentence}</a | |
| 362 | >` | |
| 363 | : html`<span class="repo-audience" title="${sentence}">${sentence}</span>`; | |
| 364 | } | |
| 365 | ||
| 350 | 366 | export { hostOf }; |
| 351 | 367 | |
| 352 | 368 | /** This repo's own rows for the command palette — the studio's nouns for a |
src/web/client.ts +41
| @@ -295,6 +295,40 @@ export const studioRunJs = /* js */ ` | ||
| 295 | 295 | |
| 296 | 296 | ${elapsedTickJs} |
| 297 | 297 | |
| 298 | // A check's step rows are server-derived from the log; when a line that | |
| 299 | // can move them lands (a command's echo, the shard runner's banner or a | |
| 300 | // shard header) or the run ends, the rows are fetched again and swapped in. | |
| 301 | var stepsTimer = 0; | |
| 302 | function steps() { | |
| 303 | var cur = document.getElementById('check-steps'); | |
| 304 | if (!cur || !window.fetch || !window.DOMParser) return; | |
| 305 | fetch(location.href, { credentials: 'same-origin', headers: { accept: 'text/html' } }) | |
| 306 | .then(function (r) { return r.ok ? r.text() : ''; }) | |
| 307 | .then(function (body) { | |
| 308 | var doc = new DOMParser().parseFromString(body, 'text/html'); | |
| 309 | var next = doc.getElementById('check-steps'); | |
| 310 | var now = document.getElementById('check-steps'); | |
| 311 | if (next && now && now.parentNode) now.parentNode.replaceChild(next, now); | |
| 312 | // The pin slot is only patched, never replaced: a keyboard user | |
| 313 | // reading a failure keeps focus and scroll. | |
| 314 | var wrap = document.getElementById('check-progress'), pw = doc.getElementById('check-progress'); | |
| 315 | var slot = document.getElementById('check-pins'), ps = doc.getElementById('check-pins'); | |
| 316 | if (wrap && pw) wrap.setAttribute('data-state', pw.getAttribute('data-state') || ''); | |
| 317 | if (slot && ps) { | |
| 318 | if (slot.innerHTML !== ps.innerHTML) slot.innerHTML = ps.innerHTML; | |
| 319 | ['role', 'tabindex', 'aria-label'].forEach(function (a) { | |
| 320 | var v = ps.getAttribute(a); | |
| 321 | if (v === null) slot.removeAttribute(a); else if (slot.getAttribute(a) !== v) slot.setAttribute(a, v); | |
| 322 | }); | |
| 323 | } | |
| 324 | }) | |
| 325 | .catch(function () {}); | |
| 326 | } | |
| 327 | function stepsSoon() { | |
| 328 | if (!document.getElementById('check-steps') || stepsTimer) return; | |
| 329 | stepsTimer = window.setTimeout(function () { stepsTimer = 0; steps(); }, 400); | |
| 330 | } | |
| 331 | ||
| 298 | 332 | if (steer && window.fetch) { |
| 299 | 333 | var text = steer.querySelector('textarea'); |
| 300 | 334 | function size() { if (!text) return; text.style.height = 'auto'; text.style.height = Math.min(160, text.scrollHeight) + 'px'; fit(); } |
| @@ -341,12 +375,15 @@ export const studioRunJs = /* js */ ` | ||
| 341 | 375 | var text = event.data; |
| 342 | 376 | try { var parsed = JSON.parse(text); if (typeof parsed === 'string') text = parsed; } catch (err) {} |
| 343 | 377 | add(draw(text)); |
| 378 | if (/^(\\$ |test-shards: |\\(fail\\) |\u2500\u2500 shard )/.test(text)) stepsSoon(); | |
| 344 | 379 | }); |
| 345 | 380 | source.addEventListener('done', function (event) { |
| 346 | 381 | source.close(); |
| 347 | 382 | var run; |
| 348 | 383 | try { run = JSON.parse(event.data); } catch (err) { return; } |
| 349 | 384 | paint(run); |
| 385 | if (stepsTimer) { window.clearTimeout(stepsTimer); stepsTimer = 0; } | |
| 386 | steps(); | |
| 350 | 387 | if (steer) { |
| 351 | 388 | steer.setAttribute('data-running', '0'); |
| 352 | 389 | var n = document.getElementById('run-steer-note'); |
| @@ -1930,6 +1967,10 @@ export const liveRegionsJs = /* js */ ` | ||
| 1930 | 1967 | // post is in flight) is never pulled out from under the click; the next |
| 1931 | 1968 | // matching event tries the swap again. |
| 1932 | 1969 | if (mine.querySelector('[data-busy]')) return; |
| 1970 | // Likewise a region with a ticked box (apps picked for Archive, or the | |
| 1971 | // Select toggle itself): a tap on a phone does not focus a checkbox, so | |
| 1972 | // focus cannot be what protects the picks. | |
| 1973 | if (mine.querySelector('input[type=checkbox]:checked')) return; | |
| 1933 | 1974 | var active = document.activeElement; |
| 1934 | 1975 | if (active && mine.contains(active) && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return; |
| 1935 | 1976 | if (same(mine.innerHTML, theirs.innerHTML)) return; |
src/web/live.ts +1 −1
| @@ -302,7 +302,7 @@ export const liveClientJs = /* js */ ` | ||
| 302 | 302 | 'label.created', 'label.updated', 'label.deleted', |
| 303 | 303 | 'run.queued', 'run.started', 'run.finished', 'app.serving', |
| 304 | 304 | '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', | |
| 306 | 306 | ]; |
| 307 | 307 | var extra = (cfg.getAttribute('data-repos') || '').split(' ').filter(Boolean); |
| 308 | 308 | 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"; | ||
| 31 | 31 | import { listKeys } from "../../core/keys.ts"; |
| 32 | 32 | import { endAccountSession } from "../../actions/signins.ts"; |
| 33 | 33 | import { listSessions, type SessionInfo } from "../../core/sessions.ts"; |
| 34 | import { listTokens, type TokenScope } from "../../core/tokens.ts"; | |
| 34 | import { ADMIN_TOKEN_WORDS, listTokens, type TokenScope } from "../../core/tokens.ts"; | |
| 35 | 35 | import type { CommitEmail, User } from "../../core/users.ts"; |
| 36 | 36 | import { cookieValue, type Ctx } from "../../http/context.ts"; |
| 37 | 37 | import { sshCommand } from "../../ssh/command.ts"; |
| @@ -539,7 +539,9 @@ function tokensView(ctx: Ctx): Html { | ||
| 539 | 539 | options: [ |
| 540 | 540 | { value: "rw", label: "read + write — clone, push, open issues" }, |
| 541 | 541 | { value: "ro", label: "read only — clone and browse" }, |
| 542 | { value: "admin", label: "admin — everything, including settings" }, | |
| 542 | ...(ctx.auth.user?.isAdmin | |
| 543 | ? [{ value: "admin", label: "admin — everything, including settings" }] | |
| 544 | : []), | |
| 543 | 545 | ], |
| 544 | 546 | hint: "A token can never mint another token, whatever its scope.", |
| 545 | 547 | })} |
| @@ -594,6 +596,8 @@ export async function tokenCreate(ctx: Ctx): Promise<Response> { | ||
| 594 | 596 | field(form, "name").trim() || "token", |
| 595 | 597 | chosen, |
| 596 | 598 | ); |
| 599 | if (!minted.ok && minted.error === "forbidden") | |
| 600 | return flashTo(ctx, "/settings/tokens", { tone: "danger", message: ADMIN_TOKEN_WORDS }); | |
| 597 | 601 | if (!minted.ok) return signInFirst(ctx); |
| 598 | 602 | // Post-redirect-get: the secret rides a one-shot flash, so a refresh shows |
| 599 | 603 | // the list rather than minting a second token. |
src/web/pages/accounts.ts +2 −2
| @@ -20,7 +20,7 @@ import { | ||
| 20 | 20 | revokeAccountToken, |
| 21 | 21 | } from "../../actions/signins.ts"; |
| 22 | 22 | import { mailConfigured } from "../../actions/verify.ts"; |
| 23 | import type { AccountRow } from "../../core/accounts.ts"; | |
| 23 | import { originOf, type AccountRow } from "../../core/accounts.ts"; | |
| 24 | 24 | import { |
| 25 | 25 | OWNERS_TEAM, |
| 26 | 26 | ownerlessOrgs, |
| @@ -73,7 +73,7 @@ function standing(a: AccountRow): Html { | ||
| 73 | 73 | function row(a: AccountRow, sole: readonly string[]): Html { |
| 74 | 74 | const at = `/settings/accounts/${encodeURIComponent(a.username)}`; |
| 75 | 75 | const how = [ |
| 76 | a.via ?? (a.kind === "agent" ? "crew" : ""), | |
| 76 | originOf(a), | |
| 77 | 77 | a.address ?? "", |
| 78 | 78 | ].filter((s) => s !== ""); |
| 79 | 79 | const holds = [ |
src/web/pages/backups.ts +16 −6
| @@ -20,6 +20,7 @@ import { resolveDestination, sealedSet } from "../../snapshot/destination.ts"; | ||
| 20 | 20 | import { keyOf } from "../../snapshot/snapshot.ts"; |
| 21 | 21 | import { lastDrill } from "../../snapshot/drills.ts"; |
| 22 | 22 | import { snapshotsOf } from "../../snapshot/service.ts"; |
| 23 | import { lastSnapshotOutcome } from "../../snapshot/outcome.ts"; | |
| 23 | 24 | import type { Ctx } from "../../http/context.ts"; |
| 24 | 25 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; |
| 25 | 26 | import { field, readForm, type Form } from "../form.ts"; |
| @@ -183,9 +184,18 @@ export async function backupsPage(ctx: Ctx): Promise<Response> { | ||
| 183 | 184 | note: "This forge was given nowhere to put them. On the platform an app's bucket (S3_*) is the place; on a machine, FORGE_SNAPSHOT_DIR names a directory. Seal a bucket below, or restart with one set, and a snapshot is taken every night at 08:00 UTC.", |
| 184 | 185 | })}${form}`, |
| 185 | 186 | ); |
| 187 | // A failure is kept on disk, so it is shown after a restart too; this | |
| 188 | // process's own failure is newer when the file could not be written. | |
| 189 | const kept = lastSnapshotOutcome(ctx.app.config.dataDir); | |
| 190 | const last = svc.last(); | |
| 191 | const failing = | |
| 192 | kept?.failingSince !== undefined | |
| 193 | ? { at: kept.at, since: kept.failingSince, error: kept.error ?? "unknown reason" } | |
| 194 | : last.error !== undefined && (kept === undefined || (last.at ?? "") > kept.at) | |
| 195 | ? { at: last.at ?? "", since: last.at ?? "", error: last.error } | |
| 196 | : undefined; | |
| 186 | 197 | // The store is somewhere else; a listing that fails is a sentence on the |
| 187 | 198 | // page, never a page that fails. |
| 188 | const last = svc.last(); | |
| 189 | 199 | let snapshots: Awaited<ReturnType<typeof svc.list>> = []; |
| 190 | 200 | let listing: string | undefined; |
| 191 | 201 | try { |
| @@ -227,11 +237,11 @@ bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre>` | ||
| 227 | 237 | </p>`, |
| 228 | 238 | )} |
| 229 | 239 | ${when( |
| 230 | last.error, | |
| 231 | (error) => | |
| 232 | html`<p class="backups-error"> | |
| 233 | ${icon("alert")} The last snapshot failed ${time(last.at ?? "")}: | |
| 234 | ${error} | |
| 240 | failing, | |
| 241 | (f) => | |
| 242 | html`<p class="backups-error" role="alert"> | |
| 243 | ${icon("alert")} The last snapshot failed ${time(f.at)}, failing | |
| 244 | since ${time(f.since)}: ${f.error} | |
| 235 | 245 | </p>`, |
| 236 | 246 | )} |
| 237 | 247 | ${when( |
src/web/pages/limb.ts +4
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * take it back once. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { isReplanAction } from "../../actions/limb_replan.ts"; | |
| 9 | 10 | import { getFleetOp, undoFleetOp } from "../../actions/fleet.ts"; |
| 10 | 11 | import { |
| 11 | 12 | applyLimb, |
| @@ -32,6 +33,7 @@ import { field, readForm } from "../form.ts"; | ||
| 32 | 33 | import { html, when, type Html } from "../html.ts"; |
| 33 | 34 | import { page, redirect } from "../layout.ts"; |
| 34 | 35 | import { btn } from "../ui.ts"; |
| 36 | import { replanPreviewPage, replanSubmit } from "./limb_replan.ts"; | |
| 35 | 37 | import { milestoneHref, milestonesCss } from "./milestones.ts"; |
| 36 | 38 | |
| 37 | 39 | const WORDS: Record<LimbAction, { ask: string; does: string; go: string }> = { |
| @@ -166,6 +168,7 @@ export function limbPreviewPage(ctx: Ctx): Response { | ||
| 166 | 168 | if (!ctx.auth.user) return signInFirst(ctx); |
| 167 | 169 | const number = Number(ctx.params.number); |
| 168 | 170 | const action = ctx.url.searchParams.get("action"); |
| 171 | if (action === "replan") return replanPreviewPage(ctx, repo, number); | |
| 169 | 172 | if (!Number.isInteger(number) || !isLimbAction(action)) |
| 170 | 173 | return notFoundPage(ctx); |
| 171 | 174 | const request = requestOf(action, (n) => ctx.url.searchParams.get(n) ?? ""); |
| @@ -350,6 +353,7 @@ export async function limbSubmit(ctx: Ctx): Promise<Response> { | ||
| 350 | 353 | if (form instanceof Response) return form; |
| 351 | 354 | const number = Number(ctx.params.number); |
| 352 | 355 | const action = field(form, "action"); |
| 356 | if (isReplanAction(action)) return replanSubmit(ctx, repo, number, action); | |
| 353 | 357 | if (!Number.isInteger(number) || !isLimbAction(action)) |
| 354 | 358 | return notFoundPage(ctx); |
| 355 | 359 | 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 { | ||
| 34 | 34 | import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 35 | 35 | import { isWatching } from "../../core/watches.ts"; |
| 36 | 36 | import { watchMilestone } from "../../actions/watches.ts"; |
| 37 | import { replanResult } from "../../actions/limb_replan.ts"; | |
| 38 | import { given, pendingFor } from "../../core/limb_replan.ts"; | |
| 37 | 39 | import type { Ctx } from "../../http/context.ts"; |
| 38 | 40 | import { askToLiveFor } from "../ask_to_live.ts"; |
| 39 | 41 | import { |
| @@ -59,7 +61,7 @@ export const milestoneHref = (base: string, number: number): string => | ||
| 59 | 61 | export const limbHref = ( |
| 60 | 62 | base: string, |
| 61 | 63 | number: number, |
| 62 | action: LimbAction, | |
| 64 | action: LimbAction | "replan", | |
| 63 | 65 | under?: string, |
| 64 | 66 | ): string => |
| 65 | 67 | `${milestoneHref(base, number)}/limb?action=${action}${under === undefined ? "" : `&under=${under}`}`; |
| @@ -120,11 +122,13 @@ function limbActions( | ||
| 120 | 122 | furniture: boolean, |
| 121 | 123 | lead: Html | null, |
| 122 | 124 | move: Html | null, |
| 125 | replan: Html | null, | |
| 123 | 126 | ): Html { |
| 124 | 127 | const number = m.issue.number; |
| 125 | 128 | return html`<p class="bar ms-actions" id="limb-actions"> |
| 126 | 129 | ${lead} |
| 127 | 130 | ${move} |
| 131 | ${replan} | |
| 128 | 132 | ${furniture |
| 129 | 133 | ? null |
| 130 | 134 | : m.deferred |
| @@ -242,6 +246,58 @@ function folded(rows: Html[], what: string, shown: number): Html { | ||
| 242 | 246 | </details>`; |
| 243 | 247 | } |
| 244 | 248 | |
| 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 | ||
| 245 | 301 | /** |
| 246 | 302 | * Watch, or stop: one button, for a person who is signed in. Watching puts |
| 247 | 303 | * the milestone's progress in their feed and tells their phone once, when |
| @@ -490,17 +546,20 @@ export function milestonePage(ctx: Ctx): Response { | ||
| 490 | 546 | const move = moverFor(ctx.app.db, repo, viewer, plan.read); |
| 491 | 547 | // Its own place: among the limbs of whichever milestone holds it first. |
| 492 | 548 | 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 | |
| 494 | 552 | ? limbActions( |
| 495 | 553 | repoBase(repo), |
| 496 | 554 | milestone, |
| 497 | 555 | isStructural(pillarsOf(ctx.app.db, repo), milestone.issue.id), |
| 498 | 556 | watch, |
| 499 | 557 | seat === undefined ? null : (move?.(milestone.issue, seat) ?? null), |
| 558 | replan, | |
| 500 | 559 | ) |
| 501 | : watch === null | |
| 560 | : watch === null && replan === null | |
| 502 | 561 | ? null |
| 503 | : html`<p class="bar ms-actions">${watch}</p>`; | |
| 562 | : html`<p class="bar ms-actions">${watch}${replan}</p>`; | |
| 504 | 563 | return page({ |
| 505 | 564 | ctx, |
| 506 | 565 | title: `${milestone.name} · ${repo.name}`, |
| @@ -508,6 +567,7 @@ export function milestonePage(ctx: Ctx): Response { | ||
| 508 | 567 | palette: repoPalette(ctx, repo), |
| 509 | 568 | body: html`${repoHeader(ctx, repo, "map")} |
| 510 | 569 | ${milestoneBody(repo, milestone, owner, actions, move)} |
| 570 | ${replanChanged(ctx, repo, milestone)} | |
| 511 | 571 | ${liveMount({ repo: repoBase(repo) })}`, |
| 512 | 572 | styleCss: milestonesCss, |
| 513 | 573 | scriptJs: liveClientJs, |
| @@ -621,6 +681,7 @@ export const milestonesCss = /* css */ ` | ||
| 621 | 681 | .ms-top h1, .ms-top p { margin: 0; } |
| 622 | 682 | .ms-head { display: grid; grid-template-columns: minmax(0, 1fr); gap: 4px; } |
| 623 | 683 | .ms-actions { margin-top: 4px; } |
| 684 | .ms-planner { display: inline-flex; flex-wrap: wrap; gap: 4px 8px; align-items: baseline; } | |
| 624 | 685 | .ms-move { grid-column: 1 / -1; display: flex; gap: 14px; } |
| 625 | 686 | .ms-section { margin-top: 22px; } |
| 626 | 687 | .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"; | ||
| 15 | 15 | import { verifyEmail } from "../../actions/verify.ts"; |
| 16 | 16 | import { STRANGER_MAX } from "../../http/limits.ts"; |
| 17 | 17 | import { createRepo, repositoryLimitMessage, resolveOwner } from "../../actions/repos.ts"; |
| 18 | import { HUMAN_CHECK_MESSAGE, PASSKEY_REQUIRED_MESSAGE, SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts"; | |
| 18 | import { HUMAN_CHECK_MESSAGE, INVITE_SPENT_MESSAGE, PASSKEY_REQUIRED_MESSAGE, SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts"; | |
| 19 | 19 | import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts"; |
| 20 | 20 | import { listPins } from "../../core/pins.ts"; |
| 21 | 21 | import { getReposByIds } from "../../core/repos.ts"; |
| @@ -419,10 +419,6 @@ export function inviteOf(ctx: Ctx): string | undefined { | ||
| 419 | 419 | return token === "" ? undefined : token; |
| 420 | 420 | } |
| 421 | 421 | |
| 422 | /** What a person with a link that no longer opens the door is told. */ | |
| 423 | const INVITE_SPENT_MESSAGE = | |
| 424 | "This invite link has been used, revoked or has expired. Ask whoever sent it for a new one."; | |
| 425 | ||
| 426 | 422 | /** The whole of `/signup` while the forge makes no new accounts: a sentence, no form. */ |
| 427 | 423 | function signupClosedPage(ctx: Ctx): Response { |
| 428 | 424 | const said = inviteOf(ctx) === undefined ? SIGNUP_CLOSED_MESSAGE : INVITE_SPENT_MESSAGE; |
src/web/studio/feed_style.ts +3
| @@ -27,6 +27,9 @@ export const feedCss = /* css */ ` | ||
| 27 | 27 | /* Your apps: a narrow column on a desk, a tab away on a phone. */ |
| 28 | 28 | .home-tabs { display: none; margin-bottom: 12px; } |
| 29 | 29 | .home-app { gap: 8px; } |
| 30 | .home-pick { display: none; } | |
| 31 | #apps-select:checked ~ * .home-pick { display: inline-flex; } | |
| 32 | #apps-select:focus-visible ~ * .home-apps-verbs label { outline: 2px solid var(--accent, currentColor); } | |
| 30 | 33 | .home-apps-verbs { flex-wrap: wrap; padding: 10px 14px; } |
| 31 | 34 | @media (max-width: 899px) { |
| 32 | 35 | .home-tabs { display: inline-flex; } |
src/web/studio/home.ts +31 −4
| @@ -60,7 +60,7 @@ import { passkeyJs } from "../passkey-client.ts"; | ||
| 60 | 60 | const ON_YOU_LIVE = |
| 61 | 61 | "issue.assigned,pull.assigned,issue.closed,pull.closed,issue.reopened,pull.reopened,pull.merged,fleet.operated"; |
| 62 | 62 | /** The events that can change how an app on the rail is. */ |
| 63 | const APPS_LIVE = "app.serving,app.stopped,run.finished"; | |
| 63 | const APPS_LIVE = "app.serving,app.stopped,run.finished,fleet.operated"; | |
| 64 | 64 | /** How many of your apps the rail names before "All apps". */ |
| 65 | 65 | const RAIL_APPS = 8; |
| 66 | 66 | |
| @@ -83,13 +83,23 @@ function yourApps(ctx: Ctx, user: User): Html { | ||
| 83 | 83 | ? undefined |
| 84 | 84 | : runtime.status(record).find((env) => env.env === "production")?.state; |
| 85 | 85 | return html`<li class="row home-app"> |
| 86 | ${when( | |
| 87 | user.isAdmin, | |
| 88 | () => | |
| 89 | html`<input | |
| 90 | class="home-pick" | |
| 91 | type="checkbox" | |
| 92 | name="repo" | |
| 93 | value="${repo.id}" | |
| 94 | aria-label="Select ${repo.name}" | |
| 95 | />`, | |
| 96 | )} | |
| 86 | 97 | <a class="truncate" href="${repoBase(repo)}">${repo.ownerName}/${repo.name}</a> |
| 87 | 98 | <span class="spacer"></span> |
| 88 | 99 | ${state === undefined ? null : envStateBadge(state)} |
| 89 | 100 | </li>`; |
| 90 | 101 | }); |
| 91 | return card( | |
| 92 | html`${rows.length === 0 | |
| 102 | const list = html`${rows.length === 0 | |
| 93 | 103 | ? html`<p class="muted small">No apps yet.</p>` |
| 94 | 104 | : html`<ul class="list"> |
| 95 | 105 | ${rows} |
| @@ -108,7 +118,24 @@ function yourApps(ctx: Ctx, user: User): Html { | ||
| 108 | 118 | ${when(user.isAdmin, () => |
| 109 | 119 | btn("Front page", { href: "/front", size: "sm", icon: "globe" }), |
| 110 | 120 | )} |
| 111 | </div>`, | |
| 121 | ${when( | |
| 122 | user.isAdmin && rows.length > 0, | |
| 123 | () => html`<label class="btn btn-sm" for="apps-select">Select</label> | |
| 124 | <span class="home-pick">${btn("Archive", { size: "sm" })}</span>`, | |
| 125 | )} | |
| 126 | </div>`; | |
| 127 | return card( | |
| 128 | // An admin's list is a form: ticks cost no space until Select shows them. | |
| 129 | user.isAdmin && rows.length > 0 | |
| 130 | ? html`<form method="post" action="/home/archive"> | |
| 131 | <input | |
| 132 | class="sr-only" | |
| 133 | type="checkbox" | |
| 134 | id="apps-select" | |
| 135 | aria-label="Select apps" | |
| 136 | />${list} | |
| 137 | </form>` | |
| 138 | : list, | |
| 112 | 139 | { |
| 113 | 140 | head: html`${icon("rocket")} |
| 114 | 141 | <h2>Your apps</h2>`, |
src/web/studio/home_archive.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | /** | |
| 2 | * Archiving apps from the home: an admin ticks apps in the rail and archives | |
| 3 | * them in one step. It is the fleet's own `archive` operation over the ticked | |
| 4 | * repositories (`actions/fleet.ts`) — nothing new is written here — and the | |
| 5 | * page that answers lists what was archived with the operation's undo. | |
| 6 | */ | |
| 7 | ||
| 8 | import { applyFleetIds, getFleetOp, undoFleetOp } from "../../actions/fleet.ts"; | |
| 9 | import type { Ctx } from "../../http/context.ts"; | |
| 10 | import { signInFirst } from "../chrome.ts"; | |
| 11 | import { readForm } from "../form.ts"; | |
| 12 | import { html } from "../html.ts"; | |
| 13 | import { page, redirect } from "../layout.ts"; | |
| 14 | import { btn } from "../ui.ts"; | |
| 15 | import { MAX_ROWS } from "../../fleet/selection.ts"; | |
| 16 | ||
| 17 | /** The ticked repository ids, once each, as long as they are plausible ids. */ | |
| 18 | function picked(form: Awaited<ReturnType<typeof readForm>>): string[] { | |
| 19 | if (form instanceof Response) return []; | |
| 20 | const ids = form | |
| 21 | .getAll("repo") | |
| 22 | .filter((v): v is string => typeof v === "string" && /^[\w-]{1,64}$/.test(v)); | |
| 23 | return [...new Set(ids)]; | |
| 24 | } | |
| 25 | ||
| 26 | // POST /home/archive — archive the ticked apps, a site admin's. | |
| 27 | export async function homeArchive(ctx: Ctx): Promise<Response> { | |
| 28 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 29 | const form = await readForm(ctx, "admin"); | |
| 30 | if (form instanceof Response) return form; | |
| 31 | if (!ctx.auth.user.isAdmin) return new Response("forbidden", { status: 403 }); | |
| 32 | const ids = picked(form); | |
| 33 | if (ids.length === 0 || ids.length > MAX_ROWS) | |
| 34 | return redirect("/", { | |
| 35 | url: ctx.url, | |
| 36 | flash: { tone: "danger", message: "Tick the apps to archive first." }, | |
| 37 | }); | |
| 38 | const op = await applyFleetIds( | |
| 39 | ctx.app, | |
| 40 | ctx.auth.user, | |
| 41 | "repo", | |
| 42 | ids, | |
| 43 | `home: ${ids.length} selected`, | |
| 44 | { verb: "archive", value: true }, | |
| 45 | ); | |
| 46 | if (!op.ok) | |
| 47 | return redirect("/", { | |
| 48 | url: ctx.url, | |
| 49 | flash: { tone: "danger", message: op.error.message }, | |
| 50 | }); | |
| 51 | return redirect(`/home/archived/${op.value.id}`); | |
| 52 | } | |
| 53 | ||
| 54 | // GET /home/archived/:op — what was archived, and the undo. | |
| 55 | export function homeArchived(ctx: Ctx): Response { | |
| 56 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 57 | const found = getFleetOp(ctx.app, ctx.auth.user, ctx.params.op ?? ""); | |
| 58 | if (!found.ok || found.value.verb !== "archive") | |
| 59 | return new Response("not found", { status: 404 }); | |
| 60 | const op = found.value; | |
| 61 | const wasUndo = op.undoOf !== null; | |
| 62 | const rows = op.rows.filter((r) => r.outcome === "ok"); | |
| 63 | const left = op.rows.filter((r) => r.outcome !== "ok"); | |
| 64 | // Only the person who ran it may undo it (`undoFleetOp`), whoever may read it. | |
| 65 | const canUndo = | |
| 66 | op.reversible && op.undoneBy === null && !wasUndo && op.actorId === ctx.auth.user.id; | |
| 67 | const body = html`<h1>${wasUndo ? "Brought back" : "Archived"}</h1> | |
| 68 | <p id="home-archived" role="status"> | |
| 69 | ${rows.length} app${rows.length === 1 ? "" : "s"} | |
| 70 | ${wasUndo ? "back on the home" : "archived"}${left.length === 0 | |
| 71 | ? "" | |
| 72 | : `, ${left.length} left as they were`}. | |
| 73 | </p> | |
| 74 | <ul class="list"> | |
| 75 | ${rows.map((r) => html`<li class="row">${r.label}</li>`)} | |
| 76 | </ul> | |
| 77 | ${left.length === 0 | |
| 78 | ? null | |
| 79 | : html`<ul class="list muted"> | |
| 80 | ${left.map((r) => html`<li class="row">${r.label}: ${r.reason}</li>`)} | |
| 81 | </ul>`} | |
| 82 | <div class="bar mt"> | |
| 83 | ${canUndo | |
| 84 | ? html`<form method="post" action="/home/archived/${op.id}/undo"> | |
| 85 | ${btn("Undo", { variant: "primary" })} | |
| 86 | </form>` | |
| 87 | : null} | |
| 88 | ${btn("Home", { href: "/" })} | |
| 89 | </div> | |
| 90 | ${canUndo ? html`<p class="small muted">Undo is kept for a day.</p>` : null}`; | |
| 91 | return page({ ctx, title: wasUndo ? "Brought back" : "Archived", body }); | |
| 92 | } | |
| 93 | ||
| 94 | // POST /home/archived/:op/undo — the operation's own undo. | |
| 95 | export async function homeArchiveUndo(ctx: Ctx): Promise<Response> { | |
| 96 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 97 | const form = await readForm(ctx, "admin"); | |
| 98 | if (form instanceof Response) return form; | |
| 99 | const undone = await undoFleetOp(ctx.app, ctx.auth.user, ctx.params.op ?? ""); | |
| 100 | if (!undone.ok && undone.error.code === "not_found") | |
| 101 | return new Response("not found", { status: 404 }); | |
| 102 | if (!undone.ok) | |
| 103 | return redirect("/", { | |
| 104 | url: ctx.url, | |
| 105 | flash: { tone: "danger", message: undone.error.message }, | |
| 106 | }); | |
| 107 | return redirect(`/home/archived/${undone.value.id}`); | |
| 108 | } |
src/web/studio/previews.ts +6
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * deploy event refreshes (`appStateFragment`) share. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { audienceFacts, audienceOf } from "../../core/audience.ts"; | |
| 10 | 11 | import { sealedScope } from "../../core/sealed.ts"; |
| 11 | 12 | import { terminalFor } from "../../actions/apps.ts"; |
| 12 | 13 | import { envPull, previewEnv } from "../../apps/data.ts"; |
| @@ -298,6 +299,10 @@ export function appPanels( | ||
| 298 | 299 | // The state in one word, and under it the facts — why it is not up, and |
| 299 | 300 | // the deploy whose log says the rest. |
| 300 | 301 | const live = envPhrase(production); |
| 302 | // Who the app answers to, in the sentence the forge's own rules produce. | |
| 303 | const audience = audienceOf( | |
| 304 | audienceFacts(db, repo, ctx.auth.user), | |
| 305 | ).sentence; | |
| 301 | 306 | const head = html`<div class="page-head studio-head"> |
| 302 | 307 | <div class="stack stack-tight"> |
| 303 | 308 | <h1> |
| @@ -320,6 +325,7 @@ export function appPanels( | ||
| 320 | 325 | >` |
| 321 | 326 | : html`· <span class="mono">${url.replace(/^https?:\/\//, "")}</span>`} |
| 322 | 327 | </p> |
| 328 | <p class="muted small" id="app-audience">${audience}</p> | |
| 323 | 329 | ${when(production, (env) => envFacts(base, env))} |
| 324 | 330 | ${when(writer, () => |
| 325 | 331 | terminalLine( |
src/web/studio/run.ts +55 −1
| @@ -8,6 +8,8 @@ | ||
| 8 | 8 | * at `?all=1`. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { checkSteps, stepWord } from "../../checks/summary.ts"; | |
| 12 | import { parseTestsRow, testsRowWords } from "../../checks/tests-row.ts"; | |
| 11 | 13 | import { directionNote } from "../../crew/direct.ts"; |
| 12 | 14 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 13 | 15 | import { getAppByRepo } from "../../core/apps.ts"; |
| @@ -24,7 +26,7 @@ import { repoDir } from "../../git/repos.ts"; | ||
| 24 | 26 | import type { Ctx } from "../../http/context.ts"; |
| 25 | 27 | import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts"; |
| 26 | 28 | import { studioRunJs } from "../client.ts"; |
| 27 | import { html, when, type Html } from "../html.ts"; | |
| 29 | import { html, raw, when, type Html } from "../html.ts"; | |
| 28 | 30 | import { runLine } from "../describe.ts"; |
| 29 | 31 | import { page } from "../layout.ts"; |
| 30 | 32 | import { btn, sha } from "../ui.ts"; |
| @@ -244,6 +246,58 @@ export async function runView( | ||
| 244 | 246 | Nothing has been logged yet. |
| 245 | 247 | </p>`, |
| 246 | 248 | )} |
| 249 | ${when( | |
| 250 | // Progress is read from the log, so a reader the log is hidden from | |
| 251 | // gets no rows either. | |
| 252 | run.kind === "check" && !logHidden, | |
| 253 | () => { | |
| 254 | const steps = checkSteps(run, lines, truncated); | |
| 255 | const row = parseTestsRow(lines); | |
| 256 | const words = testsRowWords(row); | |
| 257 | const known = row.of !== undefined && row.started !== undefined && row.started <= row.of; | |
| 258 | const open = run.state === "queued" || run.state === "running"; | |
| 259 | const running = row.running ?? []; | |
| 260 | const runningWords = | |
| 261 | running.length === 0 | |
| 262 | ? "" | |
| 263 | : `running ${running.slice(0, 2).join(", ")}${running.length > 2 ? ` +${running.length - 2}` : ""}`; | |
| 264 | // While the run is open every part is rendered at a fixed size (the | |
| 265 | // pin slot by CSS), so the log below never moves as numbers and pins | |
| 266 | // arrive; a run that has ended reserves nothing. | |
| 267 | return html`<div class="check-progress" id="check-progress" data-state="${run.state}"> | |
| 268 | <div id="check-steps" role="status" aria-live="polite"> | |
| 269 | <ol class="check-steps"> | |
| 270 | ${steps.map( | |
| 271 | (s) => | |
| 272 | html`<li data-step="${s.name}" data-state="${s.state}"> | |
| 273 | <span class="step-name">${s.name}</span> | |
| 274 | <span class="step-state">${stepWord(s.state)}</span> | |
| 275 | </li>`, | |
| 276 | )} | |
| 277 | </ol> | |
| 278 | ${known | |
| 279 | ? html`<progress class="tests-bar" aria-label="Test files started" max="${row.of ?? 0}" value="${row.started ?? 0}"></progress>` | |
| 280 | : open | |
| 281 | ? html`<progress class="tests-bar" aria-label="Test files started"></progress>` | |
| 282 | : ""} | |
| 283 | <p class="tests-words small muted">${[words, runningWords].filter((w) => w !== "").join(" · ")}</p> | |
| 284 | </div> | |
| 285 | <div class="pinned-slot" id="check-pins"${row.failures.length > 0 ? raw(' role="region" tabindex="0" aria-label="Failed tests"') : ""}> | |
| 286 | ${when( | |
| 287 | row.failures.length > 0, | |
| 288 | () => html`<ul class="pinned-failures"> | |
| 289 | ${row.failures.map( | |
| 290 | (f) => html`<li><strong>${f.name}</strong>${when( | |
| 291 | f.lines.length > 0, | |
| 292 | () => html`<pre>${f.lines.join("\n")}</pre>`, | |
| 293 | )}</li>`, | |
| 294 | )} | |
| 295 | </ul>`, | |
| 296 | )} | |
| 297 | </div> | |
| 298 | </div>`; | |
| 299 | }, | |
| 300 | )} | |
| 247 | 301 | <div class="run-live" id="run-live" data-state="${run.state}"> |
| 248 | 302 | ${when(!logHidden, () => logBox({ |
| 249 | 303 | id: "run-log", |
src/web/style/content.ts +25
| @@ -25,6 +25,16 @@ export const content = /* css */ ` | ||
| 25 | 25 | } |
| 26 | 26 | .repo-title .owner { font-weight: 400; color: var(--muted-fg); } |
| 27 | 27 | .repo-title .slash { color: var(--faint); font-weight: 400; } |
| 28 | .repo-audience { | |
| 29 | flex: 1 1 16ch; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | |
| 30 | font-size: 13px; font-weight: 400; letter-spacing: 0; color: var(--muted-fg); | |
| 31 | } | |
| 32 | a.repo-audience:hover { color: var(--fg); } | |
| 33 | /* A phone has no hover for the title: the whole sentence, on its own line. */ | |
| 34 | @media (max-width: 640px) { | |
| 35 | .repo-audience { flex-basis: 100%; white-space: normal; text-overflow: clip; } | |
| 36 | a.repo-audience { display: block; padding: 12px 0; min-height: 44px; box-sizing: border-box; } | |
| 37 | } | |
| 28 | 38 | .repo-desc { margin-top: 4px; color: var(--muted-fg); font-size: 13.5px; text-wrap: pretty; } |
| 29 | 39 | .repo-lineage { margin-top: 3px; font-size: 12.5px; color: var(--faint); } |
| 30 | 40 | .repo-head .tabs { margin-top: 12px; } |
| @@ -865,6 +875,21 @@ form.inline { display: inline; margin-left: auto; } | ||
| 865 | 875 | .checks-line .ok { display: inline-flex; align-items: center; gap: 4px; color: var(--success); } |
| 866 | 876 | .checks-line .bad { display: inline-flex; align-items: center; gap: 4px; color: var(--danger); } |
| 867 | 877 | .checks-line .now { display: inline-flex; align-items: center; gap: 4px; color: var(--attn); } |
| 878 | .check-steps { list-style: none; margin: 12px 0 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } | |
| 879 | .check-steps li { display: flex; justify-content: space-between; gap: 8px; padding: 8px 12px; min-height: 2.5em; border: 1px solid var(--border); border-radius: var(--radius); } | |
| 880 | .tests-bar { display: block; width: 100%; height: 8px; margin-top: 8px; } | |
| 881 | .tests-words { margin: 4px 0 0; min-height: 1.4em; } | |
| 882 | /* Reserved only while the run is open: pins arriving never move the log. */ | |
| 883 | .pinned-slot { max-height: 9em; overflow-y: auto; } | |
| 884 | .check-progress[data-state="queued"] .pinned-slot, .check-progress[data-state="running"] .pinned-slot { height: 9em; } | |
| 885 | .pinned-failures { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 6px; } | |
| 886 | .pinned-failures li { padding: 6px 12px; border: 1px solid var(--danger); border-radius: var(--radius); } | |
| 887 | .pinned-failures pre { margin: 4px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 0.85em; } | |
| 888 | @media (max-width: 480px) { .check-steps { grid-template-columns: minmax(0, 1fr); } } | |
| 889 | .check-steps .step-state { color: var(--faint); } | |
| 890 | .check-steps [data-state="ok"] .step-state { color: var(--success); } | |
| 891 | .check-steps [data-state="failed"] .step-state { color: var(--danger); } | |
| 892 | .check-steps [data-state="running"] .step-state { color: var(--attn); } | |
| 868 | 893 | /* A run, live: a transcript with a speaker column, bounded so the page |
| 869 | 894 | head stays reachable, and a composer that sits under it. */ |
| 870 | 895 | .run-live { position: relative; margin-top: 12px; } |
test/accounts.test.ts +46 −2
| @@ -13,6 +13,7 @@ import { | ||
| 13 | 13 | import { queueAgentRun } from "../src/crew/dispatch.ts"; |
| 14 | 14 | import { fetchInternal } from "../src/git/hook-address.ts"; |
| 15 | 15 | import { addComment, openIssue } from "../src/actions/issues.ts"; |
| 16 | import { newAccountLines } from "../src/actions/accounts.ts"; | |
| 16 | 17 | import { createRepo } from "../src/actions/repos.ts"; |
| 17 | 18 | import { createOrg } from "../src/core/orgs.ts"; |
| 18 | 19 | import { getAgent } from "../src/core/agents.ts"; |
| @@ -175,6 +176,7 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 175 | 176 | items: { |
| 176 | 177 | username: string; |
| 177 | 178 | via: string | null; |
| 179 | signsInThrough: string | null; | |
| 178 | 180 | repos: number; |
| 179 | 181 | spentUsd: number; |
| 180 | 182 | suspendedAt: string | null; |
| @@ -186,6 +188,8 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 186 | 188 | expect(ada.spentUsd).toBe(0); |
| 187 | 189 | expect(ada.suspendedAt).toBeNull(); |
| 188 | 190 | expect(items.find((i) => i.username === "edgy")?.via).toBe("edge"); |
| 191 | expect(items.find((i) => i.username === "edgy")?.signsInThrough).toBe("open-platform.sh"); | |
| 192 | expect(ada.signsInThrough).toBeNull(); | |
| 189 | 193 | const page = await fetch(`${base}/settings/accounts`, { |
| 190 | 194 | headers: { authorization: `Bearer ${ownerToken}` }, |
| 191 | 195 | }); |
| @@ -193,6 +197,12 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 193 | 197 | const html = await page.text(); |
| 194 | 198 | expect(html).toContain("ada"); |
| 195 | 199 | expect(html).toContain("Suspend"); |
| 200 | expect(html).toContain("signs in through open-platform.sh"); | |
| 201 | expect(html.match(/signs in through/g)?.length).toBe(1); | |
| 202 | const owner = getUserByName(app.db, "olive")!; | |
| 203 | const lines = newAccountLines(app.db, owner).map((l) => l.line); | |
| 204 | expect(lines).toContain("edgy joined the forge (signs in through open-platform.sh)"); | |
| 205 | expect(lines).toContain("ada joined the forge (signup)"); | |
| 196 | 206 | }); |
| 197 | 207 | |
| 198 | 208 | test("suspending stops every credential at once", async () => { |
| @@ -598,7 +608,7 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 598 | 608 | test("a refusal the database raises answers 403 with its reason, not 500", async () => { |
| 599 | 609 | // An operator's guard, the way production holds new accounts back. |
| 600 | 610 | app.db.exec( |
| 601 | "CREATE TRIGGER test_refuse BEFORE INSERT ON repo BEGIN SELECT RAISE(ABORT, 'new repositories are paused here'); END;", | |
| 611 | "CREATE TRIGGER test_refuse BEFORE INSERT ON repo BEGIN SELECT RAISE(ABORT, 'forge: new repositories are paused here'); END;", | |
| 602 | 612 | ); |
| 603 | 613 | try { |
| 604 | 614 | const res = await api("/api/v1/repos", { |
| @@ -609,7 +619,7 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 609 | 619 | expect(res.status).toBe(403); |
| 610 | 620 | expect(await res.json()).toEqual({ |
| 611 | 621 | error: "refused", |
| 612 | message: "new repositories are paused here", | |
| 622 | message: "forge: new repositories are paused here", | |
| 613 | 623 | }); |
| 614 | 624 | // The refusal leaves nothing on disk. |
| 615 | 625 | expect(existsSync(repoDir(app.reposRoot, "olive", "guarded"))).toBe(false); |
| @@ -618,6 +628,40 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 618 | 628 | } |
| 619 | 629 | }); |
| 620 | 630 | |
| 631 | test("a refusal without the forge: prefix is answered generically to anyone, and logged in full (#723)", async () => { | |
| 632 | const secret = "constraint on table repo_internal_x failed"; | |
| 633 | app.db.exec( | |
| 634 | `CREATE TRIGGER test_refuse_plain BEFORE INSERT ON repo BEGIN SELECT RAISE(ABORT, '${secret}'); END;`, | |
| 635 | ); | |
| 636 | const logged: { message: string; fields: Record<string, unknown> | undefined }[] = []; | |
| 637 | const log = app.log; | |
| 638 | (app as { log: typeof log }).log = { | |
| 639 | ...log, | |
| 640 | warn: (message, fields) => { | |
| 641 | logged.push({ message, fields }); | |
| 642 | log.warn(message, fields); | |
| 643 | }, | |
| 644 | }; | |
| 645 | try { | |
| 646 | const res = await api("/api/v1/repos", { | |
| 647 | method: "POST", | |
| 648 | token: ownerToken, | |
| 649 | body: JSON.stringify({ name: "guarded-plain" }), | |
| 650 | }); | |
| 651 | expect(res.status).toBe(403); | |
| 652 | expect(await res.json()).toEqual({ | |
| 653 | error: "refused", | |
| 654 | message: "refused", | |
| 655 | }); | |
| 656 | expect( | |
| 657 | logged.some((l) => l.fields?.reason === secret), | |
| 658 | ).toBe(true); | |
| 659 | } finally { | |
| 660 | (app as { log: typeof log }).log = log; | |
| 661 | app.db.exec("DROP TRIGGER test_refuse_plain"); | |
| 662 | } | |
| 663 | }); | |
| 664 | ||
| 621 | 665 | test("the owner can verify a newcomer's email, and it can create at once", async () => { |
| 622 | 666 | // Made first: the helper proves every account waiting on a link. |
| 623 | 667 | const { token: other } = await outsider("mallet"); |
test/app-audience.test.ts added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | /** | |
| 2 | * The app page says who the app answers to, in the head beside its state: | |
| 3 | * a public app, a private app, and a private app on a public repository | |
| 4 | * (#620). | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { newApp } from "../src/actions/apps.ts"; | |
| 11 | import { updateApp } from "../src/core/apps.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { createUser, type User } from "../src/core/users.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | describe("the app page's audience sentence", () => { | |
| 20 | let dataDir: string; | |
| 21 | let app: App; | |
| 22 | let servers: Servers; | |
| 23 | let alice: User; | |
| 24 | let cookie = ""; | |
| 25 | ||
| 26 | const sentence = async (name: string, withCookie = cookie) => { | |
| 27 | const res = await fetch(`${servers.url}/alice/${name}/app`, { | |
| 28 | headers: withCookie ? { cookie: withCookie } : {}, | |
| 29 | }); | |
| 30 | expect(res.status).toBe(200); | |
| 31 | const body = await res.text(); | |
| 32 | return /<p class="muted small" id="app-audience">([^<]*)<\/p>/.exec(body)?.[1]; | |
| 33 | }; | |
| 34 | ||
| 35 | beforeAll(async () => { | |
| 36 | dataDir = await mkdtemp(join(tmpdir(), "forge-app-audience-")); | |
| 37 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 38 | servers = await serve(app); | |
| 39 | const made = await createUser(app.db, { | |
| 40 | origin: "seed", | |
| 41 | trusted: true, | |
| 42 | username: "alice", | |
| 43 | email: "[email protected]", | |
| 44 | password: "a-good-passphrase", | |
| 45 | adminIfFirst: true, | |
| 46 | }); | |
| 47 | if (!made.ok) throw new Error(made.error); | |
| 48 | alice = made.value; | |
| 49 | for (const [name, visibility, access] of [ | |
| 50 | ["open", "public", "public"], | |
| 51 | ["closed", "private", "private"], | |
| 52 | ["split", "public", "private"], | |
| 53 | ] as const) { | |
| 54 | const r = await newApp(app, alice, { name, description: "x", visibility }); | |
| 55 | if (!r.ok) throw new Error(String(r.error)); | |
| 56 | updateApp(app.db, r.value.app.id, { access }); | |
| 57 | } | |
| 58 | const res = await fetch(`${servers.url}/signin`, { | |
| 59 | method: "POST", | |
| 60 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 61 | redirect: "manual", | |
| 62 | }); | |
| 63 | cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 64 | }, 120_000); | |
| 65 | ||
| 66 | afterAll(async () => { | |
| 67 | servers.stop(); | |
| 68 | app.close(); | |
| 69 | await rm(dataDir, { recursive: true, force: true }); | |
| 70 | }); | |
| 71 | ||
| 72 | test("a public app says anyone can read it", async () => { | |
| 73 | expect(await sentence("open")).toStartWith("Public: anyone can read this;"); | |
| 74 | }); | |
| 75 | ||
| 76 | test("a private app says it is private", async () => { | |
| 77 | expect(await sentence("closed")).toStartWith("Private: only you"); | |
| 78 | }); | |
| 79 | ||
| 80 | test("a private app on a public repository says the code is public (#620)", async () => { | |
| 81 | expect(await sentence("split")).toStartWith( | |
| 82 | "The code is public; the app answers only to you", | |
| 83 | ); | |
| 84 | // Signed out, the page is seen as a stranger: never told "you". | |
| 85 | const out = await sentence("split", ""); | |
| 86 | expect(out).toStartWith("The code is public; the app answers only to "); | |
| 87 | expect(out).not.toContain("you"); | |
| 88 | }); | |
| 89 | }); |
test/apps-api.test.ts +2 −14
| @@ -17,6 +17,7 @@ import { getUserByName } from "../src/core/users.ts"; | ||
| 17 | 17 | import { serve, type Servers } from "../src/http/server.ts"; |
| 18 | 18 | import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts"; |
| 19 | 19 | import { seedAdmin } from "./helpers/admin.ts"; |
| 20 | import { tokenFor } from "./helpers/tokens.ts"; | |
| 20 | 21 | |
| 21 | 22 | /** Read from an SSE body until `predicate(buffer)` holds or we time out — |
| 22 | 23 | * never to a "done" that #284's still-live stream no longer sends. */ |
| @@ -455,20 +456,7 @@ describe("apps, runs and the crew over the API", () => { | ||
| 455 | 456 | }, |
| 456 | 457 | null, |
| 457 | 458 | ); |
| 458 | const mallory = ( | |
| 459 | (await ( | |
| 460 | await post( | |
| 461 | "/api/v1/tokens", | |
| 462 | { | |
| 463 | username: "mallory", | |
| 464 | password: "a-good-passphrase", | |
| 465 | name: "admin", | |
| 466 | scope: "admin", | |
| 467 | }, | |
| 468 | null, | |
| 469 | ) | |
| 470 | ).json()) as { token: string } | |
| 471 | ).token; | |
| 459 | const mallory = tokenFor(app, "mallory", "admin", "admin"); | |
| 472 | 460 | expect((await api(env, { token: mallory })).status).toBe(404); |
| 473 | 461 | expect((await put("GREETING", "x", mallory)).status).toBe(404); |
| 474 | 462 | expect( |
test/archive-mcp.test.ts +53 −2
| @@ -18,6 +18,7 @@ let dataDir: string; | ||
| 18 | 18 | let app: App; |
| 19 | 19 | let servers: Servers; |
| 20 | 20 | let repo: Repo; |
| 21 | let second: Repo; | |
| 21 | 22 | const tok: Record<string, string> = {}; |
| 22 | 23 | |
| 23 | 24 | const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { |
| @@ -26,7 +27,7 @@ const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => | ||
| 26 | 27 | }; |
| 27 | 28 | const archived = (): boolean => getRepoById(app.db, repo.id)!.archivedAt !== null; |
| 28 | 29 | |
| 29 | async function mcp(token: string, args: unknown): Promise<{ error?: string; value?: any }> { | |
| 30 | async function mcp(token: string, args: unknown, tool = "archive"): Promise<{ error?: string; value?: any }> { | |
| 30 | 31 | const res = await fetch(`${servers.url}/mcp`, { |
| 31 | 32 | method: "POST", |
| 32 | 33 | headers: { |
| @@ -38,7 +39,7 @@ async function mcp(token: string, args: unknown): Promise<{ error?: string; valu | ||
| 38 | 39 | jsonrpc: "2.0", |
| 39 | 40 | id: 1, |
| 40 | 41 | method: "tools/call", |
| 41 | params: { name: "archive", arguments: args }, | |
| 42 | params: { name: tool, arguments: args }, | |
| 42 | 43 | }), |
| 43 | 44 | }); |
| 44 | 45 | const body = (await res.json()) as any; |
| @@ -65,6 +66,9 @@ beforeAll(async () => { | ||
| 65 | 66 | repo = ok( |
| 66 | 67 | await createRepo(app.db, app.reposRoot, { ownerId: org.id, name: "svc", visibility: "public" }), |
| 67 | 68 | ); |
| 69 | second = ok( | |
| 70 | await createRepo(app.db, app.reposRoot, { ownerId: org.id, name: "web", visibility: "public" }), | |
| 71 | ); | |
| 68 | 72 | const bot = ok(await createAgent(app, founder, { username: "bot" })); |
| 69 | 73 | expect(addToCrew(app, founder, repo, "bot").ok).toBe(true); |
| 70 | 74 | const owners = listTeams(app.db, org.id).find((t) => t.name === OWNERS_TEAM)!; |
| @@ -111,3 +115,50 @@ test("an admin needs an admin token; then archives and wakes", async () => { | ||
| 111 | 115 | expect(woke.value).toMatchObject({ archived: false, was: true }); |
| 112 | 116 | expect(archived()).toBe(false); |
| 113 | 117 | }); |
| 118 | ||
| 119 | const asleep = (r: Repo): boolean => getRepoById(app.db, r.id)!.archivedAt !== null; | |
| 120 | ||
| 121 | test("`repos`: a dry run over two changes nothing; applying archives both; the undo wakes both", async () => { | |
| 122 | const repos = ["acme/svc", "acme/web"]; | |
| 123 | const planned = await mcp(tok["founder"]!, { repos }); | |
| 124 | expect(planned.value.dryRun).toBe(true); | |
| 125 | expect(planned.value.rows.map((r: any) => r.outcome)).toEqual(["change", "change"]); | |
| 126 | expect(asleep(repo) || asleep(second)).toBe(false); | |
| 127 | ||
| 128 | const done = await mcp(tok["founder"]!, { repos, dryRun: false }); | |
| 129 | expect(done.value.rows.map((r: any) => r.repo)).toEqual(["acme/svc", "acme/web"]); | |
| 130 | expect(done.value.rows.every((r: any) => r.outcome === "ok")).toBe(true); | |
| 131 | expect(asleep(repo) && asleep(second)).toBe(true); | |
| 132 | ||
| 133 | const undone = await mcp( | |
| 134 | tok["founder"]!, | |
| 135 | { app: "acme/svc", action: "undo", operation: done.value.operation, dryRun: false }, | |
| 136 | "limb", | |
| 137 | ); | |
| 138 | expect(undone.error).toBeUndefined(); | |
| 139 | expect(asleep(repo) || asleep(second)).toBe(false); | |
| 140 | }); | |
| 141 | ||
| 142 | test("`repos`: a non-admin is refused per row, an agent and a small token are refused outright", async () => { | |
| 143 | const repos = ["acme/svc", "acme/web", "acme/nope"]; | |
| 144 | const tried = await mcp(tok["mallory"]!, { repos, dryRun: false }); | |
| 145 | expect(tried.value.rows.map((r: any) => r.outcome)).toEqual(["refused", "refused", "refused"]); | |
| 146 | expect(asleep(repo) || asleep(second)).toBe(false); | |
| 147 | expect((await mcp(tok["bot"]!, { repos })).error).toContain("forbidden"); | |
| 148 | expect((await mcp(tok["founderRw"]!, { repos, dryRun: false })).error).toContain("forbidden"); | |
| 149 | expect((await mcp(tok["founder"]!, { repos: [] })).error).toContain("invalid"); | |
| 150 | expect((await mcp(tok["founder"]!, { repo: "acme/svc", repos })).error).toContain("invalid"); | |
| 151 | expect((await mcp(tok["founder"]!, { repos, archived: "yes" })).error).toContain("invalid"); | |
| 152 | expect((await mcp(tok["founder"]!, { repos: Array(51).fill("acme/svc") })).error).toContain("invalid"); | |
| 153 | expect(asleep(repo) || asleep(second)).toBe(false); | |
| 154 | }); | |
| 155 | ||
| 156 | test("`repos` with `archived: false` wakes several in one operation", async () => { | |
| 157 | const repos = ["acme/svc", "acme/web"]; | |
| 158 | await mcp(tok["founder"]!, { repos, dryRun: false }); | |
| 159 | expect(asleep(repo) && asleep(second)).toBe(true); | |
| 160 | const woke = await mcp(tok["founder"]!, { repos, archived: false, dryRun: false }); | |
| 161 | expect(woke.value.rows.map((r: any) => r.outcome)).toEqual(["ok", "ok"]); | |
| 162 | expect(typeof woke.value.operation).toBe("string"); | |
| 163 | expect(asleep(repo) || asleep(second)).toBe(false); | |
| 164 | }); |
test/audience-surfaces.test.ts added +119
| @@ -0,0 +1,119 @@ | ||
| 1 | /** | |
| 2 | * The page, the API and the MCP say the same sentence about who can see a | |
| 3 | * repository (#809); the wording itself is tested in audience.test.ts. | |
| 4 | */ | |
| 5 | import { proveAll } from "./helpers/verified.ts"; | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { audienceFor } from "../src/core/audience.ts"; | |
| 13 | import { createApp as makeApp } from "../src/core/apps.ts"; | |
| 14 | import { createToken } from "../src/core/tokens.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | ||
| 18 | const esc = (s: string): string => | |
| 19 | s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); | |
| 20 | ||
| 21 | /** The audience element of a page: its tag, link and text. */ | |
| 22 | function audienceOn(page: string) { | |
| 23 | const m = /<(a|span) class="repo-audience"(?: href="([^"]*)")? title="([^"]*)"\s*>([^<]*)<\/\1\s*>/.exec(page); | |
| 24 | if (m === null) throw new Error("no audience element on the page"); | |
| 25 | return { tag: m[1], href: m[2], title: m[3], text: m[4] }; | |
| 26 | } | |
| 27 | ||
| 28 | describe("one sentence on every surface", () => { | |
| 29 | let dataDir: string; | |
| 30 | let app: App; | |
| 31 | let servers: Servers; | |
| 32 | let olive: User; | |
| 33 | ||
| 34 | beforeAll(async () => { | |
| 35 | dataDir = await mkdtemp(join(tmpdir(), "forge-audience-")); | |
| 36 | app = createApp({ dataDir, port: 0 }); | |
| 37 | servers = await serve(app); | |
| 38 | const made = await createUser(app.db, { | |
| 39 | origin: "seed", | |
| 40 | username: "olive", | |
| 41 | email: "[email protected]", | |
| 42 | password: "a-good-passphrase", | |
| 43 | isAdmin: true, | |
| 44 | }); | |
| 45 | if (!made.ok) throw new Error(made.error); | |
| 46 | olive = made.value; | |
| 47 | proveAll(app); | |
| 48 | }); | |
| 49 | afterAll(async () => { | |
| 50 | servers.stop(); | |
| 51 | app.close(); | |
| 52 | await rm(dataDir, { recursive: true, force: true }); | |
| 53 | }); | |
| 54 | ||
| 55 | test("the API and the MCP say the same words, and the page shows them", async () => { | |
| 56 | const made = await createRepo(app, olive, { name: "garden", visibility: "public" }); | |
| 57 | if (!made.ok) throw new Error(String(made.error)); | |
| 58 | makeApp(app.db, { repoId: made.value.id, slug: "garden", access: "private" }); | |
| 59 | const token = createToken(app.db, olive.id, "t", "ro").token; | |
| 60 | const auth = { authorization: `Bearer ${token}` }; | |
| 61 | const res = await fetch(`${servers.url}/api/v1/repos/olive/garden`, { headers: auth }); | |
| 62 | const body = (await res.json()) as { audience: { level: string; sentence: string } }; | |
| 63 | expect(body.audience).toEqual(audienceFor(app.db, made.value, olive)); | |
| 64 | expect(body.audience.sentence).toStartWith("The code is public; the app answers only to"); | |
| 65 | ||
| 66 | const mcp = await fetch(`${servers.url}/mcp`, { | |
| 67 | method: "POST", | |
| 68 | headers: { | |
| 69 | ...auth, | |
| 70 | "content-type": "application/json", | |
| 71 | accept: "application/json, text/event-stream", | |
| 72 | }, | |
| 73 | body: JSON.stringify({ | |
| 74 | jsonrpc: "2.0", | |
| 75 | id: 1, | |
| 76 | method: "tools/call", | |
| 77 | params: { name: "repo", arguments: { repo: "olive/garden" } }, | |
| 78 | }), | |
| 79 | }); | |
| 80 | expect(await mcp.text()).toContain("The code is public; the app answers only to"); | |
| 81 | ||
| 82 | const page = await (await fetch(`${servers.url}/olive/garden`, { headers: auth })).text(); | |
| 83 | const said = esc(body.audience.sentence); | |
| 84 | expect(audienceOn(page)).toEqual({ | |
| 85 | tag: "a", | |
| 86 | href: "/olive/garden/settings", | |
| 87 | title: said, | |
| 88 | text: said, | |
| 89 | }); | |
| 90 | }); | |
| 91 | ||
| 92 | test("signed out, the page and the API say the same; a non-admin gets no link", async () => { | |
| 93 | const made = await createRepo(app, olive, { name: "meadow", visibility: "public" }); | |
| 94 | if (!made.ok) throw new Error(String(made.error)); | |
| 95 | const api = (await (await fetch(`${servers.url}/api/v1/repos/olive/meadow`)).json()) as { | |
| 96 | audience: { sentence: string }; | |
| 97 | }; | |
| 98 | expect(api.audience.sentence).toStartWith("Public: anyone can read this"); | |
| 99 | const said = esc(api.audience.sentence); | |
| 100 | const out = await (await fetch(`${servers.url}/olive/meadow`)).text(); | |
| 101 | expect(audienceOn(out)).toEqual({ tag: "span", href: undefined, title: said, text: said }); | |
| 102 | ||
| 103 | const wren = await createUser(app.db, { | |
| 104 | origin: "seed", | |
| 105 | username: "wren", | |
| 106 | email: "[email protected]", | |
| 107 | password: "a-good-passphrase", | |
| 108 | }); | |
| 109 | if (!wren.ok) throw new Error(wren.error); | |
| 110 | proveAll(app); | |
| 111 | const token = createToken(app.db, wren.value.id, "t", "ro").token; | |
| 112 | const seen = await ( | |
| 113 | await fetch(`${servers.url}/olive/meadow`, { | |
| 114 | headers: { authorization: `Bearer ${token}` }, | |
| 115 | }) | |
| 116 | ).text(); | |
| 117 | expect(audienceOn(seen)).toEqual({ tag: "span", href: undefined, title: said, text: said }); | |
| 118 | }); | |
| 119 | }); |
60 more files not shown.
new above
offline