Public: anyone can read this; the crew can change it.
The Runs page's "Working" tab shows what is working now #7744
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7719-the-runs-page-s-working-tab-shows-what-i → main 3 commits fork 2 threads · 2 unresolved
2 files
changed +147
−8
src/web/studio/lists.ts +45 −8
| @@ -629,9 +629,48 @@ function runsHref( | ||
| 629 | 629 | return text === "" ? `${base}/runs` : `${base}/runs?${text}`; |
| 630 | 630 | } |
| 631 | 631 | |
| 632 | /** What a queued run waits on, in the words its row says it by: a builder | |
| 633 | * for an agent run, a slot for a check, the deploy lane for a deploy; a run | |
| 634 | * paused on a limit says it resumes later. */ | |
| 635 | export function whatRunWaitsOn(run: Run): string { | |
| 636 | if (run.notBefore !== null) return "paused on a limit, resumes soon"; | |
| 637 | return run.kind === "agent" | |
| 638 | ? "waiting for a builder" | |
| 639 | : run.kind === "check" | |
| 640 | ? "waiting for a check slot" | |
| 641 | : "waiting for the deploy lane"; | |
| 642 | } | |
| 643 | ||
| 644 | /** Whether a run is doing something at this moment — the only runs under | |
| 645 | * Working. A deploy is not counted: the flow report's stages are builders, | |
| 646 | * reviewers and checks, and the tab's count is held to theirs. */ | |
| 647 | export function isWorkingNow(run: Run): boolean { | |
| 648 | return run.state === "running" && run.kind !== "deploy"; | |
| 649 | } | |
| 650 | ||
| 651 | /** Whether a run belongs on the page asked for. Working is exactly the | |
| 652 | * runs running now. With no word chosen a check or a deploy that finished | |
| 653 | * is news too, and an explicit `?state=` shows every run it fetched: the | |
| 654 | * default list is the whole story, newest first. Otherwise the word rules. */ | |
| 655 | export function onRunsList( | |
| 656 | run: Run, | |
| 657 | word: Word, | |
| 658 | filter: WordFilter | undefined, | |
| 659 | state: RunState | undefined, | |
| 660 | ): boolean { | |
| 661 | if (filter === "working") return isWorkingNow(run); | |
| 662 | if (filter === undefined && (run.kind !== "agent" || state !== undefined)) | |
| 663 | return true; | |
| 664 | return inWordFilter(word, filter); | |
| 665 | } | |
| 666 | ||
| 632 | 667 | /** A check's state in the words its row names it by. */ |
| 633 | 668 | const checkState = (run: Run): string => |
| 634 | run.state === "ok" ? "passed" : run.state; | |
| 669 | run.state === "ok" | |
| 670 | ? "passed" | |
| 671 | : run.state === "queued" | |
| 672 | ? whatRunWaitsOn(run) | |
| 673 | : run.state; | |
| 635 | 674 | |
| 636 | 675 | const RUNS_KIND_WORDS: Record<RunsKind, string> = { |
| 637 | 676 | agent: "Agent runs", |
| @@ -661,7 +700,9 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 661 | 700 | const reading = performance.now(); |
| 662 | 701 | const listed = listRepoRuns(ctx.app, ctx.auth.user, repo, { |
| 663 | 702 | kind, |
| 664 | state, | |
| 703 | // Working is what runs now: read only the running ones, so the page | |
| 704 | // is not spent on finished rows it would drop. | |
| 705 | state: state ?? (filter === "working" ? "running" : undefined), | |
| 665 | 706 | withProofs: true, |
| 666 | 707 | limit: LIST_PAGE, |
| 667 | 708 | ...(cursor ? { cursor } : {}), |
| @@ -682,11 +723,7 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 682 | 723 | const rows = fetched |
| 683 | 724 | .map((run, i) => ({ run, info: words[i]! })) |
| 684 | 725 | .filter( |
| 685 | // With no word chosen a check or a deploy that finished is news too: | |
| 686 | // the default list is the whole story, newest first. | |
| 687 | (r) => | |
| 688 | (filter === undefined && (r.run.kind !== "agent" || state !== undefined)) || | |
| 689 | inWordFilter(r.info.word, filter), | |
| 726 | (r) => onRunsList(r.run, r.info.word, filter, state), | |
| 690 | 727 | ); |
| 691 | 728 | |
| 692 | 729 | notePhase(ctx.request, "runs", performance.now() - reading); |
| @@ -724,7 +761,7 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 724 | 761 | ? html`${run.commitOid.slice(0, 7)} · ${checkState(run)} |
| 725 | 762 | ${when(durationText(run), (d) => html` · took ${d}`)} · |
| 726 | 763 | ${time(run.finishedAt ?? run.createdAt)}` |
| 727 | : html`${run.kind === "agent" ? html`${who} · ` : null}${info.note} | |
| 764 | : html`${run.kind === "agent" ? html`${who} · ` : null}${run.state === "queued" ? whatRunWaitsOn(run) : info.note} | |
| 728 | 765 | · ${time(run.finishedAt ?? run.createdAt)}`, |
| 729 | 766 | trail: wordBadge(info.word), |
| 730 | 767 | }); |
test/runs-working-tab.test.ts added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | /** | |
| 2 | * The Runs page's Working tab lists only runs doing something now, and its | |
| 3 | * count is the flow report's building + review + checking on the same data; | |
| 4 | * queued runs say what they wait on, finished ones are not under Working (#7719). | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { createRun } from "../src/core/runs.ts"; | |
| 14 | import { createUser } from "../src/core/users.ts"; | |
| 15 | import { readPipeline } from "../src/flow/pipeline.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | ||
| 18 | let dataDir: string; | |
| 19 | let app: App; | |
| 20 | let servers: Servers; | |
| 21 | let cookie = ""; | |
| 22 | ||
| 23 | const page = async (query: string) => | |
| 24 | await ( | |
| 25 | await fetch(`${servers.url}/alice/shop/runs${query}`, { headers: { cookie } }) | |
| 26 | ).text(); | |
| 27 | const rowCount = (body: string) => (body.match(/href="\/alice\/shop\/runs\/[^"]+"/g) ?? []).length; | |
| 28 | ||
| 29 | beforeAll(async () => { | |
| 30 | dataDir = await mkdtemp(join(tmpdir(), "forge-runs-working-")); | |
| 31 | app = createApp({ dataDir, port: 0 }); | |
| 32 | servers = await serve(app); | |
| 33 | const a = await createUser(app.db, { | |
| 34 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 35 | password: "a-good-passphrase", adminIfFirst: true, | |
| 36 | }); | |
| 37 | if (!a.ok) throw new Error(a.error); | |
| 38 | const made = await createRepo(app, a.value, { name: "shop" }); | |
| 39 | if (!made.ok) throw new Error(String(made.error)); | |
| 40 | const repo = made.value; | |
| 41 | const res = await fetch(`${servers.url}/signin`, { | |
| 42 | method: "POST", redirect: "manual", | |
| 43 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 44 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 45 | }); | |
| 46 | cookie = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 47 | const make = (kind: "agent" | "check" | "deploy", state: string, reason = "") => { | |
| 48 | const run = createRun(app.db, { repoId: repo.id, kind, reason, env: kind === "deploy" ? "preview" : "" }); | |
| 49 | app.db.query("UPDATE run SET state = ?1 WHERE id = ?2").run(state, run.id); | |
| 50 | }; | |
| 51 | make("agent", "running", "assigned"); | |
| 52 | make("agent", "running", "review"); | |
| 53 | make("check", "running", "push"); | |
| 54 | make("check", "queued", "push"); | |
| 55 | make("agent", "queued", "assigned"); | |
| 56 | make("deploy", "running"); | |
| 57 | make("deploy", "ok"); | |
| 58 | make("check", "ok", "push"); | |
| 59 | make("agent", "failed", "assigned"); | |
| 60 | make("agent", "ok", "assigned"); | |
| 61 | const paused = createRun(app.db, { | |
| 62 | repoId: repo.id, kind: "check", reason: "push", | |
| 63 | notBefore: new Date(Date.now() + 3_600_000).toISOString(), | |
| 64 | }); | |
| 65 | app.db.query("UPDATE run SET state = 'queued' WHERE id = ?1").run(paused.id); | |
| 66 | make("deploy", "queued"); | |
| 67 | }); | |
| 68 | ||
| 69 | afterAll(async () => { | |
| 70 | await servers.stop(); | |
| 71 | await rm(dataDir, { recursive: true, force: true }); | |
| 72 | }); | |
| 73 | ||
| 74 | test("Working counts what the flow report counts as active, and nothing finished or queued", async () => { | |
| 75 | const stages = Object.fromEntries(readPipeline(app.db).map((s) => [s.key, s])); | |
| 76 | const active = ["building", "review", "checking"].reduce( | |
| 77 | (n, k) => n + (stages[k]?.active ?? 0), | |
| 78 | 0, | |
| 79 | ); | |
| 80 | expect(active).toBe(3); | |
| 81 | expect(rowCount(await page("?word=working&kind=check"))).toBe(1); | |
| 82 | expect(rowCount(await page("?word=working&kind=agent"))).toBe(2); | |
| 83 | expect(rowCount(await page("?word=working&kind=deploy"))).toBe(0); | |
| 84 | expect(rowCount(await page("?word=working&state=running"))).toBe(3); | |
| 85 | expect(rowCount(await page("?word=working&state=queued"))).toBe(0); | |
| 86 | expect(rowCount(await page("?word=working&state=ok"))).toBe(0); | |
| 87 | const body = await page("?word=working"); | |
| 88 | expect(rowCount(body)).toBe(active); | |
| 89 | expect(body).not.toContain("waiting for"); | |
| 90 | }); | |
| 91 | ||
| 92 | test("a queued run says what it waits on", async () => { | |
| 93 | const body = await page(""); | |
| 94 | expect(body).toContain("waiting for a builder"); | |
| 95 | expect(body).toContain("waiting for a check slot"); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a paused run and a queued deploy say what they wait on", async () => { | |
| 99 | const body = await page("?state=queued"); | |
| 100 | expect(body).toContain("paused on a limit, resumes soon"); | |
| 101 | expect(body).toContain("waiting for the deploy lane"); | |
| 102 | }); |
new above
offline
Name clashes with
waitingOnin asks.ts (asks a user must answer). Rename to something about a run, and add a test for the notBefore branch.