Public: anyone can read this; the crew can change it.
The held counts and pipeline that /flow shows are a /boards panel #2774
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2766-the-held-counts-and-pipeline-that-flow-s → main 3 commits fork 2 threads · 2 unresolved
5 files
changed +168
−72
README.md +1 −1
| @@ -1444,7 +1444,7 @@ work here. | ||
| 1444 | 1444 | default branches (force-push and deletion refused, honest fast-forwards |
| 1445 | 1445 | allowed). |
| 1446 | 1446 | - **System memory** — `/system`, for a forge admin who is a person (anyone else gets 403): the forge's RSS beside its JS heap (`heapStats()` from bun:jsc), hourly mean and max for the last 24 h, drawn on the server. Sampled once a minute into `memory_hour` in `app.db` (a week kept). RSS rising while the heap stays flat is native memory growth. In the terminal the same admin has a **System memory** command in the palette: the last 24 hours as text rows (hour, RSS mean/max, heap mean/max in MiB), read-only. The account menu links `/system` for that admin. |
| 1447 | - **Boards** — `/boards/data`, for a forge admin who is a person (anyone else | |
| 1447 | - **Boards** — `/boards` also shows the pipeline (a row per stage, the bottleneck named) and the held counts (why each autonomous repository's ready issues wait; private repositories included), for the same admin who is a person; they sit as two cards under the board list, not as panels of a board, because they are not saved queries. The terminal's Boards screen does not draw them: its door to the same rows is the **Flow** dialog. `/boards/data`, for a forge admin who is a person (anyone else | |
| 1448 | 1448 | gets a 404 on the page): the query is the URL (`?source=runs&aggregate=count&by=day&fcolumn=state&fvalue=failed`), |
| 1449 | 1449 | drawn server-side as the chart chosen beside it (`chart=line|bar|area|number|table|heat`) |
| 1450 | 1450 | with its points in a table; a point opens the rows behind it, keeping your filter. |
src/web/pages/boards-held.ts added +126
| @@ -0,0 +1,126 @@ | ||
| 1 | /** | |
| 2 | * The pipeline and the held counts as a panel of `/boards`: one row per | |
| 3 | * stage (`flow/pipeline.ts`) and, per autonomous repository, its ready list | |
| 4 | * as one bar with the count each filter holds (`flow/held.ts`). /flow draws | |
| 5 | * the same held card from here. A private repository is in the rows for the | |
| 6 | * owner only. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { App } from "../../app.ts"; | |
| 10 | import { HOLDS } from "../../crew/held.ts"; | |
| 11 | import { readHeld, type HeldRow } from "../../flow/held.ts"; | |
| 12 | import { bottleneck, readPipeline, STAGE_HOLDS, STAGE_LABEL, verdict, waited } from "../../flow/pipeline.ts"; | |
| 13 | import { html, type Html } from "../html.ts"; | |
| 14 | import { badge, card } from "../ui.ts"; | |
| 15 | ||
| 16 | export const heldCss = /* css */ ` | |
| 17 | .held-bar { width: 100%; height: 12px; display: block; } | |
| 18 | .held-key { display: inline-flex; align-items: center; gap: 4px; margin-right: 12px; font-variant-numeric: tabular-nums; } | |
| 19 | .held-key svg { width: 10px; height: 10px; } | |
| 20 | .held-startable { fill: var(--success); } | |
| 21 | .held-running { fill: var(--accent); } | |
| 22 | .held-open_pull { fill: var(--merged); } | |
| 23 | .held-planner { fill: var(--primary); } | |
| 24 | .held-done_on_main { fill: var(--add-fg); } | |
| 25 | .held-stalled { fill: var(--attn); } | |
| 26 | .held-run_cap { fill: var(--danger); } | |
| 27 | .held-parked_twice { fill: var(--del-fg); } | |
| 28 | .held-red_fix { fill: var(--muted-fg); } | |
| 29 | .held-setting { fill: var(--border-strong); } | |
| 30 | `; | |
| 31 | ||
| 32 | /** One segment per kind, as wide as its share: a picture only; the counts beside it are the words. */ | |
| 33 | function heldBar(row: HeldRow): Html { | |
| 34 | const parts: [string, number][] = [ | |
| 35 | ["startable", row.startable], | |
| 36 | ...HOLDS.map((h): [string, number] => [h, row.counts[h]]), | |
| 37 | ]; | |
| 38 | const total = parts.reduce((n, [, c]) => n + c, 0); | |
| 39 | let x = 0; | |
| 40 | const rects = parts | |
| 41 | .filter(([, c]) => c > 0) | |
| 42 | .map(([kind, c]) => { | |
| 43 | const w = (c / total) * 100; | |
| 44 | const rect = html`<rect class="held-${kind}" x="${x.toFixed(2)}" y="0" width="${w.toFixed(2)}" height="12" />`; | |
| 45 | x += w; | |
| 46 | return rect; | |
| 47 | }); | |
| 48 | return html`<svg class="held-bar" viewBox="0 0 100 12" preserveAspectRatio="none" aria-hidden="true" focusable="false">${rects}</svg>`; | |
| 49 | } | |
| 50 | ||
| 51 | function heldKey(kind: string, n: number): Html { | |
| 52 | return html`<span class="held-key small" data-held="${kind}"> | |
| 53 | <svg viewBox="0 0 10 10" aria-hidden="true" focusable="false"><rect class="held-${kind}" width="10" height="10" /></svg> | |
| 54 | <span class="mono">${kind}=${String(n)}</span> | |
| 55 | </span>`; | |
| 56 | } | |
| 57 | ||
| 58 | /** Why ready issues cannot start, per autonomous repository (#2244). */ | |
| 59 | export function heldCard(rows: readonly HeldRow[]): Html { | |
| 60 | return card( | |
| 61 | html`<h2>Ready, and why it waits</h2> | |
| 62 | <p class="muted small"> | |
| 63 | Each ready issue a builder could start, or the first filter that holds | |
| 64 | it: a run on it, an open pull, the planner, stalled, its run cap, | |
| 65 | parked twice, a red-main fix, a setting's finding. When nothing can | |
| 66 | start and builders are free, the planner is asked to re-plan what is | |
| 67 | stalled or capped. | |
| 68 | </p> | |
| 69 | ${rows.length === 0 | |
| 70 | ? html`<p class="muted">No repository runs on its own.</p>` | |
| 71 | : rows.map( | |
| 72 | (r) => html`<div data-held-repo="${r.repo}"> | |
| 73 | <p><a href="/${r.repo}/map">${r.repo}</a></p> | |
| 74 | ${heldBar(r)} | |
| 75 | <p> | |
| 76 | ${heldKey("startable", r.startable)} | |
| 77 | ${HOLDS.filter((h) => r.counts[h] > 0).map((h) => heldKey(h, r.counts[h]))} | |
| 78 | </p> | |
| 79 | </div>`, | |
| 80 | )}`, | |
| 81 | { id: "held" }, | |
| 82 | ); | |
| 83 | } | |
| 84 | ||
| 85 | /** The pipeline, a row per stage, under the sentence naming the bottleneck. */ | |
| 86 | function pipelineCard(app: App, now: number): Html { | |
| 87 | const stages = readPipeline(app.db, new Date(now)); | |
| 88 | const worst = bottleneck(stages); | |
| 89 | return card( | |
| 90 | html`<p data-stage="${worst?.key ?? ""}"><strong>${verdict(stages, now)}</strong></p> | |
| 91 | <div class="table-scroll"> | |
| 92 | <table class="table" aria-label="Pipeline stages"> | |
| 93 | <thead> | |
| 94 | <tr> | |
| 95 | <th scope="col">Stage</th> | |
| 96 | <th scope="col">Waiting</th> | |
| 97 | <th scope="col">Active</th> | |
| 98 | <th scope="col">Oldest wait</th> | |
| 99 | <th scope="col">Capacity</th> | |
| 100 | </tr> | |
| 101 | </thead> | |
| 102 | <tbody> | |
| 103 | ${stages.map( | |
| 104 | (s) => html`<tr data-stage="${s.key}" ${s === worst ? html`data-bottleneck="true"` : null}> | |
| 105 | <th scope="row"> | |
| 106 | ${STAGE_LABEL[s.key]} | |
| 107 | ${s === worst ? badge("bottleneck", { tone: "danger" }) : null} | |
| 108 | <div class="muted small">${STAGE_HOLDS[s.key]}</div> | |
| 109 | </th> | |
| 110 | <td class="mono">${String(s.waiting)}</td> | |
| 111 | <td class="mono">${String(s.active)}</td> | |
| 112 | <td class="mono">${waited(s, now) ?? "—"}</td> | |
| 113 | <td class="small">${s.capacity ?? "—"}</td> | |
| 114 | </tr>`, | |
| 115 | )} | |
| 116 | </tbody> | |
| 117 | </table> | |
| 118 | </div>`, | |
| 119 | { head: html`<h2>Pipeline</h2>`, id: "pipeline", pad: false }, | |
| 120 | ); | |
| 121 | } | |
| 122 | ||
| 123 | /** The pipeline and the held counts; `owner` adds the private repositories. */ | |
| 124 | export function pipelinePanel(app: App, owner: boolean, now = Date.now()): Html { | |
| 125 | return html`${pipelineCard(app, now)}${heldCard(readHeld(app, owner, now))}`; | |
| 126 | } |
src/web/pages/boards-view.ts +3 −1
| @@ -17,6 +17,7 @@ import { notFoundPage } from "../chrome.ts"; | ||
| 17 | 17 | import { html, raw, when, type Html } from "../html.ts"; |
| 18 | 18 | import { fragment, page } from "../layout.ts"; |
| 19 | 19 | import { btn, card, empty } from "../ui.ts"; |
| 20 | import { heldCss, pipelinePanel } from "./boards-held.ts"; | |
| 20 | 21 | import { cellsOf, chartBox, paramsOf, queryFields, readParams, runFor } from "./boards.ts"; |
| 21 | 22 | |
| 22 | 23 | const RANGE_MS = { "1h": 3_600_000, "24h": 86_400_000, "7d": 604_800_000, "30d": 2_592_000_000 } as const; |
| @@ -93,7 +94,8 @@ export async function boardsList(ctx: Ctx): Promise<Response> { | ||
| 93 | 94 | title: "Boards", |
| 94 | 95 | body: html`<div class="page-head"><h1>Boards</h1> |
| 95 | 96 | ${btn("Explore the data", { href: "/boards/data", variant: "ghost" })}</div> |
| 96 | ${card(make)} ${body}`, | |
| 97 | ${card(make)} ${body} ${pipelinePanel(ctx.app, true)}`, | |
| 98 | styleCss: heldCss, | |
owner=true is hard-coded; this is safe only because listAllBoards gated above. Add a test for a non-admin and an agent so a later change to that gate cannot leak private repositories. |
||
| 97 | 99 | }); |
| 98 | 100 | } |
| 99 | 101 |
src/web/pages/flow.ts +3 −69
| @@ -52,12 +52,12 @@ import { | ||
| 52 | 52 | type StageThroughput, |
| 53 | 53 | } from "../../flow/throughput.ts"; |
| 54 | 54 | import type { QueueRow } from "../../flow/queue.ts"; |
| 55 | import { HOLDS } from "../../crew/held.ts"; | |
| 56 | import { readHeld, type HeldRow } from "../../flow/held.ts"; | |
| 55 | import { readHeld } from "../../flow/held.ts"; | |
| 57 | 56 | import type { Ctx } from "../../http/context.ts"; |
| 58 | 57 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| 59 | 58 | import { html, type Html } from "../html.ts"; |
| 60 | 59 | import { page } from "../layout.ts"; |
| 60 | import { heldCard, heldCss } from "./boards-held.ts"; | |
| 61 | 61 | import { badge, card } from "../ui.ts"; |
| 62 | 62 | |
| 63 | 63 | const css = /* css */ ` |
| @@ -65,19 +65,6 @@ const css = /* css */ ` | ||
| 65 | 65 | .flow-out svg { width: 72px; height: 16px; } |
| 66 | 66 | .flow-bar { fill: var(--muted-fg); } |
| 67 | 67 | .flow-bar[data-empty] { fill: var(--muted); } |
| 68 | .held-bar { width: 100%; height: 12px; display: block; } | |
| 69 | .held-key { display: inline-flex; align-items: center; gap: 4px; margin-right: 12px; font-variant-numeric: tabular-nums; } | |
| 70 | .held-key svg { width: 10px; height: 10px; } | |
| 71 | .held-startable { fill: var(--success); } | |
| 72 | .held-running { fill: var(--accent); } | |
| 73 | .held-open_pull { fill: var(--merged); } | |
| 74 | .held-planner { fill: var(--primary); } | |
| 75 | .held-done_on_main { fill: var(--add-fg); } | |
| 76 | .held-stalled { fill: var(--attn); } | |
| 77 | .held-run_cap { fill: var(--danger); } | |
| 78 | .held-parked_twice { fill: var(--del-fg); } | |
| 79 | .held-red_fix { fill: var(--muted-fg); } | |
| 80 | .held-setting { fill: var(--border-strong); } | |
| 81 | 68 | `; |
| 82 | 69 | |
| 83 | 70 | /** What `?out=` and `?hour=` ask to list, checked: an unknown stage or hour lists nothing. */ |
| @@ -215,60 +202,7 @@ export function flowPage(ctx: Ctx): Response { | ||
| 215 | 202 | ${heldCard(readHeld(ctx.app, reader.kind === "owner", now))} |
| 216 | 203 | ${queueCard(queuesFor(ctx.app, reader, new Date(now)))} |
| 217 | 204 | ${listed === null || shown === undefined ? null : outList(shown, listed.hour, now)}`; |
| 218 | return page({ ctx, title: "Flow", body, styleCss: css }); | |
| 219 | } | |
| 220 | ||
| 221 | /** One segment per kind, as wide as its share: a picture only; the counts beside it are the words. */ | |
| 222 | function heldBar(row: HeldRow): Html { | |
| 223 | const parts: [string, number][] = [ | |
| 224 | ["startable", row.startable], | |
| 225 | ...HOLDS.map((h): [string, number] => [h, row.counts[h]]), | |
| 226 | ]; | |
| 227 | const total = parts.reduce((n, [, c]) => n + c, 0); | |
| 228 | let x = 0; | |
| 229 | const rects = parts | |
| 230 | .filter(([, c]) => c > 0) | |
| 231 | .map(([kind, c]) => { | |
| 232 | const w = (c / total) * 100; | |
| 233 | const rect = html`<rect class="held-${kind}" x="${x.toFixed(2)}" y="0" width="${w.toFixed(2)}" height="12" />`; | |
| 234 | x += w; | |
| 235 | return rect; | |
| 236 | }); | |
| 237 | return html`<svg class="held-bar" viewBox="0 0 100 12" preserveAspectRatio="none" aria-hidden="true" focusable="false">${rects}</svg>`; | |
| 238 | } | |
| 239 | ||
| 240 | function heldKey(kind: string, n: number): Html { | |
| 241 | return html`<span class="held-key small" data-held="${kind}"> | |
| 242 | <svg viewBox="0 0 10 10" aria-hidden="true" focusable="false"><rect class="held-${kind}" width="10" height="10" /></svg> | |
| 243 | <span class="mono">${kind}=${String(n)}</span> | |
| 244 | </span>`; | |
| 245 | } | |
| 246 | ||
| 247 | /** Why ready issues cannot start, per autonomous repository (#2244). */ | |
| 248 | function heldCard(rows: readonly HeldRow[]): Html { | |
| 249 | return card( | |
| 250 | html`<h2>Ready, and why it waits</h2> | |
| 251 | <p class="muted small"> | |
| 252 | Each ready issue a builder could start, or the first filter that holds | |
| 253 | it: a run on it, an open pull, the planner, stalled, its run cap, | |
| 254 | parked twice, a red-main fix, a setting's finding. When nothing can | |
| 255 | start and builders are free, the planner is asked to re-plan what is | |
| 256 | stalled or capped. | |
| 257 | </p> | |
| 258 | ${rows.length === 0 | |
| 259 | ? html`<p class="muted">No repository runs on its own.</p>` | |
| 260 | : rows.map( | |
| 261 | (r) => html`<div data-held-repo="${r.repo}"> | |
| 262 | <p><a href="/${r.repo}/map">${r.repo}</a></p> | |
| 263 | ${heldBar(r)} | |
| 264 | <p> | |
| 265 | ${heldKey("startable", r.startable)} | |
| 266 | ${HOLDS.filter((h) => r.counts[h] > 0).map((h) => heldKey(h, r.counts[h]))} | |
| 267 | </p> | |
| 268 | </div>`, | |
| 269 | )}`, | |
| 270 | { id: "held" }, | |
| 271 | ); | |
| 205 | return page({ ctx, title: "Flow", body, styleCss: css + heldCss }); | |
| 272 | 206 | } |
| 273 | 207 | |
| 274 | 208 | /** The merge queue, one row per repository with something in it or landed. */ |
test/crew-held.test.ts +35 −1
| @@ -20,6 +20,7 @@ import { getIssueById, type Issue } from "../src/core/issues.ts"; | ||
| 20 | 20 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 21 | 21 | import { DONE_NOTE } from "../src/core/done_on_main.ts"; |
| 22 | 22 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; |
| 23 | import { createToken } from "../src/core/tokens.ts"; | |
| 23 | 24 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 24 | 25 | import { driveRepo } from "../src/crew/autonomy.ts"; |
| 25 | 26 | import { taskPrompt } from "../src/crew/brief.ts"; |
| @@ -329,7 +330,7 @@ describe("the tick says why each ready issue cannot start", () => { | ||
| 329 | 330 | expect(getIssueById(app.db, direction.id)!.assigneeId).toBeNull(); |
| 330 | 331 | }); |
| 331 | 332 | |
| 332 | test("both doors: /flow and the terminal's Flow dialog show the counts, private ones to the owner only", async () => { | |
| 333 | test("three doors: /flow, /boards and the terminal's Flow dialog show the counts, private ones to the owner only", async () => { | |
| 333 | 334 | const open = await crewed(app, alice, "open-door", "public"); |
| 334 | 335 | const shut = await crewed(app, alice, "shut-door"); |
| 335 | 336 | for (const { repo, mason } of [open, shut]) { |
| @@ -362,6 +363,39 @@ describe("the tick says why each ready issue cannot start", () => { | ||
| 362 | 363 | expect(row).toContain('<span class="mono">run_cap=1</span>'); |
| 363 | 364 | expect(page).toContain('data-held-repo="alice/shut-door"'); |
| 364 | 365 | |
| 366 | // /boards is the owner's: the same counts, private ones included, and a | |
| 367 | // stranger is not shown the page at all. | |
| 368 | const boards = await (await fetch(`${servers.url}/boards`, { headers: { cookie } })).text(); | |
| 369 | expect(boards).toContain('id="held"'); | |
| 370 | expect(boards).toContain('id="pipeline"'); | |
| 371 | expect(boards).toContain('data-held-repo="alice/open-door"'); | |
| 372 | expect(boards).toContain('data-held-repo="alice/shut-door"'); | |
| 373 | expect(boards).toContain('<span class="mono">run_cap=1</span>'); | |
| 374 | // Signed out, a signed-in non-admin and an agent token all get the | |
| 375 | // not-found page: none sees the rows or the pipeline. | |
| 376 | const bob = await createUser(app.db, { | |
| 377 | origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase", | |
| 378 | }); | |
| 379 | if (!bob.ok) throw new Error(bob.error); | |
| 380 | const made = await createAgent(app, alice, { username: "scout-reader" }); | |
| 381 | if (!made.ok) throw new Error(String(made.error)); | |
| 382 | const bobIn = await fetch(`${servers.url}/signin`, { | |
| 383 | method: "POST", | |
| 384 | redirect: "manual", | |
| 385 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 386 | body: new URLSearchParams({ username: "bob", password: "a-good-passphrase" }), | |
| 387 | }); | |
| 388 | const bobCookie = (bobIn.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 389 | const agentToken = createToken(app.db, made.value.user.id, "t", "rw").token; | |
| 390 | for (const headers of [{}, { cookie: bobCookie }, { authorization: `Bearer ${agentToken}` }]) { | |
| 391 | const seen = await fetch(`${servers.url}/boards`, { headers, redirect: "manual" }); | |
| 392 | const text = await seen.text(); | |
| 393 | expect(text).not.toContain("data-held-repo"); | |
| 394 | expect(text).not.toContain('id="held"'); | |
| 395 | expect(text).not.toContain('id="pipeline"'); | |
| 396 | expect(text).not.toContain("alice/shut-door"); | |
| 397 | } | |
| 398 | ||
| 365 | 399 | // The terminal reads the same rows: the owner sees both, an agent |
| 366 | 400 | // reader the public one alone. |
| 367 | 401 | const owner = flowLines(app.db, new Date(), [], undefined, readHeld(app, true)).join("\n"); |
new above
offline
owner=true is only safe because listAllBoards returned ok above. Derive it from that result so the two cannot drift apart.