Public: anyone can read this; the crew can change it.
/flow says in one sentence what is clogging the lane, and names a failing test shared by three pulls #11465
mason/forge:mason/11454-flow-says-in-one-sentence-what-is-cloggi → main 10 commits fork 5 threads · 5 unresolved
9 files
changed +252
−3
docs/features/flow-clog.md added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | # Flow clog (#11454) | |
| 2 | ||
| 3 | What is clogging the lane, on both doors, admins only. `src/flow/clog_view.ts` reads it once; `/flow` draws it (`src/web/pages/flow-clog.ts`, mounted with one line in `flow.ts`) and the Flow screen of the terminal draws it as lines (`src/flow/clog_lines.ts`, in `tui/screens/flow.ts`). | |
| 4 | ||
| 5 | - **The sentence**: `clogSays` (`flow/clog_words.ts`), fed by the standing clog's reasons, the conflicted count from the vitals, the hottest file, and the stranded dollars. It is the heading. | |
| 6 | - **Shared red**: `sharedRed` (`flow/shared_red.ts`). Each open pull's failing tests are those of the last kept red gate that carried it (`gate_result`); a test on three or more open pulls of a repository is one row: the test, then its pulls. | |
| 7 | - **Pairs**: `conflictPairs` (`flow/conflict_pairs.ts`) over the files each open pull touches, as `crew/hot_files.ts` holds them in memory; at most 20, one row each. | |
| 8 | ||
| 9 | When there is nothing to say, nothing is drawn. The open pulls are learned into memory after a boot; until they are, nothing is drawn either, rather than a claim that the lane is clear. Nothing reads git on the request. Tests: `test/flow-clog-page.test.ts` (HTML and terminal lines), plus the module tests. |
scripts/unreached-allow.json −3
| @@ -1,9 +1,6 @@ | ||
| 1 | 1 | [ |
| 2 | 2 | "src/crew/governor_log.ts", |
| 3 | 3 | "src/design/first_five_minutes.ts", |
| 4 | "src/flow/clog_words.ts", | |
| 5 | "src/flow/conflict_pairs.ts", | |
| 6 | "src/flow/shared_red.ts", | |
| 7 | 4 | "src/perf/page_budget.ts", |
| 8 | 5 | "src/spawn/pod.ts", |
| 9 | 6 | "src/tui/ui/qr.ts", |
src/crew/hot_files.ts +5
| @@ -304,3 +304,8 @@ export function hotSays(hold: HotHold): string { | ||
| 304 | 304 | .join(", "); |
| 305 | 305 | return `#${hold.number} waits: ${files === "" ? "a file it names is hot" : files} (hot at ${HOT_PULLS}).`; |
| 306 | 306 | } |
| 307 | ||
| 308 | /** Every repository with pulls learned, and the open pulls of each with their files. Memory only: never git. */ | |
| 309 | export function learnedPulls(): { repoId: string; pulls: { number: number; files: readonly string[] }[] }[] { | |
| 310 | return [...touched].map(([repoId, m]) => ({ repoId, pulls: [...m.values()].map((p) => ({ number: p.number, files: p.files })) })); | |
| 311 | } |
src/flow/clog_lines.ts added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | /** The clog view (`clog_view.ts`) as terminal lines: the sentence, then one line per shared test and per pair; none when there is nothing to say. */ | |
| 2 | ||
| 3 | import type { ClogView } from "./clog_view.ts"; | |
| 4 | ||
| 5 | export function clogViewLines(v: ClogView): string[] { | |
| 6 | return [ | |
| 7 | ...(v.says === "" ? [] : [v.says]), | |
| 8 | ...v.shared.map((s) => `${s.test} · ${s.repo} ${s.pulls.map((n) => `#${n}`).join(" ")}`), | |
| 9 | ...v.pairs.map((p) => `#${p.a} #${p.b} · ${p.repo} · ${p.shared} shared file${p.shared === 1 ? "" : "s"}`), | |
| 10 | ]; | |
| 11 | } |
src/flow/clog_view.ts added +99
| @@ -0,0 +1,99 @@ | ||
| 1 | /** | |
| 2 | * What is clogging the lane (#11454), read once for both doors: one sentence | |
| 3 | * (`clogSays`), a test failing on three or more open pulls named once | |
| 4 | * (`sharedRed`), and the pairs of open pulls touching the same files | |
| 5 | * (`conflictPairs`). It reads what the forge already holds (the clog state, | |
| 6 | * the vitals, the hot-file memory and the kept red gates), never git. | |
| 7 | * Admin eyes only: the callers gate it. Plain data out; the page and the | |
| 8 | * terminal each draw it. | |
| 9 | */ | |
| 10 | ||
| 11 | import type { Database } from "bun:sqlite"; | |
| 12 | import { redGates } from "../checks/gate_result.ts"; | |
| 13 | import { getRepoById } from "../core/repos.ts"; | |
| 14 | import { learnedPulls, warmFiles } from "../crew/hot_files.ts"; | |
| 15 | import { readClog } from "./clog_read.ts"; | |
| 16 | import { clogSays } from "./clog_words.ts"; | |
| 17 | import { conflictPairs } from "./conflict_pairs.ts"; | |
| 18 | import { sharedRed, SHARED_RED_PULLS } from "./shared_red.ts"; | |
| 19 | import { readStranded } from "./stranded.ts"; | |
| 20 | import { readVitals } from "./vitals.ts"; | |
| 21 | ||
| 22 | const LIMIT = 20; | |
| 23 | ||
| 24 | export interface SharedTest { | |
| 25 | readonly repo: string; | |
| 26 | readonly test: string; | |
| 27 | readonly pulls: readonly number[]; | |
| 28 | } | |
| 29 | ||
| 30 | export interface PullPair { | |
| 31 | readonly repo: string; | |
| 32 | readonly a: number; | |
| 33 | readonly b: number; | |
| 34 | readonly shared: number; | |
| 35 | } | |
| 36 | ||
| 37 | export interface ClogView { | |
| 38 | /** The one sentence; empty when there is nothing to say. */ | |
| 39 | readonly says: string; | |
| 40 | readonly shared: readonly SharedTest[]; | |
| 41 | readonly pairs: readonly PullPair[]; | |
| 42 | } | |
| 43 | ||
| 44 | /** Nothing to say, which is also what is shown until the open pulls are learned after a boot. */ | |
| 45 | const EMPTY: ClogView = { says: "", shared: [], pairs: [] }; | |
| 46 | ||
| 47 | /** `owner/name` for each repository id; a repository that is gone is left out. */ | |
| 48 | function names(db: Database, ids: readonly string[]): Map<string, string> { | |
| 49 | const out = new Map<string, string>(); | |
| 50 | for (const id of ids) { | |
| 51 | const r = getRepoById(db, id); | |
| 52 | if (r) out.set(id, `${r.ownerName}/${r.name}`); | |
| 53 | } | |
| 54 | return out; | |
| 55 | } | |
| 56 | ||
| 57 | /** Each open pull's failing tests, from the last red gate that carried it; a pull no red gate carried has no row. */ | |
| 58 | function redRows(db: Database, repoId: string, open: ReadonlySet<number>): { pull: number; failingTests: string[] }[] { | |
| 59 | const seen = new Set<number>(); | |
| 60 | const rows: { pull: number; failingTests: string[] }[] = []; | |
| 61 | for (const gate of redGates(db, repoId)) { | |
| 62 | const tests = gate.failing.map((t) => t.name); | |
| 63 | for (const pull of gate.pulls) { | |
| 64 | if (!open.has(pull) || seen.has(pull)) continue; | |
| 65 | seen.add(pull); | |
| 66 | rows.push({ pull, failingTests: tests }); | |
| 67 | } | |
| 68 | } | |
| 69 | return rows; | |
| 70 | } | |
| 71 | ||
| 72 | export function readClogView(db: Database, now = new Date()): ClogView { | |
| 73 | const learned = learnedPulls(); | |
| 74 | // Before the open pulls are learned (after a boot) the hot-file memory is empty: that is not "nothing clogs". | |
| 75 | if (learned.length === 0) return EMPTY; | |
| 76 | const slug = names(db, learned.map((l) => l.repoId)); | |
| 77 | const vitals = readVitals(db, now); | |
| 78 | const hot = warmFiles().filter((w) => slug.has(w.repoId)).sort((a, b) => b.pulls - a.pulls || a.path.localeCompare(b.path))[0]; | |
| 79 | const says = clogSays({ | |
| 80 | reasons: readClog(db, now).standing?.reasons ?? [], | |
| 81 | conflictedApproved: vitals.conflicted, | |
| 82 | hottest: hot && { path: hot.path, pulls: hot.pulls }, | |
| 83 | strandedUsd: readStranded(db).usd, | |
| 84 | }); | |
| 85 | ||
| 86 | const shared = learned.flatMap((l) => { | |
| 87 | const repo = slug.get(l.repoId); | |
| 88 | if (repo === undefined || l.pulls.length < SHARED_RED_PULLS) return []; | |
| 89 | return sharedRed(redRows(db, l.repoId, new Set(l.pulls.map((p) => p.number)))).map((s) => ({ repo, ...s })); | |
| 90 | }); | |
| 91 | const pairs = learned | |
| 92 | .flatMap((l) => { | |
| 93 | const repo = slug.get(l.repoId); | |
| 94 | return repo === undefined ? [] : conflictPairs(l.pulls).map((p) => ({ repo, ...p })); | |
| 95 | }) | |
| 96 | .sort((p, q) => q.shared - p.shared || p.repo.localeCompare(q.repo) || p.a - q.a || p.b - q.b) | |
| 97 | .slice(0, LIMIT); | |
| 98 | return { says, shared, pairs }; | |
| 99 | } |
src/tui/screens/flow.ts +3
| @@ -45,6 +45,8 @@ import { buildYieldSays } from "../../flow/build_yield.ts"; | ||
| 45 | 45 | import { reviewLatencySays } from "../../flow/review_latency.ts"; |
| 46 | 46 | import type { Opener } from "../../flow/openers.ts"; |
| 47 | 47 | import { clogLines, readClog } from "../../flow/clog_read.ts"; |
| 48 | import { readClogView } from "../../flow/clog_view.ts"; | |
| 49 | import { clogViewLines } from "../../flow/clog_lines.ts"; | |
| 48 | 50 | import { readRepeats } from "../../flow/repeats.ts"; |
| 49 | 51 | import { readVerdicts } from "../../flow/culprits.ts"; |
| 50 | 52 | import { readQueues, redBatchWords } from "../../flow/queue.ts"; |
| @@ -117,6 +119,7 @@ export function flowLines( | ||
| 117 | 119 | "", |
| 118 | 120 | ...wrap(vitalsLine(vitalsFor(readVitals(db, now, agent !== undefined), agent === undefined)), inner), |
| 119 | 121 | ...(agent === undefined ? clogLines(readClog(db, now), now.getTime()).flatMap((l) => wrap(l, inner)) : []), |
| 122 | ...(agent === undefined ? clogViewLines(readClogView(db, now)).flatMap((l) => wrap(l, inner)) : []), | |
| 120 | 123 | ...firedAlarms(db, now, { publicOnly: agent !== undefined, ceilingUsd, dataDir }).flatMap((a) => wrap(a.says, inner)), |
| 121 | 124 | "", |
| 122 | 125 | ...wrap(parkedSays(fig.parked), inner), |
src/web/pages/flow-clog.ts added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | /** | |
| 2 | * /flow's clog line (#11454): what the forge reads of the clog | |
| 3 | * (`flow/clog_view.ts`), drawn only when there is something to say; the | |
| 4 | * sentence is its own heading. Admin eyes only (`flow.ts` gates the page). | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | import { readClogView } from "../../flow/clog_view.ts"; | |
| 9 | import { html, type Html } from "../html.ts"; | |
| 10 | ||
| 11 | export function clogSection(db: Database, now = new Date()): Html { | |
| 12 | const v = readClogView(db, now); | |
| 13 | if (v.says === "" && v.shared.length === 0 && v.pairs.length === 0) return html``; | |
| 14 | const link = (repo: string, n: number): Html => html`<a href="/${repo}/pulls/${String(n)}">#${String(n)}</a>`; | |
| 15 | return html`<section id="clog"> | |
| 16 | ${v.says === "" ? "" : html`<h2 id="clog-says">${v.says}</h2>`} | |
| 17 | ${v.shared.length === 0 ? "" : html`<ul class="list" id="shared-red">${v.shared.map((s) => html`<li data-shared-red="${s.test}"><span class="mono">${s.test}</span> <span class="muted small">${s.repo} · ${s.pulls.map((n, i) => html`${i === 0 ? "" : ", "}${link(s.repo, n)}`)}</span></li>`)}</ul>`} | |
| 18 | ${v.pairs.length === 0 ? "" : html`<ul class="list" id="conflict-pairs">${v.pairs.map((p) => html`<li data-pair="${String(p.a)}-${String(p.b)}">${link(p.repo, p.a)}, ${link(p.repo, p.b)} <span class="muted small">${p.repo} · ${String(p.shared)} shared file${p.shared === 1 ? "" : "s"}</span></li>`)}</ul>`} | |
| 19 | </section>`; | |
| 20 | } |
src/web/pages/flow.ts +2
| @@ -15,6 +15,7 @@ import { readPipes, type ValveRow } from "../../flow/pipes.ts"; | ||
| 15 | 15 | import { levelWords } from "../../flow/valve.ts"; |
| 16 | 16 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 17 | 17 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| 18 | import { clogSection } from "./flow-clog.ts"; | |
| 18 | 19 | import { pipesSvg } from "../flow_pipes.ts"; |
| 19 | 20 | import { field, readForm } from "../form.ts"; |
| 20 | 21 | import { html, when } from "../html.ts"; |
| @@ -101,6 +102,7 @@ export async function flowPage(ctx: Ctx): Promise<Response> { | ||
| 101 | 102 | const body = html`<div class="page-head"><h1>Flow</h1>${btn("Boards", { href: "/boards", variant: "ghost" })}</div> |
| 102 | 103 | <p class="mb">Money flows from the left to shipped on the right, as wide as this week's real spend. Every repository has a valve: drag it to give the repository a share of the week, all the way down to shut. The crew does exactly what the valves say, from the next tick, and nothing but you turns one.</p> |
| 103 | 104 | ${backupRow(alarmBackup(ctx.app.config.dataDir, new Date()))} |
| 105 | ${clogSection(ctx.app.db)} | |
| 104 | 106 | ${waitingSection(await readWaiting(ctx.app.db, ctx.app.reposRoot, new Date()))} |
| 105 | 107 | ${pipesSvg(p)} |
| 106 | 108 | <p class="muted small">${money(p.usd)} spent in the last 7 days; ${plural(p.shipped, "pull")} shipped.</p> |
test/flow-clog-page.test.ts added +103
| @@ -0,0 +1,103 @@ | ||
| 1 | /** /flow's clog section (#11454): the one sentence, a test shared by three pulls, and the pairs touching the same files, on the page's HTML. */ | |
| 2 | import { afterAll, beforeAll, 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 { keepGateResult } from "../src/checks/gate_result.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { flowLines } from "../src/tui/screens/flow.ts"; | |
| 10 | import { rememberPullFiles } from "../src/crew/hot_files.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { quietLogs } from "./helpers/globals.ts"; | |
| 13 | ||
| 14 | quietLogs(); | |
| 15 | ||
| 16 | let root: string; | |
| 17 | let app: App; | |
| 18 | let servers: Servers; | |
| 19 | let cookie = ""; | |
| 20 | ||
| 21 | beforeAll(async () => { | |
| 22 | root = mkdtempSync(join(tmpdir(), "flow-clog-page-")); | |
| 23 | app = createApp({ dataDir: root, port: 0 }); | |
| 24 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 25 | if (!u.ok) throw new Error(u.error); | |
| 26 | app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('r1', ?1, 'forge', 'public')", [u.value.id]); | |
| 27 | for (let n = 1; n <= 4; n++) { | |
| 28 | app.db.run("INSERT INTO issue (id, repo_id, number, kind, title, author_id, state, created_at) VALUES (?1, 'r1', ?2, 'pull', 't', ?3, 'open', ?4)", [`p${n}`, n, u.value.id, new Date().toISOString()]); | |
| 29 | app.db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?1, 'main', ?2, '', 'h')", [`p${n}`, `b${n}`]); | |
| 30 | } | |
| 31 | servers = await serve(app); | |
| 32 | const res = await fetch(`${servers.url}/signin`, { method: "POST", headers: { origin: servers.url }, body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), redirect: "manual" }); | |
| 33 | cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 34 | }); | |
| 35 | afterAll(() => { | |
| 36 | servers.stop(); | |
| 37 | app.close(); | |
| 38 | rmSync(root, { recursive: true, force: true }); | |
| 39 | }); | |
| 40 | ||
| 41 | const flow = async (): Promise<string> => (await fetch(`${servers.url}/flow`, { headers: { cookie } })).text(); | |
| 42 | ||
| 43 | test("with nothing to say, or before the pulls are learned, the section is absent", async () => { | |
| 44 | rememberPullFiles("r1", []); | |
| 45 | const page = await flow(); | |
| 46 | expect(page).not.toContain('id="clog"'); | |
| 47 | expect(page).not.toContain("Nothing is clogging"); | |
| 48 | }); | |
| 49 | ||
| 50 | test("a test failing on three open pulls is shown once with its pulls linked; pairs sharing files are linked to both", async () => { | |
| 51 | rememberPullFiles("r1", [ | |
| 52 | { number: 1, files: ["src/a.ts", "src/b.ts"] }, | |
| 53 | { number: 2, files: ["src/a.ts", "src/b.ts"] }, | |
| 54 | { number: 3, files: ["src/a.ts"] }, | |
| 55 | { number: 4, files: ["src/z.ts"] }, | |
| 56 | ]); | |
| 57 | keepGateResult(app.db, "g1", { | |
| 58 | repoId: "r1", runId: "gate-1", tipOid: "x", pulls: [1, 2, 3], state: "red", passed: 9, failed: 1, | |
| 59 | failing: [{ name: "size budget <b>", errors: [] }], startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), | |
| 60 | }); | |
| 61 | const page = await flow(); | |
| 62 | expect(page.match(/data-shared-red=/g)?.length).toBe(1); | |
| 63 | expect(page).toContain("size budget <b>"); | |
| 64 | for (const n of [1, 2, 3]) expect(page).toContain(`href="/alice/forge/pulls/${n}"`); | |
| 65 | expect(page).toContain('data-pair="1-2"'); | |
| 66 | expect(page).toContain('data-pair="1-3"'); | |
| 67 | expect(page).toContain('data-pair="2-3"'); | |
| 68 | expect(page).not.toContain('data-pair="1-4"'); | |
| 69 | expect(page).toContain("3 open pulls touch src/a.ts"); | |
| 70 | const lines = flowLines(app.db).join("\n"); | |
| 71 | expect(lines).toContain("size budget <b> · alice/forge #1 #2 #3"); | |
| 72 | expect(lines).toContain("#1 #2 · alice/forge · 2 shared files"); | |
| 73 | expect(lines).toContain("3 open pulls touch src/a.ts"); | |
| 74 | // an agent's reading carries none of it | |
| 75 | expect(flowLines(app.db, new Date(), undefined, { queues: [] }).join("\n")).not.toContain("size budget"); | |
| 76 | }); | |
| 77 | ||
| 78 | test("two pulls failing a test is not shared", async () => { | |
| 79 | app.db.run("DELETE FROM gate_result"); | |
| 80 | keepGateResult(app.db, "g2", { | |
| 81 | repoId: "r1", runId: "gate-2", tipOid: "x", pulls: [1, 2], state: "red", passed: 9, failed: 1, | |
| 82 | failing: [{ name: "two only", errors: [] }], startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), | |
| 83 | }); | |
| 84 | expect(await flow()).not.toContain('id="shared-red"'); | |
| 85 | }); | |
| 86 | ||
| 87 | test("a pull no red gate carried is not counted; a repository with fewer than three open pulls is skipped", async () => { | |
| 88 | app.db.run("DELETE FROM gate_result"); | |
| 89 | keepGateResult(app.db, "g3", { | |
| 90 | repoId: "r1", runId: "gate-3", tipOid: "x", pulls: [1, 2, 9], state: "red", passed: 9, failed: 1, | |
| 91 | failing: [{ name: "carried by two open", errors: [] }], startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), | |
| 92 | }); | |
| 93 | // pull 3 is open, but no red gate carried it; 9 is not open | |
| 94 | expect(await flow()).not.toContain('data-shared-red='); | |
| 95 | const owner = app.db.query<{ owner_id: string }, []>("SELECT owner_id FROM repo WHERE id='r1'").get()!.owner_id; | |
| 96 | app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('r2', ?1, 'small', 'public')", [owner]); | |
| 97 | keepGateResult(app.db, "g4", { | |
| 98 | repoId: "r2", runId: "gate-4", tipOid: "x", pulls: [1, 2, 3], state: "red", passed: 9, failed: 1, | |
| 99 | failing: [{ name: "small repo", errors: [] }], startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), | |
| 100 | }); | |
| 101 | rememberPullFiles("r2", [{ number: 1, files: [] }, { number: 2, files: [] }]); | |
| 102 | expect(await flow()).not.toContain("small repo"); | |
| 103 | }); |