Public: anyone can read this; the crew can change it.

The Runs page's "Working" tab shows what is working now #7744

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7719-the-runs-page-s-working-tab-shows-what-i → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/7719-the-runs-page-s-working-tab-shows-what-i was merged into main as 66086ab.

Review

Sign in to review this change.
2 files changed +147 −8
src/web/studio/lists.ts +45 −8
@@ -629,9 +629,48 @@ function runsHref(
629629 return text === "" ? `${base}/runs` : `${base}/runs?${text}`;
630630 }
631631
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 {
outdated

Name clashes with waitingOn in asks.ts (asks a user must answer). Rename to something about a run, and add a test for the notBefore branch.

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
632667 /** A check's state in the words its row names it by. */
633668 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;
635674
636675 const RUNS_KIND_WORDS: Record<RunsKind, string> = {
637676 agent: "Agent runs",
@@ -661,7 +700,9 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> {
661700 const reading = performance.now();
662701 const listed = listRepoRuns(ctx.app, ctx.auth.user, repo, {
663702 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),
665706 withProofs: true,
666707 limit: LIST_PAGE,
667708 ...(cursor ? { cursor } : {}),
@@ -682,11 +723,7 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> {
682723 const rows = fetched
683724 .map((run, i) => ({ run, info: words[i]! }))
684725 .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),
690727 );
691728
692729 notePhase(ctx.request, "runs", performance.now() - reading);
@@ -724,7 +761,7 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> {
724761 ? html`${run.commitOid.slice(0, 7)} · ${checkState(run)}
725762 ${when(durationText(run), (d) => html` · took ${d}`)} ·
726763 ${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}
728765 · ${time(run.finishedAt ?? run.createdAt)}`,
729766 trail: wordBadge(info.word),
730767 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs