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

The flow panel on /boards says the idle pulls and the parked count, each linking to what it names #6677

Merged opened this

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

Merged

scout/forge:scout/6480-the-flow-panel-on-boards-says-the-idle-p → main 8 commits fork 6 threads · 6 unresolved

  • Merged. scout/6480-the-flow-panel-on-boards-says-the-idle-p was merged into main as f270d86.

Review

Sign in to review this change.
7 files changed +158 −4
docs/STATUS.md +1 −1
@@ -59,7 +59,7 @@
5959 A live board (any `refresh` but `off`; the form offers off or on, and `10s` and `1m` are still accepted and mean the same) redraws its grid when a run, pull, check, issue or usage event arrives, through the live-region swap
6060 (`/boards/:slug/live`, etag'd, one fetch per burst). The same doors are in the API: `GET /api/v1/boards`, `GET|PUT|DELETE /api/v1/boards/:slug`, and each panel's data as JSON or CSV at `GET /api/v1/boards/:slug/panels/:id?from=&to=&step=5m&format=csv`, bucketed in SQL to the step and never cut short (`docs/APPS.md`); each panel's card links `json` and `csv` (a plain download link) beside `explore`
6161 (an agent or anyone but an admin is 403; a panel whose query cannot run is refused with 422). The same four doors are MCP tools (`boards`, `board_write`, `board_delete`; `boards` with a `slug` reads one, and with a `panel` too that panel's data, with the same `from`, `to` and `step`) and, for the same person, a **Boards** screen in the terminal that lists boards and shows a board's panels as text, each with what its data holds now (a time panel's last bucket and step, another's row count). No dependency was added.
62 - **Parked work** — the terminal's Flow dialog says how many issues the crew parked (an open issue whose newest comment is the crew's `Parked by the crew` note) in the last 24 h and 7 d, and names the newest three; the MCP `flow` tool returns the same as `parked`. See `docs/TOUR.md`.
62 - **Parked work** — the terminal's Flow dialog says how many issues the crew parked (an open issue whose newest comment is the crew's `Parked by the crew` note) in the last 24 h and 7 d, and names the newest three; the MCP `flow` tool returns the same as `parked`. The flow panel on `/boards` draws two lines under its numbers, each left out at 0: `N idle pulls` (open crew pulls whose head has not moved for 72 h; the oldest three linked, `and K more` after) and `N parked today, M this week` (linking the newest parked issue). The Flow dialog lists them too (`idle: N`, a row each, `+K more`). `/boards` is the owner's; the MCP `flow` tool, as an agent, reads public repositories only (sealed issues left out). `and K more` opens `/boards?idle=all`, a list of every idle pull. See `docs/TOUR.md`.
6363 - **Issues** — labels, threaded comments, close/reopen, markdown.
6464 - **Pull requests** — open from a branch, a **beautiful diff viewer** (syntax
6565 highlighting composed with intra-line word highlighting, mobile-first,
docs/design/areas/lists.md +2
@@ -12,3 +12,5 @@ trails a palette row.
1212 end, ellipsised past 45% of the row. Written by `src/web/client.ts` (the file finder's rows and the command
1313 palette's `row()`), never in server markup. Styled in `src/web/style/components.ts` (`.palette-item .cat`) and
1414 `src/web/style/content.ts` (`.finder .palette-item .cat`).
15 - `.flow-row` — a §5 `.row` link on the flow panel (`flow-panel.ts`: idle pulls and the "and K more" row) with a 44px
16 minimum height, so it is a touch target on a 360px phone. Styled in `src/web/style/components.ts` (list rows).
src/flow/idle_pulls.ts +5 −2
@@ -14,6 +14,7 @@ export const IDLE_LIMIT = 20;
1414
1515 export interface IdlePull {
1616 readonly pull: number;
17 readonly owner: string;
1718 readonly repo: string;
1819 readonly issue: number | null;
1920 readonly hours: number;
@@ -23,11 +24,12 @@ export interface IdlePull {
2324 export function readIdlePulls(db: Database, now = new Date(), publicOnly = false): IdlePull[] {
2425 const cutoff = new Date(now.getTime() - IDLE_HOURS * HOUR_MS).toISOString();
2526 const rows = db
26 .query<{ number: number; name: string; moved_at: string; body: string }, [number, string, number]>(
27 `SELECT i.number AS number, r.name AS name, m.at AS moved_at, i.body AS body
27 .query<{ number: number; owner: string; name: string; moved_at: string; body: string }, [number, string, number]>(
28 `SELECT i.number AS number, ro.username AS owner, r.name AS name, m.at AS moved_at, i.body AS body
2829 FROM pull p
2930 JOIN issue i ON i.id = p.issue_id
3031 JOIN repo r ON r.id = i.repo_id
32 JOIN user ro ON ro.id = r.owner_id
3133 JOIN user u ON u.id = i.author_id AND u.is_agent = 1
3234 JOIN (SELECT p2.issue_id AS issue_id,
3335 COALESCE((SELECT MAX(e.created_at) FROM event e
@@ -42,6 +44,7 @@ export function readIdlePulls(db: Database, now = new Date(), publicOnly = false
4244 .all(publicOnly ? 1 : 0, cutoff, IDLE_LIMIT);
4345 return rows.map((r) => ({
4446 pull: r.number,
47 owner: r.owner,
4548 repo: r.name,
4649 issue: closesFromBody(r.body)[0] ?? null,
4750 hours: Math.max(0, (now.getTime() - Date.parse(r.moved_at)) / HOUR_MS),
src/web/pages/flow-panel.ts +38
@@ -36,6 +36,8 @@ import { type QueueRow, redBatchWords } from "../../flow/queue.ts";
3636 import { HOT_PULLS, pullCounts, warmFiles } from "../../crew/hot_files.ts";
3737 import { warmingLine } from "../../crew/hot_history.ts";
3838 import { getRepoById } from "../../core/repos.ts";
39 import { IDLE_LIMIT, readIdlePulls, type IdlePull } from "../../flow/idle_pulls.ts";
40 import { readParked } from "../../flow/parked.ts";
3941 import { hoursWords, readLeadTime } from "../../flow/lead_time.ts";
4042 import { openerCounts, readOpeners } from "../../flow/openers.ts";
4143 import { readHeld } from "../../flow/held.ts";
@@ -107,6 +109,9 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm
107109 const lead = readLeadTime(app.db, new Date(now), agent);
108110 const openers = readOpeners(app.db, new Date(now), agent);
109111 const pile = pileSays(readPile(app.db, reader.kind === "agent"));
112 const idle = readIdlePulls(app.db, new Date(now), reader.kind === "agent");
113 const parked = readParked(app.db, new Date(now), reader.kind === "agent");
114 const newest = parked.newest[0];
110115 const listed = asked(url);
111116 const shown = listed === null ? undefined : out.get(listed.key);
112117 return html`${card(
@@ -119,6 +124,22 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm
119124 ${onBuilder === null ? "" : html`<p id="requested-builder">${onBuilder}</p>`}
120125 ${onReviewer === null ? "" : html`<p id="requested-reviewer">${onReviewer}</p>`}
121126 ${reviewing === null ? "" : html`<p id="review-load">${reviewing}</p>`}
127 ${idle.length === 0
128 ? ""
outdated

'and K more' links to only the fourth pull's repository, so with idle pulls across repos the count does not match the page it opens. Link to a list of all idle pulls, or drop the count.

129 : html`<div id="idle-pulls" data-count="${String(idle.length)}">
130 <p>${idleCount(idle)} idle pull${idle.length === 1 ? "" : "s"}:</p>
outdated

readIdlePulls stops at IDLE_LIMIT (20), so this says 20 idle pulls when there are more. Say 20+ when idle.length >= IDLE_LIMIT, as the dialog's idleLines does, here and in the #idle-list heading. Cover it with a test.

131 <ul class="list">${idle.slice(0, 3).map(idleRow)}
132 ${idle.length > 3
133 ? html`<li><a class="row flow-row small" href="${base}?idle=all#idle-list" data-more><span class="row-main">and ${String(idle.length - 3)}${idle.length >= IDLE_LIMIT ? "+" : ""} more</span></a></li>`
134 : null}
135 </ul>
136 </div>`}
137 ${parked.week === 0 || newest === undefined
138 ? ""
139 : html`<p id="parked" data-day="${String(parked.day)}" data-week="${String(parked.week)}">
140 ${String(parked.day)} parked today, ${String(parked.week)} this week; newest
141 <a href="/${newest.repo}/issues/${String(newest.issue)}">${newest.repo} #${String(newest.issue)}</a>

The parked link is an inline anchor inside a paragraph, smaller than a thumb target. Render it as a .flow-row link like the idle pulls.

142 </p>`}
122143 <p id="throughput">
123144 landed
124145 <a href="${outHref(base, "approved", 0)}" data-landed-hour>${String(landed?.hours[0] ?? 0)}</a>
@@ -193,9 +214,26 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm
193214 ${hotFilesCard(app, reader)}
194215 ${heldCard(readHeld(app, reader.kind === "owner", now))}
195216 ${queueCard(queuesFor(app, reader, new Date(now), { batches: true }))}
217 ${url.searchParams.get("idle") === "all" && idle.length > 0 ? idleList(idle) : null}
196218 ${listed === null || shown === undefined ? null : outList(shown, listed.hour, now)}`;
197219 }
198220
221 /** The count, `20+` when the read stopped at its limit. */
222 const idleCount = (idle: readonly IdlePull[]): string => `${idle.length}${idle.length >= IDLE_LIMIT ? "+" : ""}`;
223
224 /** One idle pull as a touch-sized row linking to the pull. */
225 function idleRow(p: IdlePull): Html {
226 return html`<li><a class="row flow-row" href="/${p.owner}/${p.repo}/pulls/${String(p.pull)}"><span class="row-main"><span class="row-title">${p.repo} #${String(p.pull)}</span><span class="row-meta">idle ${String(Math.floor(p.hours))} h</span></span></a></li>`;
227 }
228
229 /** Every idle pull, oldest first: where "and K more" lands. */
230 function idleList(idle: readonly IdlePull[]): Html {
231 return card(html`<h2>${idleCount(idle)} idle pull${idle.length === 1 ? "" : "s"}</h2><ul class="list">${idle.map(idleRow)}</ul>`, {
232 id: "idle-list",
233 pad: false,
234 });
235 }
236
199237 /** The merge queue, one row per repository with something in it or landed. */
200238 function queueCard(rows: readonly QueueRow[]): Html {
201239 return card(
src/web/style/components.ts +1
@@ -389,6 +389,7 @@ a.sha:hover { color: var(--accent); }
389389 padding: 8px 14px; min-height: 40px;
390390 transition: background-color 100ms var(--ease);
391391 }
392 a.flow-row { min-height: 44px; }
392393 .row:focus-visible { outline-offset: -2px; }
393394 a.row:hover, .row[data-nav]:hover { background: var(--muted); color: inherit; }
394395 .list li[aria-current="true"] > .row { background: var(--muted); }
test/flow-idle-pulls.test.ts +1 −1
@@ -77,7 +77,7 @@ describe("readIdlePulls", () => {
7777 pull({ openedH: 200, merged: true });
7878 pull({ openedH: 200, by: person });
7979 const rows = readIdlePulls(app.db, NOW, false);
80 expect(rows).toEqual([{ pull: idle, repo: "pub", issue: 7, hours: 80 }]);
80 expect(rows).toEqual([{ pull: idle, owner: "alice", repo: "pub", issue: 7, hours: 80 }]);
8181 });
8282
8383 test("a pull with no push is idle from its opening, and 72 h is the line", () => {
test/flow-panel-idle-parked.test.ts added +110
@@ -0,0 +1,110 @@
1 /** The flow panel's idle-pull and parked lines (#6480): drawn when non-zero, each linking what it names. */
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 { flowPanel } from "../src/web/pages/flow-panel.ts";
9
10 const ago = (h: number): string => new Date(Date.now() - h * 3_600_000).toISOString();
11 let app: App;
12 let alice: string;
13 let scout: string;
14
15 beforeAll(async () => {
16 app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "panel-idle-")), port: 0 });
17 const mk = async (username: string, first: boolean): Promise<string> => {
18 const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first });
19 if (!u.ok) throw new Error(u.error);
20 return u.value.id;
21 };
22 alice = await mk("alice", true);
23 scout = await mk("scout", false);
24 app.db.run("UPDATE user SET is_agent = 1 WHERE id = ?1", [scout]);
25 app.db.run("INSERT INTO repo (id, owner_id, name, visibility, autonomy_by) VALUES ('pub', ?1, 'pub', 'public', ?1)", [alice]);
26 });
27 afterAll(() => app.close?.());
28
29 const draw = (kind: "owner" | "agent"): string => String(flowPanel(app, { kind }, new URL("http://x/boards"), "/boards"));
30
31 describe("flow panel idle pulls and parked", () => {
32 test("neither line is drawn with nothing idle or parked", () => {
33 const page = draw("owner");
34 expect(page).not.toContain('id="idle-pulls"');
35 expect(page).not.toContain('id="parked"');
36 });
37
38 test("one idle pull and one parked issue are drawn and linked", () => {
39 app.db.run("INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES ('p1', 'pub', 1, 'pull', 't', '', ?1, 'open', ?2)", [scout, ago(100)]);
40 app.db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES ('p1', 'main', 'b', '', 'h')");
41 app.db.run("INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state) VALUES ('i2', 'pub', 2, 'issue', 'Stuck', '', ?1, 'open')", [alice]);
42 app.db.run("INSERT INTO comment (id, issue_id, author_id, body, created_at) VALUES ('c1', 'i2', ?1, 'Parked by the crew: could not finish.', ?2)", [alice, ago(1)]);
43 for (const kind of ["owner", "agent"] as const) {
44 const page = draw(kind);
45 expect(page).toContain('id="idle-pulls"');
46 expect(page).toContain("1 idle pull:");
47 expect(page).toContain('href="/alice/pub/pulls/1"');
48 expect(page).toContain("1 parked today, 1 this week");
49 expect(page).toContain('href="/alice/pub/issues/2"');
50 }
51 });
52 });
53
54 describe("flow panel public cut", () => {
55 test("a private repo's idle pull and parked issue, and a sealed parked issue, show to the owner only", () => {
56 app.db.run("INSERT INTO repo (id, owner_id, name, visibility, autonomy_by) VALUES ('priv', ?1, 'priv', 'private', ?1)", [alice]);
57 app.db.run("INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES ('pp', 'priv', 1, 'pull', 't', '', ?1, 'open', ?2)", [scout, ago(200)]);
58 app.db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES ('pp', 'main', 'b', '', 'h')");
59 const parkedIssue = (id: string, repo: string, n: number, sealed: number): void => {
60 app.db.run("INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, sealed) VALUES (?1, ?2, ?3, 'issue', 'S', '', ?4, 'open', ?5)", [id, repo, n, alice, sealed]);
61 app.db.run("INSERT INTO comment (id, issue_id, author_id, body, created_at) VALUES (?1, ?2, ?3, 'Parked by the crew: x.', ?4)", ["c" + id, id, alice, ago(0.5)]);
62 };
63 parkedIssue("pi", "priv", 5, 0);
64 parkedIssue("si", "pub", 9, 1);
65 const owner = draw("owner");
66 expect(owner).toContain("2 idle pulls");
67 expect(owner).toContain("/alice/priv/pulls/1");
68 expect(owner).toContain("3 parked today, 3 this week");
69 const agent = draw("agent");
70 expect(agent).toContain("1 idle pull:");
71 expect(agent).not.toContain("/alice/priv/");
72 expect(agent).not.toContain("/alice/pub/issues/9");
73 expect(agent).toContain("1 parked today, 1 this week");
74 });
75 });
76
77 describe("flow panel many idle pulls", () => {
78 test("lists three as rows and links the rest to the pulls list; links carry no aria-label", () => {
79 for (let n = 20; n < 23; n++) {
80 app.db.run("INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, 'pub', ?2, 'pull', 't', '', ?3, 'open', ?4)", [`m${n}`, n, scout, ago(300)]);
81 app.db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?1, 'main', ?2, '', 'h')", [`m${n}`, `m${n}`]);
82 }
83 const page = draw("agent");
84 const idle = page.slice(page.indexOf('id="idle-pulls"'), page.indexOf('id="parked"'));
85 expect(idle).toContain("4 idle pulls");
86 expect(idle.match(/<li/g)?.length).toBe(4);
87 expect(idle).toMatch(/<a class="row flow-row small" href="\/boards\?idle=all#idle-list"[^>]*>/);
88 expect(idle).toContain("and 1 more");
89 const all = String(flowPanel(app, { kind: "agent" }, new URL("http://x/boards?idle=all"), "/boards"));
90 const list = all.slice(all.indexOf('id="idle-list"'));
91 expect(list).toContain("4 idle pulls");
92 expect(list.match(/flow-row/g)?.length).toBe(4);
93 expect(page).not.toContain('id="idle-list"');
94 expect(idle).not.toContain("aria-label");
95 });
96 });
97
98 describe("flow panel idle pulls at the cap", () => {
99 test("says 20+ when the read stopped at its limit", () => {
100 for (let n = 100; n < 120; n++) {
101 app.db.run("INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, 'pub', ?2, 'pull', 't', '', ?3, 'open', ?4)", [`c${n}`, n, scout, ago(300)]);
102 app.db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?1, 'main', ?2, '', 'h')", [`c${n}`, `c${n}`]);
103 }
104 const page = draw("agent");
105 expect(page).toContain("20+ idle pulls:");
106 expect(page).toContain("and 17+ more");
107 const all = String(flowPanel(app, { kind: "agent" }, new URL("http://x/boards?idle=all"), "/boards"));
108 expect(all).toContain("<h2>20+ idle pulls</h2>");
109 });
110 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs