Public: anyone can read this; the crew can change it.
A proof is a run with a state, and a proof that cannot start or is cut off says why on the pull #7708
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7706-a-proof-is-a-run-with-a-state-and-a-proo → main 5 commits fork 4 threads · 4 unresolved
21 files
changed +809
−26
docs/CREW.md +13
| @@ -181,6 +181,19 @@ again before it joins, and a pull already in the line is left out of its | ||
| 181 | 181 | next batch until it is proved again. A proof that has not answered by the |
| 182 | 182 | gate's deadline (15 minutes) is cut off and no verdict is kept, so a hung |
| 183 | 183 | one never holds its pull or a check slot. |
| 184 | ||
| 185 | A **proof** is a run of kind `proof`: the full suite on a pull's head squashed | |
| 186 | onto the base, before the pull joins the merge queue. It is `queued` while no | |
| 187 | check slot is spare, `running`, then `ok` (shown *Green*), `failed` (red) or | |
| 188 | `cancelled` (shown *Cut off*: the deadline passed or the forge restarted; it is | |
| 189 | proved again on the next sweep). A push ends the old head's unfinished proof as | |
| 190 | `cancelled`, "superseded by a push". The run's summary says why a proof waited | |
| 191 | or could not start, and the pull is told once per head in a comment and in its | |
| 192 | next step ("approved and green, but its proof waits: no check slot is spare"). | |
| 193 | Read them in the runs list (`?kind=proof`), the terminal's Runs screen, the API | |
| 194 | (`GET …/runs?kind=proof`) and the MCP `runs` tool (`kind: "proof"`); only those | |
| 195 | lists mix proofs in with the other kinds. | |
| 196 | ||
| 184 | 197 | The queue builds one candidate: main, then each queued pull's squash on top of the one before, in |
| 185 | 198 | queue order, leaving out any pull that conflicts or has nothing left to land. |
| 186 | 199 | A candidate carries at most `queue.batch` pulls (Settings → Capacity, |
docs/MCP.md +1 −1
| @@ -149,7 +149,7 @@ for a tunnel and a forge that sleeps. | ||
| 149 | 149 | | `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 | |
| 150 | 150 | | `rollback` | `{ 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 | |
| 151 | 151 | | `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 | | |
| 152 | | `runs` | `{ repo, limit? }`: the repository's recent runs, newest first (id, kind, state, issue or pull number, started, ended), 1–50, default 20; read as the runs page reads them | | | |
| 152 | | `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 | | | |
| 153 | 153 | | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes | |
| 154 | 154 | | `scorecard` | (forge admin) no input: the crew's scorecard per agent, all time and 30 days: landed, review rounds, red rate, reverts | | |
| 155 | 155 | | `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 | |
src/checks/proof.ts +86 −9
| @@ -17,17 +17,23 @@ | ||
| 17 | 17 | * proof stands for its head until a push, or until the base has moved that |
| 18 | 18 | * far — a red that was main's may be gone from a newer main. |
| 19 | 19 | * |
| 20 | * A proof cut off (the forge stopping, the Job lost) is no verdict: nothing | |
| 21 | * is kept, and the next sweep proves it again. One that has not answered by | |
| 22 | * the gate's own deadline (`GATE_DEADLINE_MS`, #3430) is cut off the same | |
| 23 | * way, its run aborted, so a hung proof never holds its pull or a check slot | |
| 24 | * for good. One proof runs at a time per | |
| 25 | * pull, and only one beyond the first while no check slot is spare. | |
| 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. | |
| 26 | 30 | */ |
| 27 | 31 | |
| 28 | 32 | import type { Database } from "bun:sqlite"; |
| 29 | 33 | import { latestDecision, recordDecision } from "../core/decisions.ts"; |
| 34 | import { cutOffBefore, cutSummary, endedBefore, endProofRun, latestProofRun, openProofRun, startProofRun } from "../core/proof_runs.ts"; | |
| 30 | 35 | import type { Pull } from "../core/pulls.ts"; |
| 36 | import { STOPPED_SUMMARY } from "../core/runs.ts"; | |
| 31 | 37 | import { runGit } from "../git/env.ts"; |
| 32 | 38 | import { mergeable } from "../git/merge.ts"; |
| 33 | 39 | import { GATE_DEADLINE_MS } from "./queue.ts"; |
| @@ -160,18 +166,69 @@ export interface ProofRun { | ||
| 160 | 166 | readonly room: () => number; |
| 161 | 167 | /** The proof was judged and kept. */ |
| 162 | 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; | |
| 163 | 171 | /** How long the proof may run before it is cut off; the gate's deadline unless a test shortens it. */ |
| 164 | 172 | readonly deadlineMs?: number; |
| 165 | 173 | } |
| 166 | 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 | ||
| 167 | 211 | /** |
| 168 | 212 | * Start proving `pull` on `tip` beside the caller, which does not wait. |
| 169 | 213 | * False when it did not start: a proof of this head runs already, no slot |
| 170 | * is free, or the head does not merge onto the tip. | |
| 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. | |
| 171 | 216 | */ |
| 172 | 217 | export async function startProof(x: ProofRun): Promise<boolean> { |
| 173 | 218 | if (proving.has(x.pull.id)) return false; |
| 174 | if (proving.size > 0 && x.room() <= 0) 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 | } | |
| 175 | 232 | proving.set(x.pull.id, x.pull.headOid); |
| 176 | 233 | let commit: string | undefined; |
| 177 | 234 | try { |
| @@ -181,13 +238,28 @@ export async function startProof(x: ProofRun): Promise<boolean> { | ||
| 181 | 238 | } |
| 182 | 239 | if (commit === undefined) { |
| 183 | 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 | ); | |
| 184 | 246 | return false; |
| 185 | 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 | } | |
| 186 | 256 | const abandon = new AbortController(); |
| 187 | 257 | let timer: ReturnType<typeof setTimeout> | undefined; |
| 258 | let deadline = false; | |
| 188 | 259 | // A proof that never answers is cut off at the deadline: no verdict (#3430). |
| 189 | 260 | const cut = new Promise<LandVerdict>((resolve) => { |
| 190 | 261 | timer = setTimeout(() => { |
| 262 | deadline = true; | |
| 191 | 263 | abandon.abort(); |
| 192 | 264 | resolve({ lands: false, interrupted: true }); |
| 193 | 265 | }, x.deadlineMs ?? GATE_DEADLINE_MS); |
| @@ -198,14 +270,19 @@ export async function startProof(x: ProofRun): Promise<boolean> { | ||
| 198 | 270 | // Released before the answer is acted on: `done` may sweep, and must not read it as running. |
| 199 | 271 | clearTimeout(timer); |
| 200 | 272 | proving.delete(x.pull.id); |
| 201 | if ("interrupted" in verdict) return; | |
| 273 | if ("interrupted" in verdict) { | |
| 274 | cutOff(x, runId, x.pull.headOid, deadline ? "at the deadline" : "the gate was interrupted"); | |
| 275 | return; | |
| 276 | } | |
| 202 | 277 | const proof: Proof = { head: x.pull.headOid, base: x.tip, green: verdict.lands }; |
| 203 | 278 | keepProof(x.db, x.repoId, x.pull.id, proof); |
| 279 | endProofRun(x.db, runId, verdict.lands ? "ok" : "failed", verdict.lands ? "green" : "red"); | |
| 204 | 280 | x.done(proof, verdict); |
| 205 | 281 | }, |
| 206 | 282 | () => { |
| 207 | 283 | clearTimeout(timer); |
| 208 | 284 | proving.delete(x.pull.id); |
| 285 | cutOff(x, runId, x.pull.headOid, "the gate failed to answer"); | |
| 209 | 286 | }, |
| 210 | 287 | ); |
| 211 | 288 | return true; |
src/core/issues.ts +3
| @@ -9,6 +9,7 @@ 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"; | |
| 12 | 13 | |
| 13 | 14 | export type IssueKind = "issue" | "pull"; |
| 14 | 15 | export type IssueState = "open" | "closed"; |
| @@ -548,6 +549,8 @@ export function setIssueState( | ||
| 548 | 549 | WHERE id = ?4 AND state != ?1`, |
| 549 | 550 | ) |
| 550 | 551 | .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"); | |
| 551 | 554 | return changed > 0 ? getIssueById(db, issueId) : undefined; |
| 552 | 555 | } |
| 553 | 556 |
src/core/migration-pins/20261101120000-proof-run.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 750769103b9d02aa1baf5fb82fb9084cbcd197123a4d19fe0b69c57da9403d14 |
src/core/migrations/20261101120000-proof-run.ts added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | // A proof is a run with a state (#7706): the full suite on a pull's head | |
| 2 | // squashed onto the base, before the pull joins the merge queue. `run` has a | |
| 3 | // CHECK on its kind that SQLite cannot widen without a rebuild an old process | |
| 4 | // would not survive (#875), so a proof's rows sit beside it and are read as | |
| 5 | // runs of kind `proof` (`core/proof_runs.ts`). `state` is a run's: queued | |
| 6 | // (waiting for a check slot), running, ok, failed, cancelled (cut off). | |
| 7 | export default ` | |
| 8 | CREATE TABLE IF NOT EXISTS proof_run ( | |
| 9 | id TEXT PRIMARY KEY, | |
| 10 | repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE, | |
| 11 | issue_id TEXT NOT NULL REFERENCES issue(id) ON DELETE CASCADE, | |
| 12 | head TEXT NOT NULL, | |
| 13 | base TEXT NOT NULL DEFAULT '', | |
| 14 | state TEXT NOT NULL DEFAULT 'queued' | |
| 15 | CHECK (state IN ('queued','running','ok','failed','cancelled')), | |
| 16 | summary TEXT NOT NULL DEFAULT '', | |
| 17 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 18 | started_at TEXT, | |
| 19 | finished_at TEXT | |
| 20 | ); | |
| 21 | CREATE INDEX IF NOT EXISTS proof_run_repo ON proof_run(repo_id, id DESC); | |
| 22 | CREATE INDEX IF NOT EXISTS proof_run_issue ON proof_run(issue_id, id DESC); | |
| 23 | `; |
src/core/proof_runs.ts added +160
| @@ -0,0 +1,160 @@ | ||
| 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( | |
outdated
Unfinished proof runs are ended only when a new head opens a run. Also end them when the pull merges or closes, otherwise they sit 'Queued' forever. |
||
| 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,6 +9,7 @@ | ||
| 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"; | |
| 12 | 13 | import type { Database } from "bun:sqlite"; |
| 13 | 14 | import { diffRange, type Diff, type DiffLaps } from "../git/diff.ts"; |
| 14 | 15 | import { isSafeRef } from "../git/guard.ts"; |
| @@ -1088,6 +1089,7 @@ function closeAsMerged( | ||
| 1088 | 1089 | landed.headOid, |
| 1089 | 1090 | pullId, |
| 1090 | 1091 | ); |
| 1092 | endProofRunsForPull(db, pullId, "pull landed"); | |
| 1091 | 1093 | setIssueState(db, pullId, "closed"); |
| 1092 | 1094 | }); |
| 1093 | 1095 | } |
src/core/runs.ts +24 −5
| @@ -11,8 +11,10 @@ | ||
| 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"; | |
| 14 | 15 | |
| 15 | export type RunKind = "deploy" | "agent" | "check"; | |
| 16 | /** `proof` is read from `proof_run` (`core/proof_runs.ts`), never stored in `run`. */ | |
| 17 | export type RunKind = "deploy" | "agent" | "check" | "proof"; | |
RunKind gains "proof" but RUN_KINDS (line 27) does not; kind filters will reject it. Add it there. |
||
| 16 | 18 | |
| 17 | 19 | /** |
| 18 | 20 | * The one summary a run cancelled by the forge's own restart carries — never |
| @@ -24,7 +26,7 @@ export const STOPPED_SUMMARY = "the forge stopped"; | ||
| 24 | 26 | |
| 25 | 27 | /** The kinds that pass judgement on a commit: a preview's deploy, or a repository's own check. */ |
| 26 | 28 | export const CHECK_KINDS: readonly RunKind[] = ["deploy", "check"]; |
| 27 | export const RUN_KINDS: readonly RunKind[] = ["deploy", "agent", "check"]; | |
| 29 | export const RUN_KINDS: readonly RunKind[] = ["deploy", "agent", "check", "proof"]; | |
| 28 | 30 | /** Every state a run can be in — the type is read off this list, so a new state is named here once. */ |
| 29 | 31 | export const RUN_STATES = ["queued", "running", "ok", "failed", "cancelled"] as const; |
| 30 | 32 | export type RunState = (typeof RUN_STATES)[number]; |
| @@ -206,7 +208,7 @@ export function getRun(db: Database, runId: string): Run | undefined { | ||
| 206 | 208 | const row = db |
| 207 | 209 | .query<RunRow, [string]>("SELECT * FROM run WHERE id = ?1") |
| 208 | 210 | .get(runId); |
| 209 | return row ? toRun(row) : undefined; | |
| 211 | return row ? toRun(row) : getProofRun(db, runId); | |
| 210 | 212 | } |
| 211 | 213 | |
| 212 | 214 | /** |
| @@ -688,6 +690,13 @@ export interface ListRunsOptions { | ||
| 688 | 690 | limit?: number | undefined; |
| 689 | 691 | /** Resume after this run id (exclusive) — lists run newest first. */ |
| 690 | 692 | cursor?: string | undefined; |
| 693 | /** | |
| 694 | * With no `kind`, merge in the proof runs (`core/proof_runs.ts`, #7706): | |
| 695 | * for the lists that show the whole story. Off, the rest of the callers | |
| 696 | * (counts, "latest run" lookups) read `run` alone. A proof has no | |
| 697 | * environment, so `env` leaves proofs out. | |
| 698 | */ | |
| 699 | withProofs?: boolean | undefined; | |
| 691 | 700 | } |
| 692 | 701 | |
| 693 | 702 | export function listRuns( |
| @@ -695,6 +704,15 @@ export function listRuns( | ||
| 695 | 704 | repoId: string, |
| 696 | 705 | options: ListRunsOptions = {}, |
| 697 | 706 | ): Run[] { |
| 707 | const limit = Math.min(Math.max(options.limit ?? 50, 1), 200); | |
| 708 | // A proof is read from its own table; the rest of the list is `run`'s. | |
| 709 | const proofs = | |
| 710 | options.kind === "proof" || (options.kind === undefined && options.withProofs === true) | |
| 711 | ? options.env === undefined | |
| 712 | ? listProofRuns(db, repoId, { state: options.state, issueId: options.issueId, cursor: options.cursor, limit }) | |
| 713 | : [] | |
| 714 | : []; | |
| 715 | if (options.kind === "proof") return proofs; | |
| 698 | 716 | const params: (string | number)[] = [repoId]; |
| 699 | 717 | let where = "repo_id = ?1"; |
| 700 | 718 | if (options.kind !== undefined) { |
| @@ -717,13 +735,14 @@ export function listRuns( | ||
| 717 | 735 | params.push(options.cursor); |
| 718 | 736 | where += ` AND id < ?${params.length}`; |
| 719 | 737 | } |
| 720 | params.push(Math.min(Math.max(options.limit ?? 50, 1), 200)); | |
| 721 | return db | |
| 738 | params.push(limit); | |
| 739 | const rows = db | |
| 722 | 740 | .query<RunRow, (string | number)[]>( |
| 723 | 741 | `SELECT * FROM run WHERE ${where} ORDER BY id DESC LIMIT ?${params.length}`, |
| 724 | 742 | ) |
| 725 | 743 | .all(...params) |
| 726 | 744 | .map(toRun); |
| 745 | return proofs.length === 0 ? rows : [...rows, ...proofs].sort((a, b) => (a.id < b.id ? 1 : -1)).slice(0, limit); | |
| 727 | 746 | } |
| 728 | 747 | |
| 729 | 748 | /** Queued runs of one kind, oldest first — what a worker takes from. */ |
src/crew/autonomy.ts +13
| @@ -2075,6 +2075,18 @@ async function admit( | ||
| 2075 | 2075 | }); |
| 2076 | 2076 | } |
| 2077 | 2077 | |
| 2078 | /** | |
| 2079 | * What a proof says to `pull`, said only while the pull is open on the head | |
| 2080 | * it proved: a push or a close since makes the words about nothing. | |
| 2081 | */ | |
| 2082 | export function toldWhileOpen(app: App, repo: Repo, by: User, pull: Pull): (words: string) => void { | |
| 2083 | return (words) => { | |
| 2084 | const now = getPull(app.db, repo.id, pull.number); | |
| 2085 | if (now !== undefined && now.headOid === pull.headOid && now.state === "open") | |
| 2086 | addComment(app, by, repo, getIssueById(app.db, pull.id) ?? now, words); | |
| 2087 | }; | |
| 2088 | } | |
| 2089 | ||
| 2078 | 2090 | /** |
| 2079 | 2091 | * Why a pull waits on its proof (#7616), or undefined when it is proved on |
| 2080 | 2092 | * `baseTip`. An unproved pull's proof is started here, beside the sweep: |
| @@ -2101,6 +2113,7 @@ async function proofWait( | ||
| 2101 | 2113 | tip: baseTip, |
| 2102 | 2114 | run: async (commitOid, signal) => (await proveCandidate(app, repo, pull, dir, commitOid, signal)) ?? { lands: true }, |
| 2103 | 2115 | room: () => checksFor(app)?.spareSlots() ?? 0, |
| 2116 | told: toldWhileOpen(app, repo, by, pull), | |
| 2104 | 2117 | done: (proof, verdict) => { |
| 2105 | 2118 | log.info(`proof ${proof.green ? "green" : "red"}`, { |
| 2106 | 2119 | repo: `${repo.ownerName}/${repo.name}`, |
src/flow/next_actor.ts +23
| @@ -7,6 +7,7 @@ | ||
| 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"; | |
| 10 | 11 | import { checkSummary, type RunState } from "../core/runs.ts"; |
| 11 | 12 | import { reviewSummary } from "../core/reviews.ts"; |
| 12 | 13 | |
| @@ -27,6 +28,8 @@ export interface NextInput { | ||
| 27 | 28 | readonly approvals: number; |
| 28 | 29 | /** Verdicts passed on an older head: the pull was pushed to since. */ |
| 29 | 30 | readonly pushedSince: number; |
| 31 | /** What the pull's proof on this head is doing when it is not green (#7706): "waits: no check slot is spare". */ | |
| 32 | readonly proof?: string | null; | |
| 30 | 33 | } |
| 31 | 34 | |
| 32 | 35 | const WAITING = { |
| @@ -44,6 +47,7 @@ export function nextActor(i: NextInput): Next { | ||
| 44 | 47 | return { who: "builder", why: "changes were asked on this head" }; |
| 45 | 48 | if (i.check === "failed") return { who: "builder", why: "its check is red" }; |
| 46 | 49 | if (i.approvals > 0) { |
| 50 | if (i.check === "ok" && i.proof) return { who: "queue", why: `approved and green, but its proof ${i.proof}` }; | |
| 47 | 51 | if (i.check === "ok") return { who: "queue", why: "approved and green" }; |
| 48 | 52 | return { who: "queue", why: `approved, ${WAITING[i.check]}` }; |
| 49 | 53 | } |
| @@ -53,6 +57,24 @@ export function nextActor(i: NextInput): Next { | ||
| 53 | 57 | }; |
| 54 | 58 | } |
| 55 | 59 | |
| 60 | /** What the pull's proof on `head` is doing, in words; null when it is green or has not been tried. */ | |
| 61 | export function proofWords(db: Database, pull: Pull, head: string): string | null { | |
| 62 | const run = latestProofRun(db, pull.id); | |
| 63 | if (run === undefined || run.commitOid !== head) return null; | |
| 64 | switch (run.state) { | |
| 65 | case "queued": | |
| 66 | return `waits: ${run.summary}`; | |
| 67 | case "running": | |
| 68 | return "is running"; | |
| 69 | case "failed": | |
| 70 | return run.summary === "red" ? "is red" : run.summary; | |
| 71 | case "cancelled": | |
| 72 | return `was ${run.summary}; it is proved again on the next sweep`; | |
| 73 | default: | |
| 74 | return null; | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 56 | 78 | /** The next actor of an open pull at `headOid`; null once it is merged or closed. */ |
| 57 | 79 | export function nextOfPull( |
| 58 | 80 | db: Database, |
| @@ -69,5 +91,6 @@ export function nextOfPull( | ||
| 69 | 91 | changesRequested: reviews.changesRequested, |
| 70 | 92 | approvals: reviews.approvals, |
| 71 | 93 | pushedSince: reviews.stale, |
| 94 | proof: proofWords(db, pull, live.headOid), | |
| 72 | 95 | }); |
| 73 | 96 | } |
src/http/api.ts +1 −1
| @@ -3551,7 +3551,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3551 | 3551 | // the branch. |
| 3552 | 3552 | const runs = timePhase(ctx.request, "runs", () => { |
| 3553 | 3553 | const listed = listRepoRuns(ctx.app, ctx.auth.user, repo, { |
| 3554 | ...(kind === "deploy" || kind === "agent" ? { kind } : {}), | |
| 3554 | ...(kind === "deploy" || kind === "agent" || kind === "proof" ? { kind } : { withProofs: true }), | |
| 3555 | 3555 | ...(env !== null && isEnvName(env) ? { env } : {}), |
| 3556 | 3556 | ...(q.get("cursor") ? { cursor: q.get("cursor")! } : {}), |
| 3557 | 3557 | limit, |
src/http/mcp/tools/runs.ts +14 −2
| @@ -1,16 +1,22 @@ | ||
| 1 | 1 | import { listRepoRuns } from "../../../actions/runs.ts"; |
| 2 | import { RUN_KINDS, type RunKind } from "../../../core/runs.ts"; | |
| 2 | 3 | import { getIssueById } from "../../../core/issues.ts"; |
| 3 | 4 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 4 | 5 | |
| 5 | 6 | export const tool: Tool = { |
| 6 | 7 | name: "runs", |
| 7 | doc: "`{ repo, limit? }`: the repository's recent runs, 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, 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 | 9 | description: |
| 9 | 10 | "A repository's runs, newest first: id, kind, state, number, start, end.", |
| 10 | 11 | inputSchema: { |
| 11 | 12 | type: "object", |
| 12 | 13 | properties: { |
| 13 | 14 | repo: REPO_ARG, |
| 15 | kind: { | |
| 16 | type: "string", | |
| 17 | enum: ["agent", "deploy", "check", "proof"], | |
| 18 | description: "Only runs of this kind; all kinds when omitted.", | |
| 19 | }, | |
| 14 | 20 | limit: { |
| 15 | 21 | type: "integer", |
| 16 | 22 | minimum: 1, |
| @@ -25,9 +31,15 @@ export const tool: Tool = { | ||
| 25 | 31 | const limit = args["limit"] ?? 20; |
| 26 | 32 | if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > 50) |
| 27 | 33 | return bad("bad_limit", "limit is a whole number from 1 to 50"); |
| 34 | const kind = args["kind"]; | |
| 35 | if (kind !== undefined && !RUN_KINDS.some((k) => k === kind)) | |
| 36 | return bad("bad_kind", `kind is one of ${RUN_KINDS.join(", ")}`); | |
| 28 | 37 | const repo = repoArg(ctx, user, args); |
| 29 | 38 | if (isAnswer(repo)) return repo; |
| 30 | const runs = listRepoRuns(ctx.app, user, repo, { limit }); | |
| 39 | const runs = listRepoRuns(ctx.app, user, repo, { | |
| 40 | limit, | |
| 41 | ...(kind === undefined ? { withProofs: true } : { kind: kind as RunKind }), | |
| 42 | }); | |
| 31 | 43 | if (!runs.ok) return bad("not_found", "no such app or repository (owner/name)"); |
| 32 | 44 | const answer = good({ |
| 33 | 45 | runs: runs.value.map((r) => ({ |
src/tui/screens/runlist.ts +1
| @@ -48,6 +48,7 @@ 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)}`; | |
| 51 | 52 | if (run.kind === "deploy") return `deploy ${envLabel(run.env)}`; |
| 52 | 53 | const who = |
| 53 | 54 | run.actorId === null |
src/tui/screens/runs.ts +1
| @@ -42,6 +42,7 @@ 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, | |
| 45 | 46 | limit: LIMIT, |
| 46 | 47 | }); |
| 47 | 48 | list.set(listed.ok ? listed.value : []); |
src/web/pages/runs.ts +3 −1
| @@ -76,6 +76,8 @@ 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}`}`; | |
| 79 | 81 | if (run.kind === "deploy") |
| 80 | 82 | return about === "" ? "Deploy production" : `Deploy preview ${about}`; |
| 81 | 83 | const who = actor ?? "an agent"; |
| @@ -233,7 +235,7 @@ export function runTitleFrom( | ||
| 233 | 235 | } |
| 234 | 236 | |
| 235 | 237 | const kindIcon = (kind: RunKind) => |
| 236 | icon(kind === "agent" ? "bot" : kind === "check" ? "check" : "rocket"); | |
| 238 | icon(kind === "agent" ? "bot" : kind === "check" || kind === "proof" ? "check" : "rocket"); | |
| 237 | 239 | |
| 238 | 240 | // ---- rows ---- |
| 239 | 241 |
src/web/studio/lists.ts +5 −3
| @@ -581,11 +581,11 @@ export async function changesList(ctx: Ctx): Promise<Response> { | ||
| 581 | 581 | /** Which runs a list shows: all three kinds by default, or the one that |
| 582 | 582 | * `?kind=` asks for — the narrowing dev's list offered, kept so a link |
| 583 | 583 | * written to it keeps meaning what it said. */ |
| 584 | type RunsKind = "agent" | "deploy" | "check"; | |
| 584 | type RunsKind = "agent" | "deploy" | "check" | "proof"; | |
| 585 | 585 | |
| 586 | 586 | function readRunsKind(ctx: Ctx): RunsKind | undefined { |
| 587 | 587 | const raw = ctx.url.searchParams.get("kind"); |
| 588 | return raw === "agent" || raw === "deploy" || raw === "check" | |
| 588 | return raw === "agent" || raw === "deploy" || raw === "check" || raw === "proof" | |
| 589 | 589 | ? raw |
| 590 | 590 | : undefined; |
| 591 | 591 | } |
| @@ -637,6 +637,7 @@ const RUNS_KIND_WORDS: Record<RunsKind, string> = { | ||
| 637 | 637 | agent: "Agent runs", |
| 638 | 638 | deploy: "Deploys", |
| 639 | 639 | check: "Checks", |
| 640 | proof: "Proofs", | |
| 640 | 641 | }; |
| 641 | 642 | |
| 642 | 643 | /** |
| @@ -661,6 +662,7 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 661 | 662 | const listed = listRepoRuns(ctx.app, ctx.auth.user, repo, { |
| 662 | 663 | kind, |
| 663 | 664 | state, |
| 665 | withProofs: true, | |
| 664 | 666 | limit: LIST_PAGE, |
| 665 | 667 | ...(cursor ? { cursor } : {}), |
| 666 | 668 | }); |
| @@ -754,7 +756,7 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 754 | 756 | )} |
| 755 | 757 | </div> |
| 756 | 758 | <p class="muted small mt"> |
| 757 | Show ${(["agent", "deploy", "check"] as const).map( | |
| 759 | Show ${(["agent", "deploy", "check", "proof"] as const).map( | |
| 758 | 760 | (k, i) => |
| 759 | 761 | html`${i > 0 ? " · " : ""}${k === kind |
| 760 | 762 | ? 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" : "Passed", { | |
| 372 | return badge(kind === "agent" ? "Done" : kind === "proof" ? "Green" : "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("Cancelled", { icon: "ban", variant: "outline" }); | |
| 379 | return badge(kind === "proof" ? "Cut off" : "Cancelled", { icon: "ban", variant: "outline" }); | |
| 380 | 380 | } |
| 381 | 381 | } |
| 382 | 382 |
test/merge-queue-proof.test.ts +256 −2
| @@ -23,16 +23,23 @@ import { | ||
| 23 | 23 | STALE_LANDINGS, |
| 24 | 24 | standing, |
| 25 | 25 | startProof, |
| 26 | WAIT_MERGE, | |
| 27 | WAIT_SLOT, | |
| 26 | 28 | } from "../src/checks/proof.ts"; |
| 27 | 29 | import { enqueue, type Queued } from "../src/checks/queue.ts"; |
| 28 | 30 | import { provideChecksForTest, type ChecksHandle } from "../src/checks/service.ts"; |
| 29 | import { landVerdict } from "../src/checks/tiers.ts"; | |
| 31 | import { landVerdict, type LandVerdict } from "../src/checks/tiers.ts"; | |
| 30 | 32 | import { listComments } from "../src/core/issues.ts"; |
| 33 | import { openProofRun } from "../src/core/proof_runs.ts"; | |
| 31 | 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"; | |
| 32 | 37 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 33 | 38 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; |
| 34 | 39 | import { createUser, type User } from "../src/core/users.ts"; |
| 35 | import { sweepGreen } from "../src/crew/autonomy.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"; | |
| 36 | 43 | import { repoDir } from "../src/git/repos.ts"; |
| 37 | 44 | import { logLevel, setLogLevel } from "../src/kernel/log.ts"; |
| 38 | 45 | |
| @@ -315,4 +322,251 @@ describe("the proof before the merge queue", () => { | ||
| 315 | 322 | expect(proofsInFlight()).toBe(0); |
| 316 | 323 | expect(await standing(app.db, dir, pull, mainOf(repo))).toBe("unproved"); |
| 317 | 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 | }); | |
| 318 | 572 | }); |
test/next_actor.test.ts +1
| @@ -19,6 +19,7 @@ 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"], | |
| 22 | 23 | ["approved, queued", { approvals: 1, check: "queued" }, "queue", "approved, its check is queued"], |
| 23 | 24 | ["approved, cancelled", { approvals: 1, check: "cancelled" }, "queue", "approved, its check was cancelled"], |
| 24 | 25 | ["approved, running", { approvals: 1, check: "running" }, "queue", "approved, its check is running"], |
test/proof-runs.test.ts added +176
| @@ -0,0 +1,176 @@ | ||
| 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
This dedupe only compares the latest run. Look for any run of this head with the same summary, as
cutOffBeforedoes, so merge-fail, slot-wait, merge-fail does not tell the pull twice.