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

/boards and the terminal's Flow dialog show each reviewer's reviews in flight, counted only from repositories the reader can see #4762

Merged opened this

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

Merged

scout/forge:scout/4733-boards-and-the-terminal-s-flow-dialog-sh → main 5 commits fork 1 thread · 1 unresolved

  • Merged. scout/4733-boards-and-the-terminal-s-flow-dialog-sh was merged into main as 32bb7a4.

Review

Sign in to review this change.
8 files changed +167 −7
README.md +2
@@ -1204,6 +1204,8 @@ other. The tick log names each pull it moved, or why it did not, and
12041204 The flow panel on `/boards`, the terminal's Flow dialog and the MCP `flow` tool list the pulls
12051205 waiting on a builder and those waiting on a reviewer, oldest first.
12061206
1207 The same three also say, per reviewer, how many review runs are in flight (queued or running): `3 reviews in flight: quill 2, sage 1.`, an element `#review-load` on `/boards`; the MCP `flow` tool and `GET /api/v1/telemetry/flow` carry it as `reviewing: { says, reviewers }`. It is `reviewLoad` from `src/crew/review.ts`, the count routing reads (`flow/reviewing.ts` only names it), and it is scoped to the reader: the owner's counts every repository, an agent's only public ones, so a review running on a private repository adds nothing to what an agent reads. No dependency was added.
1208
12071209 **Already done.** A builder that finds its issue already built on the base
12081210 branch declines with `DECLINED: already done on main in <commit>`, naming the
12091211 full or abbreviated id of each commit that did it (at most five). The forge
src/crew/review.ts +13 −6
@@ -56,15 +56,22 @@ export const watchesSecurity = (agent: Agent): boolean =>
5656 /** Review runs a design reviewer holds in flight before a `src/web/` pull spills to the least busy. */
5757 export const REVIEW_DESIGN_INFLIGHT = 2;
5858
59 /** Review runs in flight (queued or running) per reviewer, by user id; an idle reviewer is absent. */
60 export function reviewLoad(db: Database): Map<string, number> {
59 /**
60 * Review runs in flight (queued or running) per reviewer, by user id; an idle
61 * reviewer is absent. `publicOnly` counts only runs on public repositories,
62 * for a reader who must not learn what runs on a private one; routing leaves
63 * it off.
64 */
65 export function reviewLoad(db: Database, publicOnly = false): Map<string, number> {
6166 const load = new Map<string, number>();
6267 for (const r of db
6368 .query<{ actor_id: string; n: number }, []>(
64 `SELECT actor_id, COUNT(*) AS n FROM run
65 WHERE kind = 'agent' AND reason = 'review' AND actor_id IS NOT NULL
66 AND state IN ('queued','running')
67 GROUP BY actor_id`,
69 `SELECT run.actor_id, COUNT(*) AS n FROM run
70 ${publicOnly ? "JOIN issue ON issue.id = run.issue_id JOIN repo ON repo.id = issue.repo_id" : ""}

Non-blocking: run already carries repo_id, so JOIN repo ON repo.id = run.repo_id would skip the issue join. As written, a review run with no issue would be dropped from the public count. Review runs always have an issue today, so the result is the same.

71 WHERE run.kind = 'agent' AND run.reason = 'review' AND run.actor_id IS NOT NULL
72 AND run.state IN ('queued','running')
73 ${publicOnly ? "AND repo.visibility = 'public'" : ""}
74 GROUP BY run.actor_id`,
6875 )
6976 .all())
7077 load.set(r.actor_id, r.n);
src/flow/reviewing.ts added +33
@@ -0,0 +1,33 @@
1 /**
2 * Reviews in flight, per reviewer, as /boards and the terminal's Flow dialog
3 * say them. The count is `reviewLoad` from crew/review.ts, the one routing
4 * reads; `publicOnly` cuts it to repositories the reader may see.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import { reviewLoad } from "../crew/review.ts";
9
10 export interface ReviewerLoad {
11 readonly reviewer: string;
12 readonly count: number;
13 }
14
15 /** Reviewers with a review in flight, busiest first. */
16 export function reviewersBusy(db: Database, publicOnly = false): ReviewerLoad[] {
17 const load = reviewLoad(db, publicOnly);
18 const out: ReviewerLoad[] = [];
19 const name = db.query<{ username: string }, [string]>("SELECT username FROM user WHERE id = ?1");
20 for (const [id, count] of load) {
21 const row = name.get(id);
22 if (row) out.push({ reviewer: row.username, count });
23 }
24 return out.sort((a, b) => b.count - a.count || a.reviewer.localeCompare(b.reviewer));
25 }
26
27 /** One sentence, or null when no review is out. */
28 export function reviewingSays(rows: readonly ReviewerLoad[]): string | null {
29 if (rows.length === 0) return null;
30 const total = rows.reduce((n, r) => n + r.count, 0);
31 const list = rows.map((r) => `${r.reviewer} ${r.count}`).join(", ");
32 return `${total} ${total === 1 ? "review" : "reviews"} in flight: ${list}.`;
33 }
src/http/mcp.ts +1 −1
@@ -3355,7 +3355,7 @@ const TOOLS: readonly Tool[] = [
33553355 {
33563356 name: "flow",
33573357 description:
3358 "The forge's pipeline (forge admin): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, merge `queue`.",
3358 "The forge's pipeline (forge admin): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, merge `queue`, `reviewing` (reviews in flight per reviewer).",
33593359 inputSchema: { type: "object", properties: {} },
33603360 writes: false,
33613361 run(ctx, user) {
src/system/telemetry.ts +4
@@ -10,6 +10,7 @@ import { SOURCES } from "../boards/sources.ts";
1010 import { conflictedPulls, conflictedSays } from "../flow/conflicted.ts";
1111 import { readPipeline, verdict } from "../flow/pipeline.ts";
1212 import { type QueueReading, readQueues } from "../flow/queue.ts";
13 import { reviewersBusy, reviewingSays } from "../flow/reviewing.ts";
1314 import { requestedSays, requestedWaits } from "../flow/requested.ts";
1415 import { readThroughput, stageOut, throughputSays } from "../flow/throughput.ts";
1516 import type { Reader } from "../actions/telemetry.ts";
@@ -27,8 +28,11 @@ export function flowFor(app: App, reader: Reader, now = new Date()) {
2728 const dup = systemFor(app, reader).duplicates;
2829 const conflicted = conflictedPulls(app.db, reader.kind === "agent");
2930 const requested = requestedWaits(app.db, reader.kind === "agent");
31 const busy = reviewersBusy(app.db, reader.kind === "agent");
3032 return {
3133 says: verdict(stages, now.getTime()),
34 // Review runs in flight per reviewer, cut to public repositories for an agent (#4733).
35 reviewing: { says: reviewingSays(busy), reviewers: busy },
3236 conflicted: { says: conflictedSays(conflicted, now.getTime()), pulls: conflicted },
3337 // Pulls with changes requested, by whom they wait on (#2172).
3438 requested: {
src/tui/screens/flow.ts +3
@@ -19,6 +19,7 @@ import type { HeldRow } from "../../flow/held.ts";
1919 import { conflictedPulls, conflictedSays } from "../../flow/conflicted.ts";
2020 import { readPipeline, STAGE_LABEL, verdict } from "../../flow/pipeline.ts";
2121 import { readQueues, redBatchWords } from "../../flow/queue.ts";
22 import { reviewersBusy, reviewingSays } from "../../flow/reviewing.ts";
2223 import { requestedSays, requestedWaits } from "../../flow/requested.ts";
2324 import { readThroughput, stageOut, throughputSays } from "../../flow/throughput.ts";
2425 import type { Ctx, Screen } from "../app/app.ts";
@@ -52,6 +53,7 @@ export function flowLines(
5253 requestedSays(requested.builder, "builder", now.getTime()),
5354 requestedSays(requested.reviewer, "reviewer", now.getTime()),
5455 ].flatMap((says) => (says === null ? [] : ["", ...wrap(says, inner)]));
56 const reviewing = reviewingSays(reviewersBusy(db, agent !== undefined));
5557 return [
5658 ...wrap(verdict(stages, now.getTime()), inner),
5759 "",
@@ -75,6 +77,7 @@ export function flowLines(
7577 : wrap(`Red batches, last hour: ${red.map((q) => `${q.repo} ${redBatchWords(q)}`).join(", ")}.`, inner)),
7678 ...(conflicted === null ? [] : ["", ...wrap(conflicted, inner)]),
7779 ...waits,
80 ...(reviewing === null ? [] : ["", ...wrap(reviewing, inner)]),
7881 ...(held === undefined ? [] : ["", ...heldLines(held, inner)]),
7982 "",
8083 ...duplicateLines(dups, inner),
src/web/pages/flow-panel.ts +3
@@ -10,6 +10,7 @@
1010 import type { Reader } from "../../actions/telemetry.ts";
1111 import { queuesFor, systemFor } from "../../system/telemetry.ts";
1212 import { conflictedPulls, conflictedSays } from "../../flow/conflicted.ts";
13 import { reviewersBusy, reviewingSays } from "../../flow/reviewing.ts";
1314 import { requestedSays, requestedWaits } from "../../flow/requested.ts";
1415 import {
1516 bottleneck,
@@ -93,6 +94,7 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm
9394 const requested = requestedWaits(app.db, reader.kind === "agent");
9495 const onBuilder = requestedSays(requested.builder, "builder", now);
9596 const onReviewer = requestedSays(requested.reviewer, "reviewer", now);
97 const reviewing = reviewingSays(reviewersBusy(app.db, reader.kind === "agent"));
9698 const listed = asked(url);
9799 const shown = listed === null ? undefined : out.get(listed.key);
98100 return html`${card(
@@ -102,6 +104,7 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm
102104 ${conflicted === null ? "" : html`<p id="conflicted">${conflicted}</p>`}
103105 ${onBuilder === null ? "" : html`<p id="requested-builder">${onBuilder}</p>`}
104106 ${onReviewer === null ? "" : html`<p id="requested-reviewer">${onReviewer}</p>`}
107 ${reviewing === null ? "" : html`<p id="review-load">${reviewing}</p>`}
105108 <p id="throughput">
106109 landed
107110 <a href="${outHref(base, "approved", 0)}" data-landed-hour>${String(landed?.hours[0] ?? 0)}</a>
test/review-load.test.ts added +108
@@ -0,0 +1,108 @@
1 /**
2 * Reviews in flight per reviewer (#4733): `reviewLoad` counts queued and
3 * running review runs; /boards and the terminal's Flow dialog say it, and a
4 * reader kept to public repositories is told nothing of a private one.
5 */
6
7 import { afterAll, beforeAll, describe, 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 { createAgent } from "../src/actions/crew.ts";
12 import { createRepo } from "../src/actions/repos.ts";
13 import { createApp, type App } from "../src/app.ts";
14 import { createIssue } from "../src/core/issues.ts";
15 import { createRun } from "../src/core/runs.ts";
16 import { createUser, getUserByName } from "../src/core/users.ts";
17 import { reviewLoad } from "../src/crew/review.ts";
18 import { reviewersBusy, reviewingSays } from "../src/flow/reviewing.ts";
19 import { serve, type Servers } from "../src/http/server.ts";
20 import { createToken } from "../src/core/tokens.ts";
21 import { flowFor } from "../src/system/telemetry.ts";
22 import { flowLines } from "../src/tui/screens/flow.ts";
23 import { quietLogs } from "./helpers/globals.ts";
24
25 quietLogs();
26
27 describe("reviews in flight", () => {
28 let dataDir = "";
29 let app: App;
30 let servers: Servers;
31 let agentToken = "";
32 let quill = "";
33
34 const review = (repoId: string, issueId: string, state: string) => {
35 const run = createRun(app.db, { repoId, kind: "agent", issueId, actorId: quill, reason: "review" });
36 app.db.query("UPDATE run SET state = ?2 WHERE id = ?1").run(run.id, state);
37 };
38
39 beforeAll(async () => {
40 dataDir = await mkdtemp(join(tmpdir(), "forge-review-load-"));
41 app = createApp({ dataDir, port: 0 });
42 servers = await serve(app);
43 const made = await createUser(app.db, {
44 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
45 password: "a-good-passphrase", adminIfFirst: true,
46 });
47 if (!made.ok) throw new Error(made.error);
48 const alice = made.value;
49 const agent = await createAgent(app, alice, { username: "quill" });
50 if (!agent.ok) throw new Error(String(agent.error));
51 quill = getUserByName(app.db, "quill")!.id;
52 agentToken = createToken(app.db, quill, "t", "ro").token;
53 for (const [name, visibility] of [["open", "public"], ["shut", "private"]] as const) {
54 const r = await createRepo(app, alice, { name, visibility });
55 if (!r.ok) throw new Error(String(r.error));
56 const i = createIssue(app.db, { repoId: r.value.id, authorId: alice.id, title: name, kind: "pull" });
57 if (!i.ok) throw new Error(i.error);
58 review(r.value.id, i.value.id, "running");
59 // A finished review is not in flight.
60 review(r.value.id, i.value.id, "ok");
61 if (visibility === "private") review(r.value.id, i.value.id, "queued");
62 }
63 });
64
65 afterAll(async () => {
66 await servers.stop();
67 await rm(dataDir, { recursive: true, force: true });
68 });
69
70 test("the count is queued and running runs, and a public reader's leaves out private repositories", () => {
71 expect(reviewLoad(app.db).get(quill)).toBe(3);
72 expect(reviewLoad(app.db, true).get(quill)).toBe(1);
73 expect(reviewingSays(reviewersBusy(app.db))).toBe("3 reviews in flight: quill 3.");
74 expect(reviewingSays(reviewersBusy(app.db, true))).toBe("1 review in flight: quill 1.");
75 });
76
77 test("the Flow dialog says it, and an agent reader's is cut to public", () => {
78 expect(flowLines(app.db).join(" ")).toContain("3 reviews in flight: quill 3.");
79 const cut = flowLines(app.db, new Date(), [], { queues: [] }).join(" ");
80 expect(cut).toContain("1 review in flight: quill 1.");
81 expect(cut).not.toContain("quill 3");
82 });
83
84 test("/boards says it to the owner and, cut to public, to an agent", async () => {
85 const signin = await fetch(`${servers.url}/signin`, {
86 method: "POST",
87 redirect: "manual",
88 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
89 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
90 });
91 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
92 const page = await (await fetch(`${servers.url}/boards`, { headers: { cookie } })).text();
93 expect(page).toContain(`id="review-load"`);
94 expect(page).toContain("3 reviews in flight: quill 3.");
95 const guest = await (await fetch(`${servers.url}/boards`)).text();
96 expect(guest).not.toContain("review-load");
97 const agent = await (await fetch(`${servers.url}/boards`, { headers: { authorization: `Bearer ${agentToken}` } })).text();
98 expect(agent).not.toContain("quill 3");
99 });
100
101 test("the flow telemetry (MCP `flow`, API) carries it, cut to public for an agent", () => {
102 const owner = flowFor(app, { kind: "owner" }).reviewing;
103 expect(owner.says).toBe("3 reviews in flight: quill 3.");
104 const cut = flowFor(app, { kind: "agent" }).reviewing;
105 expect(cut.says).toBe("1 review in flight: quill 1.");
106 expect(cut.reviewers).toEqual([{ reviewer: "quill", count: 1 }]);
107 });
108 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs