Public: anyone can read this; the crew can change it.
Remove the proof step: with check.run lint and types only, it proves nothing the merge queue's gate does not #8301
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7779-remove-the-proof-step-with-check-run-lin → main 14 commits fork
33 files
changed +148
−1489
docs/CREW.md +3 −25
| @@ -169,31 +169,9 @@ In the database the pull's `close_reason` stays NULL (its CHECK predates the | ||
| 169 | 169 | word) and `issue.parked = 1` carries it, so raw SQL never reads it as done. |
| 170 | 170 | |
| 171 | 171 | **The merge queue.** A green, approved pull in an autonomous repository |
| 172 | joins its repository's queue (`src/checks/queue.ts`) once it is proved | |
| 173 | (#7616, `src/checks/proof.ts`): when its check has a fast tier, the full | |
| 174 | gate — the same command, environment and sharding — runs on its head | |
| 175 | squashed onto main's tip first, using one check slot. Green admits it, kept | |
| 176 | as a `proof` row in the decision ledger with the head and base. A red one is | |
| 177 | held (`proof-red` in the tick's waits; `proving` while it runs), and the | |
| 178 | pull gets a comment that names the failing tests, so a test the fast tier | |
| 179 | never reached costs no batch. A proof goes stale when main moves more than | |
| 180 | 16 first-parent landings past it (`STALE_LANDINGS`). The pull is then proved | |
| 181 | again before it joins, and a pull already in the line is left out of its | |
| 182 | next batch until it is proved again. A proof that has not answered by the | |
| 183 | gate's deadline (15 minutes) is cut off and no verdict is kept, so a hung | |
| 184 | one never holds its pull or a check slot. | |
| 185 | ||
| 186 | A **proof** is a run of kind `proof`: the full suite on a pull's head squashed | |
| 187 | onto the base, before the pull joins the merge queue. It is `queued` while no | |
| 188 | check slot is spare, `running`, then `ok` (shown *Green*), `failed` (red) or | |
| 189 | `cancelled` (shown *Cut off*: the deadline passed or the forge restarted; it is | |
| 190 | proved again on the next sweep). A push ends the old head's unfinished proof as | |
| 191 | `cancelled`, "superseded by a push". The run's summary says why a proof waited | |
| 192 | or could not start, and the pull is told once per head in a comment and in its | |
| 193 | next step ("approved and green, but its proof waits: no check slot is spare"). | |
| 194 | Read them in the runs list (`?kind=proof`), the terminal's Runs screen, the API | |
| 195 | (`GET …/runs?kind=proof`) and the MCP `runs` tool (`kind: "proof"`); only those | |
| 196 | lists mix proofs in with the other kinds. | |
| 172 | joins its repository's queue (`src/checks/queue.ts`) on its own check and | |
| 173 | its approval: there is no proof before the line (removed in #7779), and the | |
| 174 | queue's gate is the one full run. | |
| 197 | 175 | |
| 198 | 176 | The queue builds one candidate: main, then each queued pull's squash on top of the one before, in |
| 199 | 177 | queue order, leaving out any pull that conflicts or has nothing left to land. |
docs/MCP.md +1 −1
| @@ -154,7 +154,7 @@ for a tunnel and a forge that sleeps. | ||
| 154 | 154 | | `review` | `{ repo, number, verdict, reason? }`: approve a pull (`approve`) or request changes (`changes`, with a `reason`), as the page does; a writer's or the crew's, and never your own pull | yes | |
| 155 | 155 | | `rollback` | (admin scope) `{ sha, dryRun? }`: roll the forge back to a kept release, as /releases does; plans unless `dryRun` is false (from which release to which, and whether the live schema allows it); a person who may roll back, with an admin-scope token, never an agent | yes | |
| 156 | 156 | | `run_log` | `{ repo, run, tail? }`: a run's log, or its last `tail` lines (1–2000), with the REST route's access: a hidden log or unreadable repository is not found | | |
| 157 | | `runs` | `{ repo, kind?, limit? }`: the repository's recent runs (kind agent, deploy, check or proof — a proof is the full-suite run before the merge queue), newest first (id, kind, state, issue or pull number, started, ended), 1–50, default 20; read as the runs page reads them | | | |
| 157 | | `runs` | `{ repo, kind?, limit? }`: the repository's recent runs (kind agent, deploy or check), newest first (id, kind, state, issue or pull number, started, ended), 1–50, default 20; read as the runs page reads them | | | |
| 158 | 158 | | `say` | speak on an issue or a pull; a working builder hears it as its next step. With no `repo` or `number` it speaks to the forge's own agent (`forge talk`), which answers in `GET /api/v1/conversation/turns` | yes | |
| 159 | 159 | | `scorecard` | (forge admin) no input: the crew's scorecard per agent, all time and 30 days: landed, review rounds, red rate, reverts | | |
| 160 | 160 | | `seal` | `{ repo, number, sealed }`: seal a security report (and the pull that fixes it) so only writers and the crew see it, or unseal it for every reader; a writer's, recorded; with `action: "private"` instead of `sealed`, the private repository a writer fixes a sealed report in, made on first need, with its name and clone URL (the API's `POST …/issues/:number/private`, same rules) | yes | |
docs/features/pipeline.md +2 −2
| @@ -8,7 +8,7 @@ Every change follows four steps, and a person only needs those four words: | ||
| 8 | 8 | 4. **Live**: a release builds the default branch and production serves it. |
| 9 | 9 | |
| 10 | 10 | `/pipeline` says this to anyone. Each step's finer parts (the check queue, the |
| 11 | proof, the merge queue's gate, the release build and boot check) sit one | |
| 11 | merge queue's gate, the release build and boot check) sit one | |
| 12 | 12 | click down in a `<details>`. The live counts per stage stay on `/boards`, |
| 13 | 13 | whose Pipeline panel links back to `/pipeline`. |
| 14 | 14 | |
| @@ -67,5 +67,5 @@ label with `l`. | ||
| 67 | 67 | |
| 68 | 68 | ## Not here yet |
| 69 | 69 | |
| 70 | The rest of #7703 is filed as its own issues: remove the proof (#7779), re-run a shard that crashed | |
| 70 | The rest of #7703 is filed as its own issues: re-run a shard that crashed | |
| 71 | 71 | outside any test (#7781), reuse a verdict for the same tree (#7782), and measure each step (#7784). |
src/checks/landing.ts −32
| @@ -62,38 +62,6 @@ export async function gateCandidate( | ||
| 62 | 62 | return gateVerdict(app, repo, commitOid, await checks.gate(repo, pull, commitOid, signal)); |
| 63 | 63 | } |
| 64 | 64 | |
| 65 | /** | |
| 66 | * The proof before the line (#7616, `proof.ts`): the full gate on the | |
| 67 | * pull's head squashed onto its base, run as the gate runs. Undefined when | |
| 68 | * there is nothing to prove with — no checks service, a lane that cannot | |
| 69 | * prove, or a check with no fast tier, whose pull check was already the | |
| 70 | * whole suite. | |
| 71 | */ | |
| 72 | export async function proveCandidate( | |
| 73 | app: App, | |
| 74 | repo: Repo, | |
| 75 | pull: Pull, | |
| 76 | dir: string, | |
| 77 | commitOid: string, | |
| 78 | signal?: AbortSignal, | |
| 79 | ): Promise<LandVerdict | undefined> { | |
| 80 | const prove = checksFor(app)?.prove; | |
| 81 | if (prove === undefined || !(await provable(app, dir, commitOid))) return undefined; | |
| 82 | // Not the forge's last gate: the Resilience page reads the queue's gates only. | |
| 83 | return gateVerdict(app, repo, commitOid, await prove(repo, pull, commitOid, signal), false); | |
| 84 | } | |
| 85 | ||
| 86 | /** Whether a pull at `commitOid` is proved before the line: a lane that proves, and a check with a fast tier. */ | |
| 87 | export async function provable(app: App, dir: string, commitOid: string): Promise<boolean> { | |
| 88 | if (checksFor(app)?.prove === undefined) return false; | |
| 89 | const [forgeJson, packageJson] = await Promise.all([ | |
| 90 | readFileAt(dir, commitOid, "forge.json"), | |
| 91 | readFileAt(dir, commitOid, "package.json"), | |
| 92 | ]); | |
| 93 | const declared = parseCheck(forgeJson, packageJson); | |
| 94 | return declared.ok && declared.value !== undefined && declared.value.fast !== null; | |
| 95 | } | |
| 96 | ||
| 97 | 65 | /** How long the queue waits between reads of a gate another forge runs (#6611). */ |
| 98 | 66 | export const ADOPT_POLL_MS = 5_000; |
| 99 | 67 |
src/checks/proof.ts deleted −307
| @@ -1,307 +0,0 @@ | ||
| 1 | /** | |
| 2 | * The proof before the line (#7616). A pull's own check is the fast tier — | |
| 3 | * the tests its change reaches — while the merge queue's gate runs every | |
| 4 | * test, so a pull that broke a test its change never reached was found in | |
| 5 | * the queue, where one red costs a batch of up to eight. Before a pull | |
| 6 | * joins the line it is proved: the full gate (the same command, | |
| 7 | * environment and sharding — `ChecksHandle.prove` is the gate's own run) | |
| 8 | * on its head squashed onto the base's tip, the tree a batch of one would | |
| 9 | * gate. Green is kept in the decision ledger as a `proof` row with the head | |
| 10 | * and the base it was proved on, and admits the pull; red is told to the | |
| 11 | * pull with its failing tests named and holds it, at no cost to any batch. | |
| 12 | * | |
| 13 | * A proof goes stale once the base has moved more than `STALE_LANDINGS` | |
| 14 | * first-parent commits past the base it was proved on, or off it: the pull | |
| 15 | * is proved again before it joins, and one already waiting in the line is | |
| 16 | * left out of its next batch (`Queued.proven`) and proved again. A red | |
| 17 | * proof stands for its head until a push, or until the base has moved that | |
| 18 | * far — a red that was main's may be gone from a newer main. | |
| 19 | * | |
| 20 | * A proof is a run (#7706, `core/proof_runs.ts`, kind `proof`): waiting for | |
| 21 | * a check slot, running, then green, red or cut off. A proof that cannot | |
| 22 | * start (no slot spare, the head does not merge onto the base tip) says why | |
| 23 | * on the pull, once per head. A proof cut off (the forge stopping, the Job | |
| 24 | * lost) is no verdict: its run ends cut off, the pull is told, and the next | |
| 25 | * sweep proves it again. One that has not answered by the gate's own | |
| 26 | * deadline (`GATE_DEADLINE_MS`, #3430) is cut off the same way, its run | |
| 27 | * aborted, so a hung proof never holds its pull or a check slot for good. | |
| 28 | * One proof runs at a time per pull, and only one beyond the first while no | |
| 29 | * check slot is spare. | |
| 30 | */ | |
| 31 | ||
| 32 | import type { Database } from "bun:sqlite"; | |
| 33 | import { latestDecision, recordDecision } from "../core/decisions.ts"; | |
| 34 | import { cutOffBefore, cutSummary, endedBefore, endProofRun, latestProofRun, openProofRun, startProofRun } from "../core/proof_runs.ts"; | |
| 35 | import type { Pull } from "../core/pulls.ts"; | |
| 36 | import { STOPPED_SUMMARY } from "../core/runs.ts"; | |
| 37 | import { runGit } from "../git/env.ts"; | |
| 38 | import { mergeable } from "../git/merge.ts"; | |
| 39 | import { GATE_DEADLINE_MS } from "./queue.ts"; | |
| 40 | import type { LandVerdict } from "./tiers.ts"; | |
| 41 | ||
| 42 | /** | |
| 43 | * How far the base may move past a proof before it is proved again: two | |
| 44 | * full batches (`MAX_CANDIDATE` is 8), so a pull proved as it joined is | |
| 45 | * still proved while the two batches ahead of it land, and one that waited | |
| 46 | * longer than that is proved on what main has become. | |
| 47 | */ | |
| 48 | export const STALE_LANDINGS = 16; | |
| 49 | ||
| 50 | /** A red proof is tried again this long after it, in case what failed was not the pull. */ | |
| 51 | export const RED_PROOF_RETRY_MS = 30 * 60_000; | |
| 52 | ||
| 53 | /** What a proof found: the head it ran on, the base tip it was squashed onto, and whether it was green. */ | |
| 54 | export interface Proof { | |
| 55 | readonly head: string; | |
| 56 | readonly base: string; | |
| 57 | readonly green: boolean; | |
| 58 | /** When the ledger kept it. */ | |
| 59 | readonly at?: string; | |
| 60 | } | |
| 61 | ||
| 62 | /** Where a pull stands before the line. */ | |
| 63 | export type Standing = "proved" | "proving" | "red" | "unproved"; | |
| 64 | ||
| 65 | /** The proofs running now: pull id → the head being proved. */ | |
| 66 | const proving = new Map<string, string>(); | |
| 67 | ||
| 68 | /** How many proofs are running on this forge: they hold check slots as gates do. */ | |
| 69 | export function proofsInFlight(): number { | |
| 70 | return proving.size; | |
| 71 | } | |
| 72 | ||
| 73 | /** The newest proof of a pull, read off the ledger; undefined when it has none. */ | |
| 74 | export function proofOf(db: Database, pullId: string): Proof | undefined { | |
| 75 | const row = latestDecision(db, pullId, "proof"); | |
| 76 | if (row === undefined) return undefined; | |
| 77 | const state = row.state; | |
| 78 | if (typeof state !== "object" || state === null) return undefined; | |
| 79 | const { head, base } = state as Record<string, unknown>; | |
| 80 | if (typeof head !== "string" || typeof base !== "string") return undefined; | |
| 81 | return { head, base, green: row.answer === "green", at: row.createdAt }; | |
| 82 | } | |
| 83 | ||
| 84 | /** Keep a proof's verdict on the ledger. */ | |
| 85 | export function keepProof(db: Database, repoId: string, pullId: string, proof: Proof): void { | |
| 86 | recordDecision(db, { | |
| 87 | kind: "proof", | |
| 88 | repoId, | |
| 89 | issueId: pullId, | |
| 90 | state: { head: proof.head, base: proof.base }, | |
| 91 | answer: proof.green ? "green" : "red", | |
| 92 | p: 1, | |
| 93 | by: "rule", | |
| 94 | ms: 0, | |
| 95 | }); | |
| 96 | } | |
| 97 | ||
| 98 | /** | |
| 99 | * First-parent commits from `from` to `to`: the landings since a proof. | |
| 100 | * Undefined when `from` is not behind `to` (the base was rewritten) or git | |
| 101 | * cannot say. | |
| 102 | */ | |
| 103 | export async function landingsSince(dir: string, from: string, to: string): Promise<number | undefined> { | |
| 104 | if (from === to) return 0; | |
| 105 | const behind = await runGit(["merge-base", "--is-ancestor", from, to], { cwd: dir }); | |
| 106 | if (!behind.ok) return undefined; | |
| 107 | const count = await runGit(["rev-list", "--count", "--first-parent", `${from}..${to}`], { cwd: dir }); | |
| 108 | if (!count.ok) return undefined; | |
| 109 | const n = Number(count.value.trim()); | |
| 110 | return Number.isSafeInteger(n) && n >= 0 ? n : undefined; | |
| 111 | } | |
| 112 | ||
| 113 | /** A proof still holds on `tip`: the base has moved no more than `STALE_LANDINGS` past it. */ | |
| 114 | export async function current(dir: string, proof: Proof, tip: string): Promise<boolean> { | |
| 115 | const moved = await landingsSince(dir, proof.base, tip); | |
| 116 | return moved !== undefined && moved <= STALE_LANDINGS; | |
| 117 | } | |
| 118 | ||
| 119 | /** Where `pull` at its head stands on the base tip `tip`. */ | |
| 120 | export async function standing( | |
| 121 | db: Database, | |
| 122 | dir: string, | |
| 123 | pull: Pull, | |
| 124 | tip: string, | |
| 125 | now: number = Date.now(), | |
| 126 | ): Promise<Standing> { | |
| 127 | if (proving.get(pull.id) === pull.headOid) return "proving"; | |
| 128 | const proof = proofOf(db, pull.id); | |
| 129 | if (proof === undefined || proof.head !== pull.headOid) return "unproved"; | |
| 130 | if (!(await current(dir, proof, tip))) return "unproved"; | |
| 131 | if (proof.green) return "proved"; | |
| 132 | // A red says something only about the main it ran on, and for a while: a flake or a | |
| 133 | // broken gate must not strand a pull until its next push. | |
| 134 | const fresh = proof.at !== undefined && now - Date.parse(proof.at) < RED_PROOF_RETRY_MS; | |
| 135 | return proof.base === tip && fresh ? "red" : "unproved"; | |
| 136 | } | |
| 137 | ||
| 138 | /** The pull's head squashed onto `tip`, as a commit the gate can run on; undefined when it does not merge. */ | |
| 139 | export async function proofCommit(dir: string, tip: string, pull: Pull): Promise<string | undefined> { | |
| 140 | const merged = await mergeable(dir, tip, pull.headOid); | |
| 141 | if (!merged.ok) return undefined; | |
| 142 | const commit = await runGit( | |
| 143 | ["commit-tree", merged.value.treeOid, "-p", tip, "-m", `proof: #${pull.number}`], | |
| 144 | { cwd: dir, env: IDENTITY }, | |
| 145 | ); | |
| 146 | return commit.ok ? commit.value.trim() : undefined; | |
| 147 | } | |
| 148 | ||
| 149 | const IDENTITY = { | |
| 150 | GIT_AUTHOR_NAME: "forge", | |
| 151 | GIT_AUTHOR_EMAIL: "forge@localhost", | |
| 152 | GIT_COMMITTER_NAME: "forge", | |
| 153 | GIT_COMMITTER_EMAIL: "forge@localhost", | |
| 154 | }; | |
| 155 | ||
| 156 | /** What starting a proof needs: where, on what, how to run the gate, and what to do with its answer. */ | |
| 157 | export interface ProofRun { | |
| 158 | readonly db: Database; | |
| 159 | readonly repoId: string; | |
| 160 | readonly pull: Pull; | |
| 161 | readonly dir: string; | |
| 162 | readonly tip: string; | |
| 163 | /** The full gate on a commit; `signal` aborts at the deadline, and the run should stop. */ | |
| 164 | readonly run: (commitOid: string, signal: AbortSignal) => Promise<LandVerdict>; | |
| 165 | /** Check slots spare now; a second proof starts only on one. */ | |
| 166 | readonly room: () => number; | |
| 167 | /** The proof was judged and kept. */ | |
| 168 | readonly done: (proof: Proof, verdict: LandVerdict) => void; | |
| 169 | /** Said to the pull: a proof that cannot start, or was cut off. Once per head and reason. */ | |
| 170 | readonly told?: (words: string) => void; | |
| 171 | /** How long the proof may run before it is cut off; the gate's deadline unless a test shortens it. */ | |
| 172 | readonly deadlineMs?: number; | |
| 173 | } | |
| 174 | ||
| 175 | /** Why a proof waits or cannot start: the run's summary, and what the pull is told. */ | |
| 176 | export const WAIT_SLOT = "no check slot is spare"; | |
| 177 | export const WAIT_MERGE = "cannot start: the head does not merge onto the base tip"; | |
| 178 | ||
| 179 | /** | |
| 180 | * Say once per head why the proof has not started: a waiting run (`queued`) | |
| 181 | * for no slot, a failed one for a head that does not merge. | |
| 182 | */ | |
| 183 | function refuse(x: ProofRun, summary: string, words: string): void { | |
| 184 | const last = latestProofRun(x.db, x.pull.id); | |
| 185 | // Once per head: any run of it that already waited or failed for this reason was told. | |
| 186 | const state = summary === WAIT_SLOT ? "queued" : "failed"; | |
| 187 | const told = endedBefore(x.db, x.pull.id, x.pull.headOid, state, summary); | |
| 188 | if (told && last?.commitOid === x.pull.headOid && last.summary === summary) return; | |
| 189 | if (summary === WAIT_SLOT) { | |
| 190 | openProofRun(x.db, { repoId: x.repoId, pullId: x.pull.id, head: x.pull.headOid, base: x.tip, state: "queued", summary }); | |
| 191 | } else if (last !== undefined && last.state === "queued" && last.commitOid === x.pull.headOid) { | |
| 192 | endProofRun(x.db, last.id, "failed", summary); | |
| 193 | } else { | |
| 194 | const run = openProofRun(x.db, { repoId: x.repoId, pullId: x.pull.id, head: x.pull.headOid, base: x.tip, state: "running", summary }); | |
| 195 | endProofRun(x.db, run.id, "failed", summary); | |
| 196 | } | |
| 197 | if (!told) x.told?.(words); | |
| 198 | } | |
| 199 | ||
| 200 | /** End a proof run cut off for `reason`, and tell the pull once per head and reason. */ | |
| 201 | function cutOff(x: ProofRun, runId: string, head: string, reason: string): void { | |
| 202 | const told = cutOffBefore(x.db, x.pull.id, head, reason, runId); | |
| 203 | endProofRun(x.db, runId, "cancelled", cutSummary(reason)); | |
| 204 | if (!told && head === x.pull.headOid) x.told?.(cutWords(reason)); | |
| 205 | } | |
| 206 | ||
| 207 | /** What a pull is told when its proof was cut off. */ | |
| 208 | const cutWords = (why: string): string => | |
| 209 | `The full-suite proof of this head was cut off (${why}), so it has no verdict. It is proved again on the next sweep.`; | |
| 210 | ||
| 211 | /** | |
| 212 | * Start proving `pull` on `tip` beside the caller, which does not wait. | |
| 213 | * False when it did not start: a proof of this head runs already, no slot | |
| 214 | * is free, or the head does not merge onto the tip. The last two are kept | |
| 215 | * as a run and told to the pull, once per head. | |
| 216 | */ | |
| 217 | export async function startProof(x: ProofRun): Promise<boolean> { | |
| 218 | if (proving.has(x.pull.id)) return false; | |
| 219 | // A run still `running` that this process does not hold was cut off by a restart. | |
| 220 | const orphan = latestProofRun(x.db, x.pull.id); | |
| 221 | if (orphan?.state === "running") { | |
| 222 | cutOff(x, orphan.id, orphan.commitOid, STOPPED_SUMMARY); | |
| 223 | } | |
| 224 | if (proving.size > 0 && x.room() <= 0) { | |
| 225 | refuse( | |
| 226 | x, | |
| 227 | WAIT_SLOT, | |
| 228 | `The full-suite proof of this head is waiting: ${WAIT_SLOT}. It starts when one frees.`, | |
| 229 | ); | |
| 230 | return false; | |
| 231 | } | |
| 232 | proving.set(x.pull.id, x.pull.headOid); | |
| 233 | let commit: string | undefined; | |
| 234 | try { | |
| 235 | commit = await proofCommit(x.dir, x.tip, x.pull); | |
| 236 | } catch { | |
| 237 | commit = undefined; | |
| 238 | } | |
| 239 | if (commit === undefined) { | |
| 240 | proving.delete(x.pull.id); | |
| 241 | refuse( | |
| 242 | x, | |
| 243 | WAIT_MERGE, | |
| 244 | `The full-suite proof cannot start: this head does not merge onto ${x.pull.baseBranch} (${x.tip.slice(0, 7)}). Bring the pull up to date with the base and push; it is proved then.`, | |
| 245 | ); | |
| 246 | return false; | |
| 247 | } | |
| 248 | const waiting = latestProofRun(x.db, x.pull.id); | |
| 249 | let runId: string; | |
| 250 | if (waiting?.state === "queued" && waiting.commitOid === x.pull.headOid) { | |
| 251 | runId = waiting.id; | |
| 252 | startProofRun(x.db, runId, x.tip); | |
| 253 | } else { | |
| 254 | runId = openProofRun(x.db, { repoId: x.repoId, pullId: x.pull.id, head: x.pull.headOid, base: x.tip, state: "running", summary: "" }).id; | |
| 255 | } | |
| 256 | const abandon = new AbortController(); | |
| 257 | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 258 | let deadline = false; | |
| 259 | // A proof that never answers is cut off at the deadline: no verdict (#3430). | |
| 260 | const cut = new Promise<LandVerdict>((resolve) => { | |
| 261 | timer = setTimeout(() => { | |
| 262 | deadline = true; | |
| 263 | abandon.abort(); | |
| 264 | resolve({ lands: false, interrupted: true }); | |
| 265 | }, x.deadlineMs ?? GATE_DEADLINE_MS); | |
| 266 | }); | |
| 267 | void Promise.race([x.run(commit, abandon.signal), cut]) | |
| 268 | .then( | |
| 269 | (verdict) => { | |
| 270 | // Released before the answer is acted on: `done` may sweep, and must not read it as running. | |
| 271 | clearTimeout(timer); | |
| 272 | proving.delete(x.pull.id); | |
| 273 | if ("interrupted" in verdict) { | |
| 274 | cutOff(x, runId, x.pull.headOid, deadline ? "at the deadline" : "the gate was interrupted"); | |
| 275 | return; | |
| 276 | } | |
| 277 | const proof: Proof = { head: x.pull.headOid, base: x.tip, green: verdict.lands }; | |
| 278 | keepProof(x.db, x.repoId, x.pull.id, proof); | |
| 279 | endProofRun(x.db, runId, verdict.lands ? "ok" : "failed", verdict.lands ? "green" : "red"); | |
| 280 | x.done(proof, verdict); | |
| 281 | }, | |
| 282 | () => { | |
| 283 | clearTimeout(timer); | |
| 284 | proving.delete(x.pull.id); | |
| 285 | cutOff(x, runId, x.pull.headOid, "the gate failed to answer"); | |
| 286 | }, | |
| 287 | ); | |
| 288 | return true; | |
| 289 | } | |
| 290 | ||
| 291 | /** Forget the proofs running: a test's reset. */ | |
| 292 | export function resetProofsForTest(): void { | |
| 293 | proving.clear(); | |
| 294 | } | |
| 295 | ||
| 296 | /** What a red proof tells the pull: the base it was squashed onto, the tests that failed, and the gate's log. */ | |
| 297 | export function proofRedWords(branch: string, base: string, red: Extract<LandVerdict, { reopen: true }>): string { | |
| 298 | const names = (red.tests?.failing ?? []).map((t) => `- ${t.name}`); | |
| 299 | const more = (red.tests?.failed ?? names.length) - names.length; | |
| 300 | const log = /The gate's log: (\S+)/.exec(red.comment)?.[1]; | |
| 301 | return [ | |
| 302 | `The full suite on this head squashed onto ${branch} (${base.slice(0, 7)}) was red, so the pull has not joined the merge queue.`, | |
| 303 | names.length === 0 ? "It named no test (a type error, a timeout, a setup step)." : `Red tests:\n${names.join("\n")}${more > 0 ? `\n- and ${more} more` : ""}`, | |
| 304 | ...(log === undefined ? [] : [`The gate's log: ${log}`]), | |
| 305 | "It is proved again on a push.", | |
| 306 | ].join("\n\n"); | |
| 307 | } |
src/checks/queue.ts −15
| @@ -57,11 +57,6 @@ | ||
| 57 | 57 | * with its pulls, counts and failing tests (#6739, `gate_result.ts`), read |
| 58 | 58 | * by `GET …/queue/gates`. |
| 59 | 59 | * |
| 60 | * A pull joins proved (#7616, `proof.ts`): the full suite was green on its | |
| 61 | * head squashed onto its base. One whose proof the base has since moved too | |
| 62 | * far past is left out of its batch, as a conflict is, and proved again | |
| 63 | * before it joins once more. | |
| 64 | * | |
| 65 | 60 | * What is queued is not stored: it is the pulls `landIfGreen` admitted, and |
| 66 | 61 | * every sweep admits them again. The queue only orders them and remembers |
| 67 | 62 | * what is at the gate and what its searches found, for the tick line and |
| @@ -107,12 +102,6 @@ export interface Queued { | ||
| 107 | 102 | * that its red tree held: none when it was red on its own merge. |
| 108 | 103 | */ |
| 109 | 104 | handBack(red: Red, baseOid: string, together: readonly number[]): void; |
| 110 | /** | |
| 111 | * The pull's proof before the line still holds on this base (#7616, | |
| 112 | * `proof.ts`). False leaves it out of the batch, answered `false`, to be | |
| 113 | * proved again before it joins; absent, it always holds. | |
| 114 | */ | |
| 115 | proven?(baseOid: string): Promise<boolean>; | |
| 116 | 105 | } |
| 117 | 106 | |
| 118 | 107 | /** One pull's squash on top of the steps before it. */ |
| @@ -1607,10 +1596,6 @@ async function build(batch: readonly Waiting[], on?: Candidate): Promise<Candida | ||
| 1607 | 1596 | let treeOid = baseTreeOid; |
| 1608 | 1597 | const steps: Step[] = []; |
| 1609 | 1598 | for (const { entry } of batch) { |
| 1610 | if (entry.proven !== undefined && !(await entry.proven(tipOid))) { | |
| 1611 | log.info(`left out of the batch: #${entry.pull.number}'s proof is stale, proved again first`); | |
| 1612 | continue; | |
| 1613 | } | |
| 1614 | 1599 | const merged = await mergeable(dir, tipOid, entry.pull.headOid); |
| 1615 | 1600 | if (!merged.ok) { |
| 1616 | 1601 | // Left out, never red (#5276): the rest gate without it, and once the |
src/checks/service.ts +4 −15
| @@ -118,7 +118,6 @@ import { | ||
| 118 | 118 | import { evictSoon, lend } from "./install_evict.ts"; |
| 119 | 119 | import { meterEgress } from "../apps/egress.ts"; |
| 120 | 120 | import { checkStage, type GateStage } from "./gate_reason.ts"; |
| 121 | import { proofsInFlight } from "./proof.ts"; | |
| 122 | 121 | import { extraGates, GATE_DEADLINE_MS, gateQueued, gateRunId, gatesInFlight, watchGates } from "./queue.ts"; |
| 123 | 122 | import type { JobOptions } from "../cluster/jobs.ts"; |
| 124 | 123 | import type { CheckDetail } from "../core/runs.ts"; |
| @@ -201,14 +200,6 @@ export interface ChecksHandle { | ||
| 201 | 200 | * `signal` aborting (the queue's deadline, #3430) cuts it off the same way. |
| 202 | 201 | */ |
| 203 | 202 | gate(repo: Repo, pull: Pull, commitOid: string, signal?: AbortSignal): Promise<GateDone>; |
| 204 | /** | |
| 205 | * The proof before the line (#7616, `checks/proof.ts`): the gate's own | |
| 206 | * run — command, environment, sharding — on the pull's head squashed onto | |
| 207 | * its base, cut off as a gate is when `signal` aborts (its deadline). A | |
| 208 | * lane without it (a test's stand-in) admits on the pull's own check, as | |
| 209 | * before. | |
| 210 | */ | |
| 211 | prove?(repo: Repo, pull: Pull, commitOid: string, signal?: AbortSignal): Promise<GateDone>; | |
| 212 | 203 | /** |
| 213 | 204 | * The gate of `commitOid` this forge is running now, to wait on rather |
| 214 | 205 | * than start again (#6611); undefined when none is. |
| @@ -385,8 +376,7 @@ function gateMayBeRunning(path: string, now: number): boolean { | ||
| 385 | 376 | * directory and database with the old one, whose checks are still running in |
| 386 | 377 | * theirs; those are left alone. A gate writes no run: its checkout is left |
| 387 | 378 | * while it is younger than a gate's deadline, and past that while its |
| 388 | * `check_gate` row is open, for as long as a check may run — the proof | |
| 389 | * step's full suite outlives the window, and lost its `node_modules` (#7673). | |
| 379 | * `check_gate` row is open, for as long as a check may run. | |
| 390 | 380 | */ |
| 391 | 381 | export function sweepCheckouts(app: App, now: number = Date.now()): void { |
| 392 | 382 | const root = checksRoot(app.config.dataDir); |
| @@ -608,7 +598,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 608 | 598 | if (options.gate === undefined) gate.resize(limit); |
| 609 | 599 | startVerify(); |
| 610 | 600 | // Every merge-queue gate in flight holds a slot, a line's first too (#6376). |
| 611 | while (slotted() < limit - gatesInFlight() - proofsInFlight()) { | |
| 601 | while (slotted() < limit - gatesInFlight()) { | |
| 612 | 602 | // A pull's check waits while its main is red (`checks/red_main.ts`). |
| 613 | 603 | const queued = unheld( |
| 614 | 604 | listActive(app.db, "check").filter( |
| @@ -628,7 +618,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 628 | 618 | const { rebases, rest: others } = rebasesFirst(app.db, queued); |
| 629 | 619 | // While a gate is about to start, the last free slot is its: a pull's |
| 630 | 620 | // check waits for it, a rebase's and a main check do not. |
| 631 | const held = holdsLastSlot(limit - gatesInFlight() - proofsInFlight() - slotted(), gateQueued()); | |
| 621 | const held = holdsLastSlot(limit - gatesInFlight() - slotted(), gateQueued()); | |
| 632 | 622 | const next = startable(rebases) ?? startable(held ? others.filter((run) => run.issueId === null) : others); |
| 633 | 623 | if (!next) return; |
| 634 | 624 | const repo = getRepoById(app.db, next.repoId); |
| @@ -1689,12 +1679,11 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1689 | 1679 | const handle: ChecksHandle = { |
| 1690 | 1680 | request, |
| 1691 | 1681 | gate: gateRun, |
| 1692 | prove: gateRun, | |
| 1693 | 1682 | adopt: (commitOid) => { |
| 1694 | 1683 | for (const g of gates.values()) if (g.commitOid === commitOid) return g.done; |
| 1695 | 1684 | return undefined; |
| 1696 | 1685 | }, |
| 1697 | spareSlots: () => Math.max(0, capacity() - PULL_RESERVE - slotted() - extraGates() - proofsInFlight()), | |
| 1686 | spareSlots: () => Math.max(0, capacity() - PULL_RESERVE - slotted() - extraGates()), | |
| 1698 | 1687 | verifyDue: (now) => askVerifications(now), |
| 1699 | 1688 | lane: gate, |
| 1700 | 1689 | stop: async (stopOptions) => { |
src/core/decisions.ts +2 −6
| @@ -20,18 +20,14 @@ import { id as newId } from "../kernel/id.ts"; | ||
| 20 | 20 | * |
| 21 | 21 | * `hand` is a person's tap on "Let the decider" on a hand-over card |
| 22 | 22 | * (`crew/handover.ts`): its state is the curated context the decider saw. |
| 23 | * | |
| 24 | * `proof` is the full suite on a pull's head squashed onto its base, before | |
| 25 | * the pull joins the merge queue (`checks/proof.ts`): its state is the head | |
| 26 | * and the base, its answer `green` or `red`. */ | |
| 23 | */ | |
| 27 | 24 | export type DecisionKind = |
| 28 | 25 | | "next" |
| 29 | 26 | | "land" |
| 30 | 27 | | "retry" |
| 31 | 28 | | "lean" |
| 32 | 29 | | "guard" |
| 33 | | "hand" | |
| 34 | | "proof"; | |
| 30 | | "hand"; | |
| 35 | 31 | |
| 36 | 32 | export interface Decision { |
| 37 | 33 | readonly id: string; |
src/core/issues.ts −3
| @@ -9,7 +9,6 @@ import { id } from "../kernel/id.ts"; | ||
| 9 | 9 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 10 | 10 | import { transact } from "../kernel/db.ts"; |
| 11 | 11 | import { repoVisible } from "./visibility.ts"; |
| 12 | import { endProofRunsForPull } from "./proof_runs.ts"; | |
| 13 | 12 | |
| 14 | 13 | export type IssueKind = "issue" | "pull"; |
| 15 | 14 | export type IssueState = "open" | "closed"; |
| @@ -549,8 +548,6 @@ export function setIssueState( | ||
| 549 | 548 | WHERE id = ?4 AND state != ?1`, |
| 550 | 549 | ) |
| 551 | 550 | .run(state, closedAt, now, issueId, why, parked).changes; |
| 552 | // A closed pull's waiting proof would sit queued for good (#7706). | |
| 553 | if (changed > 0 && state === "closed") endProofRunsForPull(db, issueId, "pull closed"); | |
| 554 | 551 | return changed > 0 ? getIssueById(db, issueId) : undefined; |
| 555 | 552 | } |
| 556 | 553 |
src/core/migration-pins/20261102160000-remove-proof.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 32439e4058aba21ac63b4ae8efdf26bfc5795a937e4d708c33f4abc57e347262 |
src/core/migrations/20261102160000-remove-proof.ts added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | // The proof before the line is gone (#7779): the pull's check and the merge | |
| 2 | // queue's gate run the same command, so a proof proved nothing the gate does | |
| 3 | // not. Its ledger rows are deleted with it. The `proof_run` table stays, | |
| 4 | // unread: a migration never drops a table across a roll (the release still | |
| 5 | // in play reads it), and a later one may once no such release is. | |
| 6 | export default ` | |
| 7 | DELETE FROM decision WHERE kind = 'proof'; | |
| 8 | `; |
src/core/proof_runs.ts deleted −160
| @@ -1,160 +0,0 @@ | ||
| 1 | /** | |
| 2 | * Proof runs (#7706). A proof (`checks/proof.ts`) writes one row here and | |
| 3 | * moves it `queued` (no check slot spare) → `running` → `ok`, `failed` or | |
| 4 | * `cancelled` (cut off at the deadline or by a restart). Read as a `Run` of | |
| 5 | * kind `proof`, so the runs list, the pull and MCP `runs` show it. Pure | |
| 6 | * data: no authz, no events. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { id } from "../kernel/id.ts"; | |
| 11 | import type { Run, RunState } from "./runs.ts"; | |
| 12 | ||
| 13 | interface Row { | |
| 14 | id: string; | |
| 15 | repo_id: string; | |
| 16 | issue_id: string; | |
| 17 | head: string; | |
| 18 | base: string; | |
| 19 | state: RunState; | |
| 20 | summary: string; | |
| 21 | created_at: string; | |
| 22 | started_at: string | null; | |
| 23 | finished_at: string | null; | |
| 24 | } | |
| 25 | ||
| 26 | const toRun = (r: Row): Run => ({ | |
| 27 | id: r.id, | |
| 28 | repoId: r.repo_id, | |
| 29 | kind: "proof", | |
| 30 | issueId: r.issue_id, | |
| 31 | env: "", | |
| 32 | commitOid: r.head, | |
| 33 | branch: "", | |
| 34 | state: r.state, | |
| 35 | summary: r.summary, | |
| 36 | actorId: null, | |
| 37 | reason: "proof", | |
| 38 | sessionId: "", | |
| 39 | costUsd: 0, | |
| 40 | notBefore: null, | |
| 41 | attempt: 0, | |
| 42 | heartbeatAt: null, | |
| 43 | turns: 0, | |
| 44 | sandboxEnforced: null, | |
| 45 | sandboxNote: "", | |
| 46 | placement: "", | |
| 47 | node: "", | |
| 48 | checkDetail: null, | |
| 49 | baseOid: r.base, | |
| 50 | variantGroup: null, | |
| 51 | createdAt: r.created_at, | |
| 52 | startedAt: r.started_at, | |
| 53 | servingAt: null, | |
| 54 | finishedAt: r.finished_at, | |
| 55 | }); | |
| 56 | ||
| 57 | const NOW = "strftime('%Y-%m-%dT%H:%M:%fZ','now')"; | |
| 58 | ||
| 59 | /** A proof of `head` on `base`, waiting (`queued`) with the reason, or already `running`. */ | |
| 60 | export function openProofRun( | |
| 61 | db: Database, | |
| 62 | input: { repoId: string; pullId: string; head: string; base: string; state: "queued" | "running"; summary: string }, | |
| 63 | ): Run { | |
| 64 | const runId = id(); | |
| 65 | // A push makes the old head's proof moot: it waited or ran for a head no one will land. | |
| 66 | db.query( | |
| 67 | `UPDATE proof_run SET state = 'cancelled', summary = 'superseded by a push', finished_at = ${NOW} | |
| 68 | WHERE issue_id = ?1 AND head <> ?2 AND state IN ('queued','running')`, | |
| 69 | ).run(input.pullId, input.head); | |
| 70 | db.query( | |
| 71 | `INSERT INTO proof_run (id, repo_id, issue_id, head, base, state, summary, started_at) | |
| 72 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ${input.state === "running" ? NOW : "NULL"})`, | |
| 73 | ).run(runId, input.repoId, input.pullId, input.head, input.base, input.state, input.summary.slice(0, 500)); | |
| 74 | return getProofRun(db, runId)!; | |
| 75 | } | |
| 76 | ||
| 77 | /** A pull left `open` (landed or closed): its waiting or running proofs end, for no one will land them. */ | |
| 78 | export function endProofRunsForPull(db: Database, pullId: string, summary: "pull landed" | "pull closed"): void { | |
| 79 | db.query( | |
| 80 | `UPDATE proof_run SET state = 'cancelled', summary = ?2, finished_at = ${NOW} | |
| 81 | WHERE issue_id = ?1 AND state IN ('queued','running')`, | |
| 82 | ).run(pullId, summary); | |
| 83 | } | |
| 84 | ||
| 85 | export function getProofRun(db: Database, runId: string): Run | undefined { | |
| 86 | const row = db.query<Row, [string]>("SELECT * FROM proof_run WHERE id = ?1").get(runId); | |
| 87 | return row ? toRun(row) : undefined; | |
| 88 | } | |
| 89 | ||
| 90 | /** The newest proof run of a pull, any head. */ | |
| 91 | export function latestProofRun(db: Database, pullId: string): Run | undefined { | |
| 92 | const row = db | |
| 93 | .query<Row, [string]>("SELECT * FROM proof_run WHERE issue_id = ?1 ORDER BY id DESC LIMIT 1") | |
| 94 | .get(pullId); | |
| 95 | return row ? toRun(row) : undefined; | |
| 96 | } | |
| 97 | ||
| 98 | /** Whether a proof of `head` was already cut off for this reason, apart from run `except`. */ | |
| 99 | export function cutOffBefore(db: Database, pullId: string, head: string, reason: string, except: string): boolean { | |
| 100 | return endedBefore(db, pullId, head, "cancelled", cutSummary(reason), except); | |
| 101 | } | |
| 102 | ||
| 103 | /** Whether a proof run of `head` other than `except` ended (or waits) as `state` with `summary`. */ | |
| 104 | export function endedBefore( | |
| 105 | db: Database, | |
| 106 | pullId: string, | |
| 107 | head: string, | |
| 108 | state: RunState, | |
| 109 | summary: string, | |
| 110 | except = "", | |
| 111 | ): boolean { | |
| 112 | return ( | |
| 113 | db | |
| 114 | .query( | |
| 115 | "SELECT 1 FROM proof_run WHERE issue_id = ?1 AND head = ?2 AND id <> ?3 AND state = ?4 AND summary = ?5 LIMIT 1", | |
| 116 | ) | |
| 117 | .get(pullId, head, except, state, summary) !== null | |
| 118 | ); | |
| 119 | } | |
| 120 | ||
| 121 | /** The summary of a proof cut off for `reason`. */ | |
| 122 | export const cutSummary = (reason: string): string => `cut off: ${reason}`; | |
| 123 | ||
| 124 | /** A waiting proof starts. */ | |
| 125 | export function startProofRun(db: Database, runId: string, base: string): void { | |
| 126 | db.query( | |
| 127 | `UPDATE proof_run SET state = 'running', base = ?2, summary = '', started_at = ${NOW} | |
| 128 | WHERE id = ?1 AND state = 'queued'`, | |
| 129 | ).run(runId, base); | |
| 130 | } | |
| 131 | ||
| 132 | /** A waiting or running proof ends; one already ended stays as it ended. */ | |
| 133 | export function endProofRun( | |
| 134 | db: Database, | |
| 135 | runId: string, | |
| 136 | state: "ok" | "failed" | "cancelled", | |
| 137 | summary: string, | |
| 138 | ): void { | |
| 139 | db.query( | |
| 140 | `UPDATE proof_run SET state = ?2, summary = ?3, finished_at = ${NOW} | |
| 141 | WHERE id = ?1 AND state IN ('queued','running')`, | |
| 142 | ).run(runId, state, summary.slice(0, 500)); | |
| 143 | } | |
| 144 | ||
| 145 | /** A repository's proof runs, newest first, after `cursor`. */ | |
| 146 | export function listProofRuns( | |
| 147 | db: Database, | |
| 148 | repoId: string, | |
| 149 | options: { state?: RunState | undefined; issueId?: string | undefined; cursor?: string | undefined; limit: number }, | |
| 150 | ): Run[] { | |
| 151 | return db | |
| 152 | .query<Row, [string, string | null, string | null, string | null, number]>( | |
| 153 | `SELECT * FROM proof_run | |
| 154 | WHERE repo_id = ?1 AND (?2 IS NULL OR state = ?2) AND (?3 IS NULL OR issue_id = ?3) | |
| 155 | AND (?4 IS NULL OR id < ?4) | |
| 156 | ORDER BY id DESC LIMIT ?5`, | |
| 157 | ) | |
| 158 | .all(repoId, options.state ?? null, options.issueId ?? null, options.cursor ?? null, options.limit) | |
| 159 | .map(toRun); | |
| 160 | } |
src/core/pulls.ts −2
| @@ -9,7 +9,6 @@ | ||
| 9 | 9 | import { getRepoById } from "./repos.ts"; |
| 10 | 10 | import { repoDir } from "../git/repos.ts"; |
| 11 | 11 | import { sealedByNumber } from "./sealed.ts"; |
| 12 | import { endProofRunsForPull } from "./proof_runs.ts"; | |
| 13 | 12 | import type { Database } from "bun:sqlite"; |
| 14 | 13 | import { diffRange, type Diff, type DiffLaps } from "../git/diff.ts"; |
| 15 | 14 | import { isSafeRef } from "../git/guard.ts"; |
| @@ -1222,7 +1221,6 @@ function closeAsMerged( | ||
| 1222 | 1221 | landed.headOid, |
| 1223 | 1222 | pullId, |
| 1224 | 1223 | ); |
| 1225 | endProofRunsForPull(db, pullId, "pull landed"); | |
| 1226 | 1224 | setIssueState(db, pullId, "closed"); |
| 1227 | 1225 | }); |
| 1228 | 1226 | } |
src/core/runs.ts +4 −21
| @@ -11,10 +11,8 @@ | ||
| 11 | 11 | import type { Database } from "bun:sqlite"; |
| 12 | 12 | import { BUDGET_PREFIX } from "../kernel/budgets.ts"; |
| 13 | 13 | import { id } from "../kernel/id.ts"; |
| 14 | import { getProofRun, listProofRuns } from "./proof_runs.ts"; | |
| 15 | 14 | |
| 16 | /** `proof` is read from `proof_run` (`core/proof_runs.ts`), never stored in `run`. */ | |
| 17 | export type RunKind = "deploy" | "agent" | "check" | "proof"; | |
| 15 | export type RunKind = "deploy" | "agent" | "check"; | |
| 18 | 16 | |
| 19 | 17 | /** |
| 20 | 18 | * The one summary a run cancelled by the forge's own restart carries — never |
| @@ -26,7 +24,7 @@ export const STOPPED_SUMMARY = "the forge stopped"; | ||
| 26 | 24 | |
| 27 | 25 | /** The kinds that pass judgement on a commit: a preview's deploy, or a repository's own check. */ |
| 28 | 26 | export const CHECK_KINDS: readonly RunKind[] = ["deploy", "check"]; |
| 29 | export const RUN_KINDS: readonly RunKind[] = ["deploy", "agent", "check", "proof"]; | |
| 27 | export const RUN_KINDS: readonly RunKind[] = ["deploy", "agent", "check"]; | |
| 30 | 28 | /** Every state a run can be in — the type is read off this list, so a new state is named here once. */ |
| 31 | 29 | export const RUN_STATES = ["queued", "running", "ok", "failed", "cancelled"] as const; |
| 32 | 30 | export type RunState = (typeof RUN_STATES)[number]; |
| @@ -212,7 +210,7 @@ export function getRun(db: Database, runId: string): Run | undefined { | ||
| 212 | 210 | const row = db |
| 213 | 211 | .query<RunRow, [string]>("SELECT * FROM run WHERE id = ?1") |
| 214 | 212 | .get(runId); |
| 215 | return row ? toRun(row) : getProofRun(db, runId); | |
| 213 | return row ? toRun(row) : undefined; | |
| 216 | 214 | } |
| 217 | 215 | |
| 218 | 216 | /** |
| @@ -694,13 +692,6 @@ export interface ListRunsOptions { | ||
| 694 | 692 | limit?: number | undefined; |
| 695 | 693 | /** Resume after this run id (exclusive) — lists run newest first. */ |
| 696 | 694 | cursor?: string | undefined; |
| 697 | /** | |
| 698 | * With no `kind`, merge in the proof runs (`core/proof_runs.ts`, #7706): | |
| 699 | * for the lists that show the whole story. Off, the rest of the callers | |
| 700 | * (counts, "latest run" lookups) read `run` alone. A proof has no | |
| 701 | * environment, so `env` leaves proofs out. | |
| 702 | */ | |
| 703 | withProofs?: boolean | undefined; | |
| 704 | 695 | } |
| 705 | 696 | |
| 706 | 697 | export function listRuns( |
| @@ -709,14 +700,6 @@ export function listRuns( | ||
| 709 | 700 | options: ListRunsOptions = {}, |
| 710 | 701 | ): Run[] { |
| 711 | 702 | const limit = Math.min(Math.max(options.limit ?? 50, 1), 200); |
| 712 | // A proof is read from its own table; the rest of the list is `run`'s. | |
| 713 | const proofs = | |
| 714 | options.kind === "proof" || (options.kind === undefined && options.withProofs === true) | |
| 715 | ? options.env === undefined | |
| 716 | ? listProofRuns(db, repoId, { state: options.state, issueId: options.issueId, cursor: options.cursor, limit }) | |
| 717 | : [] | |
| 718 | : []; | |
| 719 | if (options.kind === "proof") return proofs; | |
| 720 | 703 | const params: (string | number)[] = [repoId]; |
| 721 | 704 | let where = "repo_id = ?1"; |
| 722 | 705 | if (options.kind !== undefined) { |
| @@ -746,7 +729,7 @@ export function listRuns( | ||
| 746 | 729 | ) |
| 747 | 730 | .all(...params) |
| 748 | 731 | .map(toRun); |
| 749 | return proofs.length === 0 ? rows : [...rows, ...proofs].sort((a, b) => (a.id < b.id ? 1 : -1)).slice(0, limit); | |
| 732 | return rows; | |
| 750 | 733 | } |
| 751 | 734 | |
| 752 | 735 | /** Queued runs of one kind, oldest first — what a worker takes from. */ |
src/crew/autonomy.ts +1 −79
| @@ -24,8 +24,7 @@ import { | ||
| 24 | 24 | setIssueState, |
| 25 | 25 | } from "../actions/issues.ts"; |
| 26 | 26 | import type { AccessError } from "../actions/access.ts"; |
| 27 | import { adoptGate, gateCandidate, provable, proveCandidate, releaseOnLand } from "../checks/landing.ts"; | |
| 28 | import { current, proofOf, proofRedWords, standing as proofStanding, startProof } from "../checks/proof.ts"; | |
| 27 | import { adoptGate, gateCandidate, releaseOnLand } from "../checks/landing.ts"; | |
| 29 | 28 | import { queueStore } from "../checks/queue_store.ts"; |
| 30 | 29 | import { addLearned } from "../checks/learned.ts"; |
| 31 | 30 | import { batchWords, dropFromLine, enqueue, type GateCandidate, type Queued, type QueueOptions, queueClashes, queueWords, waitingPulls } from "../checks/queue.ts"; |
| @@ -1987,14 +1986,6 @@ async function admit( | ||
| 1987 | 1986 | // not hold it for good; a red that reproduces stays red (#3155). |
| 1988 | 1987 | const told = latestDecision(app.db, pull.id, "land"); |
| 1989 | 1988 | if (redOf(told)?.head === pull.headOid && !regateDue(told!, Date.now())) return err("red"); |
| 1990 | // The proof before the line (#7616): the whole suite, green on this head | |
| 1991 | // squashed onto the base's tip, or the pull does not join — a test its | |
| 1992 | // fast tier never reached is found here, at no batch's cost. | |
| 1993 | const proves = baseTip !== "" && (await provable(app, dir, pull.headOid)); | |
| 1994 | if (proves) { | |
| 1995 | const wait = await proofWait(app, repo, by, pull, dir, baseTip); | |
| 1996 | if (wait !== undefined) return err(wait); | |
| 1997 | } | |
| 1998 | 1989 | // Green only against a base that has moved since is admitted as it is: |
| 1999 | 1990 | // the candidate's full gate runs on the base tip with this pull merged, |
| 2000 | 1991 | // which is the proof a re-check of its own would give, and a red one |
| @@ -2035,78 +2026,9 @@ async function admit( | ||
| 2035 | 2026 | addLearned(app.config.dataDir, pull.id, red.learn); |
| 2036 | 2027 | if (!again) addComment(app, by, repo, getIssueById(app.db, pull.id) ?? pull, clashSaid(red.comment, together)); |
| 2037 | 2028 | }, |
| 2038 | // A proof the base has moved too far past is proved again before the batch. | |
| 2039 | ...(proves ? { proven: (baseOid: string) => proofHolds(app, pull, dir, baseOid) } : {}), | |
| 2040 | 2029 | }); |
| 2041 | 2030 | } |
| 2042 | 2031 | |
| 2043 | /** | |
| 2044 | * What a proof says to `pull`, said only while the pull is open on the head | |
| 2045 | * it proved: a push or a close since makes the words about nothing. | |
| 2046 | */ | |
| 2047 | export function toldWhileOpen(app: App, repo: Repo, by: User, pull: Pull): (words: string) => void { | |
| 2048 | return (words) => { | |
| 2049 | const now = getPull(app.db, repo.id, pull.number); | |
| 2050 | if (now !== undefined && now.headOid === pull.headOid && now.state === "open") | |
| 2051 | addComment(app, by, repo, getIssueById(app.db, pull.id) ?? now, words); | |
| 2052 | }; | |
| 2053 | } | |
| 2054 | ||
| 2055 | /** | |
| 2056 | * Why a pull waits on its proof (#7616), or undefined when it is proved on | |
| 2057 | * `baseTip`. An unproved pull's proof is started here, beside the sweep: | |
| 2058 | * green admits it at once (`landIfGreen`), red is told to the pull with | |
| 2059 | * its failing tests named. | |
| 2060 | */ | |
| 2061 | async function proofWait( | |
| 2062 | app: App, | |
| 2063 | repo: Repo, | |
| 2064 | by: User, | |
| 2065 | pull: Pull, | |
| 2066 | dir: string, | |
| 2067 | baseTip: string, | |
| 2068 | ): Promise<"proving" | "proof-red" | undefined> { | |
| 2069 | const at = await proofStanding(app.db, dir, pull, baseTip); | |
| 2070 | if (at === "proved") return undefined; | |
| 2071 | if (at === "red") return "proof-red"; | |
| 2072 | if (at === "proving") return "proving"; | |
| 2073 | const started = await startProof({ | |
| 2074 | db: app.db, | |
| 2075 | repoId: repo.id, | |
| 2076 | pull, | |
| 2077 | dir, | |
| 2078 | tip: baseTip, | |
| 2079 | run: async (commitOid, signal) => (await proveCandidate(app, repo, pull, dir, commitOid, signal)) ?? { lands: true }, | |
| 2080 | room: () => checksFor(app)?.spareSlots() ?? 0, | |
| 2081 | told: toldWhileOpen(app, repo, by, pull), | |
| 2082 | done: (proof, verdict) => { | |
| 2083 | log.info(`proof ${proof.green ? "green" : "red"}`, { | |
| 2084 | repo: `${repo.ownerName}/${repo.name}`, | |
| 2085 | pull: pull.number, | |
| 2086 | base: proof.base.slice(0, 7), | |
| 2087 | }); | |
| 2088 | const now = getPull(app.db, repo.id, pull.number); | |
| 2089 | if (now === undefined || now.headOid !== proof.head || now.state !== "open") return; | |
| 2090 | if (!verdict.lands && "reopen" in verdict) { | |
| 2091 | addLearned(app.config.dataDir, pull.id, verdict.learn); | |
| 2092 | addComment(app, by, repo, getIssueById(app.db, pull.id) ?? now, proofRedWords(pull.baseBranch, proof.base, verdict)); | |
| 2093 | return; | |
| 2094 | } | |
| 2095 | void landIfGreen(app, repo, now).catch((error) => | |
| 2096 | log.warn("could not land after its proof", { pull: pull.number, error: String(error) }), | |
| 2097 | ); | |
| 2098 | }, | |
| 2099 | }); | |
| 2100 | if (started) log.info("proving before the line", { repo: `${repo.ownerName}/${repo.name}`, pull: pull.number, base: baseTip.slice(0, 7) }); | |
| 2101 | return "proving"; | |
| 2102 | } | |
| 2103 | ||
| 2104 | /** The pull's green proof still holds on `baseOid` (#7616). */ | |
| 2105 | async function proofHolds(app: App, pull: Pull, dir: string, baseOid: string): Promise<boolean> { | |
| 2106 | const proof = proofOf(app.db, pull.id); | |
| 2107 | return proof !== undefined && proof.green && proof.head === pull.headOid && (await current(dir, proof, baseOid)); | |
| 2108 | } | |
| 2109 | ||
| 2110 | 2032 | /** |
| 2111 | 2033 | * A red's comment, and when it was red on top of pulls of its candidate |
| 2112 | 2034 | * that landed first, a line naming them (#4235). The search does not gate |
src/crew/waits.ts −2
| @@ -21,8 +21,6 @@ export type Wait = | ||
| 21 | 21 | | "carried" |
| 22 | 22 | | "wall" |
| 23 | 23 | | "review" |
| 24 | | "proving" | |
| 25 | | "proof-red" | |
| 26 | 24 | | "red"; |
| 27 | 25 | |
| 28 | 26 | /** How many approved pulls waited on each reason. */ |
src/flow/next_actor.ts −23
| @@ -7,7 +7,6 @@ | ||
| 7 | 7 | import type { Database } from "bun:sqlite"; |
| 8 | 8 | import { getHold } from "../core/holds.ts"; |
| 9 | 9 | import type { Pull } from "../core/pulls.ts"; |
| 10 | import { latestProofRun } from "../core/proof_runs.ts"; | |
| 11 | 10 | import { checkSummary, type RunState } from "../core/runs.ts"; |
| 12 | 11 | import { reviewSummary } from "../core/reviews.ts"; |
| 13 | 12 | import { wallAsk, wallWords } from "./wall_wait.ts"; |
| @@ -29,8 +28,6 @@ export interface NextInput { | ||
| 29 | 28 | readonly approvals: number; |
| 30 | 29 | /** Verdicts passed on an older head: the pull was pushed to since. */ |
| 31 | 30 | readonly pushedSince: number; |
| 32 | /** What the pull's proof on this head is doing when it is not green (#7706): "waits: no check slot is spare". */ | |
| 33 | readonly proof?: string | null; | |
| 34 | 31 | /** What the access wall holds this head for, in plain words (#7847): a person decides it. */ |
| 35 | 32 | readonly wall?: readonly string[] | null; |
| 36 | 33 | } |
| @@ -51,7 +48,6 @@ export function nextActor(i: NextInput): Next { | ||
| 51 | 48 | if (i.check === "failed") return { who: "builder", why: "its check is red" }; |
| 52 | 49 | if (i.wall != null && i.wall.length > 0) return { who: "person", why: wallAsk(i.wall) }; |
| 53 | 50 | if (i.approvals > 0) { |
| 54 | if (i.check === "ok" && i.proof) return { who: "queue", why: `approved and green, but its proof ${i.proof}` }; | |
| 55 | 51 | if (i.check === "ok") return { who: "queue", why: "approved and green" }; |
| 56 | 52 | return { who: "queue", why: `approved, ${WAITING[i.check]}` }; |
| 57 | 53 | } |
| @@ -61,24 +57,6 @@ export function nextActor(i: NextInput): Next { | ||
| 61 | 57 | }; |
| 62 | 58 | } |
| 63 | 59 | |
| 64 | /** What the pull's proof on `head` is doing, in words; null when it is green or has not been tried. */ | |
| 65 | export function proofWords(db: Database, pull: Pull, head: string): string | null { | |
| 66 | const run = latestProofRun(db, pull.id); | |
| 67 | if (run === undefined || run.commitOid !== head) return null; | |
| 68 | switch (run.state) { | |
| 69 | case "queued": | |
| 70 | return `waits: ${run.summary}`; | |
| 71 | case "running": | |
| 72 | return "is running"; | |
| 73 | case "failed": | |
| 74 | return run.summary === "red" ? "is red" : run.summary; | |
| 75 | case "cancelled": | |
| 76 | return `was ${run.summary}; it is proved again on the next sweep`; | |
| 77 | default: | |
| 78 | return null; | |
| 79 | } | |
| 80 | } | |
| 81 | ||
| 82 | 60 | /** The next actor of an open pull at `headOid`; null once it is merged or closed. */ |
| 83 | 61 | export function nextOfPull( |
| 84 | 62 | db: Database, |
| @@ -95,7 +73,6 @@ export function nextOfPull( | ||
| 95 | 73 | changesRequested: reviews.changesRequested, |
| 96 | 74 | approvals: reviews.approvals, |
| 97 | 75 | pushedSince: reviews.stale, |
| 98 | proof: proofWords(db, pull, live.headOid), | |
| 99 | 76 | wall: wallWords(db, pull.id, live.headOid), |
| 100 | 77 | }); |
| 101 | 78 | } |
src/flow/path.ts +1 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The one path a change follows, in four words a person can hold (#7703): |
| 3 | * check, review, land, live. Every finer step (the check queue, the proof, | |
| 3 | * check, review, land, live. Every finer step (the check queue, | |
| 4 | 4 | * the merge queue, the release build, the boot check) sits one click down on |
| 5 | 5 | * /boards; this module only says which of the four a pull is at and, in one |
| 6 | 6 | * plain sentence, why it waits there. |
src/flow/pipeline.ts +2 −3
| @@ -248,7 +248,7 @@ export const STEP_WINDOW_DAYS = 30; | ||
| 248 | 248 | |
| 249 | 249 | /** One measured step of the path: what it is, and how long and how often red it has been. */ |
| 250 | 250 | export interface StepStat { |
| 251 | readonly key: "pull-check" | "review" | "proof" | "gate" | "main-check" | "release" | "boot" | "verify"; | |
| 251 | readonly key: "pull-check" | "review" | "gate" | "main-check" | "release" | "boot" | "verify"; | |
| 252 | 252 | readonly label: string; |
| 253 | 253 | /** Finished runs counted (green + red); cancelled ones are no verdict and not counted. */ |
| 254 | 254 | readonly runs: number; |
| @@ -296,7 +296,7 @@ const MS = `(julianday(x.finished_at) - julianday(x.started_at)) * 86400000.0`; | ||
| 296 | 296 | |
| 297 | 297 | /** |
| 298 | 298 | * Each step's median time and red rate over the last 30 days, from the runs |
| 299 | * table (`run`, `proof_run`) and the merge-queue gate's trace (`check_gate`, | |
| 299 | * table (`run`) and the merge-queue gate's trace (`check_gate`, | |
| 300 | 300 | * kept a week and without a verdict, so time only). `publicOnly` reads public |
| 301 | 301 | * repositories alone, for a reader who must not learn what private ones hold. |
| 302 | 302 | * |
| @@ -324,7 +324,6 @@ export function readStepStats(db: Database, now = new Date(), publicOnly = false | ||
| 324 | 324 | return [ |
| 325 | 325 | stat("pull-check", "Pull check", runs("run", `x.kind = 'check' AND x.issue_id IS NOT NULL AND NOT ${verifyLane}`)), |
| 326 | 326 | { ...stat("review", "Review run", runs("run", `x.kind = 'agent' AND x.reason = 'review'`)), redWord: "errored" }, |
| 327 | stat("proof", "Proof", runs("proof_run", "1")), | |
| 328 | 327 | { |
| 329 | 328 | ...gateStat, |
| 330 | 329 | runs: gate.length, |
src/http/api.ts +1 −1
| @@ -3496,7 +3496,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3496 | 3496 | // the branch. |
| 3497 | 3497 | const runs = timePhase(ctx.request, "runs", () => { |
| 3498 | 3498 | const listed = listRepoRuns(ctx.app, ctx.auth.user, repo, { |
| 3499 | ...(kind === "deploy" || kind === "agent" || kind === "proof" ? { kind } : { withProofs: true }), | |
| 3499 | ...(kind === "deploy" || kind === "agent" ? { kind } : {}), | |
| 3500 | 3500 | ...(env !== null && isEnvName(env) ? { env } : {}), |
| 3501 | 3501 | ...(q.get("cursor") ? { cursor: q.get("cursor")! } : {}), |
| 3502 | 3502 | limit, |
src/http/mcp/tools/runs.ts +3 −3
| @@ -5,7 +5,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts" | ||
| 5 | 5 | |
| 6 | 6 | export const tool: Tool = { |
| 7 | 7 | name: "runs", |
| 8 | doc: "`{ repo, kind?, limit? }`: the repository's recent runs (kind agent, deploy, check or proof — a proof is the full-suite run before the merge queue), newest first (id, kind, state, issue or pull number, started, ended), 1–50, default 20; read as the runs page reads them", | |
| 8 | doc: "`{ repo, kind?, limit? }`: the repository's recent runs (kind agent, deploy or check), newest first (id, kind, state, issue or pull number, started, ended), 1–50, default 20; read as the runs page reads them", | |
| 9 | 9 | description: |
| 10 | 10 | "A repository's runs, newest first: id, kind, state, number, start, end.", |
| 11 | 11 | inputSchema: { |
| @@ -14,7 +14,7 @@ export const tool: Tool = { | ||
| 14 | 14 | repo: REPO_ARG, |
| 15 | 15 | kind: { |
| 16 | 16 | type: "string", |
| 17 | enum: ["agent", "deploy", "check", "proof"], | |
| 17 | enum: ["agent", "deploy", "check"], | |
| 18 | 18 | description: "Only runs of this kind; all kinds when omitted.", |
| 19 | 19 | }, |
| 20 | 20 | limit: { |
| @@ -38,7 +38,7 @@ export const tool: Tool = { | ||
| 38 | 38 | if (isAnswer(repo)) return repo; |
| 39 | 39 | const runs = listRepoRuns(ctx.app, user, repo, { |
| 40 | 40 | limit, |
| 41 | ...(kind === undefined ? { withProofs: true } : { kind: kind as RunKind }), | |
| 41 | ...(kind === undefined ? {} : { kind: kind as RunKind }), | |
| 42 | 42 | }); |
| 43 | 43 | if (!runs.ok) return bad("not_found", "no such app or repository (owner/name)"); |
| 44 | 44 | const answer = good({ |
src/tui/screens/runlist.ts −1
| @@ -48,7 +48,6 @@ export function runTitle( | ||
| 48 | 48 | const pull = run.env.startsWith("pull-") ? Number(run.env.slice(5)) : undefined; |
| 49 | 49 | return `Check of ${run.commitOid.slice(0, 7)}${pull === undefined ? "" : ` for #${pull}`}`; |
| 50 | 50 | } |
| 51 | if (run.kind === "proof") return `Proof of ${run.commitOid.slice(0, 7)}`; | |
| 52 | 51 | if (run.kind === "deploy") return `deploy ${envLabel(run.env)}`; |
| 53 | 52 | const who = |
| 54 | 53 | run.actorId === null |
src/tui/screens/runs.ts −1
| @@ -42,7 +42,6 @@ export function runsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 42 | 42 | const read = (): void => { |
| 43 | 43 | const listed = listRepoRuns(f.app, f.user, repo, { |
| 44 | 44 | state: RUN_STATE_CYCLE[at]![0], |
| 45 | withProofs: true, | |
| 46 | 45 | limit: LIMIT, |
| 47 | 46 | }); |
| 48 | 47 | list.set(listed.ok ? listed.value : []); |
src/web/pages/board.ts −1
| @@ -155,7 +155,6 @@ const KIND_WORDS: Record<DecisionKind, string> = { | ||
| 155 | 155 | retry: "try again", |
| 156 | 156 | lean: "which way", |
| 157 | 157 | hand: "hand-over", |
| 158 | proof: "proved before the queue", | |
| 159 | 158 | // Never listed (`listDecisionsForRepos` leaves guards out); named for the type. |
| 160 | 159 | guard: "not started", |
| 161 | 160 | }; |
src/web/pages/pipeline.ts +1 −2
| @@ -38,7 +38,6 @@ export const STEP_DETAIL: Record<Step, { waits: string; catches: string; inside: | ||
| 38 | 38 | waits: "the merge queue, which merges approved, green pulls into the default branch in order.", |
| 39 | 39 | catches: "a pull that no longer merges onto the base as it is now.", |
| 40 | 40 | inside: [ |
| 41 | "Before it joins the queue, a pull is proved once on the base's tip; that proof is being removed (#7779).", | |
| 42 | 41 | "The merge queue's gate: lint and types on the pull merged with the base's tip.", |
| 43 | 42 | "A conflicting pull goes back to its builder to rebase before it can land.", |
| 44 | 43 | ], |
| @@ -58,7 +57,7 @@ export const STEP_DETAIL: Record<Step, { waits: string; catches: string; inside: | ||
| 58 | 57 | export const STEP_MEASURES: Record<Step, readonly StepStat["key"][]> = { |
| 59 | 58 | check: ["pull-check"], |
| 60 | 59 | review: ["review"], |
| 61 | land: ["proof", "gate"], | |
| 60 | land: ["gate"], | |
| 62 | 61 | live: ["main-check", "release", "boot", "verify"], |
| 63 | 62 | }; |
| 64 | 63 |
src/web/pages/runs.ts +1 −3
| @@ -76,8 +76,6 @@ export function runTitle( | ||
| 76 | 76 | const of = `Check of ${run.commitOid.slice(0, 7)}`; |
| 77 | 77 | return number === undefined ? of : `${of} for #${number}`; |
| 78 | 78 | } |
| 79 | if (run.kind === "proof") | |
| 80 | return `Proof of ${run.commitOid.slice(0, 7)}${issue === undefined ? "" : ` for #${issue.number}`}`; | |
| 81 | 79 | if (run.kind === "deploy") |
| 82 | 80 | return about === "" ? "Deploy production" : `Deploy preview ${about}`; |
| 83 | 81 | const who = actor ?? "an agent"; |
| @@ -235,7 +233,7 @@ export function runTitleFrom( | ||
| 235 | 233 | } |
| 236 | 234 | |
| 237 | 235 | const kindIcon = (kind: RunKind) => |
| 238 | icon(kind === "agent" ? "bot" : kind === "check" || kind === "proof" ? "check" : "rocket"); | |
| 236 | icon(kind === "agent" ? "bot" : kind === "check" ? "check" : "rocket"); | |
| 239 | 237 | |
| 240 | 238 | // ---- rows ---- |
| 241 | 239 |
src/web/studio/lists.ts +3 −5
| @@ -603,11 +603,11 @@ export async function changesList(ctx: Ctx): Promise<Response> { | ||
| 603 | 603 | /** Which runs a list shows: all three kinds by default, or the one that |
| 604 | 604 | * `?kind=` asks for — the narrowing dev's list offered, kept so a link |
| 605 | 605 | * written to it keeps meaning what it said. */ |
| 606 | type RunsKind = "agent" | "deploy" | "check" | "proof"; | |
| 606 | type RunsKind = "agent" | "deploy" | "check"; | |
| 607 | 607 | |
| 608 | 608 | function readRunsKind(ctx: Ctx): RunsKind | undefined { |
| 609 | 609 | const raw = ctx.url.searchParams.get("kind"); |
| 610 | return raw === "agent" || raw === "deploy" || raw === "check" || raw === "proof" | |
| 610 | return raw === "agent" || raw === "deploy" || raw === "check" | |
| 611 | 611 | ? raw |
| 612 | 612 | : undefined; |
| 613 | 613 | } |
| @@ -698,7 +698,6 @@ const RUNS_KIND_WORDS: Record<RunsKind, string> = { | ||
| 698 | 698 | agent: "Agent runs", |
| 699 | 699 | deploy: "Deploys", |
| 700 | 700 | check: "Checks", |
| 701 | proof: "Proofs", | |
| 702 | 701 | }; |
| 703 | 702 | |
| 704 | 703 | /** |
| @@ -725,7 +724,6 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 725 | 724 | // Working is what runs now: read only the running ones, so the page |
| 726 | 725 | // is not spent on finished rows it would drop. |
| 727 | 726 | state: state ?? (filter === "working" ? "running" : undefined), |
| 728 | withProofs: true, | |
| 729 | 727 | limit: LIST_PAGE, |
| 730 | 728 | ...(cursor ? { cursor } : {}), |
| 731 | 729 | }); |
| @@ -815,7 +813,7 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 815 | 813 | )} |
| 816 | 814 | </div> |
| 817 | 815 | <p class="muted small mt"> |
| 818 | Show ${(["agent", "deploy", "check", "proof"] as const).map( | |
| 816 | Show ${(["agent", "deploy", "check"] as const).map( | |
| 819 | 817 | (k, i) => |
| 820 | 818 | html`${i > 0 ? " · " : ""}${k === kind |
| 821 | 819 | ? html`<strong>${RUNS_KIND_WORDS[k]}</strong>` |
src/web/ui.ts +2 −2
| @@ -369,14 +369,14 @@ export function runStateBadge(state: RunState, kind: RunKind = "deploy"): Html { | ||
| 369 | 369 | case "running": |
| 370 | 370 | return badge("Running", { icon: "loader", tone: "attn" }); |
| 371 | 371 | case "ok": |
| 372 | return badge(kind === "agent" ? "Done" : kind === "proof" ? "Green" : "Passed", { | |
| 372 | return badge(kind === "agent" ? "Done" : "Passed", { | |
| 373 | 373 | icon: "check", |
| 374 | 374 | tone: "success", |
| 375 | 375 | }); |
| 376 | 376 | case "failed": |
| 377 | 377 | return badge("Failed", { icon: "x", tone: "danger" }); |
| 378 | 378 | case "cancelled": |
| 379 | return badge(kind === "proof" ? "Cut off" : "Cancelled", { icon: "ban", variant: "outline" }); | |
| 379 | return badge("Cancelled", { icon: "ban", variant: "outline" }); | |
| 380 | 380 | } |
| 381 | 381 | } |
| 382 | 382 |
test/merge-queue-no-proof.test.ts added +104
| @@ -0,0 +1,104 @@ | ||
| 1 | /** | |
| 2 | * No proof before the line (#7779). An approved, green pull joins the merge | |
| 3 | * queue on its own check: the queue's gate is the one full run, and nothing | |
| 4 | * else is run, kept or told to the pull before it. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, 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 { createApp, type App } from "../src/app.ts"; | |
| 11 | import { openPull } from "../src/actions/pulls.ts"; | |
| 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { provideChecksForTest, type ChecksHandle } from "../src/checks/service.ts"; | |
| 14 | import { listComments } from "../src/core/issues.ts"; | |
| 15 | import { getPull, type Pull } from "../src/core/pulls.ts"; | |
| 16 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 17 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 18 | import { createUser, type User } from "../src/core/users.ts"; | |
| 19 | import { sweepGreen } from "../src/crew/autonomy.ts"; | |
| 20 | import { nextActor } from "../src/flow/next_actor.ts"; | |
| 21 | import { logLevel, setLogLevel } from "../src/kernel/log.ts"; | |
| 22 | ||
| 23 | let dataDir: string; | |
| 24 | let app: App; | |
| 25 | let alice: User; | |
| 26 | let level: ReturnType<typeof logLevel>; | |
| 27 | const gated: string[] = []; | |
| 28 | ||
| 29 | beforeAll(async () => { | |
| 30 | level = logLevel(); | |
| 31 | setLogLevel("error"); | |
| 32 | dataDir = await mkdtemp(join(tmpdir(), "forge-merge-queue-no-proof-")); | |
| 33 | app = createApp({ dataDir, port: 0 }); | |
| 34 | const a = await createUser(app.db, { | |
| 35 | origin: "seed", | |
| 36 | trusted: true, | |
| 37 | username: "alice", | |
| 38 | email: "[email protected]", | |
| 39 | password: "a-good-passphrase", | |
| 40 | adminIfFirst: true, | |
| 41 | }); | |
| 42 | if (!a.ok) throw new Error(a.error); | |
| 43 | alice = a.value; | |
| 44 | provideChecksForTest(app, { | |
| 45 | gate: async (_repo: Repo, _pull: Pull, commitOid: string) => { | |
| 46 | gated.push(commitOid); | |
| 47 | return { ok: true, error: "", lines: [] }; | |
| 48 | }, | |
| 49 | spareSlots: () => 0, | |
| 50 | } as unknown as ChecksHandle); | |
| 51 | }); | |
| 52 | ||
| 53 | afterAll(async () => { | |
| 54 | setLogLevel(level); | |
| 55 | await app.close?.(); | |
| 56 | await rm(dataDir, { recursive: true, force: true }); | |
| 57 | }); | |
| 58 | ||
| 59 | test("an approved green pull joins the merge queue and lands with no proof run", async () => { | |
| 60 | const made = await createRepo(app, alice, { name: "no-proof", visibility: "private" }); | |
| 61 | if (!made.ok) throw new Error(String(made.error)); | |
| 62 | const seeded = await commitToBranch(app, alice, made.value, { | |
| 63 | message: "Start", | |
| 64 | files: [ | |
| 65 | { path: "README.md", content: "# no-proof\n" }, | |
| 66 | { path: "forge.json", content: JSON.stringify({ check: { run: "true", fast: "true" } }) }, | |
| 67 | ], | |
| 68 | }); | |
| 69 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 70 | app.db.query("UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?2 WHERE id = ?1").run(made.value.id, alice.id); | |
| 71 | const repo = getRepoById(app.db, made.value.id)!; | |
| 72 | const pushed = await commitToBranch(app, alice, repo, { | |
| 73 | branch: "feature/fine", | |
| 74 | base: "main", | |
| 75 | message: "fine", | |
| 76 | files: [{ path: "fine.ts", content: "export const n = 1;\n" }], | |
| 77 | }); | |
| 78 | if (!pushed.ok) throw new Error(JSON.stringify(pushed.error)); | |
| 79 | const opened = await openPull(app, alice, repo, { title: "fine", baseBranch: "main", headBranch: "feature/fine" }); | |
| 80 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 81 | const pull = getPull(app.db, repo.id, opened.value.number)!; | |
| 82 | const check = createRun(app.db, { | |
| 83 | repoId: repo.id, | |
| 84 | kind: "check", | |
| 85 | issueId: pull.id, | |
| 86 | env: `pull-${pull.number}`, | |
| 87 | commitOid: pull.headOid, | |
| 88 | reason: "pull", | |
| 89 | }); | |
| 90 | claimRun(app.db, check.id); | |
| 91 | finishRun(app.db, check.id, { state: "ok", summary: "check passed" }); | |
| 92 | ||
| 93 | // Admitted at once: no wait for a proof, so the one sweep lands it. | |
| 94 | expect(await sweepGreen(app, repo)).toBe(1); | |
| 95 | expect(getPull(app.db, repo.id, pull.number)!.mergedAt).not.toBeNull(); | |
| 96 | expect(gated).toHaveLength(1); | |
| 97 | expect(listComments(app.db, pull.id)).toHaveLength(0); | |
| 98 | expect(app.db.query("SELECT 1 FROM decision WHERE kind = 'proof'").get()).toBeNull(); | |
| 99 | }); | |
| 100 | ||
| 101 | test("the next-actor line of an approved green pull says nothing of a proof", () => { | |
| 102 | const next = nextActor({ held: null, conflicted: false, check: "ok", changesRequested: 0, approvals: 1, pushedSince: 0 }); | |
| 103 | expect(next).toEqual({ who: "queue", why: "approved and green" }); | |
| 104 | }); |
test/merge-queue-proof.test.ts deleted −572
| @@ -1,572 +0,0 @@ | ||
| 1 | /** | |
| 2 | * The proof before the line (#7616). A pull whose own check was the fast | |
| 3 | * tier joins the merge queue only once the full suite is green on its head | |
| 4 | * squashed onto main. The lane here is a stand-in: its proof goes red on a | |
| 5 | * tree holding a `breaks-*` file (a test the pull's change never reached), | |
| 6 | * and its gate counts the candidates it was asked about. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { openPull } from "../src/actions/pulls.ts"; | |
| 14 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 15 | import { | |
| 16 | keepProof, | |
| 17 | landingsSince, | |
| 18 | proofOf, | |
| 19 | proofRedWords, | |
| 20 | proofsInFlight, | |
| 21 | RED_PROOF_RETRY_MS, | |
| 22 | resetProofsForTest, | |
| 23 | STALE_LANDINGS, | |
| 24 | standing, | |
| 25 | startProof, | |
| 26 | WAIT_MERGE, | |
| 27 | WAIT_SLOT, | |
| 28 | } from "../src/checks/proof.ts"; | |
| 29 | import { enqueue, type Queued } from "../src/checks/queue.ts"; | |
| 30 | import { provideChecksForTest, type ChecksHandle } from "../src/checks/service.ts"; | |
| 31 | import { landVerdict, type LandVerdict } from "../src/checks/tiers.ts"; | |
| 32 | import { listComments } from "../src/core/issues.ts"; | |
| 33 | import { openProofRun } from "../src/core/proof_runs.ts"; | |
| 34 | import { getPull, type Pull } from "../src/core/pulls.ts"; | |
| 35 | import { listRuns } from "../src/core/runs.ts"; | |
| 36 | import { nextActor, proofWords } from "../src/flow/next_actor.ts"; | |
| 37 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 38 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 39 | import { createUser, type User } from "../src/core/users.ts"; | |
| 40 | import { sweepGreen, toldWhileOpen } from "../src/crew/autonomy.ts"; | |
| 41 | import { mergePull } from "../src/actions/pulls.ts"; | |
| 42 | import { setIssueState } from "../src/actions/issues.ts"; | |
| 43 | import { repoDir } from "../src/git/repos.ts"; | |
| 44 | import { logLevel, setLogLevel } from "../src/kernel/log.ts"; | |
| 45 | ||
| 46 | /** A loaded check machine runs git eight times slower: the clock must not decide the verdict. */ | |
| 47 | const SLOW = 120_000; | |
| 48 | ||
| 49 | describe("the proof before the merge queue", () => { | |
| 50 | let dataDir: string; | |
| 51 | let app: App; | |
| 52 | let alice: User; | |
| 53 | let level: ReturnType<typeof logLevel>; | |
| 54 | /** Every proof the lane ran, and every candidate it gated. */ | |
| 55 | const proved: { repo: string; commit: string; ok: boolean }[] = []; | |
| 56 | const gated: { repo: string; commit: string }[] = []; | |
| 57 | ||
| 58 | const git = (repo: Repo, ...args: string[]) => | |
| 59 | Bun.spawnSync(["git", ...args], { cwd: repoDir(app.reposRoot, repo.ownerName, repo.name) }) | |
| 60 | .stdout.toString() | |
| 61 | .trim(); | |
| 62 | ||
| 63 | beforeAll(async () => { | |
| 64 | level = logLevel(); | |
| 65 | setLogLevel("error"); | |
| 66 | dataDir = await mkdtemp(join(tmpdir(), "forge-merge-queue-proof-")); | |
| 67 | app = createApp({ dataDir, port: 0 }); | |
| 68 | const a = await createUser(app.db, { | |
| 69 | origin: "seed", | |
| 70 | trusted: true, | |
| 71 | username: "alice", | |
| 72 | email: "[email protected]", | |
| 73 | password: "a-good-passphrase", | |
| 74 | adminIfFirst: true, | |
| 75 | }); | |
| 76 | if (!a.ok) throw new Error(a.error); | |
| 77 | alice = a.value; | |
| 78 | provideChecksForTest(app, { | |
| 79 | gate: async (repo: Repo, _pull: Pull, commitOid: string) => { | |
| 80 | gated.push({ repo: repo.id, commit: commitOid }); | |
| 81 | return { ok: true, error: "", lines: [] }; | |
| 82 | }, | |
| 83 | prove: async (repo: Repo, _pull: Pull, commitOid: string) => { | |
| 84 | const files = git(repo, "ls-tree", "-r", "--name-only", commitOid).split("\n"); | |
| 85 | const ok = !files.some((f) => f.startsWith("breaks-")); | |
| 86 | proved.push({ repo: repo.id, commit: commitOid, ok }); | |
| 87 | return ok | |
| 88 | ? { ok, error: "", lines: [] } | |
| 89 | : { ok, error: "red", lines: ["test/unrelated.test.ts:", "(fail) unrelated > still works [1.00ms]"] }; | |
| 90 | }, | |
| 91 | spareSlots: () => 0, | |
| 92 | } as unknown as ChecksHandle); | |
| 93 | }); | |
| 94 | ||
| 95 | afterAll(async () => { | |
| 96 | resetProofsForTest(); | |
| 97 | setLogLevel(level); | |
| 98 | await app.close?.(); | |
| 99 | await rm(dataDir, { recursive: true, force: true }); | |
| 100 | }); | |
| 101 | ||
| 102 | async function autonomousRepo(name: string, autonomous = true): Promise<Repo> { | |
| 103 | const made = await createRepo(app, alice, { name, visibility: "private" }); | |
| 104 | if (!made.ok) throw new Error(String(made.error)); | |
| 105 | const seeded = await commitToBranch(app, alice, made.value, { | |
| 106 | message: "Start", | |
| 107 | files: [ | |
| 108 | { path: "README.md", content: `# ${name}\n` }, | |
| 109 | { path: "forge.json", content: JSON.stringify({ check: { run: "true", fast: "true" } }) }, | |
| 110 | ], | |
| 111 | }); | |
| 112 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 113 | if (autonomous) app.db.query("UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?2 WHERE id = ?1").run(made.value.id, alice.id); | |
| 114 | return getRepoById(app.db, made.value.id)!; | |
| 115 | } | |
| 116 | ||
| 117 | /** A pull whose own (fast) check passed on its head. */ | |
| 118 | async function greenPull(repo: Repo, path: string, content = `export const n = "${path}";\n`): Promise<Pull> { | |
| 119 | const pushed = await commitToBranch(app, alice, repo, { | |
| 120 | branch: `feature/${path}`, | |
| 121 | base: "main", | |
| 122 | message: path, | |
| 123 | files: [{ path, content }], | |
| 124 | }); | |
| 125 | if (!pushed.ok) throw new Error(JSON.stringify(pushed.error)); | |
| 126 | const opened = await openPull(app, alice, repo, { title: path, baseBranch: "main", headBranch: `feature/${path}` }); | |
| 127 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 128 | const pull = getPull(app.db, repo.id, opened.value.number)!; | |
| 129 | const check = createRun(app.db, { | |
| 130 | repoId: repo.id, | |
| 131 | kind: "check", | |
| 132 | issueId: pull.id, | |
| 133 | env: `pull-${pull.number}`, | |
| 134 | commitOid: pull.headOid, | |
| 135 | reason: "pull", | |
| 136 | }); | |
| 137 | claimRun(app.db, check.id); | |
| 138 | finishRun(app.db, check.id, { state: "ok", summary: "check passed" }); | |
| 139 | return pull; | |
| 140 | } | |
| 141 | ||
| 142 | const merged = (repo: Repo, pull: Pull) => getPull(app.db, repo.id, pull.number)!.mergedAt !== null; | |
| 143 | const tree = (repo: Repo, rev: string) => git(repo, "rev-parse", `${rev}^{tree}`); | |
| 144 | const mainOf = (repo: Repo) => git(repo, "rev-parse", "refs/heads/main"); | |
| 145 | const until = async (ok: () => boolean): Promise<void> => { | |
| 146 | for (const end = Date.now() + SLOW; !ok() && Date.now() < end; ) await Bun.sleep(5); | |
| 147 | }; | |
| 148 | ||
| 149 | test("a pull that breaks an unrelated test is never admitted, and is told the test by name", async () => { | |
| 150 | const repo = await autonomousRepo("breaks-unrelated"); | |
| 151 | const pull = await greenPull(repo, "breaks-other.ts"); | |
| 152 | expect(await sweepGreen(app, repo)).toBe(0); | |
| 153 | await until(() => proofOf(app.db, pull.id) !== undefined); | |
| 154 | // Proved on its head squashed onto main, red, and nothing reached the gate. | |
| 155 | expect(proved.filter((p) => p.repo === repo.id)).toHaveLength(1); | |
| 156 | const [proof] = proved.filter((p) => p.repo === repo.id); | |
| 157 | expect(git(repo, "rev-parse", `${proof!.commit}^`)).toBe(mainOf(repo)); | |
| 158 | expect(proofOf(app.db, pull.id)).toMatchObject({ head: pull.headOid, base: mainOf(repo), green: false }); | |
| 159 | await until(() => listComments(app.db, pull.id).length > 0); | |
| 160 | const told = listComments(app.db, pull.id).map((c) => c.body).join("\n"); | |
| 161 | expect(told).toContain("has not joined the merge queue"); | |
| 162 | expect(told).toContain("- unrelated > still works"); | |
| 163 | // The next sweep neither admits it nor proves the same head again. | |
| 164 | expect(await sweepGreen(app, repo)).toBe(0); | |
| 165 | expect(proved.filter((p) => p.repo === repo.id)).toHaveLength(1); | |
| 166 | expect(gated.filter((g) => g.repo === repo.id)).toHaveLength(0); | |
| 167 | expect(merged(repo, pull)).toBe(false); | |
| 168 | }); | |
| 169 | ||
| 170 | test("a green proof admits the pull, which lands on the tree it was proved on", async () => { | |
| 171 | const repo = await autonomousRepo("proved-green"); | |
| 172 | const pull = await greenPull(repo, "fine.ts"); | |
| 173 | // Not admitted on its fast check alone: proved first, beside the sweep. | |
| 174 | expect(await sweepGreen(app, repo)).toBe(0); | |
| 175 | await until(() => merged(repo, pull)); | |
| 176 | expect(merged(repo, pull)).toBe(true); | |
| 177 | const proofs = proved.filter((p) => p.repo === repo.id); | |
| 178 | expect(proofs).toHaveLength(1); | |
| 179 | expect(tree(repo, "refs/heads/main")).toBe(tree(repo, proofs[0]!.commit)); | |
| 180 | expect(gated.filter((g) => g.repo === repo.id)).toHaveLength(1); | |
| 181 | }); | |
| 182 | ||
| 183 | test("a proof main has moved too far past is proved again on the new main before it joins", async () => { | |
| 184 | const repo = await autonomousRepo("proof-stale"); | |
| 185 | const pull = await greenPull(repo, "late.ts"); | |
| 186 | const old = mainOf(repo); | |
| 187 | keepProof(app.db, repo.id, pull.id, { head: pull.headOid, base: old, green: true }); | |
| 188 | for (let i = 0; i <= STALE_LANDINGS; i++) { | |
| 189 | const moved = await commitToBranch(app, alice, repo, { | |
| 190 | message: `landing ${i}`, | |
| 191 | files: [{ path: `main-${i}.ts`, content: `${i}\n` }], | |
| 192 | }); | |
| 193 | if (!moved.ok) throw new Error(JSON.stringify(moved.error)); | |
| 194 | } | |
| 195 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 196 | expect(await landingsSince(dir, old, mainOf(repo))).toBe(STALE_LANDINGS + 1); | |
| 197 | const now = mainOf(repo); | |
| 198 | expect(await sweepGreen(app, repo)).toBe(0); | |
| 199 | await until(() => merged(repo, pull)); | |
| 200 | expect(merged(repo, pull)).toBe(true); | |
| 201 | const proofs = proved.filter((p) => p.repo === repo.id); | |
| 202 | expect(proofs).toHaveLength(1); | |
| 203 | expect(git(repo, "rev-parse", `${proofs[0]!.commit}^`)).toBe(now); | |
| 204 | }); | |
| 205 | ||
| 206 | test("an admitted pull whose proof went stale in the line is left out of its batch, unrun", async () => { | |
| 207 | const repo = await autonomousRepo("stale-in-line"); | |
| 208 | const pulls = [await greenPull(repo, "ok.ts"), await greenPull(repo, "stale.ts")]; | |
| 209 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 210 | const asked: string[] = []; | |
| 211 | const entries: Queued[] = pulls.map((pull, i) => ({ | |
| 212 | pull, | |
| 213 | dir, | |
| 214 | land: async () => undefined, | |
| 215 | release: () => {}, | |
| 216 | handBack: () => {}, | |
| 217 | proven: async (base) => { | |
| 218 | asked.push(base); | |
| 219 | return i === 0; | |
| 220 | }, | |
| 221 | })); | |
| 222 | const steps: number[][] = []; | |
| 223 | const answers = await enqueue(repo.id, entries, async (candidate) => { | |
| 224 | steps.push(candidate.steps.map((s) => s.entry.pull.number)); | |
| 225 | return { lands: true }; | |
| 226 | }); | |
| 227 | expect(answers).toEqual([false, false]); | |
| 228 | // The stale one never reached a gate; the fresh one gated alone. | |
| 229 | expect(steps).toEqual([[pulls[0]!.number]]); | |
| 230 | expect(asked.length).toBeGreaterThanOrEqual(2); | |
| 231 | }); | |
| 232 | ||
| 233 | test("a red proof's words name the base, every failing test and the log", () => { | |
| 234 | const red = landVerdict(false, ["a > b"], ["test/a.test.ts"], "/api/v1/repos/o/r/gates/x/log", undefined, { | |
| 235 | passed: 9, | |
| 236 | failed: 3, | |
| 237 | failing: [ | |
| 238 | { name: "a > b", errors: [] }, | |
| 239 | { name: "c", errors: [] }, | |
| 240 | ], | |
| 241 | }); | |
| 242 | if (red.lands) throw new Error("green"); | |
| 243 | const words = proofRedWords("main", "0123456789abcdef", red); | |
| 244 | expect(words).toContain("squashed onto main (0123456)"); | |
| 245 | expect(words).toContain("- a > b\n- c\n- and 1 more"); | |
| 246 | expect(words).toContain("The gate's log: /api/v1/repos/o/r/gates/x/log"); | |
| 247 | }); | |
| 248 | ||
| 249 | test("a proof that never answers is cut off at its deadline: its slot and its pull are let go, no verdict kept", async () => { | |
| 250 | const repo = await autonomousRepo("proof-hangs", false); | |
| 251 | const pull = await greenPull(repo, "hang.ts"); | |
| 252 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 253 | const tip = mainOf(repo); | |
| 254 | let aborted: AbortSignal | undefined; | |
| 255 | let answered = false; | |
| 256 | const started = await startProof({ | |
| 257 | db: app.db, | |
| 258 | repoId: repo.id, | |
| 259 | pull, | |
| 260 | dir, | |
| 261 | tip, | |
| 262 | // Hung: answers only once its signal aborts, and then too late to count. | |
| 263 | run: (_commit, signal) => | |
| 264 | new Promise((resolve) => { | |
| 265 | aborted = signal; | |
| 266 | signal.addEventListener("abort", () => setTimeout(() => resolve({ lands: true }), 50), { once: true }); | |
| 267 | }), | |
| 268 | room: () => 0, | |
| 269 | done: () => { | |
| 270 | answered = true; | |
| 271 | }, | |
| 272 | deadlineMs: 100, | |
| 273 | }); | |
| 274 | expect(started).toBe(true); | |
| 275 | // Running: it holds a slot, and the pull stands `proving`. | |
| 276 | expect(proofsInFlight()).toBe(1); | |
| 277 | expect(await standing(app.db, dir, pull, tip)).toBe("proving"); | |
| 278 | await until(() => proofsInFlight() === 0); | |
| 279 | expect(aborted?.aborted).toBe(true); | |
| 280 | expect(proofsInFlight()).toBe(0); | |
| 281 | expect(await standing(app.db, dir, pull, tip)).toBe("unproved"); | |
| 282 | await Bun.sleep(100); | |
| 283 | expect(answered).toBe(false); | |
| 284 | expect(proofOf(app.db, pull.id)).toBeUndefined(); | |
| 285 | }); | |
| 286 | ||
| 287 | test("a red proof stands only on the main it ran on, and only for a while", async () => { | |
| 288 | const repo = await autonomousRepo("proof-red-retry", false); | |
| 289 | const pull = await greenPull(repo, "red.txt", "red\n"); | |
| 290 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 291 | const tip = mainOf(repo); | |
| 292 | keepProof(app.db, repo.id, pull.id, { head: pull.headOid, base: tip, green: false }); | |
| 293 | expect(await standing(app.db, dir, pull, tip)).toBe("red"); | |
| 294 | expect(await standing(app.db, dir, pull, tip, Date.now() + RED_PROOF_RETRY_MS + 1000)).toBe("unproved"); | |
| 295 | const moved = await commitToBranch(app, alice, repo, { message: "on", files: [{ path: "on.txt", content: "on\n" }] }); | |
| 296 | if (!moved.ok) throw new Error(JSON.stringify(moved.error)); | |
| 297 | expect(await standing(app.db, dir, pull, mainOf(repo))).toBe("unproved"); | |
| 298 | }); | |
| 299 | ||
| 300 | test("a head that does not merge onto the tip is not proved, and holds no slot", async () => { | |
| 301 | const repo = await autonomousRepo("proof-conflict", false); | |
| 302 | const pull = await greenPull(repo, "README.md", "# theirs\n"); | |
| 303 | const moved = await commitToBranch(app, alice, repo, { message: "ours", files: [{ path: "README.md", content: "# ours\n" }] }); | |
| 304 | if (!moved.ok) throw new Error(JSON.stringify(moved.error)); | |
| 305 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 306 | let ran = false; | |
| 307 | const started = await startProof({ | |
| 308 | db: app.db, | |
| 309 | repoId: repo.id, | |
| 310 | pull, | |
| 311 | dir, | |
| 312 | tip: mainOf(repo), | |
| 313 | run: async () => { | |
| 314 | ran = true; | |
| 315 | return { lands: true }; | |
| 316 | }, | |
| 317 | room: () => 1, | |
| 318 | done: () => {}, | |
| 319 | }); | |
| 320 | expect(started).toBe(false); | |
| 321 | expect(ran).toBe(false); | |
| 322 | expect(proofsInFlight()).toBe(0); | |
| 323 | expect(await standing(app.db, dir, pull, mainOf(repo))).toBe("unproved"); | |
| 324 | }); | |
| 325 | ||
| 326 | test("a proof waits while every check slot is taken, says so once, and starts when one frees", async () => { | |
| 327 | const repo = await autonomousRepo("proof-slots", false); | |
| 328 | const first = await greenPull(repo, "one.ts"); | |
| 329 | const second = await greenPull(repo, "two.ts"); | |
| 330 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 331 | const tip = mainOf(repo); | |
| 332 | let release: (v: LandVerdict) => void = () => {}; | |
| 333 | expect( | |
| 334 | await startProof({ | |
| 335 | db: app.db, repoId: repo.id, pull: first, dir, tip, room: () => 1, done: () => {}, | |
| 336 | run: () => new Promise<LandVerdict>((resolve) => (release = resolve)), | |
| 337 | }), | |
| 338 | ).toBe(true); | |
| 339 | const told: string[] = []; | |
| 340 | let free = 0; | |
| 341 | const again = () => | |
| 342 | startProof({ | |
| 343 | db: app.db, repoId: repo.id, pull: second, dir, tip, room: () => free, done: () => {}, | |
| 344 | told: (w) => told.push(w), run: async () => ({ lands: true }), | |
| 345 | }); | |
| 346 | expect(await again()).toBe(false); | |
| 347 | expect(await again()).toBe(false); | |
| 348 | expect(told).toHaveLength(1); | |
| 349 | expect(told[0]).toContain("no check slot is spare"); | |
| 350 | const waiting = listRuns(app.db, repo.id, { kind: "proof", issueId: second.id }); | |
| 351 | expect(waiting).toHaveLength(1); | |
| 352 | expect(waiting[0]!.state).toBe("queued"); | |
| 353 | release({ lands: true }); | |
| 354 | await until(() => proofsInFlight() === 0); | |
| 355 | free = 1; | |
| 356 | expect(await again()).toBe(true); | |
| 357 | await until(() => proofsInFlight() === 0); | |
| 358 | const ran = listRuns(app.db, repo.id, { kind: "proof", issueId: second.id }); | |
| 359 | expect(ran).toHaveLength(1); | |
| 360 | expect(ran[0]!.id).toBe(waiting[0]!.id); | |
| 361 | expect(ran[0]!.state).toBe("ok"); | |
| 362 | // The runs list carries proofs among the rest. | |
| 363 | expect(listRuns(app.db, repo.id, { withProofs: true }).some((r) => r.kind === "proof")).toBe(true); | |
| 364 | }); | |
| 365 | ||
| 366 | test("a head that does not merge says so on the pull once, and its next names the wait", async () => { | |
| 367 | const repo = await autonomousRepo("proof-says", false); | |
| 368 | const pull = await greenPull(repo, "README.md", "# theirs\n"); | |
| 369 | const moved = await commitToBranch(app, alice, repo, { message: "ours", files: [{ path: "README.md", content: "# ours\n" }] }); | |
| 370 | if (!moved.ok) throw new Error(JSON.stringify(moved.error)); | |
| 371 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 372 | const told: string[] = []; | |
| 373 | const go = () => | |
| 374 | startProof({ | |
| 375 | db: app.db, repoId: repo.id, pull, dir, tip: mainOf(repo), room: () => 1, done: () => {}, | |
| 376 | told: (w) => told.push(w), run: async () => ({ lands: true }), | |
| 377 | }); | |
| 378 | expect(await go()).toBe(false); | |
| 379 | expect(await go()).toBe(false); | |
| 380 | expect(told).toHaveLength(1); | |
| 381 | expect(told[0]).toContain("does not merge"); | |
| 382 | const run = listRuns(app.db, repo.id, { kind: "proof" })[0]!; | |
| 383 | expect(run.state).toBe("failed"); | |
| 384 | expect(proofWords(app.db, pull, pull.headOid)).toBe(WAIT_MERGE); | |
| 385 | expect( | |
| 386 | nextActor({ held: null, conflicted: false, check: "ok", changesRequested: 0, approvals: 1, pushedSince: 0, proof: "cannot start: the head does not merge onto the base tip" }).why, | |
| 387 | ).toContain("its proof cannot start"); | |
| 388 | }); | |
| 389 | ||
| 390 | test("a proof cut off leaves a cut-off run and a comment, and is proved again", async () => { | |
| 391 | const repo = await autonomousRepo("proof-cut", false); | |
| 392 | const pull = await greenPull(repo, "cut.ts"); | |
| 393 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 394 | const tip = mainOf(repo); | |
| 395 | const told: string[] = []; | |
| 396 | const go = (run: () => Promise<LandVerdict>, deadlineMs?: number) => | |
| 397 | startProof({ | |
| 398 | db: app.db, repoId: repo.id, pull, dir, tip, room: () => 1, done: () => {}, | |
| 399 | told: (w) => told.push(w), run, ...(deadlineMs === undefined ? {} : { deadlineMs }), | |
| 400 | }); | |
| 401 | expect(await go(() => new Promise(() => {}), 50)).toBe(true); | |
| 402 | await until(() => proofsInFlight() === 0); | |
| 403 | const cut = listRuns(app.db, repo.id, { kind: "proof", issueId: pull.id })[0]!; | |
| 404 | expect(cut.state).toBe("cancelled"); | |
| 405 | expect(cut.summary).toContain("deadline"); | |
| 406 | expect(told).toHaveLength(1); | |
| 407 | expect(told[0]).toContain("cut off"); | |
| 408 | expect(proofWords(app.db, pull, pull.headOid)).toBe("was cut off: at the deadline; it is proved again on the next sweep"); | |
| 409 | // The next sweep proves it again. | |
| 410 | expect(await go(async () => ({ lands: true }))).toBe(true); | |
| 411 | await until(() => proofsInFlight() === 0); | |
| 412 | expect(listRuns(app.db, repo.id, { kind: "proof", issueId: pull.id })[0]!.state).toBe("ok"); | |
| 413 | }); | |
| 414 | ||
| 415 | test("a proof cut off twice on one head tells the pull once", async () => { | |
| 416 | const repo = await autonomousRepo("proof-cut-twice", false); | |
| 417 | const pull = await greenPull(repo, "twice.ts"); | |
| 418 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 419 | const told: string[] = []; | |
| 420 | const go = () => | |
| 421 | startProof({ | |
| 422 | db: app.db, repoId: repo.id, pull, dir, tip: mainOf(repo), room: () => 1, done: () => {}, | |
| 423 | told: (w) => told.push(w), run: () => new Promise(() => {}), deadlineMs: 30, | |
| 424 | }); | |
| 425 | expect(await go()).toBe(true); | |
| 426 | await until(() => proofsInFlight() === 0); | |
| 427 | expect(await go()).toBe(true); | |
| 428 | await until(() => proofsInFlight() === 0); | |
| 429 | const runs = listRuns(app.db, repo.id, { kind: "proof", issueId: pull.id }); | |
| 430 | expect(runs.map((r) => r.state)).toEqual(["cancelled", "cancelled"]); | |
| 431 | expect(told).toHaveLength(1); | |
| 432 | }); | |
| 433 | ||
| 434 | test("a push ends the old head's waiting proof as superseded", async () => { | |
| 435 | const repo = await autonomousRepo("proof-superseded", false); | |
| 436 | const first = await greenPull(repo, "super.ts"); | |
| 437 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 438 | let release: (v: LandVerdict) => void = () => {}; | |
| 439 | const other = await greenPull(repo, "other.ts"); | |
| 440 | expect( | |
| 441 | await startProof({ | |
| 442 | db: app.db, repoId: repo.id, pull: other, dir, tip: mainOf(repo), room: () => 1, done: () => {}, | |
| 443 | run: () => new Promise<LandVerdict>((resolve) => (release = resolve)), | |
| 444 | }), | |
| 445 | ).toBe(true); | |
| 446 | const wait = () => | |
| 447 | startProof({ db: app.db, repoId: repo.id, pull: getPull(app.db, repo.id, first.number)!, dir, tip: mainOf(repo), room: () => 0, done: () => {}, run: async () => ({ lands: true }) }); | |
| 448 | expect(await wait()).toBe(false); | |
| 449 | const queued = listRuns(app.db, repo.id, { kind: "proof", issueId: first.id }); | |
| 450 | expect(queued.map((r) => r.state)).toEqual(["queued"]); | |
| 451 | const pushed = await commitToBranch(app, alice, repo, { | |
| 452 | branch: "feature/super.ts", base: "main", message: "more", files: [{ path: "more.ts", content: "export const m = 1;\n" }], | |
| 453 | }); | |
| 454 | if (!pushed.ok) throw new Error(JSON.stringify(pushed.error)); | |
| 455 | expect(await wait()).toBe(false); | |
| 456 | const after = listRuns(app.db, repo.id, { kind: "proof", issueId: first.id }); | |
| 457 | expect(after.map((r) => [r.state, r.summary])).toEqual([ | |
| 458 | ["queued", "no check slot is spare"], | |
| 459 | ["cancelled", "superseded by a push"], | |
| 460 | ]); | |
| 461 | expect(after[0]!.commitOid).not.toBe(queued[0]!.commitOid); | |
| 462 | release({ lands: true }); | |
| 463 | await until(() => proofsInFlight() === 0); | |
| 464 | }); | |
| 465 | ||
| 466 | test("a proof a restart left running is cut off and told, then proved again", async () => { | |
| 467 | const repo = await autonomousRepo("proof-restart", false); | |
| 468 | const pull = await greenPull(repo, "restart.ts"); | |
| 469 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 470 | openProofRun(app.db, { repoId: repo.id, pullId: pull.id, head: pull.headOid, base: "", state: "running", summary: "" }); | |
| 471 | const told: string[] = []; | |
| 472 | expect( | |
| 473 | await startProof({ | |
| 474 | db: app.db, repoId: repo.id, pull, dir, tip: mainOf(repo), room: () => 1, done: () => {}, | |
| 475 | told: (w) => told.push(w), run: async () => ({ lands: true }), | |
| 476 | }), | |
| 477 | ).toBe(true); | |
| 478 | await until(() => proofsInFlight() === 0); | |
| 479 | const runs = listRuns(app.db, repo.id, { kind: "proof", issueId: pull.id }); | |
| 480 | expect(runs.map((r) => r.state)).toEqual(["ok", "cancelled"]); | |
| 481 | expect(told[0]).toContain("the forge stopped"); | |
| 482 | }); | |
| 483 | ||
| 484 | test("a gate that fails to answer cuts the proof off and tells the pull once", async () => { | |
| 485 | const repo = await autonomousRepo("proof-throws", false); | |
| 486 | const pull = await greenPull(repo, "throws.ts"); | |
| 487 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 488 | const told: string[] = []; | |
| 489 | const go = () => | |
| 490 | startProof({ | |
| 491 | db: app.db, repoId: repo.id, pull, dir, tip: mainOf(repo), room: () => 1, done: () => {}, | |
| 492 | told: (w) => told.push(w), run: () => Promise.reject(new Error("no gate")), | |
| 493 | }); | |
| 494 | expect(await go()).toBe(true); | |
| 495 | await until(() => proofsInFlight() === 0); | |
| 496 | expect(await go()).toBe(true); | |
| 497 | await until(() => proofsInFlight() === 0); | |
| 498 | const runs = listRuns(app.db, repo.id, { kind: "proof", issueId: pull.id }); | |
| 499 | expect(runs.map((r) => [r.state, r.summary])).toEqual([ | |
| 500 | ["cancelled", "cut off: the gate failed to answer"], | |
| 501 | ["cancelled", "cut off: the gate failed to answer"], | |
| 502 | ]); | |
| 503 | expect(told).toHaveLength(1); | |
| 504 | expect(told[0]).toContain("the gate failed to answer"); | |
| 505 | }); | |
| 506 | ||
| 507 | test("a head that cannot merge, then waits for a slot, then cannot merge is told each reason once", async () => { | |
| 508 | const repo = await autonomousRepo("proof-says-once", false); | |
| 509 | const pull = await greenPull(repo, "README.md", "# theirs\n"); | |
| 510 | const moved = await commitToBranch(app, alice, repo, { message: "ours", files: [{ path: "README.md", content: "# ours\n" }] }); | |
| 511 | if (!moved.ok) throw new Error(JSON.stringify(moved.error)); | |
| 512 | const other = await greenPull(repo, "busy.ts"); | |
| 513 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 514 | const told: string[] = []; | |
| 515 | const go = (room: number) => | |
| 516 | startProof({ | |
| 517 | db: app.db, repoId: repo.id, pull, dir, tip: mainOf(repo), room: () => room, done: () => {}, | |
| 518 | told: (w) => told.push(w), run: async () => ({ lands: true }), | |
| 519 | }); | |
| 520 | expect(await go(1)).toBe(false); | |
| 521 | let release: (v: LandVerdict) => void = () => {}; | |
| 522 | expect( | |
| 523 | await startProof({ | |
| 524 | db: app.db, repoId: repo.id, pull: other, dir, tip: mainOf(repo), room: () => 1, done: () => {}, | |
| 525 | run: () => new Promise<LandVerdict>((resolve) => (release = resolve)), | |
| 526 | }), | |
| 527 | ).toBe(true); | |
| 528 | expect(await go(0)).toBe(false); | |
| 529 | release({ lands: true }); | |
| 530 | await until(() => proofsInFlight() === 0); | |
| 531 | expect(await go(1)).toBe(false); | |
| 532 | expect(told).toHaveLength(2); | |
| 533 | expect(told[0]).toContain("does not merge"); | |
| 534 | expect(told[1]).toContain("no check slot is spare"); | |
| 535 | expect(listRuns(app.db, repo.id, { kind: "proof", issueId: pull.id }).map((r) => r.state)).toEqual(["failed", "failed"]); | |
| 536 | }); | |
| 537 | ||
| 538 | for (const ending of ["merge", "close"] as const) | |
| 539 | test(`${ending === "merge" ? "landing" : "closing"} a pull ends its waiting proof`, async () => { | |
| 540 | const repo = await autonomousRepo(`proof-${ending}-ends`, false); | |
| 541 | const pull = await greenPull(repo, `${ending}-ends.ts`); | |
| 542 | openProofRun(app.db, { repoId: repo.id, pullId: pull.id, head: pull.headOid, base: "", state: "queued", summary: WAIT_SLOT }); | |
| 543 | if (ending === "merge") { | |
| 544 | const landed = await mergePull(app, alice, repo, pull, "merge"); | |
| 545 | if (!landed.ok) throw new Error(String(landed.error)); | |
| 546 | } else { | |
| 547 | const closed = setIssueState(app, alice, repo, pull, "closed"); | |
| 548 | if (!closed.ok) throw new Error(String(closed.error)); | |
| 549 | } | |
| 550 | const runs = listRuns(app.db, repo.id, { kind: "proof", issueId: pull.id }); | |
| 551 | expect(runs.map((r) => [r.state, r.summary])).toEqual([["cancelled", ending === "merge" ? "pull landed" : "pull closed"]]); | |
| 552 | }); | |
| 553 | ||
| 554 | test("a proof's words reach the pull only while it is open on the head proved", async () => { | |
| 555 | const repo = await autonomousRepo("proof-told-guard", false); | |
| 556 | const pull = await greenPull(repo, "guard.ts"); | |
| 557 | const said = () => listComments(app.db, pull.id).filter((c) => c.body.startsWith("guard:")).length; | |
| 558 | toldWhileOpen(app, repo, alice, pull)("guard: current"); | |
| 559 | expect(said()).toBe(1); | |
| 560 | const pushed = await commitToBranch(app, alice, repo, { | |
| 561 | branch: "feature/guard.ts", base: "main", message: "more", files: [{ path: "guard2.ts", content: "export const g = 2;\n" }], | |
| 562 | }); | |
| 563 | if (!pushed.ok) throw new Error(JSON.stringify(pushed.error)); | |
| 564 | toldWhileOpen(app, repo, alice, pull)("guard: stale head"); | |
| 565 | expect(said()).toBe(1); | |
| 566 | const now = getPull(app.db, repo.id, pull.number)!; | |
| 567 | const closed = setIssueState(app, alice, repo, now, "closed"); | |
| 568 | if (!closed.ok) throw new Error(String(closed.error)); | |
| 569 | toldWhileOpen(app, repo, alice, now)("guard: closed"); | |
| 570 | expect(said()).toBe(1); | |
| 571 | }); | |
| 572 | }); |
test/next_actor.test.ts −1
| @@ -19,7 +19,6 @@ const cases: [string, Partial<NextInput>, string, string][] = [ | ||
| 19 | 19 | ["no review", { check: "running" }, "reviewer", "no review yet"], |
| 20 | 20 | ["approved and green", { approvals: 1, check: "ok" }, "queue", "approved and green"], |
| 21 | 21 | ["approved, no check", { approvals: 1 }, "queue", "approved, waiting for its check"], |
| 22 | ["approved and green, proof waits", { approvals: 1, check: "ok", proof: "waits: no check slot is spare" }, "queue", "approved and green, but its proof waits: no check slot is spare"], | |
| 23 | 22 | ["approved, queued", { approvals: 1, check: "queued" }, "queue", "approved, its check is queued"], |
| 24 | 23 | ["approved, cancelled", { approvals: 1, check: "cancelled" }, "queue", "approved, its check was cancelled"], |
| 25 | 24 | ["approved, running", { approvals: 1, check: "running" }, "queue", "approved, its check is running"], |
test/pipeline-steps.test.ts +4 −24
| @@ -31,13 +31,6 @@ function seed(kind: string, state: string, mins: number, daysAgo: number, set: R | ||
| 31 | 31 | .run(state, new Date(end - mins * MIN).toISOString(), new Date(end).toISOString(), set["issue"] ?? null, set["env"] ?? "", run.id); |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | function proof(state: string, mins: number, daysAgo: number, repo = repoId) { | |
| 35 | const end = Date.now() - daysAgo * DAY; | |
| 36 | app.db | |
| 37 | .query("INSERT INTO proof_run (id, repo_id, issue_id, head, base, state, summary, started_at, finished_at) VALUES (?1, ?2, ?3, 'h', 'b', ?4, '', ?5, ?6)") | |
| 38 | .run(`p${Math.random()}`, repo, repo === repoId ? pullIssueId : privIssueId, state, new Date(end - mins * MIN).toISOString(), new Date(end).toISOString()); | |
| 39 | } | |
| 40 | ||
| 41 | 34 | function gate(mins: number | null, daysAgo: number, repo = repoId) { |
| 42 | 35 | const end = Date.now() - daysAgo * DAY; |
| 43 | 36 | app.db |
| @@ -46,7 +39,6 @@ function gate(mins: number | null, daysAgo: number, repo = repoId) { | ||
| 46 | 39 | } |
| 47 | 40 | |
| 48 | 41 | let pullIssueId = ""; |
| 49 | let privIssueId = ""; | |
| 50 | 42 | |
| 51 | 43 | beforeAll(async () => { |
| 52 | 44 | dataDir = await mkdtemp(join(tmpdir(), "forge-steps-")); |
| @@ -67,7 +59,6 @@ beforeAll(async () => { | ||
| 67 | 59 | if (!po.ok) throw new Error(String(po.error)); |
| 68 | 60 | const privIssue = po.value.id; |
| 69 | 61 | pullIssueId = pullIssue; |
| 70 | privIssueId = privIssue; | |
| 71 | 62 | // Pull checks: 4, 6, 10 minutes green and one 20-minute red → median 8m, 25% red. |
| 72 | 63 | seed("check", "ok", 4, 1, { issue: pullIssue }); |
| 73 | 64 | seed("check", "ok", 6, 2, { issue: pullIssue }); |
| @@ -82,11 +73,6 @@ beforeAll(async () => { | ||
| 82 | 73 | // Verify lane: a verify and a verify-bisect check; even one tied to a pull is not a pull check. |
| 83 | 74 | seed("check", "ok", 10, 1, { env: "verify" }); |
| 84 | 75 | seed("check", "failed", 30, 1, { env: "verify-bisect", issue: pullIssue }); |
| 85 | // Proofs: 5 and 15 minutes green, 40 red → median 15m, 1 of 3 red; a private one is hidden. | |
| 86 | proof("ok", 5, 1); | |
| 87 | proof("ok", 15, 2); | |
| 88 | proof("failed", 40, 3); | |
| 89 | proof("failed", 1, 1, privateId); | |
| 90 | 76 | // Merge-queue gates: 6 and 12 minutes; an open one and a private one do not count. |
| 91 | 77 | gate(6, 1); |
| 92 | 78 | gate(12, 2); |
| @@ -124,10 +110,6 @@ test("a step's median time and red rate follow from its runs in the last 30 days | ||
| 124 | 110 | expect(by["verify"]?.runs).toBe(2); |
| 125 | 111 | expect(by["verify"]?.medianMs).toBeCloseTo(20 * MIN, -2); |
| 126 | 112 | expect(by["verify"]?.redRate).toBe(0.5); |
| 127 | // 5, 15, 40 → median 15m; 1 of 3 red; the private proof is cut. | |
| 128 | expect(by["proof"]?.runs).toBe(3); | |
| 129 | expect(by["proof"]?.medianMs).toBeCloseTo(15 * MIN, -2); | |
| 130 | expect(by["proof"]?.redRate).toBeCloseTo(1 / 3, 10); | |
| 131 | 113 | // Gates: time only — 6 and 12 → median 9m; no verdict; the open and private rows are cut. |
| 132 | 114 | expect(by["gate"]?.runs).toBe(2); |
| 133 | 115 | expect(by["gate"]?.medianMs).toBeCloseTo(9 * MIN, -2); |
| @@ -140,7 +122,6 @@ test("a step's median time and red rate follow from its runs in the last 30 days | ||
| 140 | 122 | expect(statWords(by["pull-check"] as never)).toBe("median 8m · 25% red (1 of 4)"); |
| 141 | 123 | const all = Object.fromEntries(readStepStats(app.db, new Date(), false).map((s) => [s.key, s])); |
| 142 | 124 | expect(all["pull-check"]?.runs).toBe(5); |
| 143 | expect(all["proof"]?.runs).toBe(4); | |
| 144 | 125 | expect(all["gate"]?.runs).toBe(3); |
| 145 | 126 | }); |
| 146 | 127 | |
| @@ -169,14 +150,13 @@ test("the terminal's Flow lines carry the steps, private repositories for an adm | ||
| 169 | 150 | expect(admin).toContain("Merge-queue gate: median 12m"); |
| 170 | 151 | const agent = flowLines(app.db, new Date(), undefined, { queues: [] }).join(" ").replace(/\s+/g, " "); |
| 171 | 152 | expect(agent).toContain("Merge-queue gate: median 9m"); |
| 172 | expect(admin).toContain("Proof: median"); | |
| 173 | 153 | }); |
| 174 | 154 | |
| 175 | 155 | test("the MCP flow tool and the API return the steps, an agent's cut to public repositories", () => { |
| 176 | 156 | const owner = flowFor(app, { kind: "owner" } as never).steps; |
| 177 | 157 | const agent = flowFor(app, { kind: "agent" } as never).steps; |
| 178 | const proofOf = (steps: typeof owner) => steps.find((s) => s.key === "proof"); | |
| 179 | expect(proofOf(owner)?.runs).toBe(4); | |
| 180 | expect(proofOf(agent)?.runs).toBe(3); | |
| 181 | expect(proofOf(owner)?.says).toContain("median"); | |
| 158 | const gateOf = (steps: typeof owner) => steps.find((s) => s.key === "gate"); | |
| 159 | expect(gateOf(owner)?.runs).toBe(3); | |
| 160 | expect(gateOf(agent)?.runs).toBe(2); | |
| 161 | expect(owner.some((s) => s.key === ("proof" as never))).toBe(false); | |
| 182 | 162 | }); |
test/proof-runs.test.ts deleted −176
| @@ -1,176 +0,0 @@ | ||
| 1 | /** | |
| 2 | * A proof is a run (#7706): the doors read it — MCP `runs`, the API, the | |
| 3 | * terminal's runs list, `/runs/<id>` — and a push ends the old head's wait. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createRepo } from "../src/actions/repos.ts"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createIssue } from "../src/core/issues.ts"; | |
| 12 | import { endProofRun, getProofRun, openProofRun } from "../src/core/proof_runs.ts"; | |
| 13 | import type { Repo } from "../src/core/repos.ts"; | |
| 14 | import { createRun, getRun, listRuns } from "../src/core/runs.ts"; | |
| 15 | import { createToken } from "../src/core/tokens.ts"; | |
| 16 | import { createUser, type User } from "../src/core/users.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 19 | import { runsScreen } from "../src/tui/screens/runs.ts"; | |
| 20 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 21 | import { runStateBadge } from "../src/web/ui.ts"; | |
| 22 | import { quietLogs } from "./helpers/globals.ts"; | |
| 23 | import { settleApp } from "./helpers/tui.ts"; | |
| 24 | ||
| 25 | quietLogs(); | |
| 26 | ||
| 27 | describe("proof runs through the doors", () => { | |
| 28 | let dataDir: string; | |
| 29 | let app: App; | |
| 30 | let servers: Servers; | |
| 31 | let alice: User; | |
| 32 | let repo: Repo; | |
| 33 | let pullId: string; | |
| 34 | let token: string; | |
| 35 | let rpc = 1; | |
| 36 | ||
| 37 | const mcp = async (args: unknown) => { | |
| 38 | const res = await fetch(`${servers.url}/mcp`, { | |
| 39 | method: "POST", | |
| 40 | headers: { | |
| 41 | "content-type": "application/json", | |
| 42 | accept: "application/json, text/event-stream", | |
| 43 | authorization: `Bearer ${token}`, | |
| 44 | }, | |
| 45 | body: JSON.stringify({ jsonrpc: "2.0", id: rpc++, method: "tools/call", params: { name: "runs", arguments: args } }), | |
| 46 | }); | |
| 47 | const body: any = await res.json(); | |
| 48 | const text: string = body.result.content[0].text; | |
| 49 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 50 | }; | |
| 51 | ||
| 52 | beforeAll(async () => { | |
| 53 | dataDir = await mkdtemp(join(tmpdir(), "forge-proof-runs-")); | |
| 54 | app = createApp({ dataDir, port: 0 }); | |
| 55 | servers = await serve(app); | |
| 56 | const u = await createUser(app.db, { | |
| 57 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 58 | password: "a-good-passphrase", adminIfFirst: true, | |
| 59 | }); | |
| 60 | if (!u.ok) throw new Error(u.error); | |
| 61 | alice = u.value; | |
| 62 | token = createToken(app.db, alice.id, "ro", "ro").token; | |
| 63 | const made = await createRepo(app, alice, { name: "vault", visibility: "private" }); | |
| 64 | if (!made.ok) throw new Error(String(made.error)); | |
| 65 | repo = made.value; | |
| 66 | const issue = createIssue(app.db, { repoId: repo.id, authorId: alice.id, title: "A pull" }); | |
| 67 | if (!issue.ok) throw new Error(issue.error); | |
| 68 | pullId = issue.value.id; | |
| 69 | }, 30_000); | |
| 70 | ||
| 71 | afterAll(async () => { | |
| 72 | servers.stop(); | |
| 73 | app.close(); | |
| 74 | await rm(dataDir, { recursive: true, force: true }); | |
| 75 | }); | |
| 76 | ||
| 77 | const open = (head: string, state: "queued" | "running" = "queued", summary = "no check slot is spare") => | |
| 78 | openProofRun(app.db, { repoId: repo.id, pullId, head, base: "b".repeat(40), state, summary }); | |
| 79 | ||
| 80 | test("a new head ends the old head's waiting proof as superseded, and leaves the new one queued", () => { | |
| 81 | const old = open("a".repeat(40)); | |
| 82 | const fresh = open("c".repeat(40)); | |
| 83 | const ended = getProofRun(app.db, old.id)!; | |
| 84 | expect(ended.state).toBe("cancelled"); | |
| 85 | expect(ended.summary).toBe("superseded by a push"); | |
| 86 | expect(ended.finishedAt).not.toBeNull(); | |
| 87 | expect(getProofRun(app.db, fresh.id)!.state).toBe("queued"); | |
| 88 | endProofRun(app.db, fresh.id, "ok", "green"); | |
| 89 | }); | |
| 90 | ||
| 91 | test("MCP runs lists proofs unfiltered and filters by kind=proof; a bad kind is refused", async () => { | |
| 92 | createRun(app.db, { repoId: repo.id, kind: "deploy", env: "production", reason: "push" }); | |
| 93 | const proof = open("d".repeat(40), "running", ""); | |
| 94 | const all = await mcp({ repo: "alice/vault" }); | |
| 95 | expect(all.value.runs.map((r: any) => r.kind)).toContain("proof"); | |
| 96 | const only = await mcp({ repo: "alice/vault", kind: "proof" }); | |
| 97 | expect(only.value.runs.length).toBeGreaterThan(0); | |
| 98 | expect(only.value.runs.every((r: any) => r.kind === "proof")).toBe(true); | |
| 99 | expect(only.value.runs[0].id).toBe(proof.id); | |
| 100 | const deploys = await mcp({ repo: "alice/vault", kind: "deploy" }); | |
| 101 | expect(deploys.value.runs.every((r: any) => r.kind === "deploy")).toBe(true); | |
| 102 | expect((await mcp({ repo: "alice/vault", kind: "bogus" })).error).toContain("kind"); | |
| 103 | endProofRun(app.db, proof.id, "failed", "red"); | |
| 104 | }); | |
| 105 | ||
| 106 | test("the API filters ?kind=proof", async () => { | |
| 107 | const res = await fetch(`${servers.url}/api/v1/repos/alice/vault/runs?kind=proof`, { | |
| 108 | headers: { authorization: `Bearer ${token}` }, | |
| 109 | }); | |
| 110 | const body: any = await res.json(); | |
| 111 | const items = body.items ?? body.runs ?? body; | |
| 112 | expect(items.length).toBeGreaterThan(0); | |
| 113 | expect(items.every((r: any) => r.kind === "proof")).toBe(true); | |
| 114 | }); | |
| 115 | ||
| 116 | test("callers that omit kind still read only `run`; the lists ask for proofs", () => { | |
| 117 | expect(listRuns(app.db, repo.id).some((r) => r.kind === "proof")).toBe(false); | |
| 118 | expect(listRuns(app.db, repo.id, { withProofs: true }).some((r) => r.kind === "proof")).toBe(true); | |
| 119 | expect(listRuns(app.db, repo.id, { kind: "proof", env: "production" })).toEqual([]); | |
| 120 | }); | |
| 121 | ||
| 122 | test("the merged list pages across both tables with no run lost or repeated", () => { | |
| 123 | createRun(app.db, { repoId: repo.id, kind: "deploy", env: "production", reason: "push" }); | |
| 124 | open("9".repeat(40), "running", ""); | |
| 125 | createRun(app.db, { repoId: repo.id, kind: "agent", reason: "assigned" }); | |
| 126 | const seen: string[] = []; | |
| 127 | let cursor: string | undefined; | |
| 128 | for (let i = 0; i < 20; i++) { | |
| 129 | const page = listRuns(app.db, repo.id, { withProofs: true, limit: 2, ...(cursor ? { cursor } : {}) }); | |
| 130 | if (page.length === 0) break; | |
| 131 | seen.push(...page.map((r) => r.id)); | |
| 132 | cursor = page[page.length - 1]!.id; | |
| 133 | } | |
| 134 | const whole = listRuns(app.db, repo.id, { withProofs: true, limit: 200 }).map((r) => r.id); | |
| 135 | expect(seen).toEqual(whole); | |
| 136 | expect(new Set(seen).size).toBe(seen.length); | |
| 137 | expect([...whole].sort().reverse()).toEqual(whole); | |
| 138 | expect(whole.length).toBeGreaterThanOrEqual(7); | |
| 139 | }); | |
| 140 | ||
| 141 | test("the badges name a proof's states Green, Cut off and Failed", () => { | |
| 142 | expect(String(runStateBadge("ok", "proof"))).toContain("Green"); | |
| 143 | expect(String(runStateBadge("cancelled", "proof"))).toContain("Cut off"); | |
| 144 | expect(String(runStateBadge("queued", "proof"))).toContain("Queued"); | |
| 145 | expect(String(runStateBadge("ok", "check"))).toContain("Passed"); | |
| 146 | }); | |
| 147 | ||
| 148 | test("/runs/<id> opens a proof run, and its short door finds it", async () => { | |
| 149 | const run = open("e".repeat(40), "running", ""); | |
| 150 | endProofRun(app.db, run.id, "ok", "green"); | |
| 151 | expect(getRun(app.db, run.id)!.kind).toBe("proof"); | |
| 152 | const page = await fetch(`${servers.url}/alice/vault/runs/${run.id}`, { headers: { authorization: `Bearer ${token}` } }); | |
| 153 | expect(page.status).toBe(200); | |
| 154 | expect(await page.text()).toContain("Proof of eeeeeee"); | |
| 155 | const api = await fetch(`${servers.url}/api/v1/repos/alice/vault/runs/${run.id}`, { headers: { authorization: `Bearer ${token}` } }); | |
| 156 | expect(((await api.json()) as any).kind).toBe("proof"); | |
| 157 | }); | |
| 158 | ||
| 159 | test("the terminal's runs list shows a proof row with its summary", async () => { | |
| 160 | const run = open("f".repeat(40), "queued", "no check slot is spare"); | |
| 161 | const { app: tui, session } = createForgeApp( | |
| 162 | app, | |
| 163 | { sshUsername: "alice", remoteAddress: "127.0.0.1", user: alice }, | |
| 164 | { terminal: headlessTerminal(140, 30), exitHooks: false }, | |
| 165 | ); | |
| 166 | tui.start(); | |
| 167 | await settleApp(tui); | |
| 168 | tui.push(runsScreen(session, tui.ctx, repo)); | |
| 169 | await settleApp(tui); | |
| 170 | const text = tui.screen.toText(); | |
| 171 | expect(text).toContain("Proof of fffffff"); | |
| 172 | expect(text).toContain("no check slot is spare"); | |
| 173 | tui.stop(); | |
| 174 | endProofRun(app.db, run.id, "cancelled", "cut off: the forge stopped"); | |
| 175 | }); | |
| 176 | }); |
new above
offline