Public: anyone can read this; the crew can change it.
Move the Forgejo Actions jobs that are not releases into the forge: flake hunt, outside gate, sync-env #8957
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8943-move-the-forgejo-actions-jobs-that-are-n → main 3 commits fork 3 threads · 3 unresolved
15 files
changed +752
−10
docs/DEVELOP.md +20 −7
| @@ -164,8 +164,8 @@ own green check, not ten runs. That is accepted on purpose — a flaky new | ||
| 164 | 164 | test can turn the gate red once before anyone sees it — and the nightly |
| 165 | 165 | flake hunt below is the backstop that files it and quarantines it. |
| 166 | 166 | |
| 167 | **No flaky tests.** Nightly, `.forgejo/workflows/flake-hunt.yml` runs every | |
| 168 | gate file five times on a loaded box, each run a fresh seed that orders both | |
| 167 | **No flaky tests.** Nightly at 02:17 UTC the forge itself (`src/nightly/`, | |
| 168 | a cell on a checkout of main) runs every gate file five times on a loaded box, each run a fresh seed that orders both | |
| 169 | 169 | the files and (through `--randomize`) the tests inside them, nothing stopped |
| 170 | 170 | early. A file red in some runs and green in others is flaky: the hunt files |
| 171 | 171 | a `Flaky: test/x.test.ts` issue carrying the failure text, the seed and the |
| @@ -182,18 +182,31 @@ a retry: `test/flake.test.ts` fails on `--retry`, `--rerun-each`, a bunfig | ||
| 182 | 182 | `retry` or a `{ retry: N }` anywhere the gate runs, and on any test that |
| 183 | 183 | bounds a measured time under 200 ms (a loopback fetch on a loaded runner |
| 184 | 184 | takes tens of ms): assert relative to a baseline from the same run, or bound |
| 185 | only the failure the test names. The workflow needs a | |
| 186 | `FORGE_TOKEN` secret and a `FORGE_URL` variable on the mirror. | |
| 185 | only the failure the test names. The hunt takes the gate in | |
| 186 | 20 slices, one after another, each cut at 30 minutes, and ends by proposing the | |
| 187 | quarantine as a pull on `flake-hunt/<date>`. Worst case, all 20 slices hit | |
| 188 | their cut: 20 x 30 minutes, plus the install and the quarantine (10 each), so | |
| 189 | about 10 h 20 min, ending near 12:40 UTC. Jobs run one at a time, so the | |
| 190 | outside run (03:47) queues behind a long hunt and starts late; it still keeps | |
| 191 | its own slot, which is what the record and the owed-on-waking rule read. A | |
| 192 | stop (a release) aborts the running step at once. The forge needs `FORGE_TOKEN` (the | |
| 193 | hunt's author) and cells; a preview runs none, and a forge without either says | |
| 194 | so in its log and runs none. A job whose slot passed while the forge was down | |
| 195 | runs on waking; one restarted mid-run is not run twice (`nightly/<job>.json` | |
| 196 | in `FORGE_APP_DATA`). What each last did is on Resilience and in the | |
| 197 | terminal's Resilience dialog (`docs/features/nightly.md`). | |
| 187 | 198 | |
| 188 | **No unseen red outside the gate.** Nightly, `.forgejo/workflows/outside-gate.yml` | |
| 199 | **No unseen red outside the gate.** Nightly at 03:47 UTC, the forge itself | |
| 189 | 200 | runs `bun scripts/flake-hunt.ts outside --file-issues`: every file in |
| 190 | 201 | `EXCLUDED` (the complement of the gate, from the same list, so a file moved |
| 191 | 202 | into the gate is no longer run there), once, one at a time, with the gate's |
| 192 | 203 | 60 s timeout. A file that newly fails opens one `Red: test/x.test.ts` issue |
| 193 | 204 | naming its first failing test; still red on another test, it updates that |
| 194 | 205 | issue; still red on the same test, it says nothing, so the owner's asks show |
| 195 | it once; passing again, it closes the issue. Same `FORGE_TOKEN` and | |
| 196 | `FORGE_URL` as the hunt. | |
| 206 | it once; passing again, it closes the issue. Same `FORGE_TOKEN` as the hunt. | |
| 207 | ||
| 208 | The Forgejo workflows these replace, and `sync-env`, are covered in | |
| 209 | `docs/features/nightly.md`. | |
| 197 | 210 | |
| 198 | 211 | Bun's own `--randomize --seed=N` drives every hunt and admission run. Not |
| 199 | 212 | used: `--rerun-each` (one seed for every rerun, so ten reruns explore one |
docs/PLATFORM.md +1 −1
| @@ -55,7 +55,7 @@ What it did **not** retire, because production does not run it yet: | ||
| 55 | 55 | | 7 | **Forgejo, where outside people send pulls.** grokbot's #42 to #67, `jmoran/forge-jay` (#8935), tdobbertin-espo's six (#8938) on git.open-platform.sh. | The forge: a real merge for a kept fork (#8935), a URL-to-landed-pull path for an agent (#6359), forge-to-forge origin (`docs/features/forge-origin.md`). | Origins and Bring exist (`docs/ORIGINS.md`). | Tell each person, then disable (not delete) new pulls on the Forgejo repository (#8950; waits for #6359, #8935). | |
| 56 | 56 | | 8 | **Hosted apps.** Apps the platform runs that are not on the forge. The list is in the cluster: **unread**. | Bring (paste a link) or Migrate; GitHub and Vercel go through #6007, not here. | Bring an app whole (#8919). | List with the owner's call per app (#8951). | |
| 57 | 57 | | 9 | **The droplet release tick.** `forge-release-tick` on pg-grand and the Mac's `tick release`. | The keeper (#1). | The keeper's code. | Switched off by the owner on the day #8942 records. | |
| 58 | | 10 | **Forgejo Actions jobs that are not releases.** `flake-hunt.yml`, `outside-gate.yml`, `sync-env.yml` on platform runners. | Scheduled jobs the forge runs, same verdicts. | `scripts/flake-hunt.ts`, checks and cells. | #8943. | | |
| 58 | | 10 | **Forgejo Actions jobs that are not releases.** `flake-hunt.yml`, `outside-gate.yml`, `sync-env.yml` on platform runners. | Scheduled jobs the forge runs, same verdicts. | `src/nightly/`: the hunt (02:17 UTC) and the outside run (03:47 UTC) in cells, filing the same issues and the quarantine pull; `sync-env` has nothing to port (`docs/DEVELOP.md`). | Compare one night from both, then delete the three workflows (#8943). | | |
| 59 | 59 | | 11 | **Previews of the forge's own repository.** `preview.yml` builds an image, signs it, pushes a chart. | A child forge in a cell (`src/spawn/placement.ts`, kind `cell`). | Apps preview from branch in a cell today. | #8944. | |
| 60 | 60 | |
| 61 | 61 | ## Order and the gate |
docs/features/nightly.md added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | # Nightly jobs | |
| 2 | ||
| 3 | The forge runs two jobs itself, at a fixed UTC slot each (`src/nightly/jobs.ts`): | |
| 4 | ||
| 5 | - **flake-hunt, 02:17.** The gate in 20 slices, five loaded runs each, then the quarantine. Files `Flaky:` issues and proposes the quarantine as a pull on `flake-hunt/<date>`. | |
| 6 | - **outside-gate, 03:47.** Every file in `EXCLUDED` once. Opens, updates and closes `Red:` issues. | |
| 7 | ||
| 8 | Both are `scripts/flake-hunt.ts`, run in a cell on a checkout of the default branch that reaches only the forge and the registry. Only steps that file hold `FORGE_TOKEN`. | |
| 9 | ||
| 10 | ## Seeing it | |
| 11 | ||
| 12 | Resilience (admins) and the terminal's Resilience dialog say, per job, when it last ran and what it filed or why it did not run. The record is `FORGE_APP_DATA/nightly/<job>.json`. | |
| 13 | ||
| 14 | ## When it runs | |
| 15 | ||
| 16 | One job at a time. A job queued behind another keeps its own slot. A forge that was down at a slot runs the job on waking; a restart mid-run does not run it twice, and the record says the run was stopped. Off in a preview, without `FORGE_TOKEN`, and where cells do not work. | |
| 17 | ||
| 18 | ## Trying it | |
| 19 | ||
| 20 | With a token and cells, a job runs when its slot comes. To run one now from a test or a console: `startNightly(app, { forgeUrl, token })?.run("outside-gate")`. | |
| 21 | ||
| 22 | ## What the Forgejo workflows were | |
| 23 | ||
| 24 | `flake-hunt.yml` and `outside-gate.yml` stay on the mirror until the forge's own run has filed one real result. Both dedupe on the open `Flaky:` and `Red:` issues, so they cannot double-file; the pull that deletes them carries one night's output from each side. `sync-env.yml` sealed the repository's Actions secrets into the platform's `forge-user-env` Secret, which fed the pod's environment (the crew's Claude credential, `FORGE_TOKEN`). The forge already holds its own secrets, so there is nothing to port: the Secret goes with the pod (`docs/PLATFORM.md`, rows 2 and 3). |
scripts/gate.ts +2 −2
| @@ -14,9 +14,9 @@ | ||
| 14 | 14 | * check is green and whose description carries ten green runs in random |
| 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 | * issue the nightly hunt filed (`.forgejo/workflows/flake-hunt.yml`). | |
| 17 | * issue the nightly hunt filed (`src/nightly/`). | |
| 18 | 18 | * |
| 19 | * The excluded files are not unseen: `.forgejo/workflows/outside-gate.yml` | |
| 19 | * The excluded files are not unseen: the forge's nightly job (`src/nightly/`) | |
| 20 | 20 | * runs `outsideFiles` nightly and files a `Red:` issue for each that fails (#812). |
| 21 | 21 | */ |
| 22 | 22 |
src/main.ts +6
| @@ -48,6 +48,7 @@ import { stopQueue } from "./checks/queue.ts"; | ||
| 48 | 48 | import { startLooks } from "./looks/service.ts"; |
| 49 | 49 | import { startMirrorOut } from "./origins/mirror-out.ts"; |
| 50 | 50 | import { startProbes } from "./contract/probes.ts"; |
| 51 | import { startNightly } from "./nightly/service.ts"; | |
| 51 | 52 | import { startSnapshots } from "./snapshot/service.ts"; |
| 52 | 53 | import { resolveDestination } from "./snapshot/destination.ts"; |
| 53 | 54 | import { resolveMirror } from "./snapshot/mirror.ts"; |
| @@ -445,6 +446,10 @@ const looks = appsOn ? startLooks(app, { localUrl: servers.url }) : undefined; | ||
| 445 | 446 | // Is it so: the forge knocks on every door of an app with a contract after |
| 446 | 447 | // each production deploy and once a night, and compares with the table. |
| 447 | 448 | const probes = apps === undefined ? undefined : startProbes(app, apps); |
| 449 | // The nightly jobs that were Forgejo workflows: the flake hunt and the run | |
| 450 | // of what the gate excludes, filing the same `Flaky:` and `Red:` issues and | |
| 451 | // the quarantine pull (docs/DEVELOP.md). | |
| 452 | const nightly = appsOn ? startNightly(app, { forgeUrl, token: process.env["FORGE_TOKEN"] }) : undefined; | |
| 448 | 453 | // The snapshot: everything that cannot be rebuilt, nightly, to what an |
| 449 | 454 | // operator sealed on the Backups page, else the platform's (`S3_*`) or a |
| 450 | 455 | // directory (`FORGE_SNAPSHOT_DIR`). A forge given none says so on that page. |
| @@ -842,6 +847,7 @@ function drain(signal: NodeJS.Signals): Promise<unknown> { | ||
| 842 | 847 | mirrorOut(), |
| 843 | 848 | looks?.stop(), |
| 844 | 849 | probes?.stop(), |
| 850 | nightly?.stop(), | |
| 845 | 851 | snapshots.stop(), |
| 846 | 852 | sampler.stop(), |
| 847 | 853 | memorySampler.stop(), |
src/nightly/jobs.ts added +75
| @@ -0,0 +1,75 @@ | ||
| 1 | /** | |
| 2 | * The nightly jobs and what each runs: the two that used to be Forgejo | |
outdated
Two jobs are listed here, not three; fix the comment. |
||
| 3 | * workflows (`flake-hunt.yml`, `outside-gate.yml`), now the forge's own. | |
| 4 | * The third, `sync-env.yml`, has nothing to run (docs/features/nightly.md). | |
| 5 | * Each step is a shell command run in a cell on a checkout of the default | |
| 6 | * branch; `reports` says it talks to the forge's API as the hunt's author, | |
| 7 | * so it is the only kind handed the token. Plain data. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Slot } from "./slots.ts"; | |
| 11 | ||
| 12 | export interface Step { | |
| 13 | readonly label: string; | |
| 14 | readonly command: string; | |
| 15 | readonly timeoutMs: number; | |
| 16 | readonly reports: boolean; | |
| 17 | } | |
| 18 | ||
| 19 | export interface Job { | |
| 20 | readonly name: "outside-gate" | "flake-hunt"; | |
| 21 | readonly slot: Slot; | |
| 22 | readonly steps: readonly Step[]; | |
| 23 | /** After the steps: the file whose quarantined edit is proposed as a pull. */ | |
| 24 | readonly proposes?: { readonly path: string; readonly branchPrefix: string }; | |
| 25 | } | |
| 26 | ||
| 27 | /** The gate is cut into this many slices, each hunted on its own. */ | |
| 28 | export const HUNT_SLICES = 20; | |
| 29 | const SLICE_MS = 30 * 60_000; | |
| 30 | ||
| 31 | const install: Step = { | |
| 32 | label: "install", | |
| 33 | command: "bun install --frozen-lockfile", | |
| 34 | timeoutMs: 10 * 60_000, | |
| 35 | reports: false, | |
| 36 | }; | |
| 37 | ||
| 38 | /** Exit 1 is "found a flake and filed it": the quarantine's work, not a broken hunt. */ | |
| 39 | const huntSlice = (n: number): Step => ({ | |
| 40 | label: `hunt slice ${n}/${HUNT_SLICES}`, | |
| 41 | command: `bun scripts/flake-hunt.ts hunt --shard ${n}/${HUNT_SLICES} --file-issues; code=$?; test "$code" -le 1`, | |
| 42 | timeoutMs: SLICE_MS, | |
| 43 | reports: true, | |
| 44 | }); | |
| 45 | ||
| 46 | export const JOBS: readonly Job[] = [ | |
| 47 | { | |
| 48 | name: "flake-hunt", | |
| 49 | slot: { hour: 2, minute: 17 }, | |
| 50 | steps: [ | |
| 51 | install, | |
| 52 | ...Array.from({ length: HUNT_SLICES }, (_, i) => huntSlice(i + 1)), | |
| 53 | { | |
| 54 | label: "quarantine", | |
| 55 | command: "bun scripts/flake-hunt.ts quarantine", | |
| 56 | timeoutMs: 10 * 60_000, | |
| 57 | reports: true, | |
| 58 | }, | |
| 59 | ], | |
| 60 | proposes: { path: "scripts/gate.ts", branchPrefix: "flake-hunt" }, | |
| 61 | }, | |
| 62 | { | |
| 63 | name: "outside-gate", | |
| 64 | slot: { hour: 3, minute: 47 }, | |
| 65 | steps: [ | |
| 66 | install, | |
| 67 | { | |
| 68 | label: "run the excluded files", | |
| 69 | command: "bun scripts/flake-hunt.ts outside --file-issues", | |
| 70 | timeoutMs: SLICE_MS, | |
| 71 | reports: true, | |
| 72 | }, | |
| 73 | ], | |
| 74 | }, | |
| 75 | ]; |
src/nightly/propose.ts added +47
| @@ -0,0 +1,47 @@ | ||
| 1 | /** | |
| 2 | * The quarantine, as a pull: when the job moved files out of the gate, the | |
| 3 | * edited file goes up as one commit on a dated branch and a pull asks for it, | |
| 4 | * opened as the repository's owner like the crew's own proposals. Nobody | |
| 5 | * hand-edits the list; the pull body says which command did. | |
| 6 | */ | |
| 7 | ||
| 8 | import { readFileSync } from "node:fs"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import type { App } from "../app.ts"; | |
| 11 | import { proposeEdit } from "../actions/propose_edit.ts"; | |
| 12 | import { readFileAt } from "../apps/checkout.ts"; | |
| 13 | import type { Repo } from "../core/repos.ts"; | |
| 14 | import { getUserById } from "../core/users.ts"; | |
| 15 | import { repoDir } from "../git/repos.ts"; | |
| 16 | import { createLog } from "../kernel/log.ts"; | |
| 17 | ||
| 18 | const log = createLog("nightly"); | |
| 19 | ||
| 20 | export async function proposeQuarantine( | |
| 21 | app: App, | |
| 22 | repo: Repo, | |
| 23 | ran: { readonly dir: string; readonly oid: string }, | |
| 24 | path: string, | |
| 25 | branchPrefix: string, | |
| 26 | now: number, | |
| 27 | ): Promise<string | undefined> { | |
| 28 | const was = await readFileAt(repoDir(app.reposRoot, repo.ownerName, repo.name), ran.oid, path); | |
| 29 | const edited = readFileSync(join(ran.dir, path), "utf8"); | |
| 30 | if (was === undefined || was === edited) return undefined; | |
| 31 | const owner = getUserById(app.db, repo.ownerId); | |
| 32 | if (owner === undefined || owner.kind !== "user") return undefined; | |
| 33 | const branch = `${branchPrefix}/${new Date(now).toISOString().slice(0, 10)}`; | |
| 34 | const proposed = await proposeEdit(app, owner, repo, { | |
| 35 | base: repo.defaultBranch, | |
| 36 | branch, | |
| 37 | files: [{ path, content: edited }], | |
| 38 | message: "Quarantine the files the flake hunt found flaky", | |
| 39 | title: `Quarantine flaky tests (${branch})`, | |
| 40 | body: "Moved by `bun scripts/flake-hunt.ts quarantine`, not by hand: each file leaves the gate under the open `Flaky:` issue that carries its failure, seed and run.", | |
| 41 | }); | |
| 42 | if (!proposed.ok) { | |
| 43 | log.warn("could not propose the quarantine", { branch, step: proposed.error.step, reason: proposed.error.reason }); | |
| 44 | return undefined; | |
| 45 | } | |
| 46 | return branch; | |
| 47 | } |
src/nightly/run.ts added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | /** | |
| 2 | * One nightly job, once: the default branch exported to a directory of its | |
| 3 | * own, each step run in a cell (`apps/contain.ts`) that reaches the forge | |
| 4 | * and the registry and nothing else, the step's output kept in the log. | |
| 5 | * The token goes only to steps that report, as FORGE_TOKEN, which the hunt | |
| 6 | * script already drops from the environment of the tests it runs. | |
| 7 | */ | |
| 8 | ||
| 9 | import { mkdirSync } from "node:fs"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import type { App } from "../app.ts"; | |
| 13 | import { exportTree } from "../apps/checkout.ts"; | |
| 14 | import { type CellSpec, cellsAvailable, resolveLimits, STEP_HOSTS } from "../apps/contain.ts"; | |
| 15 | import { childEnv, runStep } from "../apps/process.ts"; | |
| 16 | import type { Repo } from "../core/repos.ts"; | |
| 17 | import { repoDir } from "../git/repos.ts"; | |
| 18 | import { refTip } from "../git/read.ts"; | |
| 19 | import { createLog } from "../kernel/log.ts"; | |
| 20 | import type { Job } from "./jobs.ts"; | |
| 21 | ||
| 22 | const log = createLog("nightly"); | |
| 23 | ||
| 24 | export interface Ran { | |
| 25 | readonly dir: string; | |
| 26 | readonly oid: string; | |
| 27 | /** The labels of the steps that failed. */ | |
| 28 | readonly failed: readonly string[]; | |
| 29 | } | |
| 30 | ||
| 31 | export async function runJob( | |
| 32 | app: App, | |
| 33 | repo: Repo, | |
| 34 | job: Job, | |
| 35 | options: { readonly forgeUrl: string; readonly token: string; readonly signal: AbortSignal }, | |
| 36 | ): Promise<Ran | { readonly error: string }> { | |
| 37 | const bare = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 38 | const oid = await refTip(bare, `refs/heads/${repo.defaultBranch}`); | |
| 39 | if (oid === undefined) return { error: `no ${repo.defaultBranch} to check out` }; | |
| 40 | const dir = join(app.config.dataDir, "nightly", `${job.name}-checkout`); | |
| 41 | mkdirSync(join(app.config.dataDir, "nightly"), { recursive: true }); | |
| 42 | const exported = await exportTree(bare, oid, dir); | |
| 43 | if (!exported.ok) return { error: exported.error }; | |
| 44 | ||
| 45 | const cells = await cellsAvailable(); | |
| 46 | // Repository code, run unattended with a token in its hands: never without a cell. | |
| 47 | if (!cells.ok) return { error: `no cell to run it in: ${cells.reason}` }; | |
| 48 | const cell: CellSpec = { | |
| 49 | dir, | |
| 50 | writable: [], | |
| 51 | home: `${dir}.home`, | |
| 52 | hide: [app.config.dataDir, process.env["HOME"] ?? "", tmpdir()], | |
| 53 | egress: [...STEP_HOSTS], | |
| 54 | reach: [options.forgeUrl], | |
| 55 | limits: resolveLimits("step", undefined), | |
| 56 | label: `nightly:${job.name}`, | |
| 57 | }; | |
| 58 | const failed: string[] = []; | |
| 59 | for (const step of job.steps) { | |
| 60 | if (options.signal.aborted) break; | |
| 61 | const env = childEnv({ | |
| 62 | FORGE_API: `${options.forgeUrl}/api/v1`, | |
| 63 | FORGE_REPO: `${repo.ownerName}/${repo.name}`, | |
| 64 | FORGE_RUN_URL: `${options.forgeUrl}/${repo.ownerName}/${repo.name}`, | |
| 65 | FORGE_CELL_CPUS: String(cell.limits.cpus), | |
| 66 | ...(step.reports ? { FORGE_TOKEN: options.token } : {}), | |
| 67 | }); | |
| 68 | const ran = await runStep(step.label, step.command, { | |
| 69 | cwd: dir, | |
| 70 | env, | |
| 71 | log: (line) => log.info(line, { job: job.name }), | |
| 72 | signal: options.signal, | |
| 73 | timeoutMs: step.timeoutMs, | |
| 74 | cell, | |
| 75 | }); | |
| 76 | if (ran.ok) continue; | |
| 77 | log.warn("step failed", { job: job.name, step: step.label, error: ran.error }); | |
| 78 | failed.push(step.label); | |
| 79 | // Nothing runs without its dependencies. | |
| 80 | if (step.label === "install") break; | |
| 81 | } | |
| 82 | return { dir, oid, failed }; | |
| 83 | } |
src/nightly/says.ts added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | /** | |
| 2 | * What the nightly jobs did, in words: the lines the Resilience page and the | |
| 3 | * terminal's Resilience dialog both show, so a person sees that a night ran | |
| 4 | * and what it filed without reading a log. Plain data in, plain text out. | |
| 5 | */ | |
| 6 | ||
| 7 | import { JOBS } from "./jobs.ts"; | |
| 8 | import { type Last, readLast } from "./stamps.ts"; | |
| 9 | ||
| 10 | const when = (ms: number): string => `${new Date(ms).toISOString().slice(0, 16).replace("T", " ")} UTC`; | |
| 11 | ||
| 12 | export function nightlyLine(name: string, slot: { hour: number; minute: number }, last: Last | undefined): string { | |
| 13 | const at = `${String(slot.hour).padStart(2, "0")}:${String(slot.minute).padStart(2, "0")} UTC`; | |
| 14 | if (last === undefined) return `${name}: has not run yet; its slot is ${at} each night.`; | |
| 15 | if (last.finishedAt === undefined) return `${name}: began ${when(last.startedAt)} and has not finished (or was cut short by a restart).`; | |
| 16 | return `${name}: ${when(last.startedAt)} to ${when(last.finishedAt)}, ${last.says}`; | |
| 17 | } | |
| 18 | ||
| 19 | export function nightlyLines(dataDir: string): string[] { | |
| 20 | return JOBS.map((job) => nightlyLine(job.name, job.slot, readLast(dataDir, job.name))); | |
| 21 | } |
src/nightly/service.ts added +132
| @@ -0,0 +1,132 @@ | ||
| 1 | /** | |
| 2 | * The nightly jobs as a service: each waits for its slot (`slots.ts`), runs | |
| 3 | * once (`run.ts`) and files what the Forgejo workflows filed before: the | |
| 4 | * `Flaky:` and `Red:` issues, the quarantine pull. One job at a time, so a | |
| 5 | * hunt never runs beside the outside run; a job that waits behind another | |
| 6 | * still keeps its own slot, so it is not owed again when it ends. On unless | |
| 7 | * the forge lacks what it needs (a token for the hunt's author, the cells to | |
| 8 | * run repository code in) or is a preview, whose copy of the data would | |
| 9 | * file twice. What each job last did is kept (`stamps.ts`) and shown | |
| 10 | * (`says.ts`). | |
| 11 | */ | |
| 12 | ||
| 13 | import type { App } from "../app.ts"; | |
| 14 | import { crewOffInPreview } from "../crew/preview.ts"; | |
| 15 | import { getRepo } from "../core/repos.ts"; | |
| 16 | import { createLog } from "../kernel/log.ts"; | |
| 17 | import { ownRepoPath } from "../kernel/own_repo.ts"; | |
| 18 | import { JOBS, type Job } from "./jobs.ts"; | |
| 19 | import { proposeQuarantine } from "./propose.ts"; | |
| 20 | import { type Ran, runJob } from "./run.ts"; | |
| 21 | import { lastSlot, owed, untilNext } from "./slots.ts"; | |
| 22 | import { readLast, writeLast } from "./stamps.ts"; | |
| 23 | ||
| 24 | const log = createLog("nightly"); | |
| 25 | ||
| 26 | /** What a job does when it runs: the seams a test replaces. */ | |
| 27 | export interface Doing { | |
| 28 | readonly run: (job: Job, signal: AbortSignal) => Promise<Ran | { readonly error: string }>; | |
| 29 | /** The branch of the pull the job opened, if it opened one. */ | |
| 30 | readonly propose: (job: Job, ran: Ran, now: number) => Promise<string | undefined>; | |
| 31 | } | |
| 32 | ||
| 33 | export interface NightlyHandle { | |
| 34 | /** Run one job now, as its slot would. */ | |
| 35 | readonly run: (name: Job["name"]) => Promise<void>; | |
| 36 | readonly stop: () => Promise<void>; | |
| 37 | } | |
| 38 | ||
| 39 | /** The real thing: the forge's own repository, a cell, a pull as its owner. */ | |
| 40 | function doing(app: App, options: { readonly forgeUrl: string; readonly token: string }): Doing { | |
| 41 | const repoOf = () => { | |
| 42 | const [owner = "", name = ""] = ownRepoPath().split("/"); | |
| 43 | return getRepo(app.db, owner, name); | |
outdated
The stamp is written when the queued run starts, not at its slot, so a job queued behind a long hunt records a late time. Record the slot, or document it. Also no test drives this path. |
||
| 44 | }; | |
| 45 | return { | |
| 46 | run: async (job, signal) => { | |
| 47 | const repo = repoOf(); | |
| 48 | if (repo === undefined) return { error: `${ownRepoPath()} is not on this forge` }; | |
| 49 | return runJob(app, repo, job, { forgeUrl: options.forgeUrl, token: options.token, signal }); | |
| 50 | }, | |
| 51 | propose: async (job, ran, now) => { | |
| 52 | const repo = repoOf(); | |
| 53 | if (repo === undefined || job.proposes === undefined) return undefined; | |
| 54 | return proposeQuarantine(app, repo, ran, job.proposes.path, job.proposes.branchPrefix, now); | |
| 55 | }, | |
| 56 | }; | |
| 57 | } | |
| 58 | ||
| 59 | const says = (ran: Ran, branch: string | undefined): string => | |
| 60 | (ran.failed.length === 0 ? "every step finished" : `failed: ${ran.failed.join(", ")}`) + | |
| 61 | (branch === undefined ? "." : `; quarantine pull on ${branch}.`); | |
| 62 | ||
| 63 | export function startNightly( | |
| 64 | app: App, | |
| 65 | options: { | |
| 66 | readonly forgeUrl: string; | |
| 67 | readonly token: string | undefined; | |
| 68 | readonly doing?: Doing; | |
| 69 | readonly now?: () => number; | |
| 70 | }, | |
| 71 | ): NightlyHandle | undefined { | |
| 72 | const token = options.token; | |
| 73 | if (token === undefined || token === "") { | |
| 74 | log.info("nightly jobs are off: no FORGE_TOKEN"); | |
| 75 | return undefined; | |
| 76 | } | |
| 77 | if (crewOffInPreview()) return undefined; | |
| 78 | const now = options.now ?? Date.now; | |
| 79 | const does = options.doing ?? doing(app, { forgeUrl: options.forgeUrl, token }); | |
| 80 | const abort = new AbortController(); | |
| 81 | const timers = new Set<ReturnType<typeof setTimeout>>(); | |
| 82 | let going: Promise<unknown> = Promise.resolve(); | |
| 83 | ||
| 84 | const run = (name: Job["name"]): Promise<void> => { | |
| 85 | const job = JOBS.find((j) => j.name === name)!; | |
| 86 | // The slot is read when the run is asked for, not when it gets its turn. | |
| 87 | const slot = lastSlot(job.slot, now()); | |
| 88 | const turn = going.then(async () => { | |
| 89 | if (abort.signal.aborted) return; | |
| 90 | const startedAt = now(); | |
| 91 | writeLast(app.config.dataDir, job.name, { slot, startedAt, says: "began" }); | |
| 92 | const end = (said: string): void => | |
| 93 | writeLast(app.config.dataDir, job.name, { slot, startedAt, finishedAt: now(), says: said }); | |
| 94 | log.info("started", { job: job.name }); | |
| 95 | const ran = await does.run(job, abort.signal); | |
| 96 | if ("error" in ran) { | |
| 97 | log.warn("not run", { job: job.name, error: ran.error }); | |
| 98 | return end(`not run: ${ran.error}.`); | |
| 99 | } | |
| 100 | const branch = | |
| 101 | job.proposes !== undefined && !ran.failed.includes("quarantine") | |
| 102 | ? await does.propose(job, ran, now()) | |
| 103 | : undefined; | |
| 104 | log.info("finished", { job: job.name, failed: ran.failed.join(", "), pull: branch ?? "" }); | |
| 105 | end(abort.signal.aborted ? `stopped by a restart; ${says(ran, branch)}` : says(ran, branch)); | |
| 106 | }); | |
| 107 | going = turn.catch((error) => log.error("failed", { job: job.name, error: String(error) })); | |
| 108 | return going as Promise<void>; | |
| 109 | }; | |
| 110 | ||
| 111 | const wait = (job: Job): void => { | |
| 112 | const timer = setTimeout(() => { | |
| 113 | timers.delete(timer); | |
| 114 | void run(job.name).finally(() => !abort.signal.aborted && wait(job)); | |
| 115 | // A second past the slot: a timer that fires early would read yesterday's. | |
| 116 | }, untilNext(job.slot, now()) + 1_000); | |
| 117 | timers.add(timer); | |
| 118 | }; | |
| 119 | for (const job of JOBS) { | |
| 120 | // A forge that was down at the slot runs the job on waking. | |
| 121 | if (owed(job.slot, readLast(app.config.dataDir, job.name)?.slot, now())) void run(job.name); | |
| 122 | wait(job); | |
| 123 | } | |
| 124 | return { | |
| 125 | run, | |
| 126 | stop: async () => { | |
| 127 | abort.abort(); | |
| 128 | for (const t of timers) clearTimeout(t); | |
| 129 | await going; | |
| 130 | }, | |
| 131 | }; | |
| 132 | } |
src/nightly/slots.ts added +31
| @@ -0,0 +1,31 @@ | ||
| 1 | /** | |
| 2 | * When a nightly job is owed: each has a slot at a fixed UTC time, and a | |
| 3 | * slot is kept by one run at or after it. A forge that was down at the slot | |
| 4 | * runs the job on waking; one that restarts after the run began does not | |
| 5 | * run it twice. A forge that has never run a job owes none until its first | |
| 6 | * slot. Plain data in, plain data out. | |
| 7 | */ | |
| 8 | ||
| 9 | const DAY = 86_400_000; | |
| 10 | ||
| 11 | export interface Slot { | |
| 12 | readonly hour: number; | |
| 13 | readonly minute: number; | |
| 14 | } | |
| 15 | ||
| 16 | /** The latest slot at or before `now`, in ms. */ | |
| 17 | export function lastSlot(slot: Slot, now: number): number { | |
| 18 | const d = new Date(now); | |
| 19 | d.setUTCHours(slot.hour, slot.minute, 0, 0); | |
| 20 | return d.getTime() > now ? d.getTime() - DAY : d.getTime(); | |
| 21 | } | |
| 22 | ||
| 23 | /** Milliseconds from `now` to the next slot; never zero. */ | |
| 24 | export function untilNext(slot: Slot, now: number): number { | |
| 25 | return lastSlot(slot, now) + DAY - now; | |
| 26 | } | |
| 27 | ||
| 28 | /** Whether a run is owed: the newest began (`last`, undefined when never) before the latest slot. */ | |
| 29 | export function owed(slot: Slot, last: number | undefined, now: number): boolean { | |
| 30 | return last !== undefined && Number.isFinite(last) && last < lastSlot(slot, now); | |
| 31 | } |
src/nightly/stamps.ts added +38
| @@ -0,0 +1,38 @@ | ||
| 1 | /** | |
| 2 | * What each nightly job last did, one small file apiece under | |
| 3 | * `FORGE_APP_DATA/nightly`: the slot it kept, when it began and ended, and | |
| 4 | * what it said. The record is written as a run begins, so a restart in the | |
| 5 | * middle of a long hunt does not start it again; a hunt cut short is next | |
| 6 | * night's, and says so. Nothing here cannot be rebuilt. | |
| 7 | */ | |
| 8 | ||
| 9 | import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; | |
| 10 | import { join } from "node:path"; | |
| 11 | ||
| 12 | export interface Last { | |
| 13 | /** The slot this run kept, not the time it got to start: a job queued behind another still keeps its own. */ | |
| 14 | readonly slot: number; | |
| 15 | readonly startedAt: number; | |
| 16 | readonly finishedAt?: number; | |
| 17 | /** One plain sentence: what it filed, what failed, why it did not run. */ | |
| 18 | readonly says: string; | |
| 19 | } | |
| 20 | ||
| 21 | const fileOf = (dataDir: string, name: string): string => join(dataDir, "nightly", `${name}.json`); | |
| 22 | ||
| 23 | /** The record of `name`, or undefined when it never ran or the file is not one. */ | |
| 24 | export function readLast(dataDir: string, name: string): Last | undefined { | |
| 25 | try { | |
| 26 | const v = JSON.parse(readFileSync(fileOf(dataDir, name), "utf8")) as Partial<Record<keyof Last, unknown>>; | |
| 27 | const whole = (x: unknown): x is number => typeof x === "number" && Number.isFinite(x); | |
| 28 | if (!whole(v.slot) || !whole(v.startedAt) || typeof v.says !== "string") return undefined; | |
| 29 | return { slot: v.slot, startedAt: v.startedAt, says: v.says, ...(whole(v.finishedAt) ? { finishedAt: v.finishedAt } : {}) }; | |
| 30 | } catch { | |
| 31 | return undefined; | |
| 32 | } | |
| 33 | } | |
| 34 | ||
| 35 | export function writeLast(dataDir: string, name: string, last: Last): void { | |
| 36 | mkdirSync(join(dataDir, "nightly"), { recursive: true }); | |
| 37 | writeFileSync(fileOf(dataDir, name), JSON.stringify(last)); | |
| 38 | } |
src/tui/screens/resilience.ts +3
| @@ -31,6 +31,7 @@ import { | ||
| 31 | 31 | type DestinationInput, |
| 32 | 32 | } from "../../actions/destination.ts"; |
| 33 | 33 | import { forgetMirror, saveMirror } from "../../actions/mirror.ts"; |
| 34 | import { nightlyLines } from "../../nightly/says.ts"; | |
| 34 | 35 | import { backupsLine } from "../../snapshot/backups_line.ts"; |
| 35 | 36 | import { backupVerdict } from "../../snapshot/bucket.ts"; |
| 36 | 37 | import { lastDrill } from "../../snapshot/drills.ts"; |
| @@ -254,6 +255,8 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen { | ||
| 254 | 255 | if (asking !== undefined) out.push(QUESTION[asking], ""); |
| 255 | 256 | else if (said !== "") out.push(...wrap(said, INNER), ""); |
| 256 | 257 | out.push(...wrap(backupsLine(f.app).says, INNER), ...verdictLines(f), ""); |
| 258 | for (const line of nightlyLines(f.app.config.dataDir)) out.push(...wrap(line, INNER)); | |
| 259 | out.push(""); | |
| 257 | 260 | out.push(...wrap(destinationLine(f), INNER), ...wrap(mirrorLine(f), INNER)); |
| 258 | 261 | if (note !== "") out.push(...wrap(note, INNER)); |
| 259 | 262 | if (mayChange()) out.push("d sets where snapshots go, x forgets it.", "m sets the mirror, f forgets it.", "t tests where snapshots go."); |
src/web/pages/resilience.ts +9
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | import { readResilience } from "../../checks/resilience.ts"; |
| 12 | 12 | import type { Standing } from "../../core/threats.ts"; |
| 13 | 13 | import type { Ctx } from "../../http/context.ts"; |
| 14 | import { nightlyLines } from "../../nightly/says.ts"; | |
| 14 | 15 | import { backupsLine } from "../../snapshot/backups_line.ts"; |
| 15 | 16 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 16 | 17 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| @@ -30,6 +31,13 @@ export function backupsLineHtml(ctx: Ctx): Html { | ||
| 30 | 31 | return html`<p id="backups-line">${line.says} <a href="/settings/backups">Backups</a> lists them.</p>`; |
| 31 | 32 | } |
| 32 | 33 | |
| 34 | /** What the nightly jobs last did (`nightly/says.ts`): that a night ran, and what it filed. */ | |
| 35 | export function nightlyHtml(ctx: Ctx): Html { | |
| 36 | return html`<ul id="nightly" class="muted"> | |
| 37 | ${nightlyLines(ctx.app.config.dataDir).map((line) => html`<li>${line}</li>`)} | |
| 38 | </ul>`; | |
| 39 | } | |
| 40 | ||
| 33 | 41 | // GET /resilience: a forge admin's, since live red lines name what to attack. |
| 34 | 42 | export function resiliencePage(ctx: Ctx): Response { |
| 35 | 43 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -46,6 +54,7 @@ export function resiliencePage(ctx: Ctx): Response { | ||
| 46 | 54 | </p> |
| 47 | 55 | </div> |
| 48 | 56 | ${backupsLineHtml(ctx)} |
| 57 | ${nightlyHtml(ctx)} | |
| 49 | 58 | <p id="tally"> |
| 50 | 59 | ${String(count("green"))} green · ${String(count("red"))} red · |
| 51 | 60 | ${String(count("untested"))} untested · ${String(count("no test yet"))} with no test yet |
test/nightly.test.ts added +260
| @@ -0,0 +1,260 @@ | ||
| 1 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; | |
| 2 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 3 | import { createApp, type App } from "../src/app.ts"; | |
| 4 | import { listPulls } from "../src/core/pulls.ts"; | |
| 5 | import type { Repo } from "../src/core/repos.ts"; | |
| 6 | import { refTip } from "../src/git/read.ts"; | |
| 7 | import { proposeQuarantine } from "../src/nightly/propose.ts"; | |
| 8 | import { startNightly } from "../src/nightly/service.ts"; | |
| 9 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 10 | import { quietLogs } from "./helpers/globals.ts"; | |
| 11 | import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 12 | import { tmpdir } from "node:os"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { HUNT_SLICES, JOBS } from "../src/nightly/jobs.ts"; | |
| 15 | import { nightlyLine } from "../src/nightly/says.ts"; | |
| 16 | import { lastSlot, owed, untilNext } from "../src/nightly/slots.ts"; | |
| 17 | import { readLast, writeLast } from "../src/nightly/stamps.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | const slot = { hour: 2, minute: 17 }; | |
| 22 | const at = (iso: string): number => Date.parse(iso); | |
| 23 | ||
| 24 | describe("nightly slots", () => { | |
| 25 | test("the latest slot is today's once it has passed, yesterday's before", () => { | |
| 26 | expect(lastSlot(slot, at("2026-11-02T03:00:00Z"))).toBe(at("2026-11-02T02:17:00Z")); | |
| 27 | expect(lastSlot(slot, at("2026-11-02T01:00:00Z"))).toBe(at("2026-11-01T02:17:00Z")); | |
| 28 | }); | |
| 29 | ||
| 30 | test("the wait to the next slot is positive and under a day", () => { | |
| 31 | expect(untilNext(slot, at("2026-11-02T02:17:00Z"))).toBe(86_400_000); | |
| 32 | expect(untilNext(slot, at("2026-11-02T02:16:00Z"))).toBe(60_000); | |
| 33 | }); | |
| 34 | ||
| 35 | test("a run is owed when the last began before the latest slot, never for a forge that has none", () => { | |
| 36 | const now = at("2026-11-02T03:00:00Z"); | |
| 37 | expect(owed(slot, at("2026-11-01T02:20:00Z"), now)).toBe(true); | |
| 38 | expect(owed(slot, at("2026-11-02T02:20:00Z"), now)).toBe(false); | |
| 39 | expect(owed(slot, undefined, now)).toBe(false); | |
| 40 | }); | |
| 41 | }); | |
| 42 | ||
| 43 | describe("nightly records", () => { | |
| 44 | test("a record reads back, and a missing or broken one reads as none", () => { | |
| 45 | const dir = mkdtempSync(join(tmpdir(), "nightly-")); | |
| 46 | try { | |
| 47 | expect(readLast(dir, "outside-gate")).toBeUndefined(); | |
| 48 | const last = { slot: at("2026-11-02T03:47:00Z"), startedAt: at("2026-11-02T09:00:00Z"), says: "ok." }; | |
| 49 | writeLast(dir, "outside-gate", last); | |
| 50 | expect(readLast(dir, "outside-gate")).toEqual(last); | |
| 51 | writeFileSync(join(dir, "nightly", "outside-gate.json"), "{nope"); | |
| 52 | expect(readLast(dir, "outside-gate")).toBeUndefined(); | |
| 53 | } finally { | |
| 54 | rmSync(dir, { recursive: true, force: true }); | |
| 55 | } | |
| 56 | }); | |
| 57 | ||
| 58 | test("a line says never run, not finished, and what a run said", () => { | |
| 59 | const s = { hour: 3, minute: 47 }; | |
| 60 | expect(nightlyLine("x", s, undefined)).toContain("has not run yet; its slot is 03:47 UTC"); | |
| 61 | expect(nightlyLine("x", s, { slot: 1, startedAt: at("2026-11-02T03:47:00Z"), says: "began" })).toContain("has not finished"); | |
| 62 | const done = { slot: 1, startedAt: at("2026-11-02T03:47:00Z"), finishedAt: at("2026-11-02T04:10:00Z"), says: "every step finished." }; | |
| 63 | expect(nightlyLine("x", s, done)).toBe("x: 2026-11-02 03:47 UTC to 2026-11-02 04:10 UTC, every step finished."); | |
| 64 | }); | |
| 65 | }); | |
| 66 | ||
| 67 | describe("nightly jobs", () => { | |
| 68 | test("the hunt covers every slice once and ends in the quarantine; the outside run is one step", () => { | |
| 69 | const hunt = JOBS.find((j) => j.name === "flake-hunt")!; | |
| 70 | const slices = hunt.steps.filter((s) => s.label.startsWith("hunt slice")); | |
| 71 | expect(slices).toHaveLength(HUNT_SLICES); | |
| 72 | expect(new Set(slices.map((s) => s.command)).size).toBe(HUNT_SLICES); | |
| 73 | expect(hunt.steps.at(-1)?.label).toBe("quarantine"); | |
| 74 | expect(JOBS.find((j) => j.name === "outside-gate")!.steps.map((s) => s.label)).toEqual([ | |
| 75 | "install", | |
| 76 | "run the excluded files", | |
| 77 | ]); | |
| 78 | }); | |
| 79 | ||
| 80 | test("only the steps that file hold the token, and every script a step runs exists", () => { | |
| 81 | for (const job of JOBS) | |
| 82 | for (const step of job.steps) { | |
| 83 | expect(step.reports).toBe(step.command.includes("flake-hunt.ts")); | |
| 84 | const script = /scripts\/[\w-]+\.ts/.exec(step.command)?.[0]; | |
| 85 | if (script !== undefined) expect(existsSync(join(import.meta.dir, "..", script))).toBe(true); | |
| 86 | } | |
| 87 | }); | |
| 88 | }); | |
| 89 | ||
| 90 | describe("the nightly service", () => { | |
| 91 | let dir: string; | |
| 92 | let app: App; | |
| 93 | const calls: string[] = []; | |
| 94 | let release: (() => void) | undefined; | |
| 95 | const instant = { | |
| 96 | run: async (job: { name: string }) => { | |
| 97 | calls.push(job.name); | |
| 98 | return { dir: "x", oid: "y", failed: [] as string[] }; | |
| 99 | }, | |
| 100 | propose: async () => undefined, | |
| 101 | }; | |
| 102 | const SLOT_AFTER = at("2026-11-02T03:00:00Z"); | |
| 103 | ||
| 104 | beforeEach(() => { | |
| 105 | dir = mkdtempSync(join(tmpdir(), "nightly-svc-")); | |
| 106 | app = createApp({ dataDir: dir, port: 0 }); | |
| 107 | calls.length = 0; | |
| 108 | delete process.env["FORGE_APP_ENV"]; | |
| 109 | delete process.env["FORGE_CREW_IN_PREVIEW"]; | |
| 110 | }); | |
| 111 | afterEach(() => { | |
| 112 | app.close(); | |
| 113 | rmSync(dir, { recursive: true, force: true }); | |
| 114 | delete process.env["FORGE_APP_ENV"]; | |
| 115 | }); | |
| 116 | ||
| 117 | test("it is off without a token", () => { | |
| 118 | expect(startNightly(app, { forgeUrl: "http://f", token: undefined, doing: instant })).toBeUndefined(); | |
| 119 | expect(startNightly(app, { forgeUrl: "http://f", token: "", doing: instant })).toBeUndefined(); | |
| 120 | }); | |
| 121 | ||
| 122 | test("it is off in a preview", () => { | |
| 123 | process.env["FORGE_APP_ENV"] = "preview"; | |
| 124 | expect(startNightly(app, { forgeUrl: "http://f", token: "t", doing: instant })).toBeUndefined(); | |
| 125 | }); | |
| 126 | ||
| 127 | test("a job whose slot passed while the forge was down runs once on waking, and keeps that slot", async () => { | |
| 128 | const yesterday = { slot: at("2026-11-01T02:17:00Z"), startedAt: at("2026-11-01T02:17:00Z"), finishedAt: at("2026-11-01T05:00:00Z"), says: "ok." }; | |
| 129 | writeLast(dir, "flake-hunt", yesterday); | |
| 130 | writeLast(dir, "outside-gate", { ...yesterday, slot: at("2026-11-01T03:47:00Z") }); | |
| 131 | const handle = startNightly(app, { forgeUrl: "http://f", token: "t", doing: instant, now: () => SLOT_AFTER })!; | |
| 132 | while (readLast(dir, "flake-hunt")?.slot !== at("2026-11-02T02:17:00Z") || calls.length === 0) await Bun.sleep(5); | |
| 133 | await handle.stop(); | |
| 134 | // Only the hunt's slot (02:17) has passed since; the outside run's (03:47) has not. | |
| 135 | expect(calls).toEqual(["flake-hunt"]); | |
| 136 | expect(readLast(dir, "flake-hunt")?.slot).toBe(at("2026-11-02T02:17:00Z")); | |
| 137 | expect(readLast(dir, "flake-hunt")?.finishedAt).toBeDefined(); | |
| 138 | }); | |
| 139 | ||
| 140 | test("a forge that never ran a job owes none, and a job already kept is not run again", async () => { | |
| 141 | const fresh = startNightly(app, { forgeUrl: "http://f", token: "t", doing: instant, now: () => SLOT_AFTER })!; | |
| 142 | await fresh.stop(); | |
| 143 | expect(calls).toEqual([]); | |
| 144 | writeLast(dir, "flake-hunt", { slot: at("2026-11-02T02:17:00Z"), startedAt: at("2026-11-02T02:17:30Z"), says: "began" }); | |
| 145 | const again = startNightly(app, { forgeUrl: "http://f", token: "t", doing: instant, now: () => SLOT_AFTER })!; | |
| 146 | await again.stop(); | |
| 147 | expect(calls).toEqual([]); | |
| 148 | }); | |
| 149 | ||
| 150 | test("a job queued behind another records its own slot, not the time it got to start", async () => { | |
| 151 | const handle = startNightly(app, { forgeUrl: "http://f", token: "t", doing: instant, now: () => at("2026-11-02T03:50:00Z") })!; | |
| 152 | await handle.run("flake-hunt"); | |
| 153 | await handle.run("outside-gate"); | |
| 154 | await handle.stop(); | |
| 155 | expect(readLast(dir, "outside-gate")?.slot).toBe(at("2026-11-02T03:47:00Z")); | |
| 156 | }); | |
| 157 | ||
| 158 | test("stop aborts a run in flight at once and the record says it was stopped", async () => { | |
| 159 | let seen: AbortSignal | undefined; | |
| 160 | const slow = { | |
| 161 | run: (_job: unknown, signal: AbortSignal) => { | |
| 162 | seen = signal; | |
| 163 | return new Promise<{ dir: string; oid: string; failed: string[] }>((resolve) => { | |
| 164 | signal.addEventListener("abort", () => resolve({ dir: "x", oid: "y", failed: ["hunt slice 3/20"] })); | |
| 165 | release = () => resolve({ dir: "x", oid: "y", failed: [] }); | |
| 166 | }); | |
| 167 | }, | |
| 168 | propose: async () => undefined, | |
| 169 | }; | |
| 170 | const handle = startNightly(app, { forgeUrl: "http://f", token: "t", doing: slow, now: () => SLOT_AFTER })!; | |
| 171 | const running = handle.run("outside-gate"); | |
| 172 | await Bun.sleep(10); | |
| 173 | expect(seen?.aborted).toBe(false); | |
| 174 | await handle.stop(); | |
| 175 | await running; | |
| 176 | expect(seen?.aborted).toBe(true); | |
| 177 | release?.(); | |
| 178 | expect(readLast(dir, "outside-gate")?.says).toContain("stopped by a restart"); | |
| 179 | }); | |
| 180 | ||
| 181 | test("a run that could not start says why, and files no pull", async () => { | |
| 182 | let proposed = 0; | |
| 183 | const handle = startNightly(app, { | |
| 184 | forgeUrl: "http://f", | |
| 185 | token: "t", | |
| 186 | now: () => SLOT_AFTER, | |
| 187 | doing: { run: async () => ({ error: "no cell to run it in" }), propose: async () => void proposed++ as undefined }, | |
| 188 | })!; | |
| 189 | await handle.run("flake-hunt"); | |
| 190 | await handle.stop(); | |
| 191 | expect(readLast(dir, "flake-hunt")?.says).toBe("not run: no cell to run it in."); | |
| 192 | expect(proposed).toBe(0); | |
| 193 | }); | |
| 194 | ||
| 195 | test("the hunt proposes after its steps, unless the quarantine itself failed; the outside run never does", async () => { | |
| 196 | let proposed = 0; | |
| 197 | const doing = (failed: string[]) => ({ | |
| 198 | run: async () => ({ dir: "x", oid: "y", failed }), | |
| 199 | propose: async () => { | |
| 200 | proposed++; | |
| 201 | return "flake-hunt/2026-11-02"; | |
| 202 | }, | |
| 203 | }); | |
| 204 | const green = startNightly(app, { forgeUrl: "http://f", token: "t", now: () => SLOT_AFTER, doing: doing([]) })!; | |
| 205 | await green.run("flake-hunt"); | |
| 206 | await green.run("outside-gate"); | |
| 207 | await green.stop(); | |
| 208 | expect(proposed).toBe(1); | |
| 209 | expect(readLast(dir, "flake-hunt")?.says).toContain("quarantine pull on flake-hunt/2026-11-02"); | |
| 210 | const red = startNightly(app, { forgeUrl: "http://f", token: "t", now: () => SLOT_AFTER, doing: doing(["quarantine"]) })!; | |
| 211 | await red.run("flake-hunt"); | |
| 212 | await red.stop(); | |
| 213 | expect(proposed).toBe(1); | |
| 214 | }); | |
| 215 | }); | |
| 216 | ||
| 217 | describe("the quarantine pull", () => { | |
| 218 | let dir: string; | |
| 219 | let work: string; | |
| 220 | let app: App; | |
| 221 | let repo: Repo; | |
| 222 | let oid: string; | |
| 223 | const ORIGINAL = "export const EXCLUDED = {};\n"; | |
| 224 | ||
| 225 | beforeAll(async () => { | |
| 226 | dir = mkdtempSync(join(tmpdir(), "nightly-pull-")); | |
| 227 | work = mkdtempSync(join(tmpdir(), "nightly-work-")); | |
| 228 | app = createApp({ dataDir: dir, port: 0 }); | |
| 229 | const owner = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 230 | if (!owner.ok) throw new Error("no owner"); | |
| 231 | const made = await createRepo(app, owner.value, { name: "forge", visibility: "public" }); | |
| 232 | if (!made.ok) throw new Error("no repo"); | |
| 233 | repo = made.value; | |
| 234 | const seeded = await commitToBranch(app, owner.value, repo, { message: "seed", files: [{ path: "scripts/gate.ts", content: ORIGINAL }] }); | |
| 235 | if (!seeded.ok) throw new Error("no seed"); | |
| 236 | oid = (await refTip(join(app.reposRoot, "alice", "forge.git"), `refs/heads/${repo.defaultBranch}`))!; | |
| 237 | mkdirSync(join(work, "scripts"), { recursive: true }); | |
| 238 | }); | |
| 239 | afterAll(() => { | |
| 240 | app.close(); | |
| 241 | rmSync(dir, { recursive: true, force: true }); | |
| 242 | rmSync(work, { recursive: true, force: true }); | |
| 243 | }); | |
| 244 | ||
| 245 | test("a file the job left unchanged proposes nothing", async () => { | |
| 246 | writeFileSync(join(work, "scripts/gate.ts"), ORIGINAL); | |
| 247 | const branch = await proposeQuarantine(app, repo, { dir: work, oid }, "scripts/gate.ts", "flake-hunt", at("2026-11-02T03:00:00Z")); | |
| 248 | expect(branch).toBeUndefined(); | |
| 249 | expect(listPulls(app.db, repo.id, {})).toHaveLength(0); | |
| 250 | }); | |
| 251 | ||
| 252 | test("a file the job edited is proposed as one pull on a dated branch", async () => { | |
| 253 | writeFileSync(join(work, "scripts/gate.ts"), "export const EXCLUDED = { a: 1 };\n"); | |
| 254 | const branch = await proposeQuarantine(app, repo, { dir: work, oid }, "scripts/gate.ts", "flake-hunt", at("2026-11-02T03:00:00Z")); | |
| 255 | expect(branch).toBe("flake-hunt/2026-11-02"); | |
| 256 | const pulls = listPulls(app.db, repo.id, {}); | |
| 257 | expect(pulls).toHaveLength(1); | |
| 258 | expect(pulls[0]?.title).toBe("Quarantine flaky tests (flake-hunt/2026-11-02)"); | |
| 259 | }); | |
| 260 | }); |
new above
offline
This line runs far past the file's wrap width; rewrap it.