Public: anyone can read this; the crew can change it.
/flow: the forge shows its own bottleneck — every stage from ready to live with its queue, wait and capacity, the knob beside it, machine use, and 24 h throughput #1729
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/1554-flow-the-forge-shows-its-own-bottleneck → main 6 commits fork 5 threads · 5 unresolved
README.md +1
| @@ -443,6 +443,7 @@ from before this record say nothing). | ||
| 443 | 443 | mechanical rather than advisory. |
| 444 | 444 | |
| 445 | 445 | `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. |
| 446 | 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. | |
| 446 | 447 | |
| 447 | 448 | `docs/PLANNING.md` is how issues depend on issues: the edge, the map, and |
| 448 | 449 | what "ready now" means to a person and to the crew. The map also shows each |
docs/FLOW.md +13
| @@ -13,3 +13,16 @@ pull 1 h, check 30 min, review 12 h, land 2 h; live never. A pull closed without | ||
| 13 | 13 | merging puts the issue back at `issue`; a re-opened pull counts from the re-open; |
| 14 | 14 | with several open pulls the furthest along speaks. An issue is linked to a pull |
| 15 | 15 | by `Closes #N` in the pull's body, as the merge itself reads it. |
| 16 | ||
| 17 | ## The forge's pipeline | |
| 18 | ||
| 19 | `src/flow/pipeline.ts` is the same road seen from the machine, across every | |
| 20 | repository: ready → building → check queue → checking → review → approved → | |
| 21 | landed → live, each with how much waits in it, how much is being worked, how | |
| 22 | long its oldest waiting item has waited, and what limits it (crew runs at once, | |
| 23 | checks at once, the autonomous repositories' slots). The stage whose oldest | |
| 24 | waiting item has waited longest is the bottleneck. A forge admin reads it at | |
| 25 | `/flow` (`web/pages/flow.ts`), one row per stage under one sentence naming the | |
| 26 | bottleneck; the knobs stay on Settings → Capacity. The same sentence | |
| 27 | (`verdict`) is the terminal's admin-only Flow command and the MCP `flow` tool, | |
| 28 | which also returns the stages; anyone but a forge admin is refused. |
scripts/gate.ts +1
| @@ -102,6 +102,7 @@ export const GATE: readonly string[] = [ | ||
| 102 | 102 | "test/decide.test.ts", |
| 103 | 103 | "test/ledger.test.ts", |
| 104 | 104 | "test/capacity.test.ts", |
| 105 | "test/flow-pipeline.test.ts", | |
| 105 | 106 | "test/limits.test.ts", |
| 106 | 107 | "test/guard-new-accounts.test.ts", |
| 107 | 108 | "test/checks.test.ts", |
src/core/reserved.ts +1
| @@ -66,6 +66,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 66 | 66 | "events", |
| 67 | 67 | "explore", |
| 68 | 68 | "fleet", |
| 69 | "flow", | |
| 69 | 70 | "front", |
| 70 | 71 | "go", |
| 71 | 72 | "icons", |
src/flow/pipeline.ts added +240
| @@ -0,0 +1,240 @@ | ||
| 1 | /** | |
| 2 | * The forge's pipeline as stages, read from what it already keeps (runs, | |
| 3 | * issues, pulls, reviews): how much is in each stage now, how long its | |
| 4 | * oldest waiting item has waited, and the capacity that limits it (#1554). | |
| 5 | * The stage whose oldest waiting item has waited longest is the bottleneck. | |
| 6 | * | |
| 7 | * Beside `stage.ts`, which follows one repository's issues, this is the | |
| 8 | * forge's work as a whole: runs queued and running, against the capacity | |
| 9 | * that limits them. | |
| 10 | * | |
| 11 | * A stage holds items that are *waiting* (queued for the next worker) and | |
| 12 | * items that are *active* (being worked). Only waiting items can make a | |
| 13 | * stage the bottleneck: a check that takes twenty minutes is working, a | |
| 14 | * check that waits twenty minutes to start is the limit. | |
| 15 | * | |
| 16 | * Pure data, forge-wide, no authorization: `/flow` is a forge admin's. | |
| 17 | */ | |
| 18 | ||
| 19 | import type { Database } from "bun:sqlite"; | |
| 20 | import { capacityValue } from "../core/capacity.ts"; | |
| 21 | ||
| 22 | export const PIPELINE = [ | |
| 23 | "ready", | |
| 24 | "building", | |
| 25 | "check-queue", | |
| 26 | "checking", | |
| 27 | "review", | |
| 28 | "approved", | |
| 29 | "landed", | |
| 30 | "live", | |
| 31 | ] as const; | |
| 32 | export type PipelineKey = (typeof PIPELINE)[number]; | |
| 33 | ||
| 34 | export const STAGE_LABEL: Record<PipelineKey, string> = { | |
| 35 | ready: "Ready", | |
| 36 | building: "Building", | |
| 37 | "check-queue": "Check queue", | |
| 38 | checking: "Checking", | |
| 39 | review: "Review", | |
| 40 | approved: "Approved", | |
| 41 | landed: "Landed", | |
| 42 | live: "Live", | |
| 43 | }; | |
| 44 | ||
| 45 | /** What each stage holds, in one line under its name. */ | |
| 46 | export const STAGE_HOLDS: Record<PipelineKey, string> = { | |
| 47 | ready: "Open, unblocked issues in autonomous repositories with nothing in flight on them", | |
| 48 | building: "Agent runs (not reviews), queued for a builder or running", | |
| 49 | "check-queue": "Checks queued to start", | |
| 50 | checking: "Checks running", | |
| 51 | review: "Review runs, queued for a reviewer or running", | |
| 52 | approved: "Open pulls whose latest verdict is an approval, waiting to land", | |
| 53 | landed: "Pulls merged into an app's default branch in the last week with no production deploy since", | |
| 54 | live: "Production deploys that went live in the last 24 hours", | |
| 55 | }; | |
| 56 | ||
| 57 | export interface PipelineStage { | |
| 58 | readonly key: PipelineKey; | |
| 59 | /** Queued for the next worker. */ | |
| 60 | readonly waiting: number; | |
| 61 | /** Being worked, or (live) done. */ | |
| 62 | readonly active: number; | |
| 63 | /** When the oldest waiting item entered the stage, or null when nothing waits. */ | |
| 64 | readonly oldestWaiting: string | null; | |
| 65 | /** What limits the stage, in words ("crew runs at once 2"), or null when nothing in the forge does. */ | |
| 66 | readonly capacity: string | null; | |
| 67 | } | |
| 68 | ||
| 69 | const DAY_MS = 24 * 3_600_000; | |
| 70 | ||
| 71 | interface Tally { | |
| 72 | waiting: number; | |
| 73 | active: number; | |
| 74 | oldest: string | null; | |
| 75 | } | |
| 76 | ||
| 77 | /** Runs of `kind` in flight; queued ones paused on a limit (`not_before` ahead) are not waiting for a worker. */ | |
| 78 | function runTally(db: Database, kind: string, where: string, now: string): Tally { | |
| 79 | return db | |
| 80 | .query<Tally, [string, string]>( | |
| 81 | `SELECT COALESCE(SUM(state = 'queued'), 0) AS waiting, | |
| 82 | COALESCE(SUM(state = 'running'), 0) AS active, | |
| 83 | MIN(CASE WHEN state = 'queued' THEN created_at END) AS oldest | |
| 84 | FROM run | |
| 85 | WHERE kind = ?1 AND state IN ('queued','running') | |
| 86 | AND (state = 'running' OR not_before IS NULL OR not_before <= ?2) | |
| 87 | AND ${where}`, | |
| 88 | ) | |
| 89 | .get(kind, now) ?? { waiting: 0, active: 0, oldest: null }; | |
| 90 | } | |
| 91 | ||
| 92 | /** Rows that all wait: `since` is when each entered the stage. */ | |
| 93 | function waitingTally(db: Database, sql: string, ...args: string[]): Tally { | |
| 94 | const r = db | |
| 95 | .query<{ n: number; oldest: string | null }, string[]>( | |
| 96 | `SELECT COUNT(*) AS n, MIN(since) AS oldest FROM (${sql})`, | |
| 97 | ) | |
| 98 | .get(...args); | |
| 99 | return { waiting: r?.n ?? 0, active: 0, oldest: r?.oldest ?? null }; | |
| 100 | } | |
| 101 | ||
| 102 | const READY = ` | |
| 103 | SELECT i.created_at AS since | |
| 104 | FROM issue i JOIN repo r ON r.id = i.repo_id | |
| 105 | WHERE i.kind = 'issue' AND i.state = 'open' AND r.autonomy != 'off' | |
| 106 | AND (r.direction_issue_id IS NULL OR r.direction_issue_id != i.id) | |
| 107 | AND NOT EXISTS (SELECT 1 FROM issue_dependency d JOIN issue b ON b.id = d.blocked_by_id | |
| 108 | WHERE d.issue_id = i.id AND b.state = 'open') | |
| 109 | AND NOT EXISTS (SELECT 1 FROM run x WHERE x.issue_id = i.id AND x.kind = 'agent' | |
| 110 | AND x.state IN ('queued','running')) | |
| 111 | AND NOT EXISTS (SELECT 1 FROM run x JOIN pull p ON p.head_branch = x.branch | |
| 112 | JOIN issue pi ON pi.id = p.issue_id AND pi.repo_id = x.repo_id | |
| 113 | WHERE x.issue_id = i.id AND x.kind = 'agent' AND x.branch != '' | |
| 114 | AND pi.state = 'open')`; | |
| 115 | ||
Untested: the latest non-comment review per pull. Add a test where an approval is followed by changes_requested; that pull must not count as approved. |
||
| 116 | const APPROVED = ` | |
| 117 | SELECT v.created_at AS since | |
| 118 | FROM issue i JOIN pull p ON p.issue_id = i.id | |
| 119 | JOIN review v ON v.id = (SELECT id FROM review WHERE issue_id = i.id AND state != 'commented' | |
| 120 | ORDER BY created_at DESC, id DESC LIMIT 1) | |
| 121 | WHERE i.state = 'open' AND v.state = 'approved'`; | |
| 122 | ||
Untested: merged into the default branch with no production deploy since. Seed one merged pull before a deploy and one after, and assert that only the second is |
||
| 123 | const LANDED = ` | |
| 124 | SELECT p.merged_at AS since | |
| 125 | FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id | |
| 126 | JOIN app a ON a.repo_id = r.id | |
| 127 | WHERE p.merged_at >= ?1 AND p.base_branch = r.default_branch | |
| 128 | AND NOT EXISTS (SELECT 1 FROM run d WHERE d.repo_id = r.id AND d.kind = 'deploy' | |
| 129 | AND d.env = 'production' AND d.created_at >= p.merged_at)`; | |
| 130 | ||
| 131 | function liveTally(db: Database, since: string): Tally { | |
| 132 | const r = db | |
| 133 | .query<{ n: number }, [string]>( | |
| 134 | `SELECT COUNT(*) AS n FROM run | |
| 135 | WHERE kind = 'deploy' AND env = 'production' AND serving_at >= ?1`, | |
| 136 | ) | |
| 137 | .get(since); | |
| 138 | return { waiting: 0, active: r?.n ?? 0, oldest: null }; | |
| 139 | } | |
| 140 | ||
| 141 | function tally(db: Database, key: PipelineKey, now: Date): Tally { | |
| 142 | const iso = now.toISOString(); | |
| 143 | switch (key) { | |
| 144 | case "ready": | |
| 145 | return waitingTally(db, READY); | |
| 146 | case "building": | |
| 147 | return runTally(db, "agent", "reason != 'review'", iso); | |
| 148 | case "check-queue": | |
| 149 | return runTally(db, "check", "state = 'queued'", iso); | |
| 150 | case "checking": | |
| 151 | return runTally(db, "check", "state = 'running'", iso); | |
| 152 | case "review": | |
| 153 | return runTally(db, "agent", "reason = 'review'", iso); | |
| 154 | case "approved": | |
| 155 | return waitingTally(db, APPROVED); | |
| 156 | case "landed": | |
| 157 | return waitingTally(db, LANDED, new Date(now.getTime() - 7 * DAY_MS).toISOString()); | |
| 158 | case "live": | |
| 159 | return liveTally(db, new Date(now.getTime() - DAY_MS).toISOString()); | |
| 160 | } | |
| 161 | } | |
| 162 | ||
| 163 | /** The autonomous repositories' slots added up, and how many repositories. */ | |
| 164 | function slots(db: Database): { slots: number; repos: number } { | |
| 165 | const r = db | |
| 166 | .query<{ slots: number | null; repos: number }, []>( | |
| 167 | `SELECT SUM(autonomy_slots) AS slots, COUNT(*) AS repos FROM repo WHERE autonomy != 'off'`, | |
| 168 | ) | |
| 169 | .get(); | |
| 170 | return { slots: r?.slots ?? 0, repos: r?.repos ?? 0 }; | |
| 171 | } | |
| 172 | ||
| 173 | const auto = (v: string) => (v === "" ? "auto" : v); | |
| 174 | ||
| 175 | /** What limits each stage, read now: the capacity settings and the repositories' slots. */ | |
| 176 | function capacities(db: Database): Record<PipelineKey, string | null> { | |
| 177 | const crew = `crew runs at once ${auto(capacityValue(db, "crew.concurrency"))}`; | |
| 178 | const checks = `checks at once ${auto(capacityValue(db, "checks.concurrency"))}`; | |
| 179 | const s = slots(db); | |
| 180 | return { | |
| 181 | ready: `${s.slots} slot${s.slots === 1 ? "" : "s"} in ${s.repos} autonomous ${s.repos === 1 ? "repository" : "repositories"}`, | |
| 182 | building: crew, | |
| 183 | "check-queue": checks, | |
| 184 | checking: checks, | |
| 185 | review: crew, | |
| 186 | approved: null, | |
| 187 | landed: null, | |
| 188 | live: null, | |
| 189 | }; | |
| 190 | } | |
| 191 | ||
| 192 | /** Every stage, left to right, as it is now. */ | |
| 193 | export function readPipeline(db: Database, now = new Date()): PipelineStage[] { | |
| 194 | const cap = capacities(db); | |
| 195 | return PIPELINE.map((key) => { | |
| 196 | const t = tally(db, key, now); | |
| 197 | return { | |
| 198 | key, | |
| 199 | waiting: t.waiting, | |
| 200 | active: t.active, | |
| 201 | oldestWaiting: t.oldest, | |
| 202 | capacity: cap[key], | |
| 203 | }; | |
| 204 | }); | |
| 205 | } | |
| 206 | ||
| 207 | /** The stage whose oldest waiting item has waited longest; undefined when nothing waits. */ | |
| 208 | export function bottleneck(stages: readonly PipelineStage[]): PipelineStage | undefined { | |
| 209 | let worst: PipelineStage | undefined; | |
| 210 | for (const s of stages) | |
| 211 | if ( | |
| 212 | s.oldestWaiting !== null && | |
| 213 | (worst?.oldestWaiting == null || s.oldestWaiting < worst.oldestWaiting) | |
| 214 | ) | |
| 215 | worst = s; | |
| 216 | return worst; | |
| 217 | } | |
| 218 | ||
| 219 | /** A span of milliseconds as a person reads it: 45s, 12m, 3h 5m, 2d 4h. */ | |
| 220 | export function span(ms: number): string { | |
| 221 | const s = Math.max(0, Math.round(ms / 1000)); | |
| 222 | if (s < 60) return `${s}s`; | |
| 223 | const m = Math.floor(s / 60); | |
| 224 | if (m < 60) return `${m}m`; | |
| 225 | const h = Math.floor(m / 60); | |
| 226 | if (h < 48) return m % 60 === 0 ? `${h}h` : `${h}h ${m % 60}m`; | |
| 227 | return `${Math.floor(h / 24)}d ${h % 24}h`; | |
| 228 | } | |
| 229 | ||
| 230 | /** How long a stage's oldest waiting item has waited, or null when nothing waits. */ | |
| 231 | export const waited = (s: PipelineStage, now: number): string | null => | |
| 232 | s.oldestWaiting === null ? null : span(now - Date.parse(s.oldestWaiting)); | |
| 233 | ||
| 234 | /** The one sentence naming the bottleneck, or saying nothing waits: the page, the terminal and MCP all say this. */ | |
| 235 | export function verdict(stages: readonly PipelineStage[], now = Date.now()): string { | |
| 236 | const worst = bottleneck(stages); | |
| 237 | if (worst === undefined) return "Nothing is waiting: every stage is empty or being worked."; | |
| 238 | const n = worst.waiting; | |
| 239 | return `${STAGE_LABEL[worst.key]} is the bottleneck: its oldest has waited ${waited(worst, now) ?? ""}, ${n} ${n === 1 ? "waits" : "wait"}${worst.capacity === null ? "" : `, ${worst.capacity}`}.`; | |
| 240 | } |
src/http/mcp.ts +14
| @@ -19,6 +19,7 @@ | ||
| 19 | 19 | |
| 20 | 20 | import { mayView, mayViewId, sealedScope } from "../core/sealed.ts"; |
| 21 | 21 | import { changeCapacity, listCapacity } from "../actions/capacity.ts"; |
| 22 | import { readPipeline, verdict } from "../flow/pipeline.ts"; | |
| 22 | 23 | import { changeLimit, listLimits } from "../actions/limits.ts"; |
| 23 | 24 | import { LIMIT_KEYS } from "../settings/limits.ts"; |
| 24 | 25 | import { relationOf, takeUpdates } from "../actions/forks.ts"; |
| @@ -1823,6 +1824,19 @@ const TOOLS: readonly Tool[] = [ | ||
| 1823 | 1824 | }); |
| 1824 | 1825 | }, |
| 1825 | 1826 | }, |
| 1827 | { | |
| 1828 | name: "flow", | |
| 1829 | description: | |
| 1830 | "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.", | |
| 1831 | inputSchema: { type: "object", properties: {} }, | |
| 1832 | writes: false, | |
| 1833 | run(ctx, user) { | |
| 1834 | if (!user.isAdmin) | |
| 1835 | return bad("forbidden", "only an admin of this forge reads its pipeline"); | |
| 1836 | const stages = readPipeline(ctx.app.db); | |
| 1837 | return good({ says: verdict(stages), stages }); | |
| 1838 | }, | |
| 1839 | }, | |
| 1826 | 1840 | { |
| 1827 | 1841 | name: "limits", |
| 1828 | 1842 | description: |
src/http/server.ts +3
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | import { TOOLBAR_NAME, toolbarResponse } from "../apps/toolbar.ts"; |
| 9 | 9 | import { unsealReleased } from "../actions/sealed.ts"; |
| 10 | 10 | import { capacityPage, capacitySet } from "../web/pages/capacity.ts"; |
| 11 | import { flowPage } from "../web/pages/flow.ts"; | |
| 11 | 12 | import { limitsPage, limitsSet } from "../web/pages/admin-limits.ts"; |
| 12 | 13 | import { |
| 13 | 14 | closeStreamsOf, |
| @@ -616,6 +617,8 @@ export function buildRouter(): Router<Handler> { | ||
| 616 | 617 | router.add("GET", "/migrate/jobs/:job", migrationJobPage); |
| 617 | 618 | // Every app on one canvas; a literal route, so it must sit above /:owner. |
| 618 | 619 | router.add("GET", "/map", globalMapPage); |
| 620 | // The pipeline's stages and its bottleneck, for a forge admin (#1554); literal, above /:owner. | |
| 621 | router.add("GET", "/flow", flowPage); | |
| 619 | 622 | // A selection's spend and runs over time; literal, above /:owner. |
| 620 | 623 | router.add("GET", "/fleet/graph", fleetGraphPage); |
| 621 | 624 | router.add("GET", "/fleet/graph/live", fleetGraphLive); |
src/tui/forge.ts +9
| @@ -11,6 +11,7 @@ | ||
| 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"; | |
| 14 | 15 | import { canRead, getRepo, type Repo } from "../core/repos.ts"; |
| 15 | 16 | import { sourceFor } from "../view/source.ts"; |
| 16 | 17 | import { App, type Ctx, type Screen } from "./app/app.ts"; |
| @@ -183,6 +184,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 183 | 184 | "default", |
| 184 | 185 | ), |
| 185 | 186 | }); |
| 187 | // The pipeline's bottleneck, as /flow's sentence says it: a site admin's (#1554). | |
| 188 | if (session.user?.isAdmin === true) | |
| 189 | commands.push({ | |
| 190 | id: "flow", | |
| 191 | title: "Flow", | |
| 192 | hint: "the pipeline's bottleneck", | |
| 193 | run: () => ctx.toast(verdict(readPipeline(session.db)), "default"), | |
| 194 | }); | |
| 186 | 195 | commands.push({ |
| 187 | 196 | id: "quit", |
| 188 | 197 | title: "Quit", |
src/web/layout.ts +16 −2
| @@ -316,7 +316,10 @@ function paletteIndex(ctx: Ctx, extra: PaletteItem[]): Html { | ||
| 316 | 316 | ? [{ t: "New forge", h: "/forges/new", c: "Create" }] |
| 317 | 317 | : []), |
| 318 | 318 | ...(ctx.auth.user?.isAdmin |
| 319 | ? [{ t: "Forges you sent", h: "/forges", c: "Go to" }] | |
| 319 | ? [ | |
| 320 | { t: "Flow", h: "/flow", c: "Go to" }, | |
| 321 | { t: "Forges you sent", h: "/forges", c: "Go to" }, | |
| 322 | ] | |
| 320 | 323 | : []), |
| 321 | 324 | ]; |
| 322 | 325 | // `studio` rides along so the palette's *remote* rows — what the search |
| @@ -437,7 +440,18 @@ function topbar( | ||
| 437 | 440 | )} |
| 438 | 441 | <span class="spacer"></span> |
| 439 | 442 | <span class="topbar-tools"> |
| 440 | ${searchBar()}<span class="hide-sm">${helpButton()}</span>${themeMenu(theme)} | |
| 443 | ${when( | |
| 444 | user?.isAdmin === true, | |
| 445 | () => | |
| 446 | html`<a | |
| 447 | id="flow-link" | |
| 448 | class="btn" | |
| 449 | data-size="sm" | |
| 450 | href="/flow" | |
| 451 | title="The pipeline and its bottleneck" | |
| 452 | >${icon("activity")}<span class="hide-sm">Flow</span></a | |
| 453 | >`, | |
| 454 | )}${searchBar()}<span class="hide-sm">${helpButton()}</span>${themeMenu(theme)} | |
| 441 | 455 | ${user |
| 442 | 456 | ? html`<span class="hide-sm">${newMenu(ctx)}</span>${userMenu( |
| 443 | 457 | ctx, |
src/web/pages/flow.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | /** | |
| 2 | * /flow, for a forge admin: the pipeline from a ready issue to a live | |
| 3 | * deploy as rows, one per stage, each with how much is in it now, how long | |
| 4 | * its oldest waiting item has waited, and the capacity that limits it | |
| 5 | * (#1554). One sentence above names the stage with the longest wait — the | |
| 6 | * bottleneck. The numbers are `flow/pipeline.ts`'s; the knobs stay on | |
| 7 | * Settings → Capacity, linked from here. | |
| 8 | */ | |
| 9 | ||
| 10 | import { | |
| 11 | bottleneck, | |
| 12 | readPipeline, | |
| 13 | STAGE_HOLDS, | |
| 14 | STAGE_LABEL, | |
| 15 | verdict, | |
| 16 | waited, | |
| 17 | } from "../../flow/pipeline.ts"; | |
| 18 | import type { Ctx } from "../../http/context.ts"; | |
| 19 | import { notFoundPage, signInFirst } from "../chrome.ts"; | |
| 20 | import { html } from "../html.ts"; | |
| 21 | import { page } from "../layout.ts"; | |
| 22 | import { badge, card } from "../ui.ts"; | |
| 23 | ||
| 24 | // GET /flow | |
| 25 | export function flowPage(ctx: Ctx): Response { | |
| 26 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 27 | if (!ctx.auth.user.isAdmin) return notFoundPage(ctx); | |
| 28 | const now = Date.now(); | |
| 29 | const stages = readPipeline(ctx.app.db, new Date(now)); | |
| 30 | const worst = bottleneck(stages); | |
| 31 | const body = html`<div class="page-head"> | |
| 32 | <h1>Flow</h1> | |
| 33 | <p class="muted"> | |
| 34 | From a ready issue to a live deploy: what is in each stage now, how | |
| 35 | long its oldest has waited, and what limits it. The limits are set on | |
| 36 | <a href="/settings/capacity">Capacity</a> and in each repository's | |
| 37 | autonomy settings. | |
| 38 | </p> | |
| 39 | </div> | |
outdated
Move this into |
||
| 40 | <p id="bottleneck" data-stage="${worst?.key ?? ""}"> | |
| 41 | <strong>${verdict(stages, now)}</strong> | |
| 42 | </p> | |
| 43 | ${card( | |
| 44 | html`<div class="table-scroll"> | |
| 45 | <table class="table" aria-label="Pipeline stages"> | |
| 46 | <thead> | |
| 47 | <tr> | |
| 48 | <th scope="col">Stage</th> | |
| 49 | <th scope="col">Waiting</th> | |
| 50 | <th scope="col">Active</th> | |
| 51 | <th scope="col">Oldest wait</th> | |
| 52 | <th scope="col">Capacity</th> | |
| 53 | </tr> | |
| 54 | </thead> | |
| 55 | <tbody> | |
| 56 | ${stages.map( | |
| 57 | (s) => | |
| 58 | html`<tr | |
| 59 | data-stage="${s.key}" | |
| 60 | ${s === worst ? html`data-bottleneck="true"` : null} | |
| 61 | > | |
| 62 | <th scope="row"> | |
| 63 | ${STAGE_LABEL[s.key]} | |
| 64 | ${s === worst ? badge("bottleneck", { tone: "danger" }) : null} | |
| 65 | <div class="muted small">${STAGE_HOLDS[s.key]}</div> | |
| 66 | </th> | |
| 67 | <td class="mono">${String(s.waiting)}</td> | |
| 68 | <td class="mono">${String(s.active)}</td> | |
| 69 | <td class="mono">${waited(s, now) ?? "—"}</td> | |
| 70 | <td class="small">${s.capacity ?? "—"}</td> | |
| 71 | </tr>`, | |
| 72 | )} | |
| 73 | </tbody> | |
| 74 | </table> | |
| 75 | </div>`, | |
| 76 | { pad: false }, | |
| 77 | )}`; | |
| 78 | return page({ ctx, title: "Flow", body }); | |
| 79 | } |
test/flow-pipeline.test.ts added +312
| @@ -0,0 +1,312 @@ | ||
| 1 | /** | |
| 2 | * /flow shows the forge's own bottleneck (#1554): from a seeded forge — | |
| 3 | * checks queued for hours, builder runs queued for minutes, two ready | |
| 4 | * issues an hour old beside open issues that are not ready, pulls approved | |
| 5 | * and overridden, merged before and after a deploy, deploys live today and | |
| 6 | * two days ago — each stage counts what it holds, the check queue is | |
| 7 | * named as the bottleneck in one sentence with its capacity — on the page, | |
| 8 | * in the terminal and through MCP — and nobody but a forge admin sees any | |
| 9 | * of it. | |
| 10 | */ | |
| 11 | ||
| 12 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 13 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 14 | import { tmpdir } from "node:os"; | |
| 15 | import { join } from "node:path"; | |
| 16 | import { createApp, type App } from "../src/app.ts"; | |
| 17 | import { openIssue } from "../src/actions/issues.ts"; | |
| 18 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 19 | import { addDependency } from "../src/core/dependencies.ts"; | |
| 20 | import { setDirectionIssue } from "../src/core/repos.ts"; | |
| 21 | import { setCapacity } from "../src/core/capacity.ts"; | |
| 22 | import { createRun } from "../src/core/runs.ts"; | |
| 23 | import { createUser, type User } from "../src/core/users.ts"; | |
| 24 | import { createToken } from "../src/core/tokens.ts"; | |
| 25 | import { bottleneck, readPipeline, span, verdict } from "../src/flow/pipeline.ts"; | |
| 26 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 27 | import type { Ctx } from "../src/tui/app/app.ts"; | |
| 28 | import { forgeCommands, Session } from "../src/tui/forge.ts"; | |
| 29 | ||
| 30 | const HOUR = 3_600_000; | |
| 31 | const ago = (ms: number) => new Date(Date.now() - ms).toISOString(); | |
| 32 | ||
| 33 | describe("/flow: the forge shows its own bottleneck", () => { | |
| 34 | let dataDir: string; | |
| 35 | let app: App; | |
| 36 | let servers: Servers; | |
| 37 | let alice: User; | |
| 38 | let bob: User; | |
| 39 | ||
| 40 | const login = async (username: string): Promise<string> => { | |
| 41 | const res = await fetch(`${servers.url}/signin`, { | |
| 42 | method: "POST", | |
| 43 | redirect: "manual", | |
| 44 | headers: { | |
| 45 | "content-type": "application/x-www-form-urlencoded", | |
| 46 | origin: servers.url, | |
| 47 | }, | |
| 48 | body: new URLSearchParams({ username, password: "a-good-passphrase" }), | |
| 49 | }); | |
| 50 | return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 51 | }; | |
| 52 | const get = (path: string, cookie: string) => | |
| 53 | fetch(`${servers.url}${path}`, { headers: { cookie }, redirect: "manual" }); | |
| 54 | ||
| 55 | beforeAll(async () => { | |
| 56 | dataDir = await mkdtemp(join(tmpdir(), "forge-flow-")); | |
| 57 | app = createApp({ dataDir, port: 0 }); | |
| 58 | servers = await serve(app); | |
| 59 | const a = await createUser(app.db, { | |
| 60 | trusted: true, | |
| 61 | username: "alice", | |
| 62 | email: "[email protected]", | |
| 63 | password: "a-good-passphrase", | |
| 64 | adminIfFirst: true, | |
| 65 | }); | |
| 66 | if (!a.ok) throw new Error(a.error); | |
| 67 | alice = a.value; | |
| 68 | const b = await createUser(app.db, { | |
| 69 | trusted: true, | |
| 70 | username: "bob", | |
| 71 | email: "[email protected]", | |
| 72 | password: "a-good-passphrase", | |
| 73 | }); | |
| 74 | if (!b.ok) throw new Error(b.error); | |
| 75 | bob = b.value; | |
| 76 | const made = await createRepo(app, alice, { name: "shop" }); | |
| 77 | if (!made.ok) throw new Error(String(made.error)); | |
| 78 | const set = await updateRepoSettings(app, alice, made.value, { | |
| 79 | autonomy: "assisted", | |
| 80 | autonomyDirection: "keep the shop open", | |
| 81 | autonomySlots: 2, | |
| 82 | autonomyCeilingUsd: 15, | |
| 83 | autonomyBy: alice.id, | |
| 84 | }); | |
| 85 | if (!set.ok) throw new Error(String(set.error)); | |
| 86 | const repo = set.value; | |
| 87 | setCapacity(app.db, "checks.concurrency", "1", alice.id); | |
| 88 | setCapacity(app.db, "crew.concurrency", "3", alice.id); | |
| 89 | ||
| 90 | // Two ready issues, opened an hour ago: they wait, but not the longest. | |
| 91 | for (const title of ["Add a cart", "Show the prices"]) { | |
| 92 | const issue = openIssue(app, alice, repo, { title }); | |
| 93 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 94 | app.db | |
| 95 | .query("UPDATE issue SET created_at = ?1 WHERE id = ?2") | |
| 96 | .run(ago(HOUR), issue.value.id); | |
| 97 | } | |
| 98 | // Three checks queued three hours ago, one running: the bottleneck. | |
| 99 | for (let n = 0; n < 4; n++) { | |
| 100 | const run = createRun(app.db, { repoId: repo.id, kind: "check", reason: "push" }); | |
| 101 | app.db | |
| 102 | .query("UPDATE run SET created_at = ?1, state = ?2, started_at = ?3 WHERE id = ?4") | |
| 103 | .run(ago(3 * HOUR), n === 0 ? "running" : "queued", n === 0 ? ago(HOUR) : null, run.id); | |
| 104 | } | |
| 105 | // A builder run queued ten minutes ago. | |
| 106 | const build = createRun(app.db, { repoId: repo.id, kind: "agent", reason: "assigned" }); | |
| 107 | app.db | |
| 108 | .query("UPDATE run SET created_at = ?1 WHERE id = ?2") | |
| 109 | .run(ago(10 * 60_000), build.id); | |
| 110 | // A check paused on a limit until later is not waiting for a worker. | |
| 111 | const paused = createRun(app.db, { repoId: repo.id, kind: "check", reason: "push" }); | |
| 112 | app.db | |
| 113 | .query("UPDATE run SET created_at = ?1, not_before = ?2 WHERE id = ?3") | |
| 114 | .run(ago(9 * HOUR), new Date(Date.now() + HOUR).toISOString(), paused.id); | |
| 115 | ||
| 116 | // Open issues that are not ready: one blocked by an open issue, the | |
| 117 | // blocker itself with an agent run queued on it, one whose agent branch | |
| 118 | // already has an open pull, and the direction issue. Ready stays at 2. | |
| 119 | const newIssue = (title: string) => { | |
| 120 | const issue = openIssue(app, alice, repo, { title }); | |
| 121 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 122 | app.db.query("UPDATE issue SET created_at = ?1 WHERE id = ?2").run(ago(HOUR), issue.value.id); | |
| 123 | return issue.value; | |
| 124 | }; | |
| 125 | const blocker = newIssue("Pick a payment provider"); | |
| 126 | const blocked = newIssue("Take payments"); | |
| 127 | const linked = addDependency(app.db, blocked, blocker, alice.id); | |
| 128 | if (!linked.ok) throw new Error(linked.error); | |
| 129 | const queuedOn = createRun(app.db, { | |
| 130 | repoId: repo.id, | |
| 131 | kind: "agent", | |
| 132 | reason: "assigned", | |
| 133 | issueId: blocker.id, | |
| 134 | }); | |
| 135 | app.db.query("UPDATE run SET created_at = ?1 WHERE id = ?2").run(ago(5 * 60_000), queuedOn.id); | |
| 136 | const built = newIssue("Add a footer"); | |
| 137 | const builtRun = createRun(app.db, { | |
| 138 | repoId: repo.id, | |
outdated
Approved, landed and live are only checked for their capacity being null. Seed one row each that should count and one that should not, and assert the counts. Without that, |
||
| 139 | kind: "agent", | |
| 140 | reason: "assigned", | |
| 141 | issueId: built.id, | |
| 142 | branch: "mason/footer", | |
| 143 | }); | |
| 144 | app.db.query("UPDATE run SET state = 'ok' WHERE id = ?1").run(builtRun.id); | |
| 145 | setDirectionIssue(app.db, repo.id, newIssue("Direction").id); | |
| 146 | ||
| 147 | // Pulls, made from issues: open or merged into the default branch. | |
| 148 | const pull = (title: string, head: string, mergedAt: string | null) => { | |
| 149 | const issue = newIssue(title); | |
| 150 | app.db | |
| 151 | .query("UPDATE issue SET kind = 'pull', state = ?1 WHERE id = ?2") | |
| 152 | .run(mergedAt === null ? "open" : "closed", issue.id); | |
| 153 | app.db | |
| 154 | .query( | |
| 155 | `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) | |
| 156 | VALUES (?1, ?2, ?3, '', '', ?4)`, | |
| 157 | ) | |
| 158 | .run(issue.id, repo.defaultBranch, head, mergedAt); | |
| 159 | return issue.id; | |
| 160 | }; | |
| 161 | const review = (issueId: string, state: string, at: string) => | |
| 162 | app.db | |
| 163 | .query( | |
| 164 | `INSERT INTO review (id, issue_id, author_id, state, created_at) | |
| 165 | VALUES (?1, ?2, ?3, ?4, ?5)`, | |
| 166 | ) | |
| 167 | .run(crypto.randomUUID(), issueId, alice.id, state, at); | |
| 168 | // Approved, with a comment after: still approved — and its head is the footer's agent branch. | |
| 169 | const approved = pull("Footer", "mason/footer", null); | |
| 170 | review(approved, "approved", ago(30 * 60_000)); | |
| 171 | review(approved, "commented", ago(20 * 60_000)); | |
| 172 | // Approved, then changes requested: not approved. | |
| 173 | const overridden = pull("Header", "mason/header", null); | |
| 174 | review(overridden, "approved", ago(40 * 60_000)); | |
| 175 | review(overridden, "changes_requested", ago(35 * 60_000)); | |
| 176 | ||
| 177 | // The shop is an app: one pull merged before its last production deploy, | |
| 178 | // one merged after (landed, not live yet). | |
| 179 | app.db | |
| 180 | .query("INSERT INTO app (id, repo_id, slug, access) VALUES (?1, ?2, 'shop', 'public')") | |
| 181 | .run(crypto.randomUUID(), repo.id); | |
| 182 | pull("Logo", "mason/logo", ago(2 * HOUR)); | |
| 183 | pull("Colours", "mason/colours", ago(30 * 60_000)); | |
| 184 | const deploy = (createdAt: string, servingAt: string) => { | |
| 185 | const run = createRun(app.db, { repoId: repo.id, kind: "deploy", env: "production" }); | |
| 186 | app.db | |
| 187 | .query("UPDATE run SET state = 'ok', created_at = ?1, serving_at = ?2 WHERE id = ?3") | |
| 188 | .run(createdAt, servingAt, run.id); | |
| 189 | }; | |
| 190 | // Live within the hour, and one two days ago that is not counted. | |
| 191 | deploy(ago(90 * 60_000), ago(HOUR - 60_000)); | |
| 192 | deploy(ago(48 * HOUR + 60_000), ago(48 * HOUR)); | |
| 193 | }); | |
| 194 | ||
| 195 | afterAll(async () => { | |
| 196 | servers.stop(); | |
| 197 | app.close(); | |
| 198 | await rm(dataDir, { recursive: true, force: true }); | |
| 199 | }); | |
| 200 | ||
| 201 | test("each stage counts what it holds, with its capacity; the longest wait is the bottleneck", () => { | |
| 202 | const stages = readPipeline(app.db); | |
| 203 | expect(stages.map((s) => s.key)).toEqual([ | |
| 204 | "ready", | |
| 205 | "building", | |
| 206 | "check-queue", | |
| 207 | "checking", | |
| 208 | "review", | |
| 209 | "approved", | |
| 210 | "landed", | |
| 211 | "live", | |
| 212 | ]); | |
| 213 | const by = new Map(stages.map((s) => [s.key, s])); | |
| 214 | expect(by.get("ready")).toMatchObject({ waiting: 2, active: 0 }); | |
| 215 | expect(by.get("ready")?.capacity).toBe("2 slots in 1 autonomous repository"); | |
| 216 | expect(by.get("building")).toMatchObject({ waiting: 2, capacity: "crew runs at once 3" }); | |
| 217 | expect(by.get("check-queue")).toMatchObject({ waiting: 3, capacity: "checks at once 1" }); | |
| 218 | expect(by.get("checking")).toMatchObject({ waiting: 0, active: 1 }); | |
| 219 | expect(by.get("review")).toMatchObject({ waiting: 0, active: 0, oldestWaiting: null }); | |
| 220 | expect(by.get("approved")).toMatchObject({ waiting: 1, active: 0, capacity: null }); | |
| 221 | expect(by.get("landed")).toMatchObject({ waiting: 1, active: 0, capacity: null }); | |
| 222 | expect(by.get("live")).toMatchObject({ waiting: 0, active: 1, capacity: null }); | |
| 223 | expect(bottleneck(stages)?.key).toBe("check-queue"); | |
| 224 | expect(verdict(stages)).toBe( | |
| 225 | "Check queue is the bottleneck: its oldest has waited 3h, 3 wait, checks at once 1.", | |
| 226 | ); | |
| 227 | }); | |
| 228 | ||
| 229 | test("with nothing waiting, nothing is named", () => { | |
| 230 | expect(verdict([])).toBe("Nothing is waiting: every stage is empty or being worked."); | |
| 231 | expect(span(45_000)).toBe("45s"); | |
| 232 | expect(span(3 * HOUR + 5 * 60_000)).toBe("3h 5m"); | |
| 233 | expect(span(52 * HOUR)).toBe("2d 4h"); | |
| 234 | }); | |
| 235 | ||
| 236 | test("an admin reads the stages as rows under the one sentence, linked from the header", async () => { | |
| 237 | const cookie = await login("alice"); | |
| 238 | const res = await get("/flow", cookie); | |
| 239 | expect(res.status).toBe(200); | |
| 240 | const page = await res.text(); | |
| 241 | expect(page).toContain('id="bottleneck" data-stage="check-queue"'); | |
| 242 | expect(page).toContain("Check queue is the bottleneck"); | |
| 243 | expect(page).toMatch(/data-stage="check-queue"\s+data-bottleneck="true"/); | |
| 244 | for (const key of ["ready", "building", "checking", "review", "approved", "landed", "live"]) | |
| 245 | expect(page).toContain(`data-stage="${key}"`); | |
| 246 | expect(page).toContain('href="/settings/capacity"'); | |
| 247 | expect(page).toContain('id="flow-link"'); | |
| 248 | }); | |
| 249 | ||
| 250 | test("nobody but a forge admin reads it", async () => { | |
| 251 | const bob = await login("bob"); | |
| 252 | expect((await get("/flow", bob)).status).toBe(404); | |
| 253 | expect(await (await get("/", bob)).text()).not.toContain('id="flow-link"'); | |
| 254 | expect((await get("/flow", "")).status).not.toBe(200); | |
| 255 | }); | |
| 256 | ||
| 257 | const flowCommand = (user?: User) => { | |
| 258 | const toasts: string[] = []; | |
| 259 | const ctx = { toast: (m: string) => toasts.push(m) } as unknown as Ctx; | |
| 260 | const session = new Session(app, { | |
| 261 | sshUsername: user?.username ?? "git", | |
| 262 | remoteAddress: "127.0.0.1", | |
| 263 | fingerprint: "SHA256:test", | |
| 264 | user, | |
| 265 | }); | |
| 266 | return { found: forgeCommands(session)(ctx).find((c) => c.id === "flow"), toasts }; | |
| 267 | }; | |
| 268 | ||
| 269 | 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 | expect(found?.title).toBe("Flow"); | |
| 272 | found!.run(); | |
| 273 | expect(toasts).toEqual([verdict(readPipeline(app.db))]); | |
| 274 | expect(toasts[0]).toStartWith("Check queue is the bottleneck"); | |
| 275 | expect(flowCommand(bob).found).toBeUndefined(); | |
| 276 | expect(flowCommand(undefined).found).toBeUndefined(); | |
| 277 | }); | |
| 278 | ||
| 279 | test("through MCP, an admin reads the stages and the sentence; anyone else is forbidden", async () => { | |
| 280 | const call = async (token: string) => { | |
| 281 | const res = await fetch(`${servers.url}/mcp`, { | |
| 282 | method: "POST", | |
| 283 | headers: { | |
| 284 | authorization: `Bearer ${token}`, | |
| 285 | "content-type": "application/json", | |
| 286 | accept: "application/json, text/event-stream", | |
| 287 | }, | |
| 288 | body: JSON.stringify({ | |
| 289 | jsonrpc: "2.0", | |
| 290 | id: 1, | |
| 291 | method: "tools/call", | |
| 292 | params: { name: "flow", arguments: {} }, | |
| 293 | }), | |
| 294 | }); | |
| 295 | const body = (await res.json()) as { | |
| 296 | result: { isError?: boolean; content: { text: string }[] }; | |
| 297 | }; | |
| 298 | return { error: body.result.isError === true, text: body.result.content[0]!.text }; | |
| 299 | }; | |
| 300 | const admin = await call(createToken(app.db, alice.id, "t", "ro").token); | |
| 301 | expect(admin.error).toBe(false); | |
| 302 | const read = JSON.parse(admin.text) as { | |
| 303 | says: string; | |
| 304 | stages: { key: string; waiting: number }[]; | |
| 305 | }; | |
| 306 | expect(read.says).toStartWith("Check queue is the bottleneck"); | |
| 307 | expect(read.stages.find((s) => s.key === "check-queue")?.waiting).toBe(3); | |
| 308 | const member = await call(createToken(app.db, bob.id, "t", "admin").token); | |
| 309 | expect(member.error).toBe(true); | |
| 310 | expect(member.text).toContain("forbidden"); | |
| 311 | }); | |
| 312 | }); |
test/mcp.test.ts +1
| @@ -200,6 +200,7 @@ describe("the forge over MCP", () => { | ||
| 200 | 200 | "hold", |
| 201 | 201 | "plan", |
| 202 | 202 | "capacity", |
| 203 | "flow", | |
| 203 | 204 | "limits", |
| 204 | 205 | "report", |
| 205 | 206 | ]); |
The exclusions here (open blocker, agent run in flight, open pull from an agent branch, direction issue) have no test. Seed a blocked issue and one with a queued agent run, and assert that
readystays at 2.