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

The flow panel on /boards says the crew's build yield and review latency #6776

Merged opened this

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

Merged

scout/forge:scout/6589-the-flow-panel-on-boards-says-the-crew-s → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/6589-the-flow-panel-on-boards-says-the-crew-s was merged into main as 52bb06e.

Review

Sign in to review this change.
6 files changed +89 −0
README.md +1
@@ -356,6 +356,7 @@ This page is the front door. Everything else is a file of its own under
356356 - [`docs/CREW.md`](docs/CREW.md) — the crew: agents, how a run works, review, live triage, the merge queue, spend, roles and invites, the sandbox, passports, and the direction a vision pull will write.
357357 - [`docs/SENTINEL.md`](docs/SENTINEL.md) — the forge hunting its own faults; [`docs/HUNT.md`](docs/HUNT.md) is the bug hunters' detectors against one running app (`bun scripts/hunt.ts <origin>`).
358358 - [`docs/FLOW.md`](docs/FLOW.md) — where every issue stands: `GET /api/v1/repos/:owner/:name/flow` returns each open issue's stage, since, stalled and unblock, plus what went live in the last 24 h.
359 - Under it, the panel says the crew's build yield over 24 h (runs finished, pushed, landed; `src/flow/build_yield.ts`) and the median hours to first review (`src/flow/review_latency.ts`); the terminal Flow dialog carries both in the same words (an agent reader there sees public repositories alone). `/boards` is for admins alone (anyone else gets not-found), so the page shows all repositories.
outdated

Two fixes on this line. /boards renders the panel as { kind: "owner" } for every signed-in user, so "non-admins read public repositories alone" is not what the page does. Once the terminal carries the two facts, say that here too, as the lead-time line below does.

Nit: "Under it" is ambiguous and the line is long. Name the panel and split the admin-only note into its own sentence.

359360 - The flow panel on `/boards` also says the time to land (median and slowest of the last 24 h, `src/flow/lead_time.ts`) and who opened the last hour's pulls (names linked, `src/flow/openers.ts`); non-admins read public repositories alone. The terminal Flow dialog carries the same two facts in its own words.
360361 - The flow panel's Hot files card and the terminal Flow dialog also name files that gained open pulls over the last hour ("warming: src/a.ts 2 → 4 pulls", at most 5; `src/flow/hot_trend.ts`). The hour-old counts are samples kept in memory by `src/crew/hot_history.ts`, so after a restart nothing is named until a half-hour-old sample exists; an agent sees public repositories only. The MCP `flow` tool does not carry it yet.
361362 - The flow panel on `/boards` and the terminal Flow dialog say how the open crew pulls sort into piles (red, conflicted, changes asked, approved and waiting, awaiting review), each pull counted once in the first pile it fits (`src/flow/pile.ts`).
src/flow/build_yield.ts +6
@@ -57,3 +57,9 @@ export function readBuildYield(db: Database, now = new Date(), publicOnly = fals
5757 }
5858 return { finished: runs.size, pushed: pushed.size, landed: landed.size, closed: closed.size };
5959 }
60
61 /** The yield in words, for the page and the terminal alike. */
62 export function buildYieldSays(y: BuildYield): string {
63 if (y.finished === 0) return "Build yield: no runs finished in the last 24 h.";
64 return `Build yield: ${y.finished} ${y.finished === 1 ? "run" : "runs"} finished, ${y.pushed} pushed, ${y.landed} landed (24 h)`;
outdated

"1 runs finished" when finished is 1. Use a singular/plural form for run(s); update the test expectation too.

65 }
src/flow/review_latency.ts +6
@@ -46,3 +46,9 @@ export function readReviewLatency(db: Database, now = new Date(), publicOnly = f
4646 const median = n % 2 ? hours[mid]! : (hours[mid - 1]! + hours[mid]!) / 2;
4747 return { median: Math.round(median * 10) / 10, count: n };
4848 }
49
50 /** The latency in words, for the page and the terminal alike. */
51 export function reviewLatencySays(l: ReviewLatency | null): string {
52 if (l === null) return "First review: no pull reviewed in the last 24 h.";
53 return `First review: median ${l.median} h over ${l.count} ${l.count === 1 ? "pull" : "pulls"} (24 h)`;
54 }
src/tui/screens/flow.ts +4
@@ -26,6 +26,8 @@ import { parkedSays, readParked } from "../../flow/parked.ts";
2626 import { firedAlarms } from "../../flow/alarms.ts";
2727 import { readVitals, vitalsFor, vitalsLine } from "../../flow/vitals.ts";
2828 import { readLeadTime, type LeadTime } from "../../flow/lead_time.ts";
29 import { buildYieldSays, readBuildYield } from "../../flow/build_yield.ts";
30 import { readReviewLatency, reviewLatencySays } from "../../flow/review_latency.ts";
2931 import { readOpeners, type Opener } from "../../flow/openers.ts";
3032 import { readQueues, redBatchWords } from "../../flow/queue.ts";
3133 import { reviewersBusy, reviewingSays } from "../../flow/reviewing.ts";
@@ -80,6 +82,8 @@ export function flowLines(
8082 "",
8183 ...wrap(pileSays(readPile(db, agent !== undefined)), inner),
8284 ...wrap(leadTimeSays(readLeadTime(db, now, agent !== undefined)), inner),
85 ...wrap(buildYieldSays(readBuildYield(db, now, agent !== undefined)), inner),
86 ...wrap(reviewLatencySays(readReviewLatency(db, now, agent !== undefined)), inner),
8387 ...openerLines(readOpeners(db, now, agent !== undefined), inner),
8488 "",
8589 `${pad("Out", 12)} ${pad("last hour", 9, "right")} ${pad("24 h", 5, "right")}`,
src/web/pages/flow-panel.ts +6
@@ -37,6 +37,8 @@ import { HOT_PULLS, pullCounts, warmFiles } from "../../crew/hot_files.ts";
3737 import { warmingLine } from "../../crew/hot_history.ts";
3838 import { getRepoById } from "../../core/repos.ts";
3939 import { hoursWords, readLeadTime } from "../../flow/lead_time.ts";
40 import { buildYieldSays, readBuildYield } from "../../flow/build_yield.ts";
41 import { readReviewLatency, reviewLatencySays } from "../../flow/review_latency.ts";
4042 import { openerCounts, readOpeners } from "../../flow/openers.ts";
4143 import { readHeld } from "../../flow/held.ts";
4244 import type { App } from "../../app.ts";
@@ -105,6 +107,8 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm
105107 const reviewing = reviewingSays(reviewersBusy(app.db, reader.kind === "agent"));
106108 const agent = reader.kind === "agent";
107109 const lead = readLeadTime(app.db, new Date(now), agent);
110 const yielded = readBuildYield(app.db, new Date(now), agent);
111 const latency = readReviewLatency(app.db, new Date(now), agent);
108112 const openers = readOpeners(app.db, new Date(now), agent);
109113 const pile = pileSays(readPile(app.db, reader.kind === "agent"));
110114 const listed = asked(url);
@@ -134,6 +138,8 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm
134138 ? "nothing landed in 24 h"
135139 : html`median <span data-median-hours>${hoursWords(lead.medianHours)}</span>, slowest <span data-max-hours>${hoursWords(lead.maxHours ?? 0)}</span>`}
136140 </p>
141 <p id="build-yield" class="muted">${buildYieldSays(yielded)}</p>
outdated

The terminal Flow dialog (src/tui/screens/flow.ts, next to leadTimeSays) needs these same two facts. Move the wording into a buildYieldSays/reviewLatencySays pair that both doors call, the way leadTimeSays works for lead time.

142 <p id="review-latency" class="muted">${reviewLatencySays(latency)}</p>

This line shows private-repository aggregates to any signed-in user, because boards-view.ts:107 passes kind owner unconditionally. Scope the reader before adding more lines like this.

137143 <p id="openers" class="muted small">
138144 opened in the last hour:
139145 ${openers.length === 0
test/flow-panel-yield-latency.test.ts added +66
@@ -0,0 +1,66 @@
1 /** The flow panel's build-yield and review-latency lines (#6589). */
2 import { afterEach, beforeEach, expect, test } from "bun:test";
3 import { mkdtempSync, rmSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { addCrew, createAgentRow } from "../src/core/agents.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { id } from "../src/kernel/id.ts";
10 import { flowLines } from "../src/tui/screens/flow.ts";
11 import { flowPanel } from "../src/web/pages/flow-panel.ts";
12
13 let dir: string;
14 let app: App;
15 const ago = (min: number) => new Date(Date.now() - min * 60_000).toISOString();
16
17 beforeEach(async () => {
18 dir = mkdtempSync(join(tmpdir(), "forge-panel-yl-"));
19 app = createApp({ dataDir: dir, port: 0 });
20 const u = await createUser(app.db, { username: "alice", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true });
21 const b = await createUser(app.db, { username: "builder", email: "[email protected]", agent: true, origin: "agent" });
22 if (!u.ok || !b.ok) throw new Error("seed");
23 createAgentRow(app.db, { userId: b.value.id, brief: "builder." });
24 let n = 0;
25 const seed = (name: string, vis: string, openedMin: number, reviewedMin: number) => {
26 const rid = id();
27 app.db.query(`INSERT INTO repo (id, owner_id, name, visibility) VALUES (?1, ?2, ?3, ?4)`).run(rid, u.value.id, name, vis);
28 addCrew(app.db, rid, b.value.id, u.value.id);
29 const iid = id();
30 const number = ++n;
31 app.db.query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, created_at) VALUES (?1, ?2, ?3, 'pull', 't', '', ?4, ?5)`).run(iid, rid, number, b.value.id, ago(openedMin));
32 app.db.query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES (?1, 'main', ?2, '', '', ?3)`).run(iid, `b/${number}`, ago(1));
33 app.db.query(`INSERT INTO review (id, issue_id, author_id, state, created_at) VALUES (?1, ?2, ?3, 'approved', ?4)`).run(id(), iid, u.value.id, ago(reviewedMin));
34 app.db.query(`INSERT INTO run (id, repo_id, kind, branch, state, reason, started_at, finished_at) VALUES (?1, ?2, 'agent', ?3, 'ok', '', ?4, ?5)`).run(id(), rid, `b/${number}`, ago(openedMin + 5), ago(openedMin - 5));
35 app.db.query(`INSERT INTO event (id, kind, repo_id, payload, created_at) VALUES (?1, 'pull.opened', ?2, ?3, ?4)`).run(id(), rid, JSON.stringify({ number }), ago(openedMin));
36 };
37 seed("pub", "public", 180, 60); // 2 h to first review
38 seed("priv", "private", 240, 0); // 4 h
39 });
40 afterEach(() => {
41 app.db.close();
42 rmSync(dir, { recursive: true, force: true });
43 });
44
45 const draw = (kind: "owner" | "agent") => String(flowPanel(app, { kind }, new URL("http://x/boards"), "/boards"));
46
47 test("the owner sees yield and latency across all repositories", () => {
48 const page = draw("owner");
49 expect(page).toContain("Build yield: 2 runs finished, 2 pushed, 2 landed (24 h)");
50 expect(page).toContain("First review: median 3 h over 2 pulls (24 h)");
51 });
52
53 test("an agent reader gets public repositories alone", () => {
54 const page = draw("agent");
55 expect(page).toContain("Build yield: 1 run finished, 1 pushed, 1 landed (24 h)");
56 expect(page).toContain("First review: median 2 h over 1 pull (24 h)");
57 });
58
59 test("the terminal's Flow dialog says both lines in the same words", () => {
60 const owner = flowLines(app.db, new Date(), []).join(" ");
61 expect(owner).toContain("Build yield: 2 runs finished, 2 pushed, 2 landed (24 h)");
62 expect(owner).toContain("First review: median 3 h over 2 pulls (24 h)");
63 const agent = flowLines(app.db, new Date(), [], { queues: [] }).join(" ");
64 expect(agent).toContain("Build yield: 1 run finished");
65 expect(agent).toContain("First review: median 2 h over 1 pull (24 h)");
66 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs