Public: anyone can read this; the crew can change it.
/flow shows actual throughput: per stage, items out per hour over the last 24 h with a sparkline, and a headline of landed in the last hour and median ask→live #1786
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/1779-flow-shows-actual-throughput-per-stage-i → main 4 commits fork 3 threads · 3 unresolved
10 files
changed +775
−18
README.md +1 −1
| @@ -500,7 +500,7 @@ from before this record say nothing). | ||
| 500 | 500 | mechanical rather than advisory. |
| 501 | 501 | |
| 502 | 502 | `docs/FLOW.md` is where every issue stands: `GET /api/v1/repos/:owner/:name/flow` returns each open issue's stage, since, stalled and unblock, plus what went live in the last 24 h. |
| 503 | Where the whole forge waits is a forge admin's: `/flow` shows each stage from ready to live with what waits, the oldest wait and its capacity, under one sentence naming the bottleneck; the terminal's Flow command and the MCP `flow` tool say the same sentence. | |
| 503 | Where the whole forge waits is a forge admin's: `/flow` shows each stage from ready to live with what waits, the oldest wait and its capacity, under one sentence naming the bottleneck, and beside each what it let out in the last hour with a 24-hour sparkline, under a line of landings and the median ask→live — every count links to the items it counts. The terminal's Flow command and the MCP `flow` tool read the same: the sentence, the landings line, and each stage's count out in the last hour and in 24 h. | |
| 504 | 504 | |
| 505 | 505 | `docs/PLANNING.md` is how issues depend on issues: the edge, the map, and |
| 506 | 506 | what "ready now" means to a person and to the crew. The map also shows each |
docs/FLOW.md +29
| @@ -26,3 +26,32 @@ waiting item has waited longest is the bottleneck. A forge admin reads it at | ||
| 26 | 26 | bottleneck; the knobs stay on Settings → Capacity. The same sentence |
| 27 | 27 | (`verdict`) is the terminal's admin-only Flow command and the MCP `flow` tool, |
| 28 | 28 | which also returns the stages; anyone but a forge admin is refused. |
| 29 | ||
| 30 | ## Throughput | |
| 31 | ||
| 32 | `src/flow/throughput.ts` counts what each pipeline stage let out over the last | |
| 33 | 24 hours, hour by hour, from the same records (no table of its own): issues | |
| 34 | started (the first builder start per issue), pulls opened, checks started, | |
| 35 | checks finished (passed or failed; cancelled is not finished), reviews returned | |
| 36 | (review runs that finished ok), landed (merged into a default branch), released | |
| 37 | (`release` rows whose preflight passed), live (production deploys serving). It | |
| 38 | also gives the median ask→live over the merges that went live in the last 24 | |
| 39 | hours: from the oldest issue the pull closes (the pull itself when it closes | |
| 40 | none) to the first production deploy created after the merge serving. | |
| 41 | ||
| 42 | On `/flow` each stage's row shows its count for the last hour, a 24-bar | |
| 43 | sparkline (oldest on the left) and the 24-hour count; the headline adds | |
| 44 | "landed N in the last hour · M in 24 h · median ask→live X h (24 h)". The | |
| 45 | sparkline is a picture only (`aria-hidden`, no links, no tab stops): the two | |
| 46 | counts beside it are the way in, two tab stops per row. Each count links to | |
| 47 | `/flow?out=<what>[&hour=0]`, which lists the items it counted (any `hour` | |
| 48 | from 0 to 23 may be asked for by hand); an unknown `out` or hour lists | |
| 49 | nothing. | |
| 50 | ||
| 51 | The MCP `flow` tool returns the same numbers under `throughput`: `says` (the | |
| 52 | headline sentence), `medianAskToLiveMs`, and per stage `key`, `out`, `label`, | |
| 53 | `lastHour`, `day` and `hours` (24 counts, 0 the last hour). The terminal's | |
| 54 | Flow command, a forge admin's like the page, opens a dialog with the | |
| 55 | bottleneck sentence, the headline and one line per stage of its last-hour | |
| 56 | and 24-hour counts. A sealed issue is counted, its title is | |
| 57 | not shown. |
scripts/gate.ts +1
| @@ -113,6 +113,7 @@ export const GATE: readonly string[] = [ | ||
| 113 | 113 | "test/capacity.test.ts", |
| 114 | 114 | "test/access-log.test.ts", |
| 115 | 115 | "test/flow-pipeline.test.ts", |
| 116 | "test/flow-throughput.test.ts", | |
| 116 | 117 | "test/limits.test.ts", |
| 117 | 118 | "test/guard-new-accounts.test.ts", |
| 118 | 119 | "test/checks.test.ts", |
src/flow/throughput.ts added +271
| @@ -0,0 +1,271 @@ | ||
| 1 | /** | |
| 2 | * What moves through the pipeline (#1779): per stage, the items that left it | |
| 3 | * in each of the last 24 hours, read from what the forge already keeps (runs, | |
| 4 | * pulls, reviews' runs, releases, issues) — no table of its own. | |
| 5 | * | |
| 6 | * Beside `pipeline.ts`, which counts what each stage holds now, this counts | |
| 7 | * what each stage let out: a ready issue leaves when a builder starts on it, | |
| 8 | * a building one when its pull opens, the check queue when a check starts, | |
| 9 | * checking when it finishes (passed or failed), review when a review run | |
| 10 | * returns, approved when the pull lands, landed when the release is | |
| 11 | * preflighted, live when a production deploy serves. | |
| 12 | * | |
| 13 | * Hours are counted back from `now`: hour 0 is the last hour, hour 23 the | |
| 14 | * one that ended 23 hours ago. Plus one number for the headline: the median | |
| 15 | * time from the ask (the issue a landed pull closes, or the pull itself when | |
| 16 | * it closes none) to the production deploy that served it, over the merges | |
| 17 | * that went live in the last 24 hours. | |
| 18 | * | |
| 19 | * Pure data, forge-wide, no authorization: `/flow` is a forge admin's. A | |
| 20 | * sealed issue is counted but its title is not repeated. | |
| 21 | */ | |
| 22 | ||
| 23 | import type { Database } from "bun:sqlite"; | |
| 24 | import { closesFromBody } from "../core/pulls.ts"; | |
| 25 | import type { PipelineKey } from "./pipeline.ts"; | |
| 26 | ||
| 27 | export const HOURS = 24; | |
| 28 | const HOUR_MS = 3_600_000; | |
| 29 | const WEEK_MS = 7 * HOURS * HOUR_MS; | |
| 30 | ||
| 31 | /** What leaving each stage is called, as the `out` a link names. */ | |
| 32 | export const OUTFLOW = { | |
| 33 | ready: { out: "started", label: "issues started" }, | |
| 34 | building: { out: "opened", label: "pulls opened" }, | |
| 35 | "check-queue": { out: "check-started", label: "checks started" }, | |
| 36 | checking: { out: "checked", label: "checks finished" }, | |
| 37 | review: { out: "reviewed", label: "reviews returned" }, | |
| 38 | approved: { out: "landed", label: "landed" }, | |
| 39 | landed: { out: "released", label: "released" }, | |
| 40 | live: { out: "live", label: "live" }, | |
| 41 | } as const satisfies Record<PipelineKey, { out: string; label: string }>; | |
| 42 | ||
| 43 | export type OutKey = (typeof OUTFLOW)[PipelineKey]["out"]; | |
| 44 | ||
| 45 | /** The stage an `out` names, or undefined when it names none: the boundary check for `?out=`. */ | |
| 46 | export function stageOf(out: string): PipelineKey | undefined { | |
| 47 | for (const [key, o] of Object.entries(OUTFLOW)) if (o.out === out) return key as PipelineKey; | |
| 48 | return undefined; | |
| 49 | } | |
| 50 | ||
| 51 | /** One item that left a stage. */ | |
| 52 | export interface Moved { | |
| 53 | /** When it left (ISO). */ | |
| 54 | readonly at: string; | |
| 55 | /** How a person names it: "shop #12 Add a cart". */ | |
| 56 | readonly label: string; | |
| 57 | /** Where it lives on the forge. */ | |
| 58 | readonly href: string; | |
| 59 | /** Checks only: how it finished. */ | |
| 60 | readonly outcome: "passed" | "failed" | null; | |
| 61 | } | |
| 62 | ||
| 63 | export interface StageThroughput { | |
| 64 | readonly key: PipelineKey; | |
| 65 | /** Items out per hour, hour 0 the last one. */ | |
| 66 | readonly hours: readonly number[]; | |
| 67 | /** Every item out in the last 24 hours, newest first. */ | |
| 68 | readonly items: readonly Moved[]; | |
| 69 | } | |
| 70 | ||
| 71 | export interface Throughput { | |
| 72 | readonly stages: readonly StageThroughput[]; | |
| 73 | /** Median ask→live in milliseconds, over what went live in the last 24 hours; null when nothing did. */ | |
| 74 | readonly medianAskToLiveMs: number | null; | |
| 75 | } | |
| 76 | ||
| 77 | /** The columns every query below returns; the page builds the label and link from them. */ | |
| 78 | interface Row { | |
| 79 | at: string; | |
| 80 | owner: string; | |
| 81 | repo: string; | |
| 82 | number: number | null; | |
| 83 | title: string | null; | |
| 84 | sealed: number | null; | |
| 85 | run_id: string | null; | |
| 86 | sha: string | null; | |
| 87 | state: string | null; | |
| 88 | } | |
| 89 | ||
| 90 | const ITEM = `u.username AS owner, r.name AS repo`; | |
| 91 | ||
| 92 | const QUERIES: Record<PipelineKey, string> = { | |
| 93 | // The first start on each issue in the window: a retry is not a second issue. | |
| 94 | ready: ` | |
| 95 | SELECT MIN(x.started_at) AS at, ${ITEM}, i.number, i.title, i.sealed, | |
| 96 | NULL AS run_id, NULL AS sha, NULL AS state | |
| 97 | FROM run x JOIN issue i ON i.id = x.issue_id | |
| 98 | JOIN repo r ON r.id = x.repo_id JOIN user u ON u.id = r.owner_id | |
| 99 | WHERE x.kind = 'agent' AND x.reason != 'review' AND i.kind = 'issue' | |
| 100 | AND x.started_at >= ?1 AND x.started_at <= ?2 | |
| 101 | GROUP BY i.id`, | |
| 102 | building: ` | |
| 103 | SELECT i.created_at AS at, ${ITEM}, i.number, i.title, i.sealed, | |
| 104 | NULL AS run_id, NULL AS sha, NULL AS state | |
| 105 | FROM issue i JOIN pull p ON p.issue_id = i.id | |
| 106 | JOIN repo r ON r.id = i.repo_id JOIN user u ON u.id = r.owner_id | |
| 107 | WHERE i.created_at >= ?1 AND i.created_at <= ?2`, | |
| 108 | "check-queue": ` | |
| 109 | SELECT x.started_at AS at, ${ITEM}, i.number, i.title, i.sealed, | |
| 110 | x.id AS run_id, NULL AS sha, NULL AS state | |
| 111 | FROM run x JOIN repo r ON r.id = x.repo_id JOIN user u ON u.id = r.owner_id | |
| 112 | LEFT JOIN issue i ON i.id = x.issue_id | |
| 113 | WHERE x.kind = 'check' AND x.started_at >= ?1 AND x.started_at <= ?2`, | |
| 114 | checking: ` | |
| 115 | SELECT x.finished_at AS at, ${ITEM}, i.number, i.title, i.sealed, | |
| 116 | x.id AS run_id, NULL AS sha, x.state | |
| 117 | FROM run x JOIN repo r ON r.id = x.repo_id JOIN user u ON u.id = r.owner_id | |
| 118 | LEFT JOIN issue i ON i.id = x.issue_id | |
| 119 | WHERE x.kind = 'check' AND x.state IN ('ok','failed') | |
| 120 | AND x.finished_at >= ?1 AND x.finished_at <= ?2`, | |
| 121 | review: ` | |
| 122 | SELECT x.finished_at AS at, ${ITEM}, i.number, i.title, i.sealed, | |
| 123 | x.id AS run_id, NULL AS sha, NULL AS state | |
| 124 | FROM run x JOIN repo r ON r.id = x.repo_id JOIN user u ON u.id = r.owner_id | |
| 125 | LEFT JOIN issue i ON i.id = x.issue_id | |
| 126 | WHERE x.kind = 'agent' AND x.reason = 'review' AND x.state = 'ok' | |
| 127 | AND x.finished_at >= ?1 AND x.finished_at <= ?2`, | |
| 128 | approved: ` | |
| 129 | SELECT p.merged_at AS at, ${ITEM}, i.number, i.title, i.sealed, | |
| 130 | NULL AS run_id, NULL AS sha, NULL AS state | |
| 131 | FROM pull p JOIN issue i ON i.id = p.issue_id | |
| 132 | JOIN repo r ON r.id = i.repo_id JOIN user u ON u.id = r.owner_id | |
| 133 | WHERE p.base_branch = r.default_branch | |
| 134 | AND p.merged_at >= ?1 AND p.merged_at <= ?2`, | |
| 135 | landed: ` | |
| 136 | SELECT e.created_at AS at, ${ITEM}, e.pull_number AS number, i.title, i.sealed, | |
| 137 | NULL AS run_id, e.sha, NULL AS state | |
| 138 | FROM release e JOIN repo r ON r.id = e.repo_id JOIN user u ON u.id = r.owner_id | |
| 139 | LEFT JOIN issue i ON i.repo_id = e.repo_id AND i.number = e.pull_number | |
| 140 | WHERE e.ok = 1 AND e.created_at >= ?1 AND e.created_at <= ?2`, | |
| 141 | live: ` | |
| 142 | SELECT x.serving_at AS at, ${ITEM}, NULL AS number, NULL AS title, NULL AS sealed, | |
| 143 | x.id AS run_id, x.commit_oid AS sha, NULL AS state | |
| 144 | FROM run x JOIN repo r ON r.id = x.repo_id JOIN user u ON u.id = r.owner_id | |
| 145 | WHERE x.kind = 'deploy' AND x.env = 'production' | |
| 146 | AND x.serving_at >= ?1 AND x.serving_at <= ?2`, | |
| 147 | }; | |
| 148 | ||
| 149 | /** How a row reads and where it links: the run when there is one, else the pull or issue, else the commit. */ | |
| 150 | function moved(key: PipelineKey, row: Row): Moved { | |
| 151 | const base = `/${row.owner}/${row.repo}`; | |
| 152 | const what = key === "ready" ? "issues" : "pulls"; | |
| 153 | const named = | |
| 154 | row.number === null | |
| 155 | ? row.sha === null || row.sha === "" | |
| 156 | ? row.repo | |
| 157 | : `${row.repo} ${row.sha.slice(0, 7)}` | |
| 158 | : `${row.repo} #${row.number}${row.sealed === 1 ? " (sealed)" : row.title === null ? "" : ` ${row.title}`}`; | |
| 159 | const href = | |
| 160 | row.run_id !== null | |
| 161 | ? `${base}/runs/${row.run_id}` | |
| 162 | : row.number !== null | |
| 163 | ? `${base}/${what}/${row.number}` | |
| 164 | : row.sha !== null && row.sha !== "" | |
| 165 | ? `${base}/commit/${row.sha}` | |
| 166 | : base; | |
| 167 | const outcome = key !== "checking" ? null : row.state === "ok" ? "passed" : "failed"; | |
| 168 | return { at: row.at, label: named, href, outcome }; | |
| 169 | } | |
| 170 | ||
| 171 | /** Which hour back from `now` a time falls in: 0 for the last hour. */ | |
| 172 | const hourOf = (at: string, now: number): number => Math.floor((now - Date.parse(at)) / HOUR_MS); | |
| 173 | ||
| 174 | function stageThroughput(db: Database, key: PipelineKey, now: Date): StageThroughput { | |
| 175 | const since = new Date(now.getTime() - HOURS * HOUR_MS).toISOString(); | |
| 176 | const rows = db.query<Row, [string, string]>(QUERIES[key]).all(since, now.toISOString()); | |
| 177 | const items = rows.map((r) => moved(key, r)).sort((a, b) => (a.at < b.at ? 1 : -1)); | |
| 178 | const hours = new Array<number>(HOURS).fill(0); | |
| 179 | for (const item of items) { | |
| 180 | const h = hourOf(item.at, now.getTime()); | |
| 181 | if (h >= 0 && h < HOURS) hours[h]! += 1; | |
| 182 | } | |
| 183 | return { key, hours, items }; | |
| 184 | } | |
| 185 | ||
| 186 | /** The middle of the values, or the mean of the middle two; null for none. */ | |
| 187 | export function median(values: readonly number[]): number | null { | |
| 188 | if (values.length === 0) return null; | |
| 189 | const sorted = [...values].sort((a, b) => a - b); | |
| 190 | const mid = sorted.length >> 1; | |
| 191 | return sorted.length % 2 === 1 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2; | |
| 192 | } | |
| 193 | ||
| 194 | /** | |
| 195 | * Ask→live for every merge into a default branch that a production deploy | |
| 196 | * served in the last 24 hours. The deploy that served it is the first one | |
| 197 | * created after the merge; the ask is the oldest issue the pull closes in its | |
| 198 | * repository, or the pull itself. | |
| 199 | */ | |
| 200 | function askToLive(db: Database, now: Date): number[] { | |
| 201 | const since = new Date(now.getTime() - HOURS * HOUR_MS).toISOString(); | |
| 202 | const rows = db | |
| 203 | .query<{ repo_id: string; body: string; created_at: string; live_at: string | null }, [string, string]>( | |
| 204 | `SELECT i.repo_id, i.body, i.created_at, | |
| 205 | (SELECT d.serving_at FROM run d | |
| 206 | WHERE d.repo_id = i.repo_id AND d.kind = 'deploy' AND d.env = 'production' | |
| 207 | AND d.created_at >= p.merged_at AND d.serving_at IS NOT NULL | |
| 208 | ORDER BY d.created_at, d.id LIMIT 1) AS live_at | |
| 209 | FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id | |
| 210 | WHERE p.base_branch = r.default_branch AND p.merged_at >= ?1 AND p.merged_at <= ?2`, | |
| 211 | ) | |
| 212 | .all(new Date(now.getTime() - WEEK_MS).toISOString(), now.toISOString()); | |
| 213 | const asked = db.query<{ created_at: string }, [string, number]>( | |
| 214 | "SELECT created_at FROM issue WHERE repo_id = ?1 AND number = ?2 AND kind = 'issue'", | |
| 215 | ); | |
| 216 | const out: number[] = []; | |
| 217 | for (const row of rows) { | |
| 218 | if (row.live_at === null || row.live_at < since || row.live_at > now.toISOString()) continue; | |
| 219 | let ask: string | null = null; | |
| 220 | for (const n of closesFromBody(row.body)) { | |
| 221 | const issue = asked.get(row.repo_id, n); | |
| 222 | if (issue !== null && (ask === null || issue.created_at < ask)) ask = issue.created_at; | |
| 223 | } | |
| 224 | ask ??= row.created_at; | |
| 225 | out.push(Date.parse(row.live_at) - Date.parse(ask)); | |
| 226 | } | |
| 227 | return out; | |
| 228 | } | |
| 229 | ||
| 230 | /** Every stage's outflow over the last 24 hours, left to right, and the median ask→live. */ | |
| 231 | export function readThroughput(db: Database, keys: readonly PipelineKey[], now = new Date()): Throughput { | |
| 232 | return { | |
| 233 | stages: keys.map((key) => stageThroughput(db, key, now)), | |
| 234 | medianAskToLiveMs: median(askToLive(db, now)), | |
| 235 | }; | |
| 236 | } | |
| 237 | ||
| 238 | /** The items of one hour back (0 the last), or of all 24 when `hour` is null. */ | |
| 239 | export function itemsIn(t: StageThroughput, hour: number | null, now: number): Moved[] { | |
| 240 | return hour === null ? [...t.items] : t.items.filter((i) => hourOf(i.at, now) === hour); | |
| 241 | } | |
| 242 | ||
| 243 | /** "3.5 h": a span of milliseconds in hours, one decimal. */ | |
| 244 | export const hoursSpan = (ms: number): string => `${(ms / HOUR_MS).toFixed(1)} h`; | |
| 245 | ||
| 246 | /** One stage's outflow as counts: what the MCP `flow` tool and the terminal's Flow command read. */ | |
| 247 | export interface StageOut { | |
| 248 | readonly key: PipelineKey; | |
| 249 | readonly out: OutKey; | |
| 250 | readonly label: string; | |
| 251 | readonly lastHour: number; | |
| 252 | readonly day: number; | |
| 253 | /** Items out per hour, hour 0 the last one. */ | |
| 254 | readonly hours: readonly number[]; | |
| 255 | } | |
| 256 | ||
| 257 | export const stageOut = (t: StageThroughput): StageOut => ({ | |
| 258 | key: t.key, | |
| 259 | out: OUTFLOW[t.key].out, | |
| 260 | label: OUTFLOW[t.key].label, | |
| 261 | lastHour: t.hours[0] ?? 0, | |
| 262 | day: t.items.length, | |
| 263 | hours: t.hours, | |
| 264 | }); | |
| 265 | ||
| 266 | /** The headline, one sentence: "landed 2 in the last hour · 6 in 24 h · median ask→live 3.5 h (24 h)". */ | |
| 267 | export function throughputSays(t: Throughput): string { | |
| 268 | const landed = t.stages.find((s) => s.key === "approved"); | |
| 269 | const median = t.medianAskToLiveMs === null ? "—" : hoursSpan(t.medianAskToLiveMs); | |
| 270 | return `landed ${landed?.hours[0] ?? 0} in the last hour · ${landed?.items.length ?? 0} in 24 h · median ask→live ${median} (24 h)`; | |
| 271 | } |
src/http/mcp.ts +14 −3
| @@ -22,6 +22,7 @@ import { mayView, mayViewId, sealedScope } from "../core/sealed.ts"; | ||
| 22 | 22 | import { listAccess } from "../actions/access_log.ts"; |
| 23 | 23 | import { changeCapacity, listCapacity } from "../actions/capacity.ts"; |
| 24 | 24 | import { readPipeline, verdict } from "../flow/pipeline.ts"; |
| 25 | import { readThroughput, stageOut, throughputSays } from "../flow/throughput.ts"; | |
| 25 | 26 | import { changeLimit, listLimits } from "../actions/limits.ts"; |
| 26 | 27 | import { LIMIT_KEYS } from "../settings/limits.ts"; |
| 27 | 28 | import { relationOf, takeUpdates } from "../actions/forks.ts"; |
| @@ -1991,14 +1992,24 @@ const TOOLS: readonly Tool[] = [ | ||
| 1991 | 1992 | { |
| 1992 | 1993 | name: "flow", |
| 1993 | 1994 | description: |
| 1994 | "The forge's pipeline, for a forge admin: ready, building, check queue, checking, review, approved, landed and live, each with how many wait, how many are being worked, when the oldest waiting one entered (oldestWaiting) and what limits it (capacity), and one sentence (says) naming the stage whose oldest has waited longest — the bottleneck. Read-only; the knobs are the `capacity` tool. The same as /flow.", | |
| 1995 | "The forge's pipeline, for a forge admin: ready, building, check queue, checking, review, approved, landed and live, each with how many wait, how many are being worked, when the oldest waiting one entered (oldestWaiting) and what limits it (capacity), and one sentence (says) naming the stage whose oldest has waited longest — the bottleneck. Beside it, throughput: per stage how many left it in the last hour (lastHour), in 24 hours (day) and in each of the last 24 hours (hours, 0 the last), the median ask→live in ms over the last 24 hours, and its own sentence (says). Read-only; the knobs are the `capacity` tool. The same as /flow.", | |
| 1995 | 1996 | inputSchema: { type: "object", properties: {} }, |
| 1996 | 1997 | writes: false, |
| 1997 | 1998 | run(ctx, user) { |
| 1998 | 1999 | if (!user.isAdmin) |
| 1999 | 2000 | return bad("forbidden", "only an admin of this forge reads its pipeline"); |
| 2000 | const stages = readPipeline(ctx.app.db); | |
| 2001 | return good({ says: verdict(stages), stages }); | |
| 2001 | const now = new Date(); | |
| 2002 | const stages = readPipeline(ctx.app.db, now); | |
| 2003 | const flow = readThroughput(ctx.app.db, stages.map((s) => s.key), now); | |
| 2004 | return good({ | |
| 2005 | says: verdict(stages, now.getTime()), | |
| 2006 | stages, | |
| 2007 | throughput: { | |
| 2008 | says: throughputSays(flow), | |
| 2009 | medianAskToLiveMs: flow.medianAskToLiveMs, | |
| 2010 | stages: flow.stages.map(stageOut), | |
| 2011 | }, | |
| 2012 | }); | |
| 2002 | 2013 | }, |
| 2003 | 2014 | }, |
| 2004 | 2015 | { |
src/tui/forge.ts +4 −4
| @@ -11,7 +11,6 @@ | ||
| 11 | 11 | import type { App as Forge } from "../app.ts"; |
| 12 | 12 | import { search } from "../actions/search.ts"; |
| 13 | 13 | import { sentinelSummary } from "../crew/sentinel.ts"; |
| 14 | import { readPipeline, verdict } from "../flow/pipeline.ts"; | |
| 15 | 14 | import { canRead, getRepo, type Repo } from "../core/repos.ts"; |
| 16 | 15 | import { sourceFor } from "../view/source.ts"; |
| 17 | 16 | import { App, type Ctx, type Screen } from "./app/app.ts"; |
| @@ -32,6 +31,7 @@ import { issueScreen } from "./screens/issue.ts"; | ||
| 32 | 31 | import { pullScreen } from "./screens/pull.ts"; |
| 33 | 32 | import { blobScreen } from "./screens/repo.ts"; |
| 34 | 33 | import { dashboardScreen } from "./screens/dashboard.ts"; |
| 34 | import { flowLines, flowScreen } from "./screens/flow.ts"; | |
| 35 | 35 | import { issuesScreen, newIssueScreen } from "./screens/issues.ts"; |
| 36 | 36 | import { limbsScreen } from "./screens/limbs.ts"; |
| 37 | 37 | import { newPullScreen } from "./screens/newpull.ts"; |
| @@ -184,13 +184,13 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 184 | 184 | "default", |
| 185 | 185 | ), |
| 186 | 186 | }); |
| 187 | // The pipeline's bottleneck, as /flow's sentence says it: a site admin's (#1554). | |
| 187 | // The pipeline's bottleneck and throughput, as /flow says them: a site admin's (#1554, #1786). | |
| 188 | 188 | if (session.user?.isAdmin === true) |
| 189 | 189 | commands.push({ |
| 190 | 190 | id: "flow", |
| 191 | 191 | title: "Flow", |
| 192 | hint: "the pipeline's bottleneck", | |
| 193 | run: () => ctx.toast(verdict(readPipeline(session.db)), "default"), | |
| 192 | hint: "the pipeline's bottleneck and throughput", | |
| 193 | run: () => ctx.push(flowScreen(ctx, flowLines(session.db))), | |
| 194 | 194 | }); |
| 195 | 195 | commands.push({ |
| 196 | 196 | id: "quit", |
src/tui/screens/flow.ts added +56
| @@ -0,0 +1,56 @@ | ||
| 1 | /** | |
| 2 | * The Flow command's dialog, a forge admin's (#1554, #1786): the sentence | |
| 3 | * /flow leads with, the throughput headline under it, then one line per | |
| 4 | * stage of what left it in the last hour and in 24 hours — the numbers of | |
| 5 | * `flow/pipeline.ts` and `flow/throughput.ts`, the same the page and the | |
| 6 | * MCP `flow` tool read. Read-only; Enter, Esc or q closes it. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { readPipeline, STAGE_LABEL, verdict } from "../../flow/pipeline.ts"; | |
| 11 | import { readThroughput, stageOut, throughputSays } from "../../flow/throughput.ts"; | |
| 12 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 13 | import { binding } from "../app/keys.ts"; | |
| 14 | import { pad, wrap } from "../term/width.ts"; | |
| 15 | import { dialog } from "../ui/widgets.ts"; | |
| 16 | ||
| 17 | const WIDTH = 64; | |
| 18 | ||
| 19 | /** What the dialog says, line by line, wrapped to fit inside it. */ | |
| 20 | export function flowLines(db: Database, now = new Date()): string[] { | |
| 21 | const stages = readPipeline(db, now); | |
| 22 | const flow = readThroughput(db, stages.map((s) => s.key), now); | |
| 23 | const inner = WIDTH - 6; | |
| 24 | return [ | |
| 25 | ...wrap(verdict(stages, now.getTime()), inner), | |
| 26 | "", | |
| 27 | ...wrap(throughputSays(flow), inner), | |
| 28 | "", | |
| 29 | `${pad("Out", 12)} ${pad("last hour", 9, "right")} ${pad("24 h", 5, "right")}`, | |
| 30 | ...flow.stages.map(stageOut).map( | |
| 31 | (s) => | |
| 32 | `${pad(STAGE_LABEL[s.key], 12)} ${pad(String(s.lastHour), 9, "right")} ${pad(String(s.day), 5, "right")} ${s.label}`, | |
| 33 | ), | |
| 34 | ]; | |
| 35 | } | |
| 36 | ||
| 37 | export function flowScreen(ctx: Ctx, lines: readonly string[]): Screen { | |
| 38 | return { | |
| 39 | title: "Flow", | |
| 40 | keys: [binding(["enter", "esc", "q"], "close")], | |
| 41 | render(area, s) { | |
| 42 | const buttons = dialog(s, area, ctx.theme.c, { | |
| 43 | title: "Flow", | |
| 44 | body: lines, | |
| 45 | actions: [{ label: "Close" }], | |
| 46 | selected: 0, | |
| 47 | width: WIDTH, | |
| 48 | }); | |
| 49 | buttons.forEach((rect) => ctx.hit(rect, () => ctx.pop())); | |
| 50 | }, | |
| 51 | key(e) { | |
| 52 | if (e.key === "enter" || e.key === "esc" || e.key === "q") ctx.pop(); | |
| 53 | return true; | |
| 54 | }, | |
| 55 | }; | |
| 56 | } |
src/web/pages/flow.ts +109 −3
| @@ -5,22 +5,90 @@ | ||
| 5 | 5 | * (#1554). One sentence above names the stage with the longest wait — the |
| 6 | 6 | * bottleneck. The numbers are `flow/pipeline.ts`'s; the knobs stay on |
| 7 | 7 | * Settings → Capacity, linked from here. |
| 8 | * | |
| 9 | * Beside what each stage holds, what it let out (#1779): items out in the | |
| 10 | * last hour and a 24-bar sparkline per stage, and a headline line of | |
| 11 | * landings and the median ask→live — `flow/throughput.ts`'s numbers, the | |
| 12 | * same ones the MCP `flow` tool and the terminal's Flow command read. Every | |
| 13 | * count links back here with `?out=<what>[&hour=<0–23>]`, which lists the | |
| 14 | * items it counted under the table. | |
| 8 | 15 | */ |
| 9 | 16 | |
| 10 | 17 | import { |
| 11 | 18 | bottleneck, |
| 12 | 19 | readPipeline, |
| 20 | span, | |
| 13 | 21 | STAGE_HOLDS, |
| 14 | 22 | STAGE_LABEL, |
| 15 | 23 | verdict, |
| 16 | 24 | waited, |
| 25 | type PipelineKey, | |
| 17 | 26 | } from "../../flow/pipeline.ts"; |
| 27 | import { | |
| 28 | hoursSpan, | |
| 29 | HOURS, | |
| 30 | itemsIn, | |
| 31 | OUTFLOW, | |
| 32 | readThroughput, | |
| 33 | stageOf, | |
| 34 | type StageThroughput, | |
| 35 | } from "../../flow/throughput.ts"; | |
| 18 | 36 | import type { Ctx } from "../../http/context.ts"; |
| 19 | 37 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| 20 | import { html } from "../html.ts"; | |
| 38 | import { html, type Html } from "../html.ts"; | |
| 21 | 39 | import { page } from "../layout.ts"; |
| 22 | 40 | import { badge, card } from "../ui.ts"; |
| 23 | 41 | |
| 42 | const css = /* css */ ` | |
| 43 | .flow-out { display: inline-flex; align-items: center; gap: 8px; font-variant-numeric: tabular-nums; } | |
| 44 | .flow-out svg { width: 72px; height: 16px; } | |
| 45 | .flow-bar { fill: var(--muted-fg); } | |
| 46 | .flow-bar[data-empty] { fill: var(--muted); } | |
| 47 | `; | |
| 48 | ||
| 49 | /** What `?out=` and `?hour=` ask to list, checked: an unknown stage or hour lists nothing. */ | |
| 50 | function asked(url: URL): { key: PipelineKey; hour: number | null } | null { | |
| 51 | const key = stageOf(url.searchParams.get("out") ?? ""); | |
| 52 | if (key === undefined) return null; | |
| 53 | const h = url.searchParams.get("hour"); | |
| 54 | if (h === null) return { key, hour: null }; | |
| 55 | if (!/^\d{1,2}$/.test(h) || Number(h) >= HOURS) return null; | |
| 56 | return { key, hour: Number(h) }; | |
| 57 | } | |
| 58 | ||
| 59 | const outHref = (key: PipelineKey, hour: number | null): string => | |
| 60 | `/flow?out=${OUTFLOW[key].out}${hour === null ? "" : `&hour=${hour}`}#out`; | |
| 61 | ||
| 62 | const hourWords = (hour: number | null): string => | |
| 63 | hour === null ? "in the last 24 hours" : hour === 0 ? "in the last hour" : `${hour}–${hour + 1} hours ago`; | |
| 64 | ||
| 65 | /** | |
| 66 | * 24 bars, oldest on the left: a picture only, hidden from assistive tech and | |
| 67 | * out of the tab order. The two counts beside it are the way in (#1786). | |
| 68 | */ | |
| 69 | function sparkline(t: StageThroughput): Html { | |
| 70 | const max = Math.max(1, ...t.hours); | |
| 71 | const bars = t.hours.map((n, hour) => { | |
outdated
role="img" hides the child links from assistive tech, and 2 px bars cannot be tapped on a phone. Make the svg aria-hidden/decorative and let the two text links carry navigation, or give the bars a real name and target size. |
||
| 72 | const x = (HOURS - 1 - hour) * 3; | |
| 73 | const h = n === 0 ? 1 : Math.max(2, Math.round((n / max) * 16)); | |
| 74 | return html`<rect class="flow-bar" ${n === 0 ? html`data-empty` : null} x="${String(x)}" y="${String(16 - h)}" width="2" height="${String(h)}" />`; | |
| 75 | }); | |
| 76 | return html`<svg viewBox="0 0 72 16" aria-hidden="true" focusable="false">${bars}</svg>`; | |
| 77 | } | |
| 78 | ||
| 79 | function outCell(t: StageThroughput): Html { | |
| 80 | const day = t.items.length; | |
| 81 | const passed = t.items.filter((i) => i.outcome === "passed").length; | |
| 82 | return html`<span class="flow-out" data-out="${OUTFLOW[t.key].out}"> | |
| 83 | <a class="mono" href="${outHref(t.key, 0)}" data-hour="0">${String(t.hours[0] ?? 0)}</a> | |
| 84 | ${sparkline(t)} | |
| 85 | <a class="muted small" href="${outHref(t.key, null)}" data-day="${String(day)}">${String(day)} in 24 h</a> | |
| 86 | </span> | |
| 87 | <div class="muted small"> | |
| 88 | ${OUTFLOW[t.key].label}${t.key === "checking" && day > 0 ? ` · ${passed} passed · ${day - passed} failed` : ""} | |
| 89 | </div>`; | |
| 90 | } | |
| 91 | ||
| 24 | 92 | // GET /flow |
| 25 | 93 | export function flowPage(ctx: Ctx): Response { |
| 26 | 94 | if (!ctx.auth.user) return signInFirst(ctx); |
| @@ -28,6 +96,11 @@ export function flowPage(ctx: Ctx): Response { | ||
| 28 | 96 | const now = Date.now(); |
The throughput numbers are computed only here. Expose the same readThroughput result through the MCP |
||
| 29 | 97 | const stages = readPipeline(ctx.app.db, new Date(now)); |
| 30 | 98 | const worst = bottleneck(stages); |
| 99 | const flow = readThroughput(ctx.app.db, stages.map((s) => s.key), new Date(now)); | |
| 100 | const out = new Map(flow.stages.map((t) => [t.key, t])); | |
| 101 | const landed = out.get("approved"); | |
| 102 | const listed = asked(ctx.url); | |
| 103 | const shown = listed === null ? undefined : out.get(listed.key); | |
| 31 | 104 | const body = html`<div class="page-head"> |
| 32 | 105 | <h1>Flow</h1> |
| 33 | 106 | <p class="muted"> |
| @@ -40,6 +113,15 @@ export function flowPage(ctx: Ctx): Response { | ||
| 40 | 113 | <p id="bottleneck" data-stage="${worst?.key ?? ""}"> |
| 41 | 114 | <strong>${verdict(stages, now)}</strong> |
| 42 | 115 | </p> |
| 116 | <p id="throughput"> | |
| 117 | landed | |
| 118 | <a href="${outHref("approved", 0)}" data-landed-hour>${String(landed?.hours[0] ?? 0)}</a> | |
| 119 | in the last hour · | |
| 120 | <a href="${outHref("approved", null)}" data-landed-day>${String(landed?.items.length ?? 0)}</a> | |
| 121 | in 24 h · median ask→live | |
| 122 | <span data-median>${flow.medianAskToLiveMs === null ? "—" : hoursSpan(flow.medianAskToLiveMs)}</span> | |
| 123 | (24 h) | |
| 124 | </p> | |
| 43 | 125 | ${card( |
| 44 | 126 | html`<div class="table-scroll"> |
| 45 | 127 | <table class="table" aria-label="Pipeline stages"> |
| @@ -50,6 +132,7 @@ export function flowPage(ctx: Ctx): Response { | ||
| 50 | 132 | <th scope="col">Active</th> |
| 51 | 133 | <th scope="col">Oldest wait</th> |
| 52 | 134 | <th scope="col">Capacity</th> |
| 135 | <th scope="col">Out, last hour · 24 h</th> | |
| 53 | 136 | </tr> |
| 54 | 137 | </thead> |
| 55 | 138 | <tbody> |
| @@ -68,12 +151,35 @@ export function flowPage(ctx: Ctx): Response { | ||
| 68 | 151 | <td class="mono">${String(s.active)}</td> |
| 69 | 152 | <td class="mono">${waited(s, now) ?? "—"}</td> |
| 70 | 153 | <td class="small">${s.capacity ?? "—"}</td> |
| 154 | <td>${outCell(out.get(s.key)!)}</td> | |
| 71 | 155 | </tr>`, |
| 72 | 156 | )} |
| 73 | 157 | </tbody> |
| 74 | 158 | </table> |
| 75 | 159 | </div>`, |
| 76 | 160 | { pad: false }, |
| 77 | )}`; | |
| 78 | return page({ ctx, title: "Flow", body }); | |
| 161 | )} | |
| 162 | ${listed === null || shown === undefined ? null : outList(shown, listed.hour, now)}`; | |
| 163 | return page({ ctx, title: "Flow", body, styleCss: css }); | |
| 164 | } | |
| 165 | ||
| 166 | /** The items one count counted, newest first, each linked to where it lives. */ | |
| 167 | function outList(t: StageThroughput, hour: number | null, now: number): Html { | |
| 168 | const items = itemsIn(t, hour, now); | |
| 169 | return card( | |
| 170 | html`<h2>${STAGE_LABEL[t.key]}: ${String(items.length)} ${OUTFLOW[t.key].label} ${hourWords(hour)}</h2> | |
| 171 | ${items.length === 0 | |
| 172 | ? html`<p class="muted">None.</p>` | |
| 173 | : html`<ul> | |
| 174 | ${items.map( | |
| 175 | (i) => | |
| 176 | html`<li> | |
| 177 | <a href="${i.href}">${i.label}</a> | |
| 178 | ${i.outcome === null ? null : badge(i.outcome, { tone: i.outcome === "passed" ? "success" : "danger" })} | |
| 179 | <span class="muted small">${span(now - Date.parse(i.at))} ago</span> | |
| 180 | </li>`, | |
| 181 | )} | |
| 182 | </ul>`}`, | |
| 183 | { id: "out" }, | |
| 184 | ); | |
| 79 | 185 | } |
test/flow-pipeline.test.ts +10 −7
| @@ -24,8 +24,9 @@ import { createUser, type User } from "../src/core/users.ts"; | ||
| 24 | 24 | import { createToken } from "../src/core/tokens.ts"; |
| 25 | 25 | import { bottleneck, readPipeline, span, verdict } from "../src/flow/pipeline.ts"; |
| 26 | 26 | import { serve, type Servers } from "../src/http/server.ts"; |
| 27 | import type { Ctx } from "../src/tui/app/app.ts"; | |
| 27 | import type { Ctx, Screen } from "../src/tui/app/app.ts"; | |
| 28 | 28 | import { forgeCommands, Session } from "../src/tui/forge.ts"; |
| 29 | import { flowLines } from "../src/tui/screens/flow.ts"; | |
| 29 | 30 | |
| 30 | 31 | const HOUR = 3_600_000; |
| 31 | 32 | const ago = (ms: number) => new Date(Date.now() - ms).toISOString(); |
| @@ -255,23 +256,25 @@ describe("/flow: the forge shows its own bottleneck", () => { | ||
| 255 | 256 | }); |
| 256 | 257 | |
| 257 | 258 | const flowCommand = (user?: User) => { |
| 258 | const toasts: string[] = []; | |
| 259 | const ctx = { toast: (m: string) => toasts.push(m) } as unknown as Ctx; | |
| 259 | const pushed: Screen[] = []; | |
| 260 | const ctx = { push: (s: Screen) => pushed.push(s) } as unknown as Ctx; | |
| 260 | 261 | const session = new Session(app, { |
| 261 | 262 | sshUsername: user?.username ?? "git", |
| 262 | 263 | remoteAddress: "127.0.0.1", |
| 263 | 264 | fingerprint: "SHA256:test", |
| 264 | 265 | user, |
| 265 | 266 | }); |
| 266 | return { found: forgeCommands(session)(ctx).find((c) => c.id === "flow"), toasts }; | |
| 267 | return { found: forgeCommands(session)(ctx).find((c) => c.id === "flow"), pushed }; | |
| 267 | 268 | }; |
| 268 | 269 | |
| 269 | 270 | test("in the terminal, an admin's Flow command says the same sentence; nobody else is offered it", () => { |
| 270 | const { found, toasts } = flowCommand(alice); | |
| 271 | const { found, pushed } = flowCommand(alice); | |
| 271 | 272 | expect(found?.title).toBe("Flow"); |
| 272 | 273 | found!.run(); |
| 273 | expect(toasts).toEqual([verdict(readPipeline(app.db))]); | |
| 274 | expect(toasts[0]).toStartWith("Check queue is the bottleneck"); | |
| 274 | expect(pushed.map((s) => s.title)).toEqual(["Flow"]); | |
| 275 | const lines = flowLines(app.db); | |
| 276 | expect(lines.join(" ").replace(/\s+/g, " ")).toContain(verdict(readPipeline(app.db))); | |
| 277 | expect(lines[0]).toStartWith("Check queue is the bottleneck"); | |
| 275 | 278 | expect(flowCommand(bob).found).toBeUndefined(); |
| 276 | 279 | expect(flowCommand(undefined).found).toBeUndefined(); |
| 277 | 280 | }); |
test/flow-throughput.test.ts added +280
| @@ -0,0 +1,280 @@ | ||
| 1 | /** | |
| 2 | * /flow shows what moves (#1779): from a seeded forge — six pulls landed | |
| 3 | * over three hours, each closing an issue asked at a known time, a | |
| 4 | * production deploy that served them, checks that passed and failed, a | |
| 5 | * builder started on an issue twice, a release preflighted — each stage | |
| 6 | * counts its items out per hour, the headline says how many landed in the | |
| 7 | * last hour and in 24 h and the median ask→live, and every count links to | |
| 8 | * the items it counted. | |
| 9 | */ | |
| 10 | ||
| 11 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 12 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 13 | import { tmpdir } from "node:os"; | |
| 14 | import { join } from "node:path"; | |
| 15 | import { createApp, type App } from "../src/app.ts"; | |
| 16 | import { openIssue } from "../src/actions/issues.ts"; | |
| 17 | import { createRepo } from "../src/actions/repos.ts"; | |
| 18 | import type { Repo } from "../src/core/repos.ts"; | |
| 19 | import { createRun } from "../src/core/runs.ts"; | |
| 20 | import { createUser, type User } from "../src/core/users.ts"; | |
| 21 | import { PIPELINE } from "../src/flow/pipeline.ts"; | |
| 22 | import { createToken } from "../src/core/tokens.ts"; | |
| 23 | import { median, readThroughput, stageOf, throughputSays } from "../src/flow/throughput.ts"; | |
| 24 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 25 | import type { Ctx, Screen } from "../src/tui/app/app.ts"; | |
| 26 | import { forgeCommands, Session } from "../src/tui/forge.ts"; | |
| 27 | import { flowLines } from "../src/tui/screens/flow.ts"; | |
| 28 | ||
| 29 | const HOUR = 3_600_000; | |
| 30 | const MIN = 60_000; | |
| 31 | const NOW = Date.now(); | |
| 32 | const ago = (ms: number) => new Date(NOW - ms).toISOString(); | |
| 33 | ||
| 34 | describe("/flow: actual throughput per stage", () => { | |
| 35 | let dataDir: string; | |
| 36 | let app: App; | |
| 37 | let servers: Servers; | |
| 38 | let alice: User; | |
| 39 | let bob: User; | |
| 40 | let repo: Repo; | |
| 41 | ||
| 42 | const login = async (username: string): Promise<string> => { | |
| 43 | const res = await fetch(`${servers.url}/signin`, { | |
| 44 | method: "POST", | |
| 45 | redirect: "manual", | |
| 46 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 47 | body: new URLSearchParams({ username, password: "a-good-passphrase" }), | |
| 48 | }); | |
| 49 | return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 50 | }; | |
| 51 | const get = async (path: string) => | |
| 52 | fetch(`${servers.url}${path}`, { headers: { cookie: await login("alice") }, redirect: "manual" }); | |
| 53 | ||
| 54 | const newIssue = (title: string, createdAt: string, body = "") => { | |
| 55 | const issue = openIssue(app, alice, repo, { title, body }); | |
| 56 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 57 | app.db.query("UPDATE issue SET created_at = ?1 WHERE id = ?2").run(createdAt, issue.value.id); | |
| 58 | return issue.value; | |
| 59 | }; | |
| 60 | const pull = (title: string, body: string, createdAt: string, mergedAt: string | null) => { | |
| 61 | const issue = newIssue(title, createdAt, body); | |
| 62 | app.db | |
| 63 | .query("UPDATE issue SET kind = 'pull', state = ?1 WHERE id = ?2") | |
| 64 | .run(mergedAt === null ? "open" : "closed", issue.id); | |
| 65 | app.db | |
| 66 | .query( | |
| 67 | `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) | |
| 68 | VALUES (?1, ?2, ?3, '', '', ?4)`, | |
| 69 | ) | |
| 70 | .run(issue.id, repo.defaultBranch, `mason/${issue.number}`, mergedAt); | |
| 71 | return issue; | |
| 72 | }; | |
| 73 | const run = (kind: "check" | "agent" | "deploy", set: Record<string, string | null>, extra = {}) => { | |
| 74 | const r = createRun(app.db, { repoId: repo.id, kind, ...extra }); | |
| 75 | for (const [column, value] of Object.entries(set)) | |
| 76 | app.db.query(`UPDATE run SET ${column} = ?1 WHERE id = ?2`).run(value, r.id); | |
| 77 | return r; | |
| 78 | }; | |
| 79 | ||
| 80 | beforeAll(async () => { | |
| 81 | dataDir = await mkdtemp(join(tmpdir(), "forge-throughput-")); | |
| 82 | app = createApp({ dataDir, port: 0 }); | |
| 83 | servers = await serve(app); | |
| 84 | const a = await createUser(app.db, { | |
| 85 | trusted: true, | |
| 86 | username: "alice", | |
| 87 | email: "[email protected]", | |
| 88 | password: "a-good-passphrase", | |
| 89 | adminIfFirst: true, | |
| 90 | }); | |
| 91 | if (!a.ok) throw new Error(a.error); | |
| 92 | alice = a.value; | |
| 93 | const b = await createUser(app.db, { | |
| 94 | trusted: true, | |
| 95 | username: "bob", | |
| 96 | email: "[email protected]", | |
| 97 | password: "a-good-passphrase", | |
| 98 | }); | |
| 99 | if (!b.ok) throw new Error(b.error); | |
| 100 | bob = b.value; | |
| 101 | const made = await createRepo(app, alice, { name: "shop" }); | |
| 102 | if (!made.ok) throw new Error(String(made.error)); | |
| 103 | repo = made.value; | |
| 104 | ||
| 105 | // Six landings over three hours: two in the last hour, three in the | |
| 106 | // one before, one in the one before that. Each closes an issue asked | |
| 107 | // 1, 2, 3, 4, 5 and 6 hours before the deploy that served them all. | |
| 108 | const landedAt = [10, 40, 70, 80, 100, 150].map((m) => ago(m * MIN)); | |
| 109 | landedAt.forEach((mergedAt, n) => { | |
| 110 | const asked = newIssue(`Ask ${n + 1}`, ago(5 * MIN + (n + 1) * HOUR)); | |
| 111 | pull(`Answer ${n + 1}`, `Closes #${asked.number}`, ago(4 * HOUR), mergedAt); | |
| 112 | }); | |
| 113 | // Merged a day and a half ago and served by the next deploy: neither | |
| 114 | // landed nor live in the window, so not in the median. | |
| 115 | pull("Old", "", ago(40 * HOUR), ago(36 * HOUR)); | |
| 116 | run("deploy", { state: "ok", created_at: ago(35 * HOUR), serving_at: ago(35 * HOUR - MIN) }, { env: "production" }); | |
| 117 | // Open, opened 20 minutes ago: one pull opened in the last hour. | |
| 118 | pull("Draft", "", ago(20 * MIN), null); | |
| 119 | // The deploy that served all six went live five minutes ago. | |
| 120 | run("deploy", { state: "ok", created_at: ago(8 * MIN), serving_at: ago(5 * MIN) }, { env: "production" }); | |
| 121 | ||
| 122 | // Checks: two passed in the last hour, one failed two hours ago, one cancelled (not finished). | |
| 123 | run("check", { state: "ok", started_at: ago(30 * MIN), finished_at: ago(20 * MIN) }); | |
| 124 | run("check", { state: "ok", started_at: ago(30 * MIN), finished_at: ago(25 * MIN) }); | |
| 125 | run("check", { state: "failed", started_at: ago(2 * HOUR + 20 * MIN), finished_at: ago(2 * HOUR + 10 * MIN) }); | |
| 126 | run("check", { state: "cancelled", finished_at: ago(15 * MIN) }); | |
| 127 | ||
| 128 | // A builder started twice on one issue: one issue started. A review returned. | |
| 129 | const cart = newIssue("Add a cart", ago(3 * HOUR)); | |
| 130 | run("agent", { state: "failed", started_at: ago(50 * MIN) }, { reason: "assigned", issueId: cart.id }); | |
| 131 | run("agent", { state: "running", started_at: ago(20 * MIN) }, { reason: "assigned", issueId: cart.id }); | |
| 132 | run("agent", { state: "ok", finished_at: ago(3 * HOUR + 10 * MIN) }, { reason: "review" }); | |
| 133 | ||
| 134 | // One release preflighted, one that failed preflight. | |
| 135 | const release = app.db.query( | |
| 136 | "INSERT INTO release (sha, repo_id, pull_number, ok, line, created_at) VALUES (?1, ?2, ?3, ?4, '', ?5)", | |
| 137 | ); | |
| 138 | release.run("a".repeat(40), repo.id, 1, 1, ago(30 * MIN)); | |
| 139 | release.run("b".repeat(40), repo.id, 2, 0, ago(30 * MIN)); | |
| 140 | }); | |
| 141 | ||
| 142 | afterAll(async () => { | |
| 143 | servers.stop(); | |
| 144 | app.close(); | |
| 145 | await rm(dataDir, { recursive: true, force: true }); | |
| 146 | }); | |
| 147 | ||
| 148 | test("each stage counts its items out per hour, hour 0 the last", () => { | |
| 149 | const t = readThroughput(app.db, PIPELINE, new Date(NOW)); | |
| 150 | const by = new Map(t.stages.map((s) => [s.key, s])); | |
| 151 | expect(by.get("approved")?.hours.slice(0, 4)).toEqual([2, 3, 1, 0]); | |
| 152 | expect(by.get("approved")?.hours.reduce((a, b) => a + b)).toBe(6); | |
| 153 | expect(by.get("approved")?.items).toHaveLength(6); | |
| 154 | expect(by.get("approved")?.items[0]?.href).toBe("/alice/shop/pulls/2"); | |
| 155 | expect(by.get("ready")?.items).toHaveLength(1); | |
| 156 | expect(by.get("ready")?.hours[0]).toBe(1); | |
| 157 | expect(by.get("building")?.hours[0]).toBe(1); | |
| 158 | expect(by.get("check-queue")?.hours.slice(0, 3)).toEqual([2, 0, 1]); | |
| 159 | expect(by.get("checking")?.hours.slice(0, 3)).toEqual([2, 0, 1]); | |
| 160 | expect(by.get("checking")?.items.map((i) => i.outcome)).toEqual(["passed", "passed", "failed"]); | |
| 161 | expect(by.get("review")?.hours[3]).toBe(1); | |
| 162 | expect(by.get("landed")?.items.map((i) => i.href)).toEqual(["/alice/shop/pulls/1"]); | |
| 163 | expect(by.get("live")?.hours[0]).toBe(1); | |
| 164 | expect(by.get("live")?.items[0]?.href).toStartWith("/alice/shop/runs/"); | |
| 165 | }); | |
| 166 | ||
| 167 | test("the median ask→live is over what went live in the last 24 hours", () => { | |
| 168 | // Asks 1h05 … 6h05 before NOW, live 5 minutes ago: 1 … 6 hours; median 3.5 h. | |
| 169 | expect(readThroughput(app.db, PIPELINE, new Date(NOW)).medianAskToLiveMs).toBe(3.5 * HOUR); | |
| 170 | expect(median([])).toBeNull(); | |
| 171 | expect(median([3, 1, 2])).toBe(2); | |
| 172 | expect(stageOf("landed")).toBe("approved"); | |
| 173 | expect(stageOf("approved")).toBeUndefined(); | |
| 174 | }); | |
| 175 | ||
| 176 | test("the page says it in the headline and beside each stage, and every count links to its items", async () => { | |
| 177 | const res = await get("/flow"); | |
| 178 | expect(res.status).toBe(200); | |
| 179 | const page = await res.text(); | |
| 180 | const headline = page.match(/<p id="throughput">([\s\S]*?)<\/p>/)?.[1] ?? ""; | |
| 181 | expect(headline.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim()).toBe( | |
| 182 | "landed 2 in the last hour · 6 in 24 h · median ask→live 3.5 h (24 h)", | |
| 183 | ); | |
| 184 | expect(throughputSays(readThroughput(app.db, PIPELINE, new Date(NOW)))).toBe( | |
| 185 | "landed 2 in the last hour · 6 in 24 h · median ask→live 3.5 h (24 h)", | |
| 186 | ); | |
| 187 | expect(headline).toContain('href="/flow?out=landed&hour=0#out"'); | |
| 188 | expect(page).toMatch(/data-out="landed">\s*<a class="mono" href="\/flow\?out=landed&hour=0#out" data-hour="0">2<\/a>/); | |
| 189 | expect(page).toContain('data-day="6">6 in 24 h'); | |
| 190 | expect(page).toContain("2 passed · 1 failed"); | |
| 191 | expect((page.match(/class="flow-bar"/g) ?? []).length).toBe(PIPELINE.length * 24); | |
| 192 | }); | |
| 193 | ||
| 194 | test("each sparkline is a picture only: hidden from assistive tech, no links or tab stops of its own", async () => { | |
| 195 | const page = await (await get("/flow")).text(); | |
| 196 | const svgs = page.match(/<svg[\s\S]*?<\/svg>/g) ?? []; | |
| 197 | const sparks = svgs.filter((s) => s.includes('class="flow-bar"')); | |
| 198 | expect(sparks).toHaveLength(PIPELINE.length); | |
| 199 | for (const svg of sparks) { | |
| 200 | expect(svg).toContain('aria-hidden="true"'); | |
| 201 | expect(svg).toContain('focusable="false"'); | |
| 202 | expect(svg).not.toContain("role="); | |
| 203 | expect(svg).not.toContain("<a"); | |
| 204 | } | |
| 205 | // The way in is the two counts per stage: last hour and 24 h. | |
| 206 | for (const key of PIPELINE) { | |
| 207 | const out = { ready: "started", building: "opened", "check-queue": "check-started", checking: "checked", | |
| 208 | review: "reviewed", approved: "landed", landed: "released", live: "live" }[key]; | |
| 209 | expect(page).toContain(`href="/flow?out=${out}&hour=0#out" data-hour="0"`); | |
| 210 | expect(page).toMatch(new RegExp(`href="/flow\\?out=${out}#out" data-day=`)); | |
| 211 | } | |
| 212 | }); | |
| 213 | ||
| 214 | const mcpFlow = async (user: User) => { | |
| 215 | const res = await fetch(`${servers.url}/mcp`, { | |
| 216 | method: "POST", | |
| 217 | headers: { | |
| 218 | authorization: `Bearer ${createToken(app.db, user.id, "t", "ro").token}`, | |
| 219 | "content-type": "application/json", | |
| 220 | accept: "application/json, text/event-stream", | |
| 221 | }, | |
| 222 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "flow", arguments: {} } }), | |
| 223 | }); | |
| 224 | const body = (await res.json()) as { result: { isError?: boolean; content: { text: string }[] } }; | |
| 225 | return { error: body.result.isError === true, text: body.result.content[0]!.text }; | |
| 226 | }; | |
| 227 | ||
| 228 | test("through MCP, the flow tool carries the same throughput, and only for a forge admin", async () => { | |
| 229 | const admin = await mcpFlow(alice); | |
| 230 | expect(admin.error).toBe(false); | |
| 231 | const read = JSON.parse(admin.text) as { | |
| 232 | throughput: { | |
| 233 | says: string; | |
| 234 | medianAskToLiveMs: number | null; | |
| 235 | stages: { key: string; out: string; lastHour: number; day: number; hours: number[] }[]; | |
| 236 | }; | |
| 237 | }; | |
| 238 | expect(read.throughput.says).toBe("landed 2 in the last hour · 6 in 24 h · median ask→live 3.5 h (24 h)"); | |
| 239 | expect(read.throughput.medianAskToLiveMs).toBeGreaterThan(3.5 * HOUR - MIN); | |
| 240 | expect(read.throughput.stages.map((s) => s.key)).toEqual([...PIPELINE]); | |
| 241 | const landed = read.throughput.stages.find((s) => s.key === "approved"); | |
| 242 | expect(landed).toMatchObject({ out: "landed", lastHour: 2, day: 6 }); | |
| 243 | expect(landed?.hours.slice(0, 4)).toEqual([2, 3, 1, 0]); | |
| 244 | expect(read.throughput.stages.find((s) => s.key === "checking")).toMatchObject({ lastHour: 2, day: 3 }); | |
| 245 | const member = await mcpFlow(bob); | |
| 246 | expect(member.error).toBe(true); | |
| 247 | expect(member.text).toContain("forbidden"); | |
| 248 | }); | |
| 249 | ||
| 250 | test("in the terminal, an admin's Flow dialog says the headline and each stage's last hour and 24 h", () => { | |
| 251 | const lines = flowLines(app.db, new Date(NOW)); | |
| 252 | expect(lines.join(" ").replace(/\s+/g, " ")).toContain("landed 2 in the last hour · 6 in 24 h · median ask→live 3.5 h (24 h)"); | |
| 253 | expect(lines.find((l) => l.startsWith("Approved "))).toMatch(/^Approved\s+2\s+6\s+landed$/); | |
| 254 | expect(lines.find((l) => l.startsWith("Checking "))).toMatch(/^Checking\s+2\s+3\s+checks finished$/); | |
| 255 | expect(lines.find((l) => l.startsWith("Ready "))).toMatch(/^Ready\s+1\s+1\s+issues started$/); | |
| 256 | expect(lines.find((l) => l.startsWith("Live "))).toMatch(/^Live\s+1\s+1\s+live$/); | |
| 257 | const pushed: Screen[] = []; | |
| 258 | const ctx = { push: (s: Screen) => pushed.push(s) } as unknown as Ctx; | |
| 259 | const command = (user: User) => | |
| 260 | forgeCommands( | |
| 261 | new Session(app, { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }), | |
| 262 | )(ctx).find((c) => c.id === "flow"); | |
| 263 | command(alice)!.run(); | |
| 264 | expect(pushed.map((s) => s.title)).toEqual(["Flow"]); | |
| 265 | expect(command(bob)).toBeUndefined(); | |
| 266 | }); | |
| 267 | ||
| 268 | test("following a count lists the items it counted; a bad ask lists nothing", async () => { | |
| 269 | const hour = await (await get("/flow?out=landed&hour=0")).text(); | |
| 270 | expect(hour).toContain('id="out"'); | |
| 271 | expect(hour).toContain("2 landed in the last hour"); | |
| 272 | expect(hour).toContain('href="/alice/shop/pulls/2"'); | |
| 273 | expect(hour).toContain('href="/alice/shop/pulls/4"'); | |
| 274 | expect(hour).not.toContain('href="/alice/shop/pulls/6"'); | |
| 275 | const day = await (await get("/flow?out=landed")).text(); | |
| 276 | expect(day).toContain("6 landed in the last 24 hours"); | |
| 277 | for (const bad of ["?out=nope", "?out=landed&hour=24", "?out=landed&hour=-1", "?out=landed&hour=1e1"]) | |
| 278 | expect(await (await get(`/flow${bad}`)).text()).not.toContain('id="out"'); | |
| 279 | }); | |
| 280 | }); |
new above
offline
Minor, not blocking:
MIN(started_at)is taken only within the window. An issue first started 30 h ago and retried in the last hour therefore counts as 'started' again, although FLOW.md says 'the first builder start per issue'. One fix isHAVING MIN(...)over all of the issue's builder runs (drop the window filter from WHERE and move it to HAVING). Another is rewording the doc to 'first start in the window'.