Public: anyone can read this; the crew can change it.
The terminal's Flow dialog lists each repository's last merge-queue gates with their verdict and first failing test, as MCP `queue_gates` does #7327
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7320-the-terminal-s-flow-dialog-lists-each-re → main 3 commits fork 4 threads · 4 unresolved
README.md +1
| @@ -375,6 +375,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 375 | 375 | - 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. |
| 376 | 376 | - 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. |
| 377 | 377 | - 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. |
| 378 | - Under each queued repository's merge-queue line, the terminal Flow dialog lists its last five gates, newest first: the pulls carried, `green`, `red` or `cut off`, how long ago, and for a red gate the first failing test with its file, `flaky` when two gates sharing no pull failed it (`src/checks/gate_result.ts`, as MCP `queue_gates` reads them); a repository with none kept says `no gates yet`, and an agent reader sees public repositories only. | |
| 378 | 379 | - 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`). |
| 379 | 380 | - [](docs/PLANNING.md) — how issues depend on issues: the edge, the map, and what "ready now" means to a person and to the crew. |
| 380 | 381 | - [](docs/FLEET.md) — one change across many repositories, runs or issues: a query, a dry-run plan of every row before → after, a receipt, and undo. |
src/tui/screens/flow.ts +50 −4
| @@ -5,7 +5,10 @@ | ||
| 5 | 5 | * `flow/pipeline.ts` and `flow/throughput.ts`, the same the page and the |
| 6 | 6 | * MCP `flow` tool read. Under them, each repository's merge queue (#2036): |
| 7 | 7 | * queued, gating, gates in flight, landed in the last hour and the median |
| 8 | * minutes from approval to landing (`flow/queue.ts`); then the | |
| 8 | * minutes from approval to landing (`flow/queue.ts`), and under each its | |
| 9 | * last five gates (#7320, `checks/gate_result.ts`, as MCP `queue_gates` | |
| 10 | * reads them): the pulls carried, the verdict, how long ago, and for a red | |
| 11 | * one the first failing test by name and file, `flaky` when it is; then the | |
| 9 | 12 | * conflicted pulls, the idle crew pulls (#6479) and the pulls with |
| 10 | 13 | * changes requested waiting on a builder and on a reviewer (`flow/requested.ts`, #2172); then each |
| 11 | 14 | * autonomous repository's ready list, startable beside the count per |
| @@ -14,9 +17,11 @@ | ||
| 14 | 17 | */ |
| 15 | 18 | |
| 16 | 19 | import type { Database } from "bun:sqlite"; |
| 20 | import { recentGates, type KeptResult } from "../../checks/gate_result.ts"; | |
| 17 | 21 | import { HOLDS } from "../../crew/held.ts"; |
| 18 | 22 | import { pullCounts } from "../../crew/hot_files.ts"; |
| 19 | 23 | import { warmingLine } from "../../crew/hot_history.ts"; |
| 24 | import { ago } from "../../feed/rank.ts"; | |
| 20 | 25 | import type { HeldRow } from "../../flow/held.ts"; |
| 21 | 26 | import { conflictedSays, rebaseLaneSays } from "../../flow/conflicted.ts"; |
| 22 | 27 | import { IDLE_LIMIT, type IdlePull } from "../../flow/idle_pulls.ts"; |
| @@ -73,6 +78,7 @@ export function flowLines( | ||
| 73 | 78 | ].flatMap((says) => (says === null ? [] : ["", ...wrap(says, inner)])); |
| 74 | 79 | const warming = warmingLine(db, now.getTime(), pullCounts, agent !== undefined); |
| 75 | 80 | const reviewing = reviewingSays(fig.reviewing); |
| 81 | const ids = repoIds(db, queues.map((q) => q.repo)); | |
| 76 | 82 | return [ |
| 77 | 83 | ...wrap(verdict(stages, now.getTime()), inner), |
| 78 | 84 | "", |
| @@ -99,10 +105,13 @@ export function flowLines( | ||
| 99 | 105 | `${pad("Merge queue", 15)} ${pad("queued", 6, "right")} ${pad("gating", 6, "right")} ${pad("gates", 5, "right")} ${pad("landed 1h", 9, "right")} ${pad("to land, min", 12, "right")}`, |
| 100 | 106 | ...(queues.length === 0 |
| 101 | 107 | ? ["Nothing queued, gating or landed in the last hour."] |
| 102 | : queues.map( | |
| 103 | (q) => | |
| 108 | : queues.flatMap((q) => { | |
| 109 | const id = ids.get(q.repo); | |
| 110 | return [ | |
| 104 | 111 | `${pad(truncate(q.repo, 15), 15)} ${pad(String(q.queued), 6, "right")} ${pad(String(q.gating), 6, "right")} ${pad(String(q.gates), 5, "right")} ${pad(String(q.landedHour), 9, "right")} ${pad(q.approvedToLanded === null ? "—" : String(q.approvedToLanded), 12, "right")}`, |
| 105 | )), | |
| 112 | ...gateLines(id === undefined ? [] : recentGates(db, id, GATES_SHOWN), now.getTime(), inner), | |
This is right: gates are read only for the queue rows this reader was given, so an agent sees public repositories only. Please pin it with a test: put a private repository's gate in the db, pass agent |
||
| 113 | ]; | |
| 114 | })), | |
| 106 | 115 | ...(red.length === 0 |
| 107 | 116 | ? [] |
| 108 | 117 | : wrap(`Red batches, last hour: ${red.map((q) => `${q.repo} ${redBatchWords(q)}`).join(", ")}.`, inner)), |
| @@ -118,6 +127,43 @@ export function flowLines( | ||
| 118 | 127 | ]; |
| 119 | 128 | } |
| 120 | 129 | |
| 130 | /** Gates shown under each repository's queue line. */ | |
| 131 | const GATES_SHOWN = 5; | |
| 132 | ||
| 133 | /** Each `owner/name` the queue lists, to its repository id: one query. */ | |
| 134 | function repoIds(db: Database, names: readonly string[]): Map<string, string> { | |
| 135 | if (names.length === 0) return new Map(); | |
| 136 | const rows = db | |
| 137 | .query<{ id: string; full: string }, string[]>( | |
| 138 | `SELECT r.id, u.username || '/' || r.name AS full | |
| 139 | FROM repo r JOIN user u ON u.id = r.owner_id | |
| 140 | WHERE u.username || '/' || r.name IN (${names.map(() => "?").join(",")})`, | |
| 141 | ) | |
| 142 | .all(...names); | |
| 143 | return new Map(rows.map((r) => [r.full, r.id])); | |
| 144 | } | |
| 145 | ||
| 146 | const STATE_WORD: Record<KeptResult["state"], string> = { green: "green", red: "red", cut_off: "cut off" }; | |
Wrap this doc comment; the last line runs far past the file's width. |
||
| 147 | ||
| 148 | /** | |
| 149 | * One indented line per kept gate, newest first: its pulls, its verdict, | |
| 150 | * how long ago it finished, and for a red one the first failing test with | |
| 151 | * its file, `flaky` when two gates sharing no pull failed it; `no gates | |
| 152 | * yet` when none is kept. | |
| 153 | */ | |
| 154 | export function gateLines(gates: readonly KeptResult[], now: number, inner: number): string[] { | |
| 155 | if (gates.length === 0) return [" no gates yet"]; | |
| 156 | return gates.map((g) => { | |
| 157 | const pulls = g.pulls.length === 0 ? "no pulls" : g.pulls.map((n) => `#${n}`).join(" "); | |
| 158 | const first = g.state === "red" ? g.failing[0] : undefined; | |
| 159 | const test = | |
| 160 | first === undefined | |
| 161 | ? "" | |
| 162 | : ` · ${first.name}${first.file === undefined ? "" : ` (${first.file})`}${first.flaky ? " flaky" : ""}`; | |
| 163 | return truncate(` ${pulls} ${STATE_WORD[g.state]} · ${ago(Date.parse(g.finishedAt), now)}${test}`, inner); | |
| 164 | }); | |
| 165 | } | |
| 166 | ||
| 121 | 167 | /** Idle pulls shown before `+N more`. */ |
| 122 | 168 | const IDLE_SHOWN = 5; |
| 123 | 169 |
test/tui-flow-gates.test.ts added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | /** The Flow dialog lists each queued repository's last gates (#7320): verdict, how long ago, a red one's first failing test, `flaky` when it is. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync } 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 { keepGateResult, recentGates, type GateResult } from "../src/checks/gate_result.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { flowLines, gateLines } from "../src/tui/screens/flow.ts"; | |
| 10 | ||
| 11 | const NOW = new Date("2026-10-27T12:00:00Z"); | |
| 12 | const at = (min: number) => new Date(NOW.getTime() - min * 60_000).toISOString(); | |
| 13 | ||
| 14 | let app: App; | |
| 15 | beforeAll(async () => { | |
| 16 | app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "tui-flow-gates-")), port: 0 }); | |
| 17 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 18 | if (!u.ok) throw new Error(u.error); | |
| 19 | app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('r1', ?1, 'svc', 'public')", [u.value.id]); | |
| 20 | app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('r2', ?1, 'quiet', 'public')", [u.value.id]); | |
| 21 | }); | |
| 22 | afterAll(() => app.close?.()); | |
| 23 | ||
| 24 | const gate = (over: Partial<GateResult>): GateResult => ({ | |
| 25 | repoId: "r1", | |
| 26 | runId: "gate-abc", | |
| 27 | tipOid: "abc", | |
| 28 | pulls: [1], | |
| 29 | state: "green", | |
| 30 | passed: 10, | |
| 31 | failed: 0, | |
| 32 | failing: [], | |
| 33 | startedAt: at(12), | |
| 34 | finishedAt: at(10), | |
| 35 | ...over, | |
| 36 | }); | |
| 37 | ||
| 38 | test("one green and one red gate: both lines, the red one names its failing test and file", () => { | |
| 39 | keepGateResult(app.db, "g1", gate({ pulls: [12, 13] }), NOW); | |
| 40 | keepGateResult( | |
| 41 | app.db, | |
| 42 | "g2", | |
| 43 | gate({ pulls: [14], state: "red", reason: "tests", passed: 9, failed: 1, failing: [{ name: "a > b", file: "test/a.test.ts", errors: ["boom"] }], finishedAt: at(5) }), | |
| 44 | NOW, | |
| 45 | ); | |
| 46 | const queues = [ | |
| 47 | { repo: "alice/svc", queued: 1, gating: 0, gates: 0, landedHour: 0, approvedToLanded: null }, | |
| 48 | { repo: "alice/quiet", queued: 0, gating: 0, gates: 0, landedHour: 1, approvedToLanded: null }, | |
| 49 | ]; | |
| 50 | const lines = flowLines(app.db, NOW, [], { queues }, []); | |
| 51 | const svc = lines.findIndex((l) => l.startsWith("alice/svc")); | |
| 52 | expect(lines[svc + 1]).toBe(" #14 red · 5 minutes ago · a > b (test/a.test.ts)"); | |
| 53 | expect(lines[svc + 2]).toBe(" #12 #13 green · 10 minutes ago"); | |
| 54 | expect(lines[svc + 3]!.startsWith("alice/quiet")).toBe(true); | |
| 55 | expect(lines[svc + 4]).toBe(" no gates yet"); | |
| 56 | }); | |
| 57 | ||
| 58 | test("a test red in two gates that share no pull is marked flaky; cut off reads as two words", () => { | |
| 59 | keepGateResult( | |
| 60 | app.db, | |
| 61 | "g3", | |
| 62 | gate({ pulls: [20], state: "red", failed: 1, failing: [{ name: "a > b", file: "test/a.test.ts", errors: [] }], finishedAt: at(2) }), | |
| 63 | NOW, | |
| 64 | ); | |
| 65 | keepGateResult(app.db, "g4", gate({ pulls: [21], state: "cut_off", finishedAt: at(1) }), NOW); | |
| 66 | const lines = gateLines(recentGates(app.db, "r1", 5), NOW.getTime(), 58); | |
| 67 | expect(lines).toEqual([ | |
| 68 | " #21 cut off · just now", | |
| 69 | " #20 red · 2 minutes ago · a > b (test/a.test.ts) flaky", | |
| 70 | " #14 red · 5 minutes ago · a > b (test/a.test.ts) flaky", | |
| 71 | " #12 #13 green · 10 minutes ago", | |
| 72 | ]); | |
| 73 | }); | |
| 74 | ||
| 75 | const svcRow = { repo: "alice/svc", queued: 1, gating: 0, gates: 0, landedHour: 0, approvedToLanded: null }; | |
outdated
This calls |
||
| 76 | const quietRow = { repo: "alice/quiet", queued: 0, gating: 0, gates: 0, landedHour: 1, approvedToLanded: null }; | |
| 77 | ||
| 78 | test("the dialog shows at most five gates a repository", () => { | |
| 79 | for (let i = 0; i < 4; i++) keepGateResult(app.db, `g9${i}`, gate({ pulls: [30 + i] }), NOW); | |
| 80 | expect(recentGates(app.db, "r1", 100).length).toBeGreaterThan(5); | |
| 81 | const lines = flowLines(app.db, NOW, [], { queues: [svcRow, quietRow] }, []); | |
| 82 | const svc = lines.findIndex((l) => l.startsWith("alice/svc")); | |
| 83 | const quiet = lines.findIndex((l) => l.startsWith("alice/quiet")); | |
| 84 | expect(quiet - svc - 1).toBe(5); | |
| 85 | expect(lines.slice(svc + 1, quiet).every((l) => l.startsWith(" #"))).toBe(true); | |
| 86 | }); | |
| 87 | ||
| 88 | test("an agent's dialog lists gates only for the queue rows it was handed: a private repository's never show", () => { | |
| 89 | app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('r3', (SELECT owner_id FROM repo WHERE id = 'r1'), 'secret', 'private')"); | |
| 90 | keepGateResult(app.db, "g99", gate({ repoId: "r3", pulls: [777], state: "red", failed: 1, failing: [{ name: "hidden > test", file: "test/hidden.test.ts", errors: [] }] }), NOW); | |
| 91 | const text = flowLines(app.db, NOW, [], { queues: [svcRow] }, []).join("\n"); | |
| 92 | expect(text).toContain("alice/svc"); | |
| 93 | expect(text).not.toContain("secret"); | |
| 94 | expect(text).not.toContain("#777"); | |
| 95 | expect(text).not.toContain("hidden"); | |
| 96 | }); | |
| 97 | ||
| 98 | test("nothing queued: the dialog says so and lists no gates", () => { | |
| 99 | const lines = flowLines(app.db, NOW, [], { queues: [] }, []); | |
| 100 | expect(lines).toContain("Nothing queued, gating or landed in the last hour."); | |
| 101 | expect(lines).not.toContain(" no gates yet"); | |
| 102 | }); |
Keep imports in path order: feed/rank.ts goes after crew/ imports.