Public: anyone can read this; the crew can change it.
/flow lists approved pulls waiting on the merge queue, stale crew pulls to fold back, and work a crew fork never proposed #11466
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11455-flow-lists-approved-pulls-waiting-on-the → main 5 commits fork 3 threads · 3 unresolved
10 files
changed +428
−11
AGENTS.md +1 −1
| @@ -15,7 +15,7 @@ A new read of a table that grows (`event`, `issue`, `run`, `comment`) names the | ||
| 15 | 15 | |
| 16 | 16 | A new `/settings…` page route goes in `src/http/settings_routes.ts` (`settingsRoutes`), not `src/http/server.ts`. |
| 17 | 17 | |
| 18 | State shared between modules through process memory, not the database, is named here. `src/perf/views_report.ts`: `readVisits` holds each scope and window's visits for 15 seconds (dropped by `flushViews`), so a page and its probe share one read; `src/perf/views_store.ts`: the view buffer, flushed every 10 seconds. The traffic sources view's terminal door is `src/tui/screens/trafficview.ts` (palette entry for admins, per-app command for a repository's admins). `src/runs/held.ts`: each dispatcher pass (`src/crew/dispatch.ts`, `pump`) records which queued agent runs it held and why (valve, pace, limit), and vitals (`src/kernel/vitals.ts`) leaves those out of `queue:agent`. A record older than `HELD_FRESH_MS` is ignored, so a stopped dispatcher cannot hide its queue. `src/runs/stoppable.ts`: the checks in flight in this process by run id (`src/checks/service.ts` `start` tracks each), so a person's cancel (`cancelRepoRun`) aborts the check and its process group ends with it; agent runs keep `src/crew/running.ts`. `src/apps/egress_refused.ts`: refused-host counts per repository, flushed to disk every 5 seconds (`flushRefused` forces it). `src/spawn/usage.ts`: the last disk reading of each child's data directory (30 s), and a cell child's last reading of its own cgroup CPU (`selfUse`, #10853), from which the next reading's cores are taken; `forgetSelfCpu` drops it. | |
| 18 | State shared between modules through process memory, not the database, is named here. `src/perf/views_report.ts`: `readVisits` holds each scope and window's visits for 15 seconds (dropped by `flushViews`), so a page and its probe share one read; `src/perf/views_store.ts`: the view buffer, flushed every 10 seconds. The traffic sources view's terminal door is `src/tui/screens/trafficview.ts` (palette entry for admins, per-app command for a repository's admins). `src/runs/held.ts`: each dispatcher pass (`src/crew/dispatch.ts`, `pump`) records which queued agent runs it held and why (valve, pace, limit), and vitals (`src/kernel/vitals.ts`) leaves those out of `queue:agent`. A record older than `HELD_FRESH_MS` is ignored, so a stopped dispatcher cannot hide its queue. `src/runs/stoppable.ts`: the checks in flight in this process by run id (`src/checks/service.ts` `start` tracks each), so a person's cancel (`cancelRepoRun`) aborts the check and its process group ends with it; agent runs keep `src/crew/running.ts`. `src/apps/egress_refused.ts`: refused-host counts per repository, flushed to disk every 5 seconds (`flushRefused` forces it). `src/spawn/usage.ts`: the last disk reading of each child's data directory (30 s), and a cell child's last reading of its own cgroup CPU (`selfUse`, #10853), from which the next reading's cores are taken; `forgetSelfCpu` drops it. `src/web/pages/flow-waiting.ts`: the unproposed-work scan (it reads git for every crew fork) is held for 60 seconds (`forgetUnproposed` drops it), so /flow and the terminal screen share one scan. | |
| 19 | 19 | |
| 20 | 20 | `src/perf/loop_worst.ts`: `loopWorst`, the worst event-loop lag of the last 10 s in ten one-second slots, fed by one unref'd 50 ms timer that the first `serve` starts (`startLoopWorst`), and read by every response's `lag;dur` and by `/healthz` asked for JSON (`loopLagMs`). Tests listing a request's phases read `requestTiming` (`test/helpers/request-timing.ts`), which leaves `lag` out. |
| 21 | 21 |
docs/features/flow-waiting.md added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | # Flow: waiting | |
| 2 | ||
| 3 | `/flow` (a forge admin who is a person) shows three quiet places work waits, each with its one action. A section with nothing to say draws nothing; when all three are quiet there is no card. | |
| 4 | ||
| 5 | - **Approved, waiting on the merge queue** — per repository, how many approved pulls wait and how long the oldest has (`src/flow/approved_wait.ts`). Action: open the oldest pull. | |
| 6 | - **Stale crew pulls** — crew pulls with no push for 7 days, oldest first (`src/flow/stale_fold.ts`), each with the note that folds it back into its issue. It only proposes; a person closes. | |
| 7 | - **Work a crew fork never proposed** — a crew fork's branch 6 h old or more that heads no pull (`src/flow/alarm_unproposed.ts`), once, with a link to the branch. Branches are read with the branches page's helpers; no branch name reaches a shell. | |
| 8 | ||
| 9 | Terminal: the palette entry "Pulls to land" (`src/tui/screens/waiting.ts`, for a forge admin who is a person, offered nothing to anyone else) reads the same `readWaiting` and lists the same three sections as text lines; a quiet lane says "Nothing waiting." | |
| 10 | ||
| 11 | Cost: the unproposed-work scan reads git for every crew fork, so `readWaiting` holds its result for 60 seconds per process; the other two sections are plain queries and are read each time. Stale pulls carry their repository, so two repositories with the same pull number and branch stay apart. | |
| 12 | ||
| 13 | Code: `src/web/pages/flow-waiting.ts` (reads and draws), mounted by one line in `src/web/pages/flow.ts`. Test: `test/flow-waiting.test.ts`. |
scripts/unreached-allow.json −3
| @@ -1,15 +1,12 @@ | ||
| 1 | 1 | [ |
| 2 | 2 | "src/crew/governor_log.ts", |
| 3 | 3 | "src/design/first_five_minutes.ts", |
| 4 | "src/flow/alarm_unproposed.ts", | |
| 5 | "src/flow/approved_wait.ts", | |
| 6 | 4 | "src/flow/clog_words.ts", |
| 7 | 5 | "src/flow/conflict_pairs.ts", |
| 8 | 6 | "src/flow/handovers.ts", |
| 9 | 7 | "src/flow/rebase_first.ts", |
| 10 | 8 | "src/flow/review_rounds.ts", |
| 11 | 9 | "src/flow/shared_red.ts", |
| 12 | "src/flow/stale_fold.ts", | |
| 13 | 10 | "src/perf/page_budget.ts", |
| 14 | 11 | "src/spawn/pod.ts", |
| 15 | 12 | "src/tui/gallery.ts", |
src/flow/stale_fold.ts +6 −5
| @@ -21,8 +21,9 @@ export interface StaleFold extends FoldablePull { | ||
| 21 | 21 | readonly note: string | null; |
| 22 | 22 | } |
| 23 | 23 | |
| 24 | export function staleFold(pulls: readonly FoldablePull[], now: Date): StaleFold[] { | |
| 25 | const out: (StaleFold & { at: number })[] = []; | |
| 24 | /** Any extra fields on a pull (its repository, say) ride through to its fold. */ | |
| 25 | export function staleFold<T extends FoldablePull>(pulls: readonly T[], now: Date): (T & StaleFold)[] { | |
| 26 | const out: { at: number; fold: T & StaleFold }[] = []; | |
| 26 | 27 | for (const p of pulls) { |
| 27 | 28 | if (p.approved) continue; |
| 28 | 29 | const at = Date.parse(p.lastPushAt); |
| @@ -33,8 +34,8 @@ export function staleFold(pulls: readonly FoldablePull[], now: Date): StaleFold[ | ||
| 33 | 34 | p.issueNumber === null |
| 34 | 35 | ? null |
| 35 | 36 | : `Folded back: pull #${p.number} had no push for ${days} days; its branch \`${p.branch}\` keeps the work.`; |
| 36 | out.push({ ...p, days, note, at }); | |
| 37 | out.push({ at, fold: { ...p, days, note } }); | |
| 37 | 38 | } |
| 38 | out.sort((a, b) => a.at - b.at || a.number - b.number); | |
| 39 | return out.map(({ at: _at, ...rest }) => rest); | |
| 39 | out.sort((a, b) => a.at - b.at || a.fold.number - b.fold.number); | |
| 40 | return out.map((x) => x.fold); | |
| 40 | 41 | } |
src/tui/screens/entries.ts +2
| @@ -56,6 +56,7 @@ import { entry as plans } from "./plans.ts"; | ||
| 56 | 56 | import { entry as economics } from "./economics.ts"; |
| 57 | 57 | import { entry as money } from "./money.ts"; |
| 58 | 58 | import { entry as loopStalls } from "./loopstalls.ts"; |
| 59 | import { entry as waiting } from "./waiting.ts"; | |
| 59 | 60 | import { entry as slowPagesEntry } from "./slowpages.ts"; |
| 60 | 61 | import { entry as maintenance } from "./maintenance.ts"; |
| 61 | 62 | import { entry as maintainRuns } from "./maintainruns.ts"; |
| @@ -118,6 +119,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 118 | 119 | homePins, |
| 119 | 120 | system, |
| 120 | 121 | loopStalls, |
| 122 | waiting, | |
| 121 | 123 | slowPagesEntry, |
| 122 | 124 | telemetry, |
| 123 | 125 | resilience, |
src/tui/screens/waiting.ts added +80
| @@ -0,0 +1,80 @@ | ||
| 1 | /** | |
| 2 | * /flow's waiting section in the terminal (#11455): the web page's three | |
| 3 | * lists, read by the same `readWaiting`, gated the same way: a forge admin who | |
| 4 | * is a person; anyone else is offered nothing and, were the screen reached, | |
| 5 | * sees no rows. A section with nothing to say draws nothing. Read-only. | |
| 6 | */ | |
| 7 | ||
| 8 | import { readWaiting, type Waiting } from "../../web/pages/flow-waiting.ts"; | |
| 9 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 10 | import { binding } from "../app/keys.ts"; | |
| 11 | import type { Session } from "../session.ts"; | |
| 12 | import { style } from "../term/style.ts"; | |
| 13 | import { Scroll, drawLines, hitScroll } from "./common.ts"; | |
| 14 | import type { PaletteEntry } from "./entries.ts"; | |
| 15 | ||
| 16 | const mayRead = (session: Session): boolean => session.user?.isAdmin === true && session.user.kind !== "agent"; | |
| 17 | const hours = (h: number): string => (h >= 48 ? `${String(Math.floor(h / 24))} d` : `${String(h)} h`); | |
| 18 | const plural = (n: number, one: string): string => `${String(n)} ${n === 1 ? one : `${one}s`}`; | |
| 19 | ||
| 20 | /** The sections as text lines, headings first; empty for anyone the gate refuses or when all three are quiet. */ | |
| 21 | export function waitingLines(session: Session, w: Waiting): string[] { | |
| 22 | if (!mayRead(session)) return []; | |
| 23 | const out: string[] = []; | |
| 24 | const section = (title: string, rows: string[]) => { | |
| 25 | if (rows.length === 0) return; | |
| 26 | if (out.length > 0) out.push(""); | |
| 27 | out.push(title, ...rows); | |
| 28 | }; | |
| 29 | section( | |
| 30 | "Approved, waiting on the merge queue", | |
| 31 | w.approved.map((a) => ` ${a.repo}: ${plural(a.waiting, "approved pull")} waiting; the oldest, #${String(a.oldest)}, for ${hours(a.oldestHours)}`), | |
| 32 | ); | |
| 33 | section( | |
| 34 | "Stale crew pulls", | |
| 35 | w.stale.flatMap((s) => [ | |
| 36 | ` ${s.repo} #${String(s.number)}: ${plural(s.days, "day")} without a push; a person closes it`, | |
| 37 | s.note === null ? " No issue to fold back into." : ` Fold back into #${String(s.issueNumber)}: ${s.note}`, | |
| 38 | ]), | |
| 39 | ); | |
| 40 | section( | |
| 41 | "Work a crew fork never proposed", | |
| 42 | w.unproposed.branches.map((b) => ` ${b.repo}: ${b.branch}, ${hours(Math.floor(b.ageMs / 3_600_000))} old, no pull`), | |
| 43 | ); | |
| 44 | return out; | |
| 45 | } | |
| 46 | ||
| 47 | export function waitingScreen(session: Session, ctx: Ctx, w: Waiting): Screen { | |
| 48 | const scroll = new Scroll(); | |
| 49 | const lines = waitingLines(session, w); | |
| 50 | const all = lines.length === 0 ? ["Nothing waiting."] : lines; | |
| 51 | return { | |
| 52 | title: "Pulls to land", | |
| 53 | keys: [binding(["j", "k"], "scroll"), binding(["esc", "q"], "back")], | |
| 54 | render(area, s) { | |
| 55 | const c = ctx.theme.c; | |
| 56 | const spans = all.map((text) => [{ text, style: style(text.startsWith(" ") || text === "Nothing waiting." ? c.mutedFg : c.fg, c.bg) }]); | |
| 57 | drawLines(s, area, c, scroll, spans); | |
| 58 | hitScroll(ctx, area, scroll, all.length); | |
| 59 | }, | |
| 60 | key(e) { | |
| 61 | if (scroll.key(e, all.length)) return true; | |
| 62 | if (e.key === "esc" || e.key === "q") { | |
| 63 | ctx.pop(); | |
| 64 | return true; | |
| 65 | } | |
| 66 | return false; | |
| 67 | }, | |
| 68 | }; | |
| 69 | } | |
| 70 | ||
| 71 | export const entry: PaletteEntry = { | |
| 72 | id: "pulls-to-land", | |
| 73 | title: "Pulls to land", | |
| 74 | hint: "approved pulls, stale crew pulls, unproposed work", | |
| 75 | after: ["flow", "boards"], | |
| 76 | offered: mayRead, | |
| 77 | run: (session, ctx) => { | |
| 78 | void readWaiting(session.app.db, session.app.reposRoot, new Date()).then((w) => ctx.push(waitingScreen(session, ctx, w))); | |
| 79 | }, | |
| 80 | }; |
src/web/pages/flow-waiting.ts added +126
| @@ -0,0 +1,126 @@ | ||
| 1 | /** | |
| 2 | * /flow's "waiting" section (#11455): three quiet places work sits unseen, each | |
| 3 | * with its one action. Approved pulls waiting on the merge queue (`approvedWait`), | |
| 4 | * crew pulls with no push for a week (`staleFold`, which only proposes the note; | |
| 5 | * a person closes) and crew-fork branches that head no pull (`alarmUnproposed`). | |
| 6 | * A section with nothing to say draws nothing. Reads are plain rows into the | |
| 7 | * pure modules; no branch name reaches a shell. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Database } from "bun:sqlite"; | |
| 11 | import { closesFromBody } from "../../core/pulls.ts"; | |
| 12 | import { alarmUnproposed, type UnproposedAlarm } from "../../flow/alarm_unproposed.ts"; | |
| 13 | import { approvedWait, type ApprovedRow, type ApprovedWait } from "../../flow/approved_wait.ts"; | |
| 14 | import { staleFold, type StaleFold } from "../../flow/stale_fold.ts"; | |
| 15 | import { html, type Html } from "../html.ts"; | |
| 16 | import { card, icon, row } from "../ui.ts"; | |
| 17 | ||
| 18 | export interface Waiting { | |
| 19 | readonly approved: readonly ApprovedWait[]; | |
| 20 | readonly stale: readonly (StaleFold & { readonly repo: string })[]; | |
| 21 | readonly unproposed: UnproposedAlarm; | |
| 22 | } | |
| 23 | ||
| 24 | /** Open, unlanded pulls whose latest verdict is an approval, with when it came. */ | |
| 25 | function approvedRows(db: Database): ApprovedRow[] { | |
| 26 | return db | |
| 27 | .query<{ repo: string; number: number; at: string }, []>( | |
| 28 | `SELECT ro.username || '/' || r.name AS repo, i.number AS number, a.at AS at | |
| 29 | FROM pull p | |
| 30 | JOIN issue i ON i.id = p.issue_id | |
| 31 | JOIN repo r ON r.id = i.repo_id | |
| 32 | JOIN user ro ON ro.id = r.owner_id | |
| 33 | JOIN (SELECT issue_id, MAX(created_at) AS at FROM review WHERE state = 'approved' GROUP BY issue_id) a ON a.issue_id = i.id | |
| 34 | WHERE i.state = 'open' AND p.merged_at IS NULL | |
| 35 | AND NOT EXISTS (SELECT 1 FROM review c WHERE c.issue_id = i.id AND c.state = 'changes_requested' AND c.created_at > a.at)`, | |
| 36 | ) | |
| 37 | .all() | |
| 38 | .map((x) => ({ repo: x.repo, number: x.number, approvedAt: x.at })); | |
| 39 | } | |
| 40 | ||
| 41 | /** Open crew pulls with the time of their newest push (their opening when none), and whether they are approved. */ | |
| 42 | function crewPulls(db: Database): { repo: string; pull: Parameters<typeof staleFold>[0][number] }[] { | |
| 43 | return db | |
| 44 | .query<{ repo: string; number: number; branch: string; body: string; moved: string; approved: number }, []>( | |
| 45 | `SELECT ro.username || '/' || r.name AS repo, i.number AS number, p.head_branch AS branch, i.body AS body, | |
| 46 | COALESCE((SELECT MAX(e.created_at) FROM event e | |
| 47 | WHERE e.repo_id = i.repo_id AND e.kind = 'pull.pushed' | |
| 48 | AND json_extract(e.payload, '$.number') = i.number + 0), i.created_at) AS moved, | |
| 49 | EXISTS (SELECT 1 FROM review a WHERE a.issue_id = i.id AND a.state = 'approved' | |
| 50 | AND NOT EXISTS (SELECT 1 FROM review c WHERE c.issue_id = i.id AND c.state = 'changes_requested' AND c.created_at > a.created_at)) AS approved | |
| 51 | FROM pull p | |
| 52 | JOIN issue i ON i.id = p.issue_id AND i.state = 'open' AND i.kind = 'pull' | |
| 53 | JOIN repo r ON r.id = i.repo_id | |
| 54 | JOIN user ro ON ro.id = r.owner_id | |
| 55 | JOIN user u ON u.id = i.author_id AND u.is_agent = 1 | |
| 56 | WHERE p.merged_at IS NULL`, | |
| 57 | ) | |
| 58 | .all() | |
| 59 | .map((x) => ({ | |
| 60 | repo: x.repo, | |
| 61 | pull: { number: x.number, issueNumber: closesFromBody(x.body)[0] ?? null, branch: x.branch, lastPushAt: x.moved, approved: x.approved === 1 }, | |
| 62 | })); | |
| 63 | } | |
| 64 | ||
| 65 | export async function readWaiting(db: Database, reposRoot: string, now: Date): Promise<Waiting> { | |
| 66 | const pulls = crewPulls(db); | |
Collides across repositories: pull number + branch is not unique forge-wide. Key by repo as well. |
||
| 67 | return { | |
| 68 | approved: approvedWait(approvedRows(db), now), | |
| 69 | stale: staleFold(pulls.map((p) => ({ ...p.pull, repo: p.repo })), now), | |
| 70 | unproposed: await unproposedCached(db, reposRoot, now), | |
| 71 | }; | |
| 72 | } | |
| 73 | ||
| 74 | /** How long the branch scan is reused: it reads git for every crew fork, /flow must answer in milliseconds. */ | |
| 75 | export const UNPROPOSED_TTL_MS = 60_000; | |
| 76 | let scanned: { root: string; at: number; value: UnproposedAlarm } | undefined; | |
| 77 | ||
| 78 | /** The unproposed-work scan, at most once a minute per process (a branch is hours old before it counts). */ | |
| 79 | async function unproposedCached(db: Database, reposRoot: string, now: Date): Promise<UnproposedAlarm> { | |
| 80 | const hit = scanned; | |
| 81 | if (hit !== undefined && hit.root === reposRoot && now.getTime() - hit.at >= 0 && now.getTime() - hit.at < UNPROPOSED_TTL_MS) return hit.value; | |
| 82 | const value = await alarmUnproposed(db, reposRoot, now); | |
| 83 | scanned = { root: reposRoot, at: now.getTime(), value }; | |
| 84 | return value; | |
| 85 | } | |
| 86 | ||
| 87 | /** Drops the held scan (tests). */ | |
| 88 | export function forgetUnproposed(): void { | |
| 89 | scanned = undefined; | |
| 90 | } | |
| 91 | ||
| 92 | const hours = (h: number): string => (h >= 48 ? `${String(Math.floor(h / 24))} d` : `${String(h)} h`); | |
| 93 | const plural = (n: number, one: string): string => `${String(n)} ${n === 1 ? one : `${one}s`}`; | |
| 94 | ||
| 95 | /** The section; empty html when all three are quiet. */ | |
| 96 | export function waitingSection(w: Waiting): Html { | |
| 97 | const parts: Html[] = []; | |
| 98 | if (w.approved.length > 0) { | |
| 99 | parts.push(html`<h2>Approved, waiting on the merge queue</h2> | |
| 100 | <ul class="list mb" id="flow-approved">${w.approved.map((a) => row({ | |
outdated
Principle 1: the paragraph restates the section title and row note. One sentence is enough. |
||
| 101 | lead: icon("check"), | |
| 102 | title: html`<a href="/${a.repo}/pulls/${String(a.oldest)}">${a.repo}</a> | |
| 103 | <span class="muted small">${plural(a.waiting, "approved pull")} waiting; the oldest, #${String(a.oldest)}, for ${hours(a.oldestHours)}</span>`, | |
| 104 | }))}</ul>`); | |
| 105 | } | |
| 106 | if (w.stale.length > 0) { | |
| 107 | parts.push(html`<h2>Stale crew pulls</h2> | |
| 108 | <ul class="list mb" id="flow-stale">${w.stale.map((s) => row({ | |
| 109 | lead: icon("clock"), | |
| 110 | title: html`<a href="/${s.repo}/pulls/${String(s.number)}">${s.repo} #${String(s.number)}</a> | |
| 111 | <span class="muted small">${plural(s.days, "day")} without a push; a person closes it</span>`, | |
| 112 | meta: s.note === null | |
| 113 | ? html`<span class="muted small">No issue to fold back into.</span>` | |
| 114 | : html`<span class="small"><a href="/${s.repo}/issues/${String(s.issueNumber)}">Fold back into #${String(s.issueNumber)}</a>: <code>${s.note}</code></span>`, | |
| 115 | }))}</ul>`); | |
| 116 | } | |
| 117 | if (w.unproposed.fires) { | |
| 118 | parts.push(html`<h2>Work a crew fork never proposed</h2> | |
| 119 | <ul class="list mb" id="flow-unproposed">${w.unproposed.branches.map((b) => row({ | |
| 120 | lead: icon("alert"), | |
| 121 | title: html`<a class="mono" href="/${b.repo}/tree/${encodeURIComponent(b.branch)}">${b.repo}: ${b.branch}</a> | |
| 122 | <span class="muted small">${hours(Math.floor(b.ageMs / 3_600_000))} old, no pull</span>`, | |
| 123 | }))}</ul>`); | |
| 124 | } | |
| 125 | return parts.length === 0 ? html`` : card(html`${parts}`, { extra: "mb", attrs: html`id="flow-waiting"` }); | |
| 126 | } |
src/web/pages/flow.ts +3 −1
| @@ -20,6 +20,7 @@ import { field, readForm } from "../form.ts"; | ||
| 20 | 20 | import { html, when } from "../html.ts"; |
| 21 | 21 | import { flashTo, page } from "../layout.ts"; |
| 22 | 22 | import { btn, icon, row, time } from "../ui.ts"; |
| 23 | import { readWaiting, waitingSection } from "./flow-waiting.ts"; | |
| 23 | 24 | |
| 24 | 25 | const money = (usd: number): string => `$${usd.toFixed(2)}`; |
| 25 | 26 | const plural = (n: number, one: string, many = `${one}s`): string => `${String(n)} ${n === 1 ? one : many}`; |
| @@ -87,7 +88,7 @@ export function backupRow(alarm: BackupAlarm) { | ||
| 87 | 88 | } |
| 88 | 89 | |
| 89 | 90 | // GET /flow |
| 90 | export function flowPage(ctx: Ctx): Response { | |
| 91 | export async function flowPage(ctx: Ctx): Promise<Response> { | |
| 91 | 92 | if (ctx.url.search !== "") return new Response(null, { status: 301, headers: { location: `/boards${ctx.url.search}` } }); |
| 92 | 93 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 93 | 94 | const user = ctx.auth.user; |
| @@ -97,6 +98,7 @@ export function flowPage(ctx: Ctx): Response { | ||
| 97 | 98 | const body = html`<div class="page-head"><h1>Flow</h1>${btn("Boards", { href: "/boards", variant: "ghost" })}</div> |
| 98 | 99 | <p class="mb">Money flows from the left to shipped on the right, as wide as this week's real spend. Every repository has a valve: drag it to give the repository a share of the week, all the way down to shut. The crew does exactly what the valves say, from the next tick, and nothing but you turns one.</p> |
| 99 | 100 | ${backupRow(alarmBackup(ctx.app.config.dataDir, new Date()))} |
| 101 | ${waitingSection(await readWaiting(ctx.app.db, ctx.app.reposRoot, new Date()))} | |
| 100 | 102 | ${pipesSvg(p)} |
| 101 | 103 | <p class="muted small">${money(p.usd)} spent in the last 7 days; ${plural(p.shipped, "pull")} shipped.</p> |
| 102 | 104 | ${p.valves.length === 0 |
test/flow-waiting.test.ts added +195
| @@ -0,0 +1,195 @@ | ||
| 1 | /** /flow's waiting section (#11455): one test per section on the page's HTML; quiet draws nothing. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createRepo, forkRepo } from "../src/actions/repos.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createUser, type User } from "../src/core/users.ts"; | |
| 9 | import type { Repo } from "../src/core/repos.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { id } from "../src/kernel/id.ts"; | |
| 12 | import { repoDir } from "../src/git/repos.ts"; | |
| 13 | import { forgetUnproposed, readWaiting } from "../src/web/pages/flow-waiting.ts"; | |
| 14 | import { waitingLines } from "../src/tui/screens/waiting.ts"; | |
| 15 | import type { Session } from "../src/tui/session.ts"; | |
| 16 | ||
| 17 | const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 18 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 19 | return r.value; | |
| 20 | }; | |
| 21 | const ago = (h: number) => new Date(Date.now() - h * 3_600_000).toISOString(); | |
| 22 | ||
| 23 | let dataDir: string; | |
| 24 | let app: App; | |
| 25 | let servers: Servers; | |
| 26 | let cookie = ""; | |
| 27 | let repoId = ""; | |
| 28 | let aliceId = ""; | |
| 29 | let botId = ""; | |
| 30 | let upstream: Repo; | |
| 31 | let bot: User; | |
| 32 | let alice: User; | |
| 33 | let n = 0; | |
| 34 | ||
| 35 | const page = async () => (await fetch(`${servers.url}/flow`, { headers: { cookie } })).text(); | |
| 36 | const git = async (cwd: string, args: string[], date: string, stdin?: string): Promise<string> => { | |
| 37 | const p = Bun.spawn(["git", ...args], { | |
| 38 | cwd, | |
| 39 | stdin: stdin === undefined ? "ignore" : new Blob([stdin]), | |
| 40 | stdout: "pipe", | |
| 41 | stderr: "pipe", | |
| 42 | env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "[email protected]", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "[email protected]", GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date }, | |
| 43 | }); | |
| 44 | const out = await new Response(p.stdout).text(); | |
| 45 | if ((await p.exited) !== 0) throw new Error(await new Response(p.stderr).text()); | |
| 46 | return out.trim(); | |
| 47 | }; | |
| 48 | const pull = (author: string, body: string, createdAt: string, repo = repoId, number?: number, branch?: string): { issueId: string; number: number } => { | |
| 49 | const issueId = id(); | |
| 50 | n += 1; | |
| 51 | const num = number ?? 100 + n; | |
| 52 | app.db | |
| 53 | .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', 'P', ?4, ?5, 'open', ?6)`) | |
| 54 | .run(issueId, repo, num, body, author, createdAt); | |
| 55 | app.db | |
| 56 | .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id) VALUES (?1, 'main', ?2, '', ?3, ?4)`) | |
| 57 | .run(issueId, branch ?? `br-${String(n)}`, `oid-${String(n)}`, repo); | |
| 58 | return { issueId, number: num }; | |
| 59 | }; | |
| 60 | ||
| 61 | beforeAll(async () => { | |
| 62 | dataDir = await mkdtemp(join(tmpdir(), "forge-waiting-")); | |
| 63 | app = createApp({ dataDir, port: 0 }); | |
| 64 | servers = await serve(app); | |
| 65 | alice = must(await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true })); | |
| 66 | aliceId = alice.id; | |
| 67 | bot = must(await createUser(app.db, { origin: "seed", trusted: true, username: "bot", email: "[email protected]", agent: true })); | |
| 68 | botId = bot.id; | |
| 69 | upstream = must(await createRepo(app, alice, { name: "app", visibility: "public" })); | |
| 70 | repoId = upstream.id; | |
| 71 | const res = await fetch(`${servers.url}/signin`, { | |
| 72 | method: "POST", | |
| 73 | redirect: "manual", | |
| 74 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 75 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 76 | }); | |
| 77 | cookie = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 78 | }, 60_000); | |
| 79 | ||
| 80 | afterAll(async () => { | |
| 81 | servers.stop(); | |
| 82 | app.close(); | |
| 83 | await rm(dataDir, { recursive: true, force: true }); | |
| 84 | }); | |
| 85 | ||
| 86 | test("a quiet lane draws no waiting card", async () => { | |
| 87 | const html = await page(); | |
| 88 | expect(html).not.toContain('id="flow-waiting"'); | |
| 89 | expect(html).not.toContain("flow-approved"); | |
| 90 | expect(html).not.toContain("flow-stale"); | |
| 91 | expect(html).not.toContain("flow-unproposed"); | |
| 92 | }); | |
| 93 | ||
| 94 | test("an approval followed by changes requested keeps the lane quiet", async () => { | |
| 95 | const p = pull(botId, "", ago(5)); | |
| 96 | app.db.query(`INSERT INTO review (id, issue_id, author_id, state, created_at) VALUES (?1, ?2, ?3, 'approved', ?4)`).run(id(), p.issueId, aliceId, ago(4)); | |
| 97 | app.db.query(`INSERT INTO review (id, issue_id, author_id, state, created_at) VALUES (?1, ?2, ?3, 'changes_requested', ?4)`).run(id(), p.issueId, aliceId, ago(3)); | |
| 98 | expect((await readWaiting(app.db, app.reposRoot, new Date())).approved).toEqual([]); | |
| 99 | expect(await page()).not.toContain("flow-approved"); | |
| 100 | app.db.query("DELETE FROM review WHERE issue_id = ?1").run(p.issueId); | |
| 101 | app.db.query("DELETE FROM pull WHERE issue_id = ?1").run(p.issueId); | |
| 102 | app.db.query("DELETE FROM issue WHERE id = ?1").run(p.issueId); | |
| 103 | }); | |
| 104 | ||
| 105 | test("approved pulls waiting are counted per repository with the oldest", async () => { | |
| 106 | for (const h of [3, 30]) { | |
| 107 | const p = pull(botId, "", ago(50)); | |
| 108 | app.db.query(`INSERT INTO review (id, issue_id, author_id, state, created_at) VALUES (?1, ?2, ?3, 'approved', ?4)`).run(id(), p.issueId, aliceId, ago(h)); | |
| 109 | } | |
| 110 | const html = await page(); | |
| 111 | expect(html).toContain('id="flow-approved"'); | |
| 112 | expect(html).toContain("2 approved pulls waiting"); | |
| 113 | expect(html).toContain("alice/app"); | |
| 114 | expect(html).toContain("for 30 h"); | |
| 115 | }); | |
| 116 | ||
| 117 | test("a stale crew pull shows the note that folds it back, and a fresh one does not", async () => { | |
| 118 | const stale = pull(botId, "Closes #7", ago(24 * 9)); | |
| 119 | pull(botId, "Closes #8", ago(2)); | |
| 120 | const html = await page(); | |
| 121 | expect(html).toContain('id="flow-stale"'); | |
| 122 | expect(html).toContain(`/alice/app/pulls/${String(stale.number)}`); | |
| 123 | expect(html).toContain("Folded back: pull #" + String(stale.number) + " had no push for 9 days"); | |
| 124 | expect(html).toContain("/alice/app/issues/7"); | |
| 125 | expect(html).not.toContain("/alice/app/issues/8"); | |
| 126 | }); | |
| 127 | ||
| 128 | test("a crew fork branch that heads no pull is shown once with a link to the branch", async () => { | |
| 129 | const up = repoDir(app.reposRoot, "alice", "app"); | |
| 130 | const empty = await git(up, ["hash-object", "-t", "tree", "-w", "--stdin"], ago(40), ""); | |
| 131 | const main = await git(up, ["commit-tree", empty, "-m", "root"], ago(40)); | |
| 132 | await git(up, ["update-ref", `refs/heads/${upstream.defaultBranch}`, main], ago(40)); | |
| 133 | must(await forkRepo(app, bot, upstream)); | |
| 134 | const dir = repoDir(app.reposRoot, "bot", "app"); | |
| 135 | const tree = await git(dir, ["rev-parse", `${main}^{tree}`], ago(7)); | |
| 136 | const oid = await git(dir, ["commit-tree", tree, "-p", main, "-m", "lonely"], ago(7)); | |
| 137 | await git(dir, ["update-ref", "refs/heads/lonely", oid], ago(7)); | |
| 138 | forgetUnproposed(); | |
| 139 | const html = await page(); | |
| 140 | expect(html).toContain('id="flow-unproposed"'); | |
| 141 | expect(html.split('href="/bot/app/tree/lonely"').length).toBe(2); | |
| 142 | }); | |
| 143 | ||
| 144 | test("the unproposed scan is held for a minute, then read again", async () => { | |
| 145 | forgetUnproposed(); | |
| 146 | const t = new Date(); | |
| 147 | expect((await readWaiting(app.db, app.reposRoot, t)).unproposed.fires).toBe(true); | |
| 148 | const dir = repoDir(app.reposRoot, "bot", "app"); | |
| 149 | await git(dir, ["update-ref", "-d", "refs/heads/lonely"], ago(1)); | |
| 150 | expect((await readWaiting(app.db, app.reposRoot, new Date(t.getTime() + 5_000))).unproposed.fires).toBe(true); | |
| 151 | expect((await readWaiting(app.db, app.reposRoot, new Date(t.getTime() + 61_000))).unproposed.fires).toBe(false); | |
| 152 | }); | |
| 153 | ||
| 154 | test("a stale pull keeps its own repository when two repositories share a number and a branch", async () => { | |
| 155 | const other = must(await createRepo(app, alice, { name: "second", visibility: "public" })); | |
| 156 | pull(botId, "Closes #1", ago(24 * 20), repoId, 900, "same-branch"); | |
| 157 | pull(botId, "Closes #1", ago(24 * 10), other.id, 900, "same-branch"); | |
| 158 | const w = await readWaiting(app.db, app.reposRoot, new Date()); | |
| 159 | const mine = w.stale.filter((s) => s.number === 900); | |
| 160 | expect(mine.map((s) => s.repo).sort()).toEqual(["alice/app", "alice/second"]); | |
| 161 | expect(mine.find((s) => s.repo === "alice/app")?.days).toBe(20); | |
| 162 | expect(mine.find((s) => s.repo === "alice/second")?.days).toBe(10); | |
| 163 | }); | |
| 164 | ||
| 165 | test("a non-admin, a guest and a crew member get no waiting card on the page", async () => { | |
| 166 | const bob = must(await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" })); | |
| 167 | expect(bob.isAdmin).toBe(false); | |
| 168 | const res = await fetch(`${servers.url}/signin`, { | |
| 169 | method: "POST", | |
| 170 | redirect: "manual", | |
| 171 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 172 | body: new URLSearchParams({ username: "bob", password: "a-good-passphrase" }), | |
| 173 | }); | |
| 174 | const bobCookie = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 175 | for (const headers of [{ cookie: bobCookie }, {}]) { | |
| 176 | const r = await fetch(`${servers.url}/flow`, { headers, redirect: "manual" }); | |
| 177 | const text = await r.text(); | |
| 178 | expect(text).not.toContain("flow-waiting"); | |
| 179 | expect(text).not.toContain("Stale crew pulls"); | |
| 180 | expect(text).not.toContain("Approved, waiting"); | |
| 181 | } | |
| 182 | }); | |
| 183 | ||
| 184 | test("the terminal lists the three sections for an admin only", async () => { | |
| 185 | const w = await readWaiting(app.db, app.reposRoot, new Date()); | |
| 186 | const as = (user: User | undefined) => ({ user }) as unknown as Session; | |
| 187 | const lines = waitingLines(as(alice), w); | |
| 188 | expect(lines).toContain("Approved, waiting on the merge queue"); | |
| 189 | expect(lines).toContain("Stale crew pulls"); | |
| 190 | expect(waitingLines(as(bot), w)).toEqual([]); | |
| 191 | expect(waitingLines(as(undefined), w)).toEqual([]); | |
| 192 | const bob = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'bob'").get(); | |
| 193 | expect(bob).not.toBeNull(); | |
| 194 | expect(waitingLines({ user: { ...alice, isAdmin: false } } as unknown as Session, w)).toEqual([]); | |
| 195 | }); |
test/palette-roster.test.ts +2 −1
| @@ -111,10 +111,11 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 111 | 111 | ["system-memory", "System memory", "RSS beside the JS heap, hourly for 24 h"], |
| 112 | 112 | ["flow", "Flow", "the pipeline's bottleneck and throughput"], |
| 113 | 113 | ["system", "System", "the machine now: CPU, memory, disks, processes"], |
| 114 | ["pulls-to-land", "Pulls to land", "approved pulls, stale crew pulls, unproposed work"], | |
| 114 | 115 | ["valves", "Valves", "the week's budget as a valve per repository; turn one and the crew obeys"], |
| 115 | ["stalls", "Main-thread stalls", "the last hour's stall ms by route"], | |
| 116 | 116 | ["disks", "Disks", "machines' disk readings: 30-day write rate and projected rated-TBW date"], |
| 117 | 117 | ["telemetry", "Telemetry", "which agents may read flow and system; enter flips the grant"], |
| 118 | ["stalls", "Main-thread stalls", "the last hour's stall ms by route"], | |
| 118 | 119 | ["capacity", "Capacity", "concurrency and guards, live; e changes a settable one"], |
| 119 | 120 | ["resilience", "Resilience", "where snapshots go and the mirror; each protection green, red or untested"], |
| 120 | 121 | ["maintenance", "Maintenance", "prepare the forge for a power-off, check the drain, or end maintenance"], |
Both doors: add the terminal screen and describe it here.