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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6589-the-flow-panel-on-boards-says-the-crew-s → main 3 commits fork 4 threads · 4 unresolved
README.md +1
| @@ -356,6 +356,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 356 | 356 | - [](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. |
| 357 | 357 | - [](docs/SENTINEL.md) — the forge hunting its own faults; [](docs/HUNT.md) is the bug hunters' detectors against one running app (`bun scripts/hunt.ts <origin>`). |
| 358 | 358 | - [](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. | |
Nit: "Under it" is ambiguous and the line is long. Name the panel and split the admin-only note into its own sentence. |
||
| 359 | 360 | - 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. |
| 360 | 361 | - 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. |
| 361 | 362 | - 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 | ||
| 57 | 57 | } |
| 58 | 58 | return { finished: runs.size, pushed: pushed.size, landed: landed.size, closed: closed.size }; |
| 59 | 59 | } |
| 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 | ||
| 46 | 46 | const median = n % 2 ? hours[mid]! : (hours[mid - 1]! + hours[mid]!) / 2; |
| 47 | 47 | return { median: Math.round(median * 10) / 10, count: n }; |
| 48 | 48 | } |
| 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"; | ||
| 26 | 26 | import { firedAlarms } from "../../flow/alarms.ts"; |
| 27 | 27 | import { readVitals, vitalsFor, vitalsLine } from "../../flow/vitals.ts"; |
| 28 | 28 | 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"; | |
| 29 | 31 | import { readOpeners, type Opener } from "../../flow/openers.ts"; |
| 30 | 32 | import { readQueues, redBatchWords } from "../../flow/queue.ts"; |
| 31 | 33 | import { reviewersBusy, reviewingSays } from "../../flow/reviewing.ts"; |
| @@ -80,6 +82,8 @@ export function flowLines( | ||
| 80 | 82 | "", |
| 81 | 83 | ...wrap(pileSays(readPile(db, agent !== undefined)), inner), |
| 82 | 84 | ...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), | |
| 83 | 87 | ...openerLines(readOpeners(db, now, agent !== undefined), inner), |
| 84 | 88 | "", |
| 85 | 89 | `${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"; | ||
| 37 | 37 | import { warmingLine } from "../../crew/hot_history.ts"; |
| 38 | 38 | import { getRepoById } from "../../core/repos.ts"; |
| 39 | 39 | 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"; | |
| 40 | 42 | import { openerCounts, readOpeners } from "../../flow/openers.ts"; |
| 41 | 43 | import { readHeld } from "../../flow/held.ts"; |
| 42 | 44 | import type { App } from "../../app.ts"; |
| @@ -105,6 +107,8 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 105 | 107 | const reviewing = reviewingSays(reviewersBusy(app.db, reader.kind === "agent")); |
| 106 | 108 | const agent = reader.kind === "agent"; |
| 107 | 109 | 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); | |
| 108 | 112 | const openers = readOpeners(app.db, new Date(now), agent); |
| 109 | 113 | const pile = pileSays(readPile(app.db, reader.kind === "agent")); |
| 110 | 114 | const listed = asked(url); |
| @@ -134,6 +138,8 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 134 | 138 | ? "nothing landed in 24 h" |
| 135 | 139 | : html`median <span data-median-hours>${hoursWords(lead.medianHours)}</span>, slowest <span data-max-hours>${hoursWords(lead.maxHours ?? 0)}</span>`} |
| 136 | 140 | </p> |
| 141 | <p id="build-yield" class="muted">${buildYieldSays(yielded)}</p> | |
outdated
The terminal Flow dialog ( |
||
| 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. |
||
| 137 | 143 | <p id="openers" class="muted small"> |
| 138 | 144 | opened in the last hour: |
| 139 | 145 | ${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 | }); |
Two fixes on this line.
/boardsrenders 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.