Public: anyone can read this; the crew can change it.
An admin re-checks, in one call, every open pull whose latest check failed on one named test #6448
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6445-an-admin-re-checks-in-one-call-every-ope → main 2 commits fork 3 threads · 3 unresolved
README.md +2
| @@ -91,6 +91,8 @@ Set `FORGE_REPO=owner/name` to the forge's own code and a pull the crew lands th | ||
| 91 | 91 | |
| 92 | 92 | **Keeping a domain drill**: `POST /api/v1/drills` (a site admin person; an agent is refused with 403, and a session needs a same-origin request) takes `{domain, seen:{health, board}, backSeconds}`: a domain key from `docs/DOMAINS.md`, what the drill read from health and the board (text, at most 8000 characters each), and whole seconds until the forge was well again (0 to 31536000). The body is capped at 32000 bytes (413). It answers 201 `{id, domain, backSeconds, green, why}`: the verdict is judged against the domain's own words and kept whether green or red. An unknown domain, bad `seen` or bad `backSeconds` is 422. Kept drills are read back on **Settings → Backups**, in its **Failure domains** card (`docs/SNAPSHOT.md`). To run a drill by hand, `FORGE_TOKEN=… bun scripts/drills/run.ts <domain> --forge <url>` (plain `http` is refused unless the host is loopback or you pass `--insecure`) prompts for the fault, watches health and the board until the forge is back, and posts this for you (`docs/DOMAINS.md`). |
| 93 | 93 | |
| 94 | **Re-checking a shared red**: `POST /api/v1/repos/:owner/:name/checks/recheck` (a repository admin, `admin` token scope; anyone else gets 404, and a session needs a same-origin request) takes `{test}`: the name of one failing test exactly as the check log's failing names show it, 1 to 300 characters (otherwise 400). It asks again, through the ordinary check queue, for every open pull whose latest check on its current head failed on that test, and answers `{queued: [pull numbers]}`. At most 50 pulls are queued per call; when more were red, the answer adds `more` (how many were left) and a `message`, and a second call takes the rest. A pull waiting for a writer's approval, or whose head declares no check, is not in `queued`. | |
| 95 | ||
| 94 | 96 | If your shell already has `FORGE_URL` set — a crew workspace sets it to the |
| 95 | 97 | forge you work for — this forge takes it as its own canonical address and |
| 96 | 98 | refuses every form a browser posts to `localhost`: run it with |
src/checks/recheck.ts added +55
| @@ -0,0 +1,55 @@ | ||
| 1 | /** | |
| 2 | * Every open pull whose latest check on its current head failed on one | |
| 3 | * named test, asked for again (#6445): once a shared red is fixed on main, | |
| 4 | * the pulls it held are handed back to the check queue in one call. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { App } from "../app.ts"; | |
| 8 | import { listOpenPulls, type Pull } from "../core/pulls.ts"; | |
| 9 | import type { Repo } from "../core/repos.ts"; | |
| 10 | import { getRun, type Run } from "../core/runs.ts"; | |
| 11 | import type { User } from "../core/users.ts"; | |
| 12 | import { askCheck } from "./again.ts"; | |
| 13 | ||
| 14 | /** The most pulls one call queues. */ | |
| 15 | export const RECHECK_MAX = 50; | |
| 16 | ||
| 17 | /** The longest test name taken. */ | |
| 18 | export const TEST_NAME_MAX = 300; | |
| 19 | ||
| 20 | export interface Rechecked { | |
| 21 | readonly queued: number[]; | |
| 22 | /** Red pulls beyond `RECHECK_MAX`, left for the next call. */ | |
| 23 | readonly left: number; | |
| 24 | } | |
| 25 | ||
| 26 | /** The newest check run of this head of the pull. */ | |
| 27 | function latestCheck(app: App, pull: Pull): Run | undefined { | |
| 28 | const row = app.db | |
| 29 | .query<{ id: string }, [string, string]>( | |
| 30 | `SELECT id FROM run WHERE kind = 'check' AND issue_id = ?1 AND commit_oid = ?2 ORDER BY id DESC LIMIT 1`, | |
| 31 | ) | |
| 32 | .get(pull.id, pull.headOid); | |
| 33 | return row === null ? undefined : getRun(app.db, row.id)!; | |
| 34 | } | |
| 35 | ||
| 36 | export async function recheckNamed( | |
| 37 | app: App, | |
| 38 | user: User, | |
| 39 | repo: Repo, | |
| 40 | test: string, | |
| 41 | max = RECHECK_MAX, | |
| 42 | ): Promise<Rechecked> { | |
| 43 | const red = listOpenPulls(app.db, repo.id).filter((pull) => { | |
| 44 | if (pull.headOid === "") return false; | |
| 45 | const run = latestCheck(app, pull); | |
| 46 | return run?.state === "failed" && (run.checkDetail?.names.includes(test) ?? false); | |
Not blocking: |
||
| 47 | }); | |
| 48 | const queued: number[] = []; | |
| 49 | for (const pull of red.slice(0, max)) { | |
| 50 | // The ordinary request: the check queue's own slots and order apply. | |
| 51 | const asked = await askCheck(app, user, repo, pull); | |
| 52 | if (asked.ok) queued.push(pull.number); | |
| 53 | } | |
| 54 | return { queued, left: Math.max(0, red.length - max) }; | |
| 55 | } |
src/contract/openapi/routes/index.ts +1
| @@ -174,6 +174,7 @@ export { route as postApiV1ReposOwnerNameAppDomainsHostCheck } from "./postApiV1 | ||
| 174 | 174 | export { route as postApiV1ReposOwnerNameAppEnvRedeploy } from "./postApiV1ReposOwnerNameAppEnvRedeploy.ts"; |
| 175 | 175 | export { route as postApiV1ReposOwnerNameAppEnvStop } from "./postApiV1ReposOwnerNameAppEnvStop.ts"; |
| 176 | 176 | export { route as postApiV1ReposOwnerNameBranches } from "./postApiV1ReposOwnerNameBranches.ts"; |
| 177 | export { route as postApiV1ReposOwnerNameChecksRecheck } from "./postApiV1ReposOwnerNameChecksRecheck.ts"; | |
| 177 | 178 | export { route as postApiV1ReposOwnerNameContents } from "./postApiV1ReposOwnerNameContents.ts"; |
| 178 | 179 | export { route as postApiV1ReposOwnerNameContribute } from "./postApiV1ReposOwnerNameContribute.ts"; |
| 179 | 180 | export { route as postApiV1ReposOwnerNameEdits } from "./postApiV1ReposOwnerNameEdits.ts"; |
src/contract/openapi/routes/postApiV1ReposOwnerNameChecksRecheck.ts added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "post", | |
| 5 | path: `${REPO}/checks/recheck`, | |
| 6 | summary: "Ask again for every open pull whose latest check on its head failed on the test named in the body { test } (admin scope, a repository admin's; at most 50 per call); answers { queued: [numbers] }, with more when some were left", | |
| 7 | auth: true, | |
| 8 | status: 200, | |
| 9 | reply: { queued: "array" }, | |
| 10 | refusals: { | |
| 11 | invalid_test: "test is a name of 1 to 300 characters (400)", | |
| 12 | }, | |
| 13 | }; |
src/http/checks_recheck.ts added +28
| @@ -0,0 +1,28 @@ | ||
| 1 | /** | |
| 2 | * The admin's one call to re-check every open pull red on one named test. | |
| 3 | */ | |
| 4 | ||
| 5 | import { RECHECK_MAX, recheckNamed, TEST_NAME_MAX } from "../checks/recheck.ts"; | |
| 6 | import { canAdmin, getRepo } from "../core/repos.ts"; | |
| 7 | import type { Handler } from "./context.ts"; | |
| 8 | import { apiError, body, json, notFound, requireScope, str } from "./respond.ts"; | |
| 9 | import type { Router } from "./router.ts"; | |
| 10 | ||
| 11 | export function checksRecheckRoutes(router: Router<Handler>): void { | |
| 12 | router.add("POST", "/api/v1/repos/:owner/:name/checks/recheck", async (ctx) => { | |
| 13 | const denied = requireScope(ctx, "admin"); | |
| 14 | if (denied) return denied; | |
| 15 | const repo = getRepo(ctx.app.db, ctx.params.owner!, ctx.params.name!); | |
| 16 | if (!repo || !canAdmin(ctx.app.db, repo, ctx.auth.user)) return notFound(); | |
| 17 | const test = str((await body(ctx.request)).test); | |
| 18 | if (test.length < 1 || test.length > TEST_NAME_MAX) | |
| 19 | return apiError("invalid_test", 400, `test is a name of 1 to ${TEST_NAME_MAX} characters`); | |
| 20 | const done = await recheckNamed(ctx.app, ctx.auth.user!, repo, test); | |
| 21 | return json( | |
| 22 | done.left > 0 | |
No test reaches this branch. Add a test with more red pulls than the cap that asserts |
||
| 23 | ? { queued: done.queued, more: done.left, message: `at most ${RECHECK_MAX} per call; ${done.left} left` } | |
| 24 | : { queued: done.queued }, | |
| 25 | 200, | |
| 26 | ); | |
| 27 | }); | |
| 28 | } |
src/http/server.ts +2
| @@ -469,6 +469,7 @@ import { proposalTokenMint, proposalTokenRevoke } from "../web/pages/proposal-to | ||
| 469 | 469 | import { adminGate } from "./admin_gate.ts"; |
| 470 | 470 | import { proposalGate } from "./proposal_gate.ts"; |
| 471 | 471 | import { runGate } from "./run_gate.ts"; |
| 472 | import { checksRecheckRoutes } from "./checks_recheck.ts"; | |
| 472 | 473 | import { feedRoutes } from "./feed.ts"; |
| 473 | 474 | import type { Database } from "bun:sqlite"; |
| 474 | 475 | import { mcpRoutes } from "./mcp.ts"; |
| @@ -1463,6 +1464,7 @@ export function buildApiRouter(): Router<Handler> { | ||
| 1463 | 1464 | const router = new Router<Handler>(); |
| 1464 | 1465 | apiRoutes(router); |
| 1465 | 1466 | feedRoutes(router); |
| 1467 | checksRecheckRoutes(router); | |
| 1466 | 1468 | return router; |
| 1467 | 1469 | } |
| 1468 | 1470 |
test/checks-recheck.test.ts added +116
| @@ -0,0 +1,116 @@ | ||
| 1 | /** | |
| 2 | * An admin re-checks, in one call, every open pull whose latest check failed | |
| 3 | * on one named test (#6445). | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, 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 { createApp, type App } from "../src/app.ts"; | |
| 10 | import { openPull } from "../src/actions/pulls.ts"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { recheckNamed, RECHECK_MAX } from "../src/checks/recheck.ts"; | |
| 13 | import { provideChecksForTest, type ChecksHandle } from "../src/checks/service.ts"; | |
| 14 | import type { Pull } from "../src/core/pulls.ts"; | |
| 15 | import type { Repo } from "../src/core/repos.ts"; | |
| 16 | import { claimRun, createRun, finishRun, listRuns } from "../src/core/runs.ts"; | |
| 17 | import { createSession } from "../src/core/sessions.ts"; | |
| 18 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 19 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 20 | import { adminCookie } from "./helpers/admin.ts"; | |
| 21 | ||
| 22 | let dataDir = ""; | |
| 23 | let app: App; | |
| 24 | let servers: Servers; | |
| 25 | let repo: Repo; | |
| 26 | let cookie = ""; | |
| 27 | let bobCookie = ""; | |
| 28 | const pulls: Pull[] = []; | |
| 29 | ||
| 30 | const asked = () => listRuns(app.db, repo.id, { kind: "check" }).filter((r) => r.reason === "asked"); | |
| 31 | ||
| 32 | function redOn(pull: Pull, name: string): void { | |
| 33 | const run = createRun(app.db, { repoId: repo.id, kind: "check", env: `pull-${pull.number}`, commitOid: pull.headOid, issueId: pull.id }); | |
| 34 | claimRun(app.db, run.id); | |
| 35 | finishRun(app.db, run.id, { state: "failed", summary: "check failed", checkDetail: { passed: 1, failed: 1, names: [name] } }); | |
| 36 | } | |
| 37 | ||
| 38 | const call = (body: unknown, headers: Record<string, string> = {}, who = cookie) => | |
| 39 | fetch(`${servers.url}/api/v1/repos/alice/engine/checks/recheck`, { | |
| 40 | method: "POST", | |
| 41 | headers: { "content-type": "application/json", cookie: who, ...headers }, | |
| 42 | body: JSON.stringify(body), | |
| 43 | }); | |
| 44 | ||
| 45 | beforeAll(async () => { | |
| 46 | dataDir = await mkdtemp(join(tmpdir(), "forge-recheck-")); | |
| 47 | app = createApp({ dataDir, port: 0 }); | |
| 48 | servers = await serve(app); | |
| 49 | cookie = await adminCookie(app, { username: "alice", email: "[email protected]", password: "a-good-passphrase" }); | |
| 50 | const alice = getUserByName(app.db, "alice")!; | |
| 51 | const bob = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" }); | |
| 52 | if (!bob.ok) throw new Error(bob.error); | |
| 53 | bobCookie = `forge_session=${createSession(app.db, bob.value.id).token}`; | |
| 54 | const made = await createRepo(app, alice, { name: "engine", visibility: "public" }); | |
| 55 | if (!made.ok) throw new Error(String(made.error)); | |
| 56 | repo = made.value; | |
| 57 | await commitToBranch(app, alice, repo, { message: "Start", files: [{ path: "a.ts", content: "1\n" }] }); | |
| 58 | for (const n of [1, 2, 3]) { | |
| 59 | await commitToBranch(app, alice, repo, { branch: `f/${n}`, base: "main", message: `F${n}`, files: [{ path: `f${n}.ts`, content: `${n}\n` }] }); | |
| 60 | const opened = await openPull(app, alice, repo, { title: `F${n}`, body: "", baseBranch: "main", headBranch: `f/${n}` }); | |
| 61 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 62 | pulls.push(opened.value); | |
| 63 | } | |
| 64 | const request: ChecksHandle["request"] = async (r, env, oid, reason, issueId) => | |
| 65 | createRun(app.db, { repoId: r.id, kind: "check", env, commitOid: oid, reason, issueId }); | |
| 66 | provideChecksForTest(app, { request } as unknown as ChecksHandle); | |
| 67 | redOn(pulls[0]!, "tools/list stays in budget"); | |
| 68 | redOn(pulls[1]!, "tools/list stays in budget"); | |
| 69 | redOn(pulls[2]!, "something else"); | |
| 70 | }); | |
| 71 | ||
| 72 | afterAll(async () => { | |
| 73 | servers.stop(); | |
| 74 | app.close(); | |
| 75 | await rm(dataDir, { recursive: true, force: true }); | |
| 76 | }); | |
| 77 | ||
| 78 | test("a non-admin gets 404 and queues nothing", async () => { | |
| 79 | expect((await call({ test: "tools/list stays in budget" }, {}, bobCookie)).status).toBe(404); | |
| 80 | expect(asked()).toHaveLength(0); | |
| 81 | }); | |
| 82 | ||
| 83 | test("a foreign Origin is refused", async () => { | |
| 84 | expect((await call({ test: "tools/list stays in budget" }, { origin: "https://evil.example" })).status).toBe(403); | |
| 85 | expect(asked()).toHaveLength(0); | |
| 86 | }); | |
| 87 | ||
| 88 | test("an empty or overlong test name is a 400", async () => { | |
| 89 | expect((await call({ test: "" })).status).toBe(400); | |
| 90 | expect((await call({})).status).toBe(400); | |
| 91 | expect((await call({ test: "x".repeat(301) })).status).toBe(400); | |
| 92 | expect(asked()).toHaveLength(0); | |
| 93 | }); | |
| 94 | ||
| 95 | test("the pulls red on the named test are queued, the one red on another is not", async () => { | |
| 96 | const res = await call({ test: "tools/list stays in budget" }); | |
| 97 | expect(res.status).toBe(200); | |
| 98 | expect(await res.json()).toEqual({ queued: [pulls[0]!.number, pulls[1]!.number] }); | |
| 99 | expect(asked().map((r) => r.issueId).sort()).toEqual([pulls[0]!.id, pulls[1]!.id].sort()); | |
| 100 | }); | |
| 101 | ||
| 102 | test("a pull whose latest check is no longer red is not queued again", async () => { | |
| 103 | const res = await call({ test: "tools/list stays in budget" }); | |
| 104 | expect(((await res.json()) as { queued: number[] }).queued).toEqual([]); | |
| 105 | }); | |
| 106 | ||
| 107 | test("past the cap, the rest are left and the answer says how many", async () => { | |
| 108 | const alice = getUserByName(app.db, "alice")!; | |
| 109 | expect(RECHECK_MAX).toBe(50); | |
| 110 | redOn(pulls[0]!, "capped"); | |
| 111 | redOn(pulls[1]!, "capped"); | |
| 112 | const done = await recheckNamed(app, alice, repo, "capped", 1); | |
| 113 | expect(done).toEqual({ queued: [pulls[0]!.number], left: 1 }); | |
| 114 | const next = await recheckNamed(app, alice, repo, "capped", 1); | |
| 115 | expect(next).toEqual({ queued: [pulls[1]!.number], left: 0 }); | |
| 116 | }); |
test/openapi.test.ts +1 −1
| @@ -30,7 +30,7 @@ test("every http route the manifest names is described", () => { | ||
| 30 | 30 | }); |
| 31 | 31 | |
| 32 | 32 | test("every described route is registered in the router", () => { |
| 33 | const src = ["src/http/server.ts", "src/http/api.ts", "src/http/mcp.ts", "src/http/passport_edge.ts", "src/http/feed.ts"] | |
| 33 | const src = ["src/http/server.ts", "src/http/api.ts", "src/http/mcp.ts", "src/http/passport_edge.ts", "src/http/feed.ts", "src/http/checks_recheck.ts"] | |
| 34 | 34 | .map((f) => readFileSync(f, "utf8")) |
| 35 | 35 | .join("\n") |
| 36 | 36 | // A route registered in a loop over issues and pulls is two routes. |
To make the cap testable without opening 51 pulls, let
recheckNamedtake the max as a parameter that defaults toRECHECK_MAX. Then the test can call it with 1 against the two red pulls already set up.