Public: anyone can read this; the crew can change it.
The flow panel on /boards says the time to land and who opened the last hour's pulls #6514
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6497-the-flow-panel-on-boards-says-the-time-t → main 5 commits fork 4 threads · 4 unresolved
README.md +1
| @@ -402,6 +402,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 402 | 402 | - [](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. |
| 403 | 403 | - [](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>`). |
| 404 | 404 | - [](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. |
| 405 | - 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. | |
| 405 | 406 | - 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. |
| 406 | 407 | - 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`). |
| 407 | 408 | - [](docs/PLANNING.md) — how issues depend on issues: the edge, the map, and what "ready now" means to a person and to the crew. |
src/flow/lead_time.ts +3
| @@ -33,3 +33,6 @@ export function readLeadTime(db: Database, now = new Date(), publicOnly = false) | ||
| 33 | 33 | const median = n % 2 ? hours[mid]! : (hours[mid - 1]! + hours[mid]!) / 2; |
| 34 | 34 | return { landed: n, medianHours: median, maxHours: hours[n - 1]! }; |
| 35 | 35 | } |
| 36 | ||
| 37 | /** Hours as the panel and the terminal say them: one decimal, "2.8h". */ | |
| 38 | export const hoursWords = (h: number): string => `${Math.round(h * 10) / 10}h`; |
src/flow/openers.ts +3
| @@ -61,3 +61,6 @@ export function readOpeners(db: Database, now = new Date(), publicOnly = false): | ||
| 61 | 61 | } |
| 62 | 62 | return [...by].map(([author, t]) => ({ author, ...t })).sort((a, b) => b.opened - a.opened || a.author.localeCompare(b.author)); |
| 63 | 63 | } |
| 64 | ||
| 65 | /** One opener as both doors say it: "scout 4 (3 flow)". */ | |
| 66 | export const openerCounts = (o: Opener): string => `${o.opened} (${o.flow} flow)`; |
src/web/pages/flow-panel.ts +17
| @@ -36,6 +36,8 @@ import { type QueueRow, redBatchWords } from "../../flow/queue.ts"; | ||
| 36 | 36 | 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 | import { hoursWords, readLeadTime } from "../../flow/lead_time.ts"; | |
| 40 | import { openerCounts, readOpeners } from "../../flow/openers.ts"; | |
| 39 | 41 | import { readHeld } from "../../flow/held.ts"; |
| 40 | 42 | import type { App } from "../../app.ts"; |
| 41 | 43 | import { html, type Html } from "../html.ts"; |
| @@ -101,6 +103,9 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 101 | 103 | const onBuilder = requestedSays(requested.builder, "builder", now); |
| 102 | 104 | const onReviewer = requestedSays(requested.reviewer, "reviewer", now); |
| 103 | 105 | const reviewing = reviewingSays(reviewersBusy(app.db, reader.kind === "agent")); |
| 106 | const agent = reader.kind === "agent"; | |
| 107 | const lead = readLeadTime(app.db, new Date(now), agent); | |
| 108 | const openers = readOpeners(app.db, new Date(now), agent); | |
| 104 | 109 | const pile = pileSays(readPile(app.db, reader.kind === "agent")); |
| 105 | 110 | const listed = asked(url); |
| 106 | 111 | const shown = listed === null ? undefined : out.get(listed.key); |
| @@ -123,6 +128,18 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 123 | 128 | <span data-median>${flow.medianAskToLiveMs === null ? "—" : hoursSpan(flow.medianAskToLiveMs)}</span> |
| 124 | 129 | (24 h) |
| 125 | 130 | </p> |
| 131 | <p id="lead-time" class="muted"> | |
| 132 | time to land: | |
| 133 | ${lead.medianHours === null | |
| 134 | ? "nothing landed in 24 h" | |
| 135 | : html`median <span data-median-hours>${hoursWords(lead.medianHours)}</span>, slowest <span data-max-hours>${hoursWords(lead.maxHours ?? 0)}</span>`} | |
| 136 | </p> | |
| 137 | <p id="openers" class="muted small"> | |
| 138 | opened in the last hour: | |
outdated
Slicing the author prefix off openerWords couples the markup to the string layout. Export the counts part (
outdated
Minor: on a 360px phone a long openers list wraps as plain text and the name links are small tap targets. Put each opener in an inline-block with padding, or one per row below a breakpoint. |
||
| 139 | ${openers.length === 0 | |
| 140 | ? "no pulls" | |
| 141 | : openers.map((o, i) => html`${i === 0 ? "" : " · "}<a href="/${o.author}">${o.author}</a> ${openerCounts(o)}`)} | |
Opener links are inline in small text separated by " · "; on 360px they are a hard thumb target. Next: render as a short list with padded links using an existing component. |
||
| 142 | </p> | |
| 126 | 143 | <p id="duplicate-work" data-count="${String(dups.length)}" class="${dups.length === 0 ? "muted" : ""}"> |
| 127 | 144 | ${dups.length === 0 |
| 128 | 145 | ? html`No test file is running in more than one check.` |
test/flow-panel-lead-openers.test.ts added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | /** The flow panel's lead-time and openers lines (#6497). */ | |
| 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 { createUser } from "../src/core/users.ts"; | |
| 8 | import { id } from "../src/kernel/id.ts"; | |
| 9 | import { flowLines } from "../src/tui/screens/flow.ts"; | |
| 10 | import { flowPanel } from "../src/web/pages/flow-panel.ts"; | |
| 11 | ||
| 12 | let dir: string; | |
| 13 | let app: App; | |
| 14 | const ago = (min: number) => new Date(Date.now() - min * 60_000).toISOString(); | |
| 15 | ||
| 16 | beforeEach(async () => { | |
| 17 | dir = mkdtempSync(join(tmpdir(), "forge-panel-lo-")); | |
| 18 | app = createApp({ dataDir: dir, port: 0 }); | |
| 19 | const u = await createUser(app.db, { username: "scout", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true }); | |
| 20 | if (!u.ok) throw new Error(u.error); | |
| 21 | const uid = u.value.id; | |
| 22 | const repo = (name: string, vis: string) => { | |
| 23 | const rid = id(); | |
| 24 | app.db.query(`INSERT INTO repo (id, owner_id, name, visibility) VALUES (?1, ?2, ?3, ?4)`).run(rid, uid, name, vis); | |
| 25 | return rid; | |
| 26 | }; | |
| 27 | let n = 0; | |
| 28 | const pull = (rid: string, openedMin: number, mergedMin: number | null) => { | |
| 29 | const iid = id(); | |
| 30 | app.db | |
| 31 | .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, created_at) VALUES (?1, ?2, ?3, 'pull', 't', '', ?4, ?5)`) | |
| 32 | .run(iid, rid, ++n, uid, ago(openedMin)); | |
| 33 | app.db | |
| 34 | .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES (?1, 'main', ?2, '', '', ?3)`) | |
| 35 | .run(iid, `b/${n}`, mergedMin === null ? null : ago(mergedMin)); | |
| 36 | }; | |
| 37 | const pub = repo("pub", "public"); | |
| 38 | const priv = repo("priv", "private"); | |
| 39 | pull(pub, 180, 10); | |
| 40 | pull(priv, 30, 5); | |
| 41 | pull(priv, 20, null); | |
| 42 | }); | |
| 43 | afterEach(() => { | |
| 44 | app.db.close(); | |
| 45 | rmSync(dir, { recursive: true, force: true }); | |
| 46 | }); | |
| 47 | ||
| 48 | const draw = (kind: "owner" | "agent") => String(flowPanel(app, { kind }, new URL("http://x/boards"), "/boards")); | |
| 49 | ||
| 50 | test("the owner sees both lines across all repositories, names linked", () => { | |
| 51 | const page = draw("owner"); | |
| 52 | expect(page).toContain("time to land:"); | |
outdated
"median" is already on the panel (the throughput line), so this assertion passes without the change. Assert |
||
| 53 | expect(page).toMatch(/data-median-hours>\d/); | |
| 54 | expect(page).toMatch(/data-max-hours>\d/); | |
| 55 | expect(page).toContain(`<a href="/scout">scout</a> 2 (0 flow)`); | |
| 56 | }); | |
| 57 | ||
| 58 | test("a non-admin reads public repositories alone", () => { | |
| 59 | const page = draw("agent"); | |
| 60 | expect(page).toMatch(/opened in the last hour:\s+no pulls/); | |
| 61 | expect(page).toContain("data-median-hours>2.8h<"); | |
| 62 | expect(page).toContain("data-max-hours>2.8h<"); | |
| 63 | expect(page).not.toContain("(0 flow)"); | |
| 64 | }); | |
| 65 | ||
| 66 | test("the terminal's Flow dialog says both lines too, public-only for an agent", () => { | |
| 67 | const owner = flowLines(app.db, new Date(), []).join(" "); | |
| 68 | expect(owner).toContain("Time to land: median"); | |
| 69 | expect(owner).toContain("scout: 2 pulls, 0 for flow"); | |
| 70 | const agent = flowLines(app.db, new Date(), [], { queues: [] }).join(" "); | |
| 71 | expect(agent).toContain("Time to land: median 2.8 h, slowest 2.8 h"); | |
| 72 | expect(agent).toContain("Opened in the last hour: no pulls"); | |
| 73 | }); |
The terminal's Flow dialog (
src/tui/screens/flow.ts) mirrors this panel line by line and has neither of these two lines. Put the sentence in a shared…Sayshelper insrc/flow/and draw it in both places.