Public: anyone can read this; the crew can change it.
The terminal's Flow screen reads the same panel cache as /boards #7005
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6975-the-terminal-s-flow-screen-reads-the-sam → main 4 commits fork 3 threads · 3 unresolved
2 files
changed +107
−24
src/tui/screens/flow.ts +25 −24
| @@ -18,21 +18,22 @@ import { HOLDS } from "../../crew/held.ts"; | ||
| 18 | 18 | import { pullCounts } from "../../crew/hot_files.ts"; |
| 19 | 19 | import { warmingLine } from "../../crew/hot_history.ts"; |
| 20 | 20 | import type { HeldRow } from "../../flow/held.ts"; |
| 21 | import { conflictedPulls, conflictedSays, rebaseLane, rebaseLaneSays } from "../../flow/conflicted.ts"; | |
| 22 | import { IDLE_LIMIT, readIdlePulls } from "../../flow/idle_pulls.ts"; | |
| 23 | import { pileSays, readPile } from "../../flow/pile.ts"; | |
| 21 | import { conflictedSays, rebaseLaneSays } from "../../flow/conflicted.ts"; | |
| 22 | import { IDLE_LIMIT, type IdlePull } from "../../flow/idle_pulls.ts"; | |
| 23 | import { pileSays } from "../../flow/pile.ts"; | |
| 24 | import { panelFigures } from "../../flow/panel_cache.ts"; | |
| 24 | 25 | import { readPipeline, STAGE_LABEL, verdict } from "../../flow/pipeline.ts"; |
| 25 | import { parkedSays, readParked } from "../../flow/parked.ts"; | |
| 26 | import { parkedSays } from "../../flow/parked.ts"; | |
| 26 | 27 | import { firedAlarms } from "../../flow/alarms.ts"; |
| 27 | 28 | import { readVitals, vitalsFor, vitalsLine } from "../../flow/vitals.ts"; |
| 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"; | |
| 31 | import { readOpeners, type Opener } from "../../flow/openers.ts"; | |
| 29 | import type { LeadTime } from "../../flow/lead_time.ts"; | |
| 30 | import { buildYieldSays } from "../../flow/build_yield.ts"; | |
| 31 | import { reviewLatencySays } from "../../flow/review_latency.ts"; | |
| 32 | import type { Opener } from "../../flow/openers.ts"; | |
| 32 | 33 | import { clogLines, readClog } from "../../flow/clog_read.ts"; |
| 33 | 34 | import { readQueues, redBatchWords } from "../../flow/queue.ts"; |
| 34 | import { reviewersBusy, reviewingSays } from "../../flow/reviewing.ts"; | |
| 35 | import { requestedSays, requestedWaits } from "../../flow/requested.ts"; | |
| 35 | import { reviewingSays } from "../../flow/reviewing.ts"; | |
| 36 | import { requestedSays } from "../../flow/requested.ts"; | |
| 36 | 37 | import { readThroughput, stageOut, throughputSays } from "../../flow/throughput.ts"; |
| 37 | 38 | import type { Ctx, Screen } from "../app/app.ts"; |
| 38 | 39 | import { binding } from "../app/keys.ts"; |
| @@ -61,16 +62,17 @@ export function flowLines( | ||
| 61 | 62 | const queues = agent?.queues ?? readQueues(db, now, { batches: true }); |
| 62 | 63 | // The row is full at 58 columns: the red-batch rate (#4235) is a line under it. |
| 63 | 64 | const red = queues.filter((q) => (q.batches?.batches ?? 0) > 0); |
| 64 | const conflictedList = conflictedPulls(db, agent !== undefined); | |
| 65 | const conflicted = conflictedSays(conflictedList, now.getTime()); | |
| 66 | const lane = rebaseLaneSays(rebaseLane(db, conflictedList)); | |
| 67 | const requested = requestedWaits(db, agent !== undefined); | |
| 65 | // The figures the /boards panel keeps (`flow/panel_cache.ts`): an agent's public-only set is apart from an admin's. | |
| 66 | const fig = panelFigures(db, agent === undefined ? "owner" : "agent", now); | |
| 67 | const conflicted = conflictedSays(fig.conflicted, now.getTime()); | |
| 68 | const lane = rebaseLaneSays(fig.lane); | |
| 69 | const requested = fig.requested; | |
| 68 | 70 | const waits = [ |
| 69 | 71 | requestedSays(requested.builder, "builder", now.getTime()), |
| 70 | 72 | requestedSays(requested.reviewer, "reviewer", now.getTime()), |
| 71 | 73 | ].flatMap((says) => (says === null ? [] : ["", ...wrap(says, inner)])); |
| 72 | 74 | const warming = warmingLine(db, now.getTime(), pullCounts, agent !== undefined); |
| 73 | const reviewing = reviewingSays(reviewersBusy(db, agent !== undefined)); | |
| 75 | const reviewing = reviewingSays(fig.reviewing); | |
| 74 | 76 | return [ |
| 75 | 77 | ...wrap(verdict(stages, now.getTime()), inner), |
| 76 | 78 | "", |
| @@ -78,15 +80,15 @@ export function flowLines( | ||
| 78 | 80 | ...(agent === undefined ? clogLines(readClog(db, now), now.getTime()).flatMap((l) => wrap(l, inner)) : []), |
| 79 | 81 | ...firedAlarms(db, now, { publicOnly: agent !== undefined, ceilingUsd }).flatMap((a) => wrap(a.says, inner)), |
| 80 | 82 | "", |
| 81 | ...wrap(parkedSays(readParked(db, now, agent !== undefined)), inner), | |
| 83 | ...wrap(parkedSays(fig.parked), inner), | |
| 82 | 84 | "", |
| 83 | 85 | ...wrap(throughputSays(flow), inner), |
| 84 | 86 | "", |
| 85 | ...wrap(pileSays(readPile(db, agent !== undefined)), inner), | |
| 86 | ...wrap(leadTimeSays(readLeadTime(db, now, agent !== undefined)), inner), | |
| 87 | ...wrap(buildYieldSays(readBuildYield(db, now, agent !== undefined)), inner), | |
| 88 | ...wrap(reviewLatencySays(readReviewLatency(db, now, agent !== undefined)), inner), | |
| 89 | ...openerLines(readOpeners(db, now, agent !== undefined), inner), | |
| 87 | ...wrap(pileSays(fig.pile), inner), | |
| 88 | ...wrap(leadTimeSays(fig.lead), inner), | |
| 89 | ...wrap(buildYieldSays(fig.yielded), inner), | |
| 90 | ...wrap(reviewLatencySays(fig.latency), inner), | |
| 91 | ...openerLines(fig.openers, inner), | |
| 90 | 92 | "", |
| 91 | 93 | `${pad("Out", 12)} ${pad("last hour", 9, "right")} ${pad("24 h", 5, "right")}`, |
| 92 | 94 | ...flow.stages.map(stageOut).map( |
| @@ -106,7 +108,7 @@ export function flowLines( | ||
| 106 | 108 | : wrap(`Red batches, last hour: ${red.map((q) => `${q.repo} ${redBatchWords(q)}`).join(", ")}.`, inner)), |
| 107 | 109 | ...(lane === null ? [] : ["", ...wrap(`Rebases: ${lane}.`, inner)]), |
| 108 | 110 | ...(conflicted === null ? [] : ["", ...wrap(conflicted, inner)]), |
| 109 | ...idleLines(db, now, agent !== undefined, inner), | |
| 111 | ...idleLines(fig.idle, inner), | |
| 110 | 112 | ...waits, |
| 111 | 113 | ...(reviewing === null ? [] : ["", ...wrap(reviewing, inner)]), |
| 112 | 114 | ...(warming === "" ? [] : ["", ...warming.split("\n").flatMap((l) => wrap(l, inner))]), |
| @@ -120,8 +122,7 @@ export function flowLines( | ||
| 120 | 122 | const IDLE_SHOWN = 5; |
| 121 | 123 | |
| 122 | 124 | /** A blank line, `idle: N`, then a line per idle crew pull (`flow/idle_pulls.ts`, #6479), oldest first; nothing when none. */ |
| 123 | export function idleLines(db: Database, now: Date, publicOnly: boolean, inner: number): string[] { | |
| 124 | const idle = readIdlePulls(db, now, publicOnly); | |
| 125 | export function idleLines(idle: readonly IdlePull[], inner: number): string[] { | |
| 125 | 126 | if (idle.length === 0) return []; |
| 126 | 127 | return [ |
| 127 | 128 | "", |
test/tui-flow-cache.test.ts added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | /** The Flow screen reads the panel cache /boards fills (#6975): no reader runs again, the figures are the page's, an agent's screen is public-only. */ | |
| 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 { createUser } from "../src/core/users.ts"; | |
| 8 | import { panelFigures } from "../src/flow/panel_cache.ts"; | |
| 9 | import { buildYieldSays } from "../src/flow/build_yield.ts"; | |
| 10 | import { leadTimeSays, flowLines } from "../src/tui/screens/flow.ts"; | |
| 11 | import { reviewLatencySays } from "../src/flow/review_latency.ts"; | |
| 12 | import { pileSays } from "../src/flow/pile.ts"; | |
| 13 | ||
| 14 | let app: App; | |
| 15 | let agentId: string; | |
| 16 | beforeAll(async () => { | |
| 17 | app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "tui-flow-cache-")), port: 0 }); | |
| 18 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 19 | if (!u.ok) throw new Error(u.error); | |
| 20 | const b = await createUser(app.db, { origin: "seed", trusted: true, username: "scout", email: "[email protected]", password: "a-good-passphrase" }); | |
| 21 | if (!b.ok) throw new Error(b.error); | |
| 22 | agentId = b.value.id; | |
| 23 | app.db.run("UPDATE user SET is_agent = 1 WHERE id = ?1", [agentId]); | |
| 24 | app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('priv', ?1, 'priv', 'private')", [u.value.id]); | |
| 25 | }); | |
| 26 | afterAll(() => app.close?.()); | |
| 27 | ||
| 28 | /** How many times the openers reader ran during `fn`: only the panel's readers call it, once per run. */ | |
| 29 | function panelRuns(fn: () => void): number { | |
| 30 | let n = 0; | |
| 31 | const q = app.db.query.bind(app.db); | |
| 32 | const orig = app.db.query; | |
| 33 | (app.db as unknown as { query: unknown }).query = (sql: string) => { | |
| 34 | if (sql.includes("WITH RECURSIVE flow")) n++; | |
| 35 | return q(sql as never); | |
| 36 | }; | |
| 37 | try { | |
| 38 | fn(); | |
| 39 | } finally { | |
| 40 | (app.db as unknown as { query: unknown }).query = orig; | |
| 41 | } | |
| 42 | return n; | |
| 43 | } | |
| 44 | ||
| 45 | let tick = 0; | |
| 46 | const write = () => app.db.run("UPDATE user SET display_name = ?1", [`n${++tick}`]); | |
| 47 | ||
| 48 | test("opened right after a /boards load, the screen runs none of the panel's readers", () => { | |
| 49 | const now = new Date(); | |
| 50 | write(); | |
| 51 | expect(panelRuns(() => panelFigures(app.db, "owner", now))).toBe(1); // what /boards does, cold | |
| 52 | expect(panelRuns(() => flowLines(app.db, now, [], undefined, []))).toBe(0); // the screen after it | |
| 53 | write(); | |
| 54 | expect(panelRuns(() => flowLines(app.db, now, [], undefined, []))).toBe(1); // nothing kept: the screen fills it | |
| 55 | }); | |
| 56 | ||
| 57 | test("the screen says the page's figures", () => { | |
| 58 | const now = new Date(); | |
| 59 | const fig = panelFigures(app.db, "owner", now); | |
| 60 | const text = flowLines(app.db, now, [], undefined, []).join("\n"); | |
| 61 | expect(text).toContain(leadTimeSays(fig.lead)); | |
| 62 | expect(text).toContain(buildYieldSays(fig.yielded)); | |
| 63 | expect(text).toContain(reviewLatencySays(fig.latency)); | |
| 64 | expect(text).toContain(pileSays(fig.pile)); | |
| 65 | }); | |
| 66 | ||
| 67 | test("an agent's screen is the public-only set, kept apart from the admin's", () => { | |
| 68 | // an agent's pull in the private repository, opened half an hour ago | |
| 69 | const ago = new Date(Date.now() - 1_800_000).toISOString(); | |
| 70 | app.db.run("INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES ('p1', 'priv', 1, 'pull', 't', '', ?1, 'open', ?2)", [agentId, ago]); | |
| 71 | app.db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES ('p1', 'main', 'b', '', 'h')"); | |
| 72 | const now = new Date(); | |
| 73 | const owner = flowLines(app.db, now, [], undefined, []).join("\n"); | |
| 74 | const agent = flowLines(app.db, now, [], { queues: [] }, []).join("\n"); | |
| 75 | expect(owner).toContain("scout: 1 pull"); | |
| 76 | expect(agent).not.toContain("scout"); | |
| 77 | expect(agent).toContain("Opened in the last hour: no pulls."); | |
| 78 | expect(panelFigures(app.db, "agent", now)).not.toBe(panelFigures(app.db, "owner", now)); | |
| 79 | // asked again, each still gets its own | |
The not.toBe on two figure objects only proves two cache entries exist. Assert the agent figures lack the private repo's opener, as the lines below do. |
||
| 80 | expect(flowLines(app.db, now, [], undefined, []).join("\n")).toContain("scout: 1 pull"); | |
| 81 | expect(flowLines(app.db, now, [], { queues: [] }, []).join("\n")).not.toContain("scout"); | |
| 82 | }); |
new above
offline
Counting by the substring WITH RECURSIVE flow ties the test to one query text. If it is reworded the count drops to 0 and the first assertion still passes. Expose a read counter, or assert the cold case is 1 so a rename fails loudly (it does here, but note this in the comment).