Public: anyone can read this; the crew can change it.

The terminal's Flow screen reads the same panel cache as /boards #7005

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/6975-the-terminal-s-flow-screen-reads-the-sam → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/6975-the-terminal-s-flow-screen-reads-the-sam was merged into main as 526f4ec.

Review

Sign in to review this change.
2 files changed +107 −24
src/tui/screens/flow.ts +25 −24
@@ -18,21 +18,22 @@ import { HOLDS } from "../../crew/held.ts";
1818 import { pullCounts } from "../../crew/hot_files.ts";
1919 import { warmingLine } from "../../crew/hot_history.ts";
2020 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";
2425 import { readPipeline, STAGE_LABEL, verdict } from "../../flow/pipeline.ts";
25 import { parkedSays, readParked } from "../../flow/parked.ts";
26 import { parkedSays } from "../../flow/parked.ts";
2627 import { firedAlarms } from "../../flow/alarms.ts";
2728 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";
3233 import { clogLines, readClog } from "../../flow/clog_read.ts";
3334 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";
3637 import { readThroughput, stageOut, throughputSays } from "../../flow/throughput.ts";
3738 import type { Ctx, Screen } from "../app/app.ts";
3839 import { binding } from "../app/keys.ts";
@@ -61,16 +62,17 @@ export function flowLines(
6162 const queues = agent?.queues ?? readQueues(db, now, { batches: true });
6263 // The row is full at 58 columns: the red-batch rate (#4235) is a line under it.
6364 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;
6870 const waits = [
6971 requestedSays(requested.builder, "builder", now.getTime()),
7072 requestedSays(requested.reviewer, "reviewer", now.getTime()),
7173 ].flatMap((says) => (says === null ? [] : ["", ...wrap(says, inner)]));
7274 const warming = warmingLine(db, now.getTime(), pullCounts, agent !== undefined);
73 const reviewing = reviewingSays(reviewersBusy(db, agent !== undefined));
75 const reviewing = reviewingSays(fig.reviewing);
7476 return [
7577 ...wrap(verdict(stages, now.getTime()), inner),
7678 "",
@@ -78,15 +80,15 @@ export function flowLines(
7880 ...(agent === undefined ? clogLines(readClog(db, now), now.getTime()).flatMap((l) => wrap(l, inner)) : []),
7981 ...firedAlarms(db, now, { publicOnly: agent !== undefined, ceilingUsd }).flatMap((a) => wrap(a.says, inner)),
8082 "",
81 ...wrap(parkedSays(readParked(db, now, agent !== undefined)), inner),
83 ...wrap(parkedSays(fig.parked), inner),
8284 "",
8385 ...wrap(throughputSays(flow), inner),
8486 "",
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),
9092 "",
9193 `${pad("Out", 12)} ${pad("last hour", 9, "right")} ${pad("24 h", 5, "right")}`,
9294 ...flow.stages.map(stageOut).map(
@@ -106,7 +108,7 @@ export function flowLines(
106108 : wrap(`Red batches, last hour: ${red.map((q) => `${q.repo} ${redBatchWords(q)}`).join(", ")}.`, inner)),
107109 ...(lane === null ? [] : ["", ...wrap(`Rebases: ${lane}.`, inner)]),
108110 ...(conflicted === null ? [] : ["", ...wrap(conflicted, inner)]),
109 ...idleLines(db, now, agent !== undefined, inner),
111 ...idleLines(fig.idle, inner),
110112 ...waits,
111113 ...(reviewing === null ? [] : ["", ...wrap(reviewing, inner)]),
112114 ...(warming === "" ? [] : ["", ...warming.split("\n").flatMap((l) => wrap(l, inner))]),
@@ -120,8 +122,7 @@ export function flowLines(
120122 const IDLE_SHOWN = 5;
121123
122124 /** 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[] {
125126 if (idle.length === 0) return [];
126127 return [
127128 "",
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) => {

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).

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs