Public: anyone can read this; the crew can change it.
The terminal's Flow dialog lists the idle crew pulls: number, repository and hours since the last push #6543
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6479-the-terminal-s-flow-dialog-lists-the-idl → main 4 commits fork 4 threads · 4 unresolved
4 files
changed +97
−5
docs/STATUS.md +1 −1
| @@ -36,7 +36,7 @@ | ||
| 36 | 36 | default branches (force-push and deletion refused, honest fast-forwards |
| 37 | 37 | allowed). |
| 38 | 38 | - **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. |
| 39 | - **Flow alarms** — a firing alarm (red main, a stuck approved pull, an idle crew, no release for 90 min) shows as one linked line above home's feed and on `/boards` (admin), under the vitals line of the terminal's Flow dialog and home, as one phone notification when it starts (not repeated while it stands), and in the `flow` tool's `alarms` (MCP, `/api/v1/telemetry/flow`; an agent's cut to public repositories). Nothing firing draws nothing. | |
| 39 | - **Flow alarms** — a firing alarm (red main, a stuck approved pull, an idle crew, no release for 90 min) shows as one linked line above home's feed and on `/boards` (admin), under the vitals line of the terminal's Flow dialog and home, as one phone notification when it starts (not repeated while it stands), and in the `flow` tool's `alarms` (MCP, `/api/v1/telemetry/flow`; an agent's cut to public repositories). Nothing firing draws nothing. The dialog also lists the idle crew pulls (number, repository, hours since the last push), oldest first, at most 20 read and five drawn with `+N more`; none draws nothing, and an agent's list is cut to public repositories. | |
| 40 | 40 | - **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 |
| 41 | 41 | gets a 404 on the page): the query is the URL (`?source=runs&aggregate=count&by=day&fcolumn=state&fvalue=failed`), |
| 42 | 42 | drawn server-side as the chart chosen beside it (`chart=line|bar|area|number|table|heat`) |
src/flow/idle_pulls.ts +3 −2
| @@ -9,7 +9,8 @@ import { closesFromBody } from "../core/pulls.ts"; | ||
| 9 | 9 | |
| 10 | 10 | const HOUR_MS = 3_600_000; |
| 11 | 11 | const IDLE_HOURS = 72; |
| 12 | const LIMIT = 20; | |
| 12 | /** The reader returns at most this many; a screen shows `N+` when it gets them all. */ | |
| 13 | export const IDLE_LIMIT = 20; | |
| 13 | 14 | |
| 14 | 15 | export interface IdlePull { |
| 15 | 16 | readonly pull: number; |
| @@ -38,7 +39,7 @@ export function readIdlePulls(db: Database, now = new Date(), publicOnly = false | ||
| 38 | 39 | ORDER BY m.at ASC, i.number ASC |
| 39 | 40 | LIMIT ?3`, |
| 40 | 41 | ) |
| 41 | .all(publicOnly ? 1 : 0, cutoff, LIMIT); | |
| 42 | .all(publicOnly ? 1 : 0, cutoff, IDLE_LIMIT); | |
| 42 | 43 | return rows.map((r) => ({ |
| 43 | 44 | pull: r.number, |
| 44 | 45 | repo: r.name, |
src/tui/screens/flow.ts +19 −2
| @@ -6,8 +6,8 @@ | ||
| 6 | 6 | * MCP `flow` tool read. Under them, each repository's merge queue (#2036): |
| 7 | 7 | * queued, gating, gates in flight, landed in the last hour and the median |
| 8 | 8 | * minutes from approval to landing (`flow/queue.ts`); then the |
| 9 | * conflicted pulls, and the pulls with changes requested waiting on a | |
| 10 | * builder and on a reviewer (`flow/requested.ts`, #2172); then each | |
| 9 | * conflicted pulls, the idle crew pulls (#6479) and the pulls with | |
| 10 | * changes requested waiting on a builder and on a reviewer (`flow/requested.ts`, #2172); then each | |
| 11 | 11 | * autonomous repository's ready list, startable beside the count per |
| 12 | 12 | * filter that holds the rest (`flow/held.ts`, #2244), as the flow panel draws it. |
| 13 | 13 | * Read-only; Enter, Esc or q closes it. |
| @@ -17,6 +17,7 @@ import type { Database } from "bun:sqlite"; | ||
| 17 | 17 | import { HOLDS } from "../../crew/held.ts"; |
| 18 | 18 | import type { HeldRow } from "../../flow/held.ts"; |
| 19 | 19 | import { conflictedPulls, conflictedSays, rebaseLane, rebaseLaneSays } from "../../flow/conflicted.ts"; |
| 20 | import { IDLE_LIMIT, readIdlePulls } from "../../flow/idle_pulls.ts"; | |
| 20 | 21 | import { pileSays, readPile } from "../../flow/pile.ts"; |
| 21 | 22 | import { readPipeline, STAGE_LABEL, verdict } from "../../flow/pipeline.ts"; |
| 22 | 23 | import { parkedSays, readParked } from "../../flow/parked.ts"; |
| @@ -96,6 +97,7 @@ export function flowLines( | ||
| 96 | 97 | : wrap(`Red batches, last hour: ${red.map((q) => `${q.repo} ${redBatchWords(q)}`).join(", ")}.`, inner)), |
| 97 | 98 | ...(lane === null ? [] : ["", ...wrap(`Rebases: ${lane}.`, inner)]), |
| 98 | 99 | ...(conflicted === null ? [] : ["", ...wrap(conflicted, inner)]), |
| 100 | ...idleLines(db, now, agent !== undefined, inner), | |
| 99 | 101 | ...waits, |
| 100 | 102 | ...(reviewing === null ? [] : ["", ...wrap(reviewing, inner)]), |
| 101 | 103 | ...(held === undefined ? [] : ["", ...heldLines(held, inner)]), |
| @@ -104,6 +106,21 @@ export function flowLines( | ||
| 104 | 106 | ]; |
| 105 | 107 | } |
| 106 | 108 | |
| 109 | /** Idle pulls shown before `+N more`. */ | |
| 110 | const IDLE_SHOWN = 5; | |
| 111 | ||
| 112 | /** A blank line, `idle: N`, then a line per idle crew pull (`flow/idle_pulls.ts`, #6479), oldest first; nothing when none. */ | |
outdated
The count here is idle.length, which the reader caps at 20, so the header understates when more are idle. Show 20+ at the cap. |
||
| 113 | export function idleLines(db: Database, now: Date, publicOnly: boolean, inner: number): string[] { | |
| 114 | const idle = readIdlePulls(db, now, publicOnly); | |
| 115 | if (idle.length === 0) return []; | |
| 116 | return [ | |
| 117 | "", | |
| 118 | `idle: ${idle.length >= IDLE_LIMIT ? `${IDLE_LIMIT}+` : idle.length}`, | |
| 119 | ...idle.slice(0, IDLE_SHOWN).map((p) => truncate(`#${p.pull} ${p.repo} · ${Math.floor(p.hours)} h since a push`, inner)), | |
| 120 | ...(idle.length > IDLE_SHOWN ? [`+${idle.length - IDLE_SHOWN}${idle.length >= IDLE_LIMIT ? "+" : ""} more`] : []), | |
| 121 | ]; | |
| 122 | } | |
| 123 | ||
| 107 | 124 | /** Hours, or minutes under one hour, so a landing never reads as 0 h. */ |
| 108 | 125 | const hours = (h: number) => |
| 109 | 126 | h < 1 ? `${Math.max(1, Math.round(h * 60))} min` : `${Math.round(h * 10) / 10} h`; |
test/tui-flow-idle.test.ts added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | /** The Flow dialog lists the idle crew pulls (#6479). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 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 { flowLines } from "../src/tui/screens/flow.ts"; | |
| 9 | ||
| 10 | const NOW = new Date("2026-10-26T12:00:00.000Z"); | |
| 11 | let dir: string; | |
| 12 | let app: App; | |
| 13 | let seed = ""; | |
| 14 | let n = 0; | |
| 15 | ||
| 16 | beforeAll(async () => { | |
| 17 | dir = await mkdtemp(join(tmpdir(), "tui-flow-idle-")); | |
| 18 | app = createApp({ dataDir: dir, port: 0 }); | |
| 19 | const u = await createUser(app.db, { | |
| 20 | origin: "seed", trusted: true, username: "scout", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true, | |
| 21 | }); | |
| 22 | if (!u.ok) throw new Error(u.error); | |
| 23 | seed = u.value.id; | |
| 24 | app.db.run("UPDATE user SET is_agent = 1 WHERE id = ?1", [seed]); | |
| 25 | app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('pub', ?1, 'pub', 'public')", [seed]); | |
| 26 | app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('priv', ?1, 'priv', 'private')", [seed]); | |
| 27 | }); | |
| 28 | afterAll(async () => { | |
| 29 | app.close?.(); | |
| 30 | await rm(dir, { recursive: true, force: true }); | |
| 31 | }); | |
| 32 | ||
| 33 | function idlePull(repo: string, hours = 100): number { | |
| 34 | n++; | |
| 35 | app.db.run( | |
| 36 | "INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', 't', '', ?4, 'open', ?5)", | |
| 37 | [`p${n}`, repo, n, seed, new Date(NOW.getTime() - hours * 3_600_000).toISOString()], | |
| 38 | ); | |
| 39 | app.db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?1, 'main', ?2, '', 'h')", [`p${n}`, `b${n}`]); | |
| 40 | return n; | |
outdated
Add a case: a private-repo idle pull is absent when the agent argument is given and present when it is undefined. That is the terminal's read gate. |
||
| 41 | } | |
| 42 | const admin = (): string => flowLines(app.db, NOW, [], undefined, undefined).join("\n"); | |
| 43 | const guest = (): string => flowLines(app.db, NOW, [], { queues: [] }, undefined).join("\n"); | |
| 44 | ||
| 45 | test("with no idle pull the dialog draws nothing about them", () => { | |
| 46 | expect(admin()).not.toContain("idle"); | |
| 47 | }); | |
| 48 | ||
| 49 | test("an idle crew pull has its line on the screen", () => { | |
| 50 | const p = idlePull("pub"); | |
| 51 | expect(admin()).toContain("idle: 1"); | |
| 52 | expect(admin()).toContain(`#${p} pub · 100 h since a push`); | |
| 53 | }); | |
| 54 | ||
| 55 | test("a private repository's idle pull is the admin's alone", () => { | |
| 56 | const p = idlePull("priv", 90); | |
| 57 | expect(admin()).toContain(`#${p} priv`); | |
| 58 | expect(guest()).not.toContain(`#${p} priv`); | |
| 59 | expect(guest()).toContain("idle: 1"); | |
| 60 | }); | |
| 61 | ||
| 62 | test("past five pulls the rest is summarised", () => { | |
| 63 | for (let i = 0; i < 6; i++) idlePull("pub", 120 + i); | |
| 64 | const text = admin(); | |
| 65 | expect(text).toContain("idle: 8"); | |
| 66 | expect(text).toContain("+3 more"); | |
| 67 | }); | |
| 68 | ||
| 69 | test("at the reader's cap the count reads 20+, not a false total", () => { | |
| 70 | for (let i = 0; i < 20; i++) idlePull("pub", 130 + i); | |
| 71 | const text = admin(); | |
| 72 | expect(text).toContain("idle: 20+"); | |
| 73 | expect(text).toContain("+15+ more"); | |
| 74 | }); |
new above
offline
Minor: this header line runs past the wrap used above; rewrap when next touched.