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

Merged opened this in answer to

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

Merged

mason/forge:mason/1779-flow-shows-actual-throughput-per-stage-i → main 4 commits fork 3 threads · 3 unresolved

  • Merged. mason/1779-flow-shows-actual-throughput-per-stage-i was merged into main as 4d7169b.

Review

Sign in to review this change.
10 files changed +775 −18
README.md +1 −1
@@ -500,7 +500,7 @@ from before this record say nothing).
500500 mechanical rather than advisory.
501501
502502 `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.
504504
505505 `docs/PLANNING.md` is how issues depend on issues: the edge, the map, and
506506 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
2626 bottleneck; the knobs stay on Settings → Capacity. The same sentence
2727 (`verdict`) is the terminal's admin-only Flow command and the MCP `flow` tool,
2828 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[] = [
113113 "test/capacity.test.ts",
114114 "test/access-log.test.ts",
115115 "test/flow-pipeline.test.ts",
116 "test/flow-throughput.test.ts",
116117 "test/limits.test.ts",
117118 "test/guard-new-accounts.test.ts",
118119 "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`,

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 is HAVING 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'.

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";
2222 import { listAccess } from "../actions/access_log.ts";
2323 import { changeCapacity, listCapacity } from "../actions/capacity.ts";
2424 import { readPipeline, verdict } from "../flow/pipeline.ts";
25 import { readThroughput, stageOut, throughputSays } from "../flow/throughput.ts";
2526 import { changeLimit, listLimits } from "../actions/limits.ts";
2627 import { LIMIT_KEYS } from "../settings/limits.ts";
2728 import { relationOf, takeUpdates } from "../actions/forks.ts";
@@ -1991,14 +1992,24 @@ const TOOLS: readonly Tool[] = [
19911992 {
19921993 name: "flow",
19931994 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.",
19951996 inputSchema: { type: "object", properties: {} },
19961997 writes: false,
19971998 run(ctx, user) {
19981999 if (!user.isAdmin)
19992000 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 });
20022013 },
20032014 },
20042015 {
src/tui/forge.ts +4 −4
@@ -11,7 +11,6 @@
1111 import type { App as Forge } from "../app.ts";
1212 import { search } from "../actions/search.ts";
1313 import { sentinelSummary } from "../crew/sentinel.ts";
14 import { readPipeline, verdict } from "../flow/pipeline.ts";
1514 import { canRead, getRepo, type Repo } from "../core/repos.ts";
1615 import { sourceFor } from "../view/source.ts";
1716 import { App, type Ctx, type Screen } from "./app/app.ts";
@@ -32,6 +31,7 @@ import { issueScreen } from "./screens/issue.ts";
3231 import { pullScreen } from "./screens/pull.ts";
3332 import { blobScreen } from "./screens/repo.ts";
3433 import { dashboardScreen } from "./screens/dashboard.ts";
34 import { flowLines, flowScreen } from "./screens/flow.ts";
3535 import { issuesScreen, newIssueScreen } from "./screens/issues.ts";
3636 import { limbsScreen } from "./screens/limbs.ts";
3737 import { newPullScreen } from "./screens/newpull.ts";
@@ -184,13 +184,13 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
184184 "default",
185185 ),
186186 });
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).
188188 if (session.user?.isAdmin === true)
189189 commands.push({
190190 id: "flow",
191191 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))),
194194 });
195195 commands.push({
196196 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 @@
55 * (#1554). One sentence above names the stage with the longest wait — the
66 * bottleneck. The numbers are `flow/pipeline.ts`'s; the knobs stay on
77 * 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.
815 */
916
1017 import {
1118 bottleneck,
1219 readPipeline,
20 span,
1321 STAGE_HOLDS,
1422 STAGE_LABEL,
1523 verdict,
1624 waited,
25 type PipelineKey,
1726 } 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";
1836 import type { Ctx } from "../../http/context.ts";
1937 import { notFoundPage, signInFirst } from "../chrome.ts";
20 import { html } from "../html.ts";
38 import { html, type Html } from "../html.ts";
2139 import { page } from "../layout.ts";
2240 import { badge, card } from "../ui.ts";
2341
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
2492 // GET /flow
2593 export function flowPage(ctx: Ctx): Response {
2694 if (!ctx.auth.user) return signInFirst(ctx);
@@ -28,6 +96,11 @@ export function flowPage(ctx: Ctx): Response {
2896 const now = Date.now();

The throughput numbers are computed only here. Expose the same readThroughput result through the MCP flow tool and the terminal Flow command (admin-only), with tests.

2997 const stages = readPipeline(ctx.app.db, new Date(now));
3098 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);
31104 const body = html`<div class="page-head">
32105 <h1>Flow</h1>
33106 <p class="muted">
@@ -40,6 +113,15 @@ export function flowPage(ctx: Ctx): Response {
40113 <p id="bottleneck" data-stage="${worst?.key ?? ""}">
41114 <strong>${verdict(stages, now)}</strong>
42115 </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>
43125 ${card(
44126 html`<div class="table-scroll">
45127 <table class="table" aria-label="Pipeline stages">
@@ -50,6 +132,7 @@ export function flowPage(ctx: Ctx): Response {
50132 <th scope="col">Active</th>
51133 <th scope="col">Oldest wait</th>
52134 <th scope="col">Capacity</th>
135 <th scope="col">Out, last hour · 24 h</th>
53136 </tr>
54137 </thead>
55138 <tbody>
@@ -68,12 +151,35 @@ export function flowPage(ctx: Ctx): Response {
68151 <td class="mono">${String(s.active)}</td>
69152 <td class="mono">${waited(s, now) ?? "—"}</td>
70153 <td class="small">${s.capacity ?? "—"}</td>
154 <td>${outCell(out.get(s.key)!)}</td>
71155 </tr>`,
72156 )}
73157 </tbody>
74158 </table>
75159 </div>`,
76160 { 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 );
79185 }
test/flow-pipeline.test.ts +10 −7
@@ -24,8 +24,9 @@ import { createUser, type User } from "../src/core/users.ts";
2424 import { createToken } from "../src/core/tokens.ts";
2525 import { bottleneck, readPipeline, span, verdict } from "../src/flow/pipeline.ts";
2626 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";
2828 import { forgeCommands, Session } from "../src/tui/forge.ts";
29 import { flowLines } from "../src/tui/screens/flow.ts";
2930
3031 const HOUR = 3_600_000;
3132 const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
@@ -255,23 +256,25 @@ describe("/flow: the forge shows its own bottleneck", () => {
255256 });
256257
257258 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;
260261 const session = new Session(app, {
261262 sshUsername: user?.username ?? "git",
262263 remoteAddress: "127.0.0.1",
263264 fingerprint: "SHA256:test",
264265 user,
265266 });
266 return { found: forgeCommands(session)(ctx).find((c) => c.id === "flow"), toasts };
267 return { found: forgeCommands(session)(ctx).find((c) => c.id === "flow"), pushed };
267268 };
268269
269270 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);
271272 expect(found?.title).toBe("Flow");
272273 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");
275278 expect(flowCommand(bob).found).toBeUndefined();
276279 expect(flowCommand(undefined).found).toBeUndefined();
277280 });
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&amp;hour=0#out"');
188 expect(page).toMatch(/data-out="landed">\s*<a class="mono" href="\/flow\?out=landed&amp;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}&amp;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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs