Public: anyone can read this; the crew can change it.
The flow panel's figures come from one short-lived cache, so a second /boards load inside 30 s runs none of its reads #7000
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6974-the-flow-panel-s-figures-come-from-one-s → main 1 commit fork
3 files
changed +183
−25
src/flow/panel_cache.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | /** | |
| 2 | * The flow panel's figures, kept per database so a second `/boards` load reads none | |
| 3 | * of them again. A value holds while the write clock (`core/write_clock.ts`) stands | |
| 4 | * still, and for 30 s at most (the figures are windows of time, so they age too). | |
| 5 | * Kept apart by who asks: an agent's public-only answer is never an admin's. | |
| 6 | * Read it only after the viewer's rights are known. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { writeStamp } from "../core/write_clock.ts"; | |
| 11 | import { conflictedPulls, rebaseLane, type ConflictedPull, type RebaseLane } from "./conflicted.ts"; | |
| 12 | import { readPile, type Pile } from "./pile.ts"; | |
| 13 | import { reviewersBusy, type ReviewerLoad } from "./reviewing.ts"; | |
| 14 | import { requestedWaits, type RequestedWaits } from "./requested.ts"; | |
| 15 | import { idlePullsCached, type IdlePull } from "./idle_pulls.ts"; | |
| 16 | import { readParked, type Parked } from "./parked.ts"; | |
| 17 | import { readLeadTime, type LeadTime } from "./lead_time.ts"; | |
| 18 | import { readBuildYield, type BuildYield } from "./build_yield.ts"; | |
| 19 | import { readReviewLatency, type ReviewLatency } from "./review_latency.ts"; | |
| 20 | import { readOpeners, type Opener } from "./openers.ts"; | |
| 21 | ||
| 22 | export const PANEL_TTL_MS = 30_000; | |
| 23 | ||
| 24 | /** Who is asking: an `agent` sees public repositories only; the `owner` sees all. */ | |
| 25 | export type PanelViewer = "owner" | "agent"; | |
| 26 | ||
| 27 | export interface PanelFigures { | |
| 28 | readonly pile: Pile; | |
| 29 | readonly conflicted: ConflictedPull[]; | |
| 30 | readonly lane: RebaseLane; | |
| 31 | readonly requested: RequestedWaits; | |
| 32 | readonly reviewing: ReviewerLoad[]; | |
| 33 | readonly idle: IdlePull[]; | |
| 34 | readonly parked: Parked; | |
| 35 | readonly lead: LeadTime; | |
| 36 | readonly yielded: BuildYield; | |
| 37 | readonly latency: ReviewLatency | null; | |
| 38 | readonly openers: Opener[]; | |
| 39 | } | |
| 40 | ||
| 41 | /** Every reader the panel's figures come from, run once. */ | |
| 42 | export function readPanelFigures(db: Database, now: Date, publicOnly: boolean): PanelFigures { | |
| 43 | const conflicted = conflictedPulls(db, publicOnly); | |
| 44 | return { | |
| 45 | pile: readPile(db, publicOnly), | |
| 46 | conflicted, | |
| 47 | lane: rebaseLane(db, conflicted), | |
| 48 | requested: requestedWaits(db, publicOnly), | |
| 49 | reviewing: reviewersBusy(db, publicOnly), | |
| 50 | idle: idlePullsCached(db, now, publicOnly), | |
| 51 | parked: readParked(db, now, publicOnly), | |
| 52 | lead: readLeadTime(db, now, publicOnly), | |
| 53 | yielded: readBuildYield(db, now, publicOnly), | |
| 54 | latency: readReviewLatency(db, now, publicOnly), | |
| 55 | openers: readOpeners(db, now, publicOnly), | |
| 56 | }; | |
| 57 | } | |
| 58 | ||
| 59 | interface Entry { | |
| 60 | readonly at: number; | |
| 61 | readonly stamp: string; | |
| 62 | readonly figures: PanelFigures; | |
| 63 | } | |
| 64 | ||
| 65 | const kept = new WeakMap<Database, Map<PanelViewer, Entry>>(); | |
| 66 | ||
| 67 | /** The panel's figures for `viewer`: the kept ones while nothing was written and under 30 s old. */ | |
| 68 | export function panelFigures(db: Database, viewer: PanelViewer, now = new Date()): PanelFigures { | |
| 69 | const mine = kept.get(db) ?? new Map<PanelViewer, Entry>(); | |
| 70 | kept.set(db, mine); | |
| 71 | const stamp = writeStamp(db); | |
| 72 | const t = now.getTime(); | |
| 73 | const hit = mine.get(viewer); | |
| 74 | if (stamp !== undefined && hit && hit.stamp === stamp && t >= hit.at && t - hit.at < PANEL_TTL_MS) return hit.figures; | |
| 75 | const figures = readPanelFigures(db, now, viewer !== "owner"); | |
| 76 | if (stamp === undefined) mine.delete(viewer); | |
| 77 | else mine.set(viewer, { at: t, stamp, figures }); | |
| 78 | return figures; | |
| 79 | } |
src/web/pages/flow-panel.ts +18 −25
| @@ -9,10 +9,10 @@ | ||
| 9 | 9 | |
| 10 | 10 | import type { Reader } from "../../actions/telemetry.ts"; |
| 11 | 11 | import { queuesFor, systemFor } from "../../system/telemetry.ts"; |
| 12 | import { conflictedPulls, conflictedSays, rebaseLane, rebaseLaneSays } from "../../flow/conflicted.ts"; | |
| 13 | import { pileSays, readPile } from "../../flow/pile.ts"; | |
| 14 | import { reviewersBusy, reviewingSays } from "../../flow/reviewing.ts"; | |
| 15 | import { requestedSays, requestedWaits } from "../../flow/requested.ts"; | |
| 12 | import { conflictedSays, rebaseLaneSays } from "../../flow/conflicted.ts"; | |
| 13 | import { pileSays } from "../../flow/pile.ts"; | |
| 14 | import { reviewingSays } from "../../flow/reviewing.ts"; | |
| 15 | import { requestedSays } from "../../flow/requested.ts"; | |
| 16 | 16 | import { |
| 17 | 17 | bottleneck, |
| 18 | 18 | readPipeline, |
| @@ -36,12 +36,12 @@ import { type QueueRow, redBatchWords } from "../../flow/queue.ts"; | ||
| 36 | 36 | import { HOT_PULLS, pullCounts, warmFiles } from "../../crew/hot_files.ts"; |
| 37 | 37 | import { warmingLine } from "../../crew/hot_history.ts"; |
| 38 | 38 | import { getRepoById } from "../../core/repos.ts"; |
| 39 | import { IDLE_LIMIT, idlePullsCached, type IdlePull } from "../../flow/idle_pulls.ts"; | |
| 40 | import { readParked } from "../../flow/parked.ts"; | |
| 41 | import { hoursWords, readLeadTime } from "../../flow/lead_time.ts"; | |
| 42 | import { buildYieldSays, readBuildYield } from "../../flow/build_yield.ts"; | |
| 43 | import { readReviewLatency, reviewLatencySays } from "../../flow/review_latency.ts"; | |
| 44 | import { openerCounts, readOpeners } from "../../flow/openers.ts"; | |
| 39 | import { IDLE_LIMIT, type IdlePull } from "../../flow/idle_pulls.ts"; | |
| 40 | import { panelFigures } from "../../flow/panel_cache.ts"; | |
| 41 | import { hoursWords } from "../../flow/lead_time.ts"; | |
| 42 | import { buildYieldSays } from "../../flow/build_yield.ts"; | |
| 43 | import { reviewLatencySays } from "../../flow/review_latency.ts"; | |
| 44 | import { openerCounts } from "../../flow/openers.ts"; | |
| 45 | 45 | import { readHeld } from "../../flow/held.ts"; |
| 46 | 46 | import type { App } from "../../app.ts"; |
| 47 | 47 | import { html, type Html } from "../html.ts"; |
| @@ -100,21 +100,14 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 100 | 100 | const out = new Map(flow.stages.map((t) => [t.key, t])); |
| 101 | 101 | const landed = out.get("approved"); |
| 102 | 102 | const dups = systemFor(app, reader).duplicates; |
| 103 | const conflictedList = conflictedPulls(app.db, reader.kind === "agent"); | |
| 104 | const conflicted = conflictedSays(conflictedList, now); | |
| 105 | const lane = rebaseLaneSays(rebaseLane(app.db, conflictedList)); | |
| 106 | const requested = requestedWaits(app.db, reader.kind === "agent"); | |
| 107 | const onBuilder = requestedSays(requested.builder, "builder", now); | |
| 108 | const onReviewer = requestedSays(requested.reviewer, "reviewer", now); | |
| 109 | const reviewing = reviewingSays(reviewersBusy(app.db, reader.kind === "agent")); | |
| 110 | const agent = reader.kind === "agent"; | |
| 111 | const lead = readLeadTime(app.db, new Date(now), agent); | |
| 112 | const yielded = readBuildYield(app.db, new Date(now), agent); | |
| 113 | const latency = readReviewLatency(app.db, new Date(now), agent); | |
| 114 | const openers = readOpeners(app.db, new Date(now), agent); | |
| 115 | const pile = pileSays(readPile(app.db, reader.kind === "agent")); | |
| 116 | const idle = idlePullsCached(app.db, new Date(now), reader.kind === "agent"); | |
| 117 | const parked = readParked(app.db, new Date(now), reader.kind === "agent"); | |
| 103 | const fig = panelFigures(app.db, reader.kind, new Date(now)); | |
| 104 | const conflicted = conflictedSays(fig.conflicted, now); | |
| 105 | const lane = rebaseLaneSays(fig.lane); | |
| 106 | const onBuilder = requestedSays(fig.requested.builder, "builder", now); | |
| 107 | const onReviewer = requestedSays(fig.requested.reviewer, "reviewer", now); | |
| 108 | const reviewing = reviewingSays(fig.reviewing); | |
| 109 | const { lead, yielded, latency, openers, idle, parked } = fig; | |
| 110 | const pile = pileSays(fig.pile); | |
| 118 | 111 | const newest = parked.newest[0]; |
| 119 | 112 | const listed = asked(url); |
| 120 | 113 | const shown = listed === null ? undefined : out.get(listed.key); |
test/flow-panel-cache.test.ts added +86
| @@ -0,0 +1,86 @@ | ||
| 1 | /** The flow panel's figures are kept (#6974): a second load inside the window runs no reader, a write or 30 s ends it, and an agent never gets the admin's. */ | |
| 2 | import { afterAll, beforeAll, describe, 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 { PANEL_TTL_MS, panelFigures } from "../src/flow/panel_cache.ts"; | |
| 9 | import { flowPanel } from "../src/web/pages/flow-panel.ts"; | |
| 10 | ||
| 11 | let app: App; | |
| 12 | let alice: string; | |
| 13 | ||
| 14 | beforeAll(async () => { | |
| 15 | app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "panel-cache-")), port: 0 }); | |
| 16 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 17 | if (!u.ok) throw new Error(u.error); | |
| 18 | alice = u.value.id; | |
| 19 | app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('pub', ?1, 'pub', 'public')", [alice]); | |
| 20 | app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('priv', ?1, 'priv', 'private')", [alice]); | |
| 21 | }); | |
| 22 | afterAll(() => app.close?.()); | |
| 23 | ||
| 24 | /** Statements run against the db, counted. */ | |
| 25 | function counting(): { n: () => number; stop: () => void } { | |
| 26 | let n = 0; | |
| 27 | const q = app.db.query.bind(app.db); | |
| 28 | const orig = app.db.query; | |
| 29 | (app.db as unknown as { query: unknown }).query = (sql: string) => { | |
| 30 | if (!/total_changes|data_version/.test(sql)) n++; | |
| 31 | return q(sql as never); | |
| 32 | }; | |
| 33 | return { n: () => n, stop: () => ((app.db as unknown as { query: unknown }).query = orig) }; | |
| 34 | } | |
| 35 | ||
| 36 | describe("flow panel cache", () => { | |
| 37 | test("a second load inside the window runs none of the readers", () => { | |
| 38 | const t = new Date(); | |
| 39 | panelFigures(app.db, "owner", t); | |
| 40 | const c = counting(); | |
| 41 | const again = panelFigures(app.db, "owner", new Date(t.getTime() + 1000)); | |
| 42 | const ran = c.n(); | |
| 43 | c.stop(); | |
| 44 | expect(ran).toBe(0); | |
| 45 | expect(again).toBe(panelFigures(app.db, "owner", t)); | |
| 46 | }); | |
| 47 | ||
| 48 | test("a write makes the next load read again", () => { | |
| 49 | const t = new Date(); | |
| 50 | const first = panelFigures(app.db, "owner", t); | |
| 51 | app.db.run("UPDATE repo SET description = 'x' WHERE id = 'pub'"); | |
| 52 | expect(panelFigures(app.db, "owner", t)).not.toBe(first); | |
| 53 | }); | |
| 54 | ||
| 55 | test("it reads again after 30 s with nothing written", () => { | |
| 56 | const t = new Date(Date.now() + 10 * PANEL_TTL_MS); | |
| 57 | const first = panelFigures(app.db, "owner", t); | |
| 58 | expect(panelFigures(app.db, "owner", new Date(t.getTime() + PANEL_TTL_MS - 1))).toBe(first); | |
| 59 | expect(panelFigures(app.db, "owner", new Date(t.getTime() + PANEL_TTL_MS))).not.toBe(first); | |
| 60 | }); | |
| 61 | ||
| 62 | test("an agent never sees an admin's figures", async () => { | |
| 63 | const b = await createUser(app.db, { origin: "seed", trusted: true, username: "scout", email: "[email protected]", password: "a-good-passphrase" }); | |
| 64 | if (!b.ok) throw new Error(b.error); | |
| 65 | app.db.run("UPDATE user SET is_agent = 1 WHERE id = ?1", [b.value.id]); | |
| 66 | const ago = new Date(Date.now() - 3_600_000).toISOString(); | |
| 67 | for (const [id, n, repo] of [["a1", 1, "pub"], ["a2", 2, "priv"]] as const) { | |
| 68 | app.db.run("INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', 't', '', ?5, 'open', ?4)", [id, repo, n, ago, b.value.id]); | |
| 69 | app.db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?1, 'main', 'b', '', 'h')", [id]); | |
| 70 | } | |
| 71 | const t = new Date(); | |
| 72 | const owner = panelFigures(app.db, "owner", t); | |
| 73 | const agent = panelFigures(app.db, "agent", t); | |
| 74 | expect(agent).not.toBe(owner); | |
| 75 | expect(owner.openers.reduce((n, o) => n + o.opened, 0)).toBe(2); | |
| 76 | expect(agent.openers.reduce((n, o) => n + o.opened, 0)).toBe(1); | |
| 77 | // asked the other way round, still apart | |
| 78 | expect(panelFigures(app.db, "owner", t)).toBe(owner); | |
| 79 | expect(panelFigures(app.db, "agent", t)).toBe(agent); | |
| 80 | }); | |
| 81 | ||
| 82 | test("the panel draws from it", () => { | |
| 83 | const page = String(flowPanel(app, { kind: "owner" }, new URL("http://x/boards"), "/boards")); | |
| 84 | expect(page).toContain('id="openers"'); | |
| 85 | }); | |
| 86 | }); |
new above
offline