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

Parked opened this
Closed without merging

mason/forge:mason/11454-flow-says-in-one-sentence-what-is-cloggi → main 10 commits fork 5 threads · 5 unresolved

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
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 @@
11 [
22 "src/crew/governor_log.ts",
33 "src/design/first_five_minutes.ts",
4 "src/flow/clog_words.ts",
5 "src/flow/conflict_pairs.ts",
6 "src/flow/shared_red.ts",
74 "src/perf/page_budget.ts",
85 "src/spawn/pod.ts",
96 "src/tui/ui/qr.ts",
src/crew/hot_files.ts +5
@@ -304,3 +304,8 @@ export function hotSays(hold: HotHold): string {
304304 .join(", ");
305305 return `#${hold.number} waits: ${files === "" ? "a file it names is hot" : files} (hot at ${HOT_PULLS}).`;
306306 }
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";
4545 import { reviewLatencySays } from "../../flow/review_latency.ts";
4646 import type { Opener } from "../../flow/openers.ts";
4747 import { clogLines, readClog } from "../../flow/clog_read.ts";
48 import { readClogView } from "../../flow/clog_view.ts";
49 import { clogViewLines } from "../../flow/clog_lines.ts";
4850 import { readRepeats } from "../../flow/repeats.ts";
4951 import { readVerdicts } from "../../flow/culprits.ts";
5052 import { readQueues, redBatchWords } from "../../flow/queue.ts";
@@ -117,6 +119,7 @@ export function flowLines(
117119 "",
118120 ...wrap(vitalsLine(vitalsFor(readVitals(db, now, agent !== undefined), agent === undefined)), inner),
119121 ...(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)) : []),
120123 ...firedAlarms(db, now, { publicOnly: agent !== undefined, ceilingUsd, dataDir }).flatMap((a) => wrap(a.says, inner)),
121124 "",
122125 ...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";
1515 import { levelWords } from "../../flow/valve.ts";
1616 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1717 import { notFoundPage, signInFirst } from "../chrome.ts";
18 import { clogSection } from "./flow-clog.ts";
1819 import { pipesSvg } from "../flow_pipes.ts";
1920 import { field, readForm } from "../form.ts";
2021 import { html, when } from "../html.ts";
@@ -101,6 +102,7 @@ export async function flowPage(ctx: Ctx): Promise<Response> {
101102 const body = html`<div class="page-head"><h1>Flow</h1>${btn("Boards", { href: "/boards", variant: "ghost" })}</div>
102103 <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>
103104 ${backupRow(alarmBackup(ctx.app.config.dataDir, new Date()))}
105 ${clogSection(ctx.app.db)}
104106 ${waitingSection(await readWaiting(ctx.app.db, ctx.app.reposRoot, new Date()))}
105107 ${pipesSvg(p)}
106108 <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 &lt;b&gt;");
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs