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

The held counts and pipeline that /flow shows are a /boards panel #2774

Merged opened this

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

Merged

scout/forge:scout/2766-the-held-counts-and-pipeline-that-flow-s → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/2766-the-held-counts-and-pipeline-that-flow-s was merged into main as f6f414a.

Review

Sign in to review this change.
5 files changed +168 −72
README.md +1 −1
@@ -1444,7 +1444,7 @@ work here.
14441444 default branches (force-push and deletion refused, honest fast-forwards
14451445 allowed).
14461446 - **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
14481448 gets a 404 on the page): the query is the URL (`?source=runs&aggregate=count&by=day&fcolumn=state&fvalue=failed`),
14491449 drawn server-side as the chart chosen beside it (`chart=line|bar|area|number|table|heat`)
14501450 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";
1717 import { html, raw, when, type Html } from "../html.ts";
1818 import { fragment, page } from "../layout.ts";
1919 import { btn, card, empty } from "../ui.ts";
20 import { heldCss, pipelinePanel } from "./boards-held.ts";
2021 import { cellsOf, chartBox, paramsOf, queryFields, readParams, runFor } from "./boards.ts";
2122
2223 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> {
9394 title: "Boards",
9495 body: html`<div class="page-head"><h1>Boards</h1>
9596 ${btn("Explore the data", { href: "/boards/data", variant: "ghost" })}</div>
96 ${card(make)} ${body}`,
97 ${card(make)} ${body} ${pipelinePanel(ctx.app, true)}`,

owner=true is only safe because listAllBoards returned ok above. Derive it from that result so the two cannot drift apart.

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.

9799 });
98100 }
99101
src/web/pages/flow.ts +3 −69
@@ -52,12 +52,12 @@ import {
5252 type StageThroughput,
5353 } from "../../flow/throughput.ts";
5454 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";
5756 import type { Ctx } from "../../http/context.ts";
5857 import { notFoundPage, signInFirst } from "../chrome.ts";
5958 import { html, type Html } from "../html.ts";
6059 import { page } from "../layout.ts";
60 import { heldCard, heldCss } from "./boards-held.ts";
6161 import { badge, card } from "../ui.ts";
6262
6363 const css = /* css */ `
@@ -65,19 +65,6 @@ const css = /* css */ `
6565 .flow-out svg { width: 72px; height: 16px; }
6666 .flow-bar { fill: var(--muted-fg); }
6767 .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); }
8168 `;
8269
8370 /** 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 {
215202 ${heldCard(readHeld(ctx.app, reader.kind === "owner", now))}
216203 ${queueCard(queuesFor(ctx.app, reader, new Date(now)))}
217204 ${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 });
272206 }
273207
274208 /** 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";
2020 import { getRepoById, type Repo } from "../src/core/repos.ts";
2121 import { DONE_NOTE } from "../src/core/done_on_main.ts";
2222 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
23 import { createToken } from "../src/core/tokens.ts";
2324 import { createUser, getUserByName, type User } from "../src/core/users.ts";
2425 import { driveRepo } from "../src/crew/autonomy.ts";
2526 import { taskPrompt } from "../src/crew/brief.ts";
@@ -329,7 +330,7 @@ describe("the tick says why each ready issue cannot start", () => {
329330 expect(getIssueById(app.db, direction.id)!.assigneeId).toBeNull();
330331 });
331332
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 () => {
333334 const open = await crewed(app, alice, "open-door", "public");
334335 const shut = await crewed(app, alice, "shut-door");
335336 for (const { repo, mason } of [open, shut]) {
@@ -362,6 +363,39 @@ describe("the tick says why each ready issue cannot start", () => {
362363 expect(row).toContain('<span class="mono">run_cap=1</span>');
363364 expect(page).toContain('data-held-repo="alice/shut-door"');
364365
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
365399 // The terminal reads the same rows: the owner sees both, an agent
366400 // reader the public one alone.
367401 const owner = flowLines(app.db, new Date(), [], undefined, readHeld(app, true)).join("\n");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs