Public: anyone can read this; the crew can change it.

The tick line and home show open pulls, opened/landed per hour and conflicted #5944

Merged opened this

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

Merged

scout/forge:scout/5935-the-tick-line-and-home-show-open-pulls-o → main 8 commits fork 3 threads · 3 unresolved

  • Merged. scout/5935-the-tick-line-and-home-show-open-pulls-o was merged into main as 4d49807.

Review

Sign in to review this change.
11 files changed +260 −4
README.md +2
@@ -382,6 +382,8 @@ with the same lines, worded by the same helpers (`src/core/client-apps.ts`).
382382
383383 **Scoring the home (#5805).** `bun scripts/home-eval.ts <url>` is the deterministic half of that loop: it reads `/` with the same text conversion, builds ground truth from the forge's own API (`GET /api/v1/telemetry/home` for landings, the live version, builders and why idle, spend against the ceiling and what waits on the owner; the repository list and each newest commit for repositories quiet 3 days), and prints each of the five facts as `stated`, `missing` or `wrong` with the page's words, then coverage, errors, characters and fetch time. The method and a table of results, one row per feed pull, are in `docs/HOME-EVAL.md`. No dependency was added.
384384
385 Under the figures, an admin's home, the terminal's dashboard and its Flow dialog carry one vitals line (#5935, `src/flow/vitals.ts`): open pulls, opened and landed in 1 h and 6 h, and conflicted, against #5924's targets; the autonomy tick line says the same as `pulls` (and `offTarget` when a target is missed). The MCP `flow` tool and `GET /api/v1/telemetry/flow` carry it as `vitals: { says, misses, … }`; the tool's description is unchanged because `tools/list` is at its size ceiling, so an agent finds `vitals` in the result, not the listing. An agent reader's counts cover public repositories only.
386
385387 Home shows nine figures first, from `src/flow/kpis.ts`: shipped per hour (with
386388 a 24 h sparkline), the live version and since when, the median check time, the crew builders
387389 running of their slots, and the crew's spend today of the forge's daily
src/crew/autonomy.ts +10
@@ -125,6 +125,7 @@ import {
125125 } from "./direct.ts";
126126 import { crewOffInPreview } from "./preview.ts";
127127 import { approvedOnHead, finishApproved, finishFields } from "./finishing.ts";
128 import { readVitals, vitalsMisses, vitalsWords } from "../flow/vitals.ts";
128129 import { moveRequested, tallyRequested, tallyWords } from "./requested.ts";
129130 import { reviewersOf, sendToReview, sweepReviews } from "./review.ts";
130131 import { lastWaits, noteWaits, tallyWaits, type Wait, waitWords } from "./waits.ts";
@@ -1283,6 +1284,8 @@ export async function autonomyTick(
12831284 ...(held.by.hot_file.length === 0 ? {} : { hot: hotWords(hotFiles(current.id)) }),
12841285 // Stalled issues this tick parked and freed for another builder (#4073).
12851286 unstuck,
1287 // Open pulls, opened/landed per hour and per 6 h, conflicted (#5935).
1288 ...flowVitals(app.db, now),
12861289 active: status.active,
12871290 slots: status.slots,
12881291 // Approved pulls being brought up to date, those still waiting (#3682),
@@ -1333,6 +1336,13 @@ export async function autonomyTick(
13331336 }
13341337 }
13351338
1339 /** The tick line's flow vitals (#5935): the counts in words, and the targets they miss. */
1340 export function flowVitals(db: App["db"], now: Date): { pulls: string; offTarget?: string } {
1341 const v = readVitals(db, now);
1342 const misses = vitalsMisses(v);
1343 return { pulls: vitalsWords(v), ...(misses.length === 0 ? {} : { offTarget: misses.join("; ") }) };
outdated

flowVitals sits between unstickNow doc comment (above) and unstickNow itself. Move it above that comment block.

1344 }
1345
13361346 /**
13371347 * The stalled issues freed this tick (`freeStalled`): none while the crew
13381348 * is off or paused, or nobody stands behind it.
src/flow/vitals.ts added +76
@@ -0,0 +1,76 @@
1 /**
2 * The flow vitals (#5935): how many pulls are open, how many opened and
3 * landed in the last hour and the last 6 h, and how many open pulls conflict
4 * with their base. One reading, shared by home, the autonomy tick line and
5 * the MCP `flow` tool, so the three say the same numbers.
6 *
7 * Targets from #5924: open < 40, conflicted < 5, landed >= opened over each
8 * 6 h. Counts only, no repository named; forge-wide for the owner,
9 * public repositories alone for an agent. "Conflicted" is the count `conflicted.ts` reads from the bring-up-to-date runs.
10 */
11
12 import type { Database } from "bun:sqlite";
13 import { conflictedPulls } from "./conflicted.ts";
14
15 const HOUR_MS = 3_600_000;
16
17 export const OPEN_TARGET = 40;
18 export const CONFLICTED_TARGET = 5;
19
20 export interface Vitals {
21 readonly open: number;
22 readonly opened1h: number;
23 readonly landed1h: number;
24 readonly opened6h: number;
25 readonly landed6h: number;
26 readonly conflicted: number;
27 }
28
29 /** `publicOnly` counts public repositories alone, for a reader who must not learn what private ones hold. */
30 export function readVitals(db: Database, now = new Date(), publicOnly = false): Vitals {
31 const to = now.toISOString();
32 const h1 = new Date(now.getTime() - HOUR_MS).toISOString();
33 const h6 = new Date(now.getTime() - 6 * HOUR_MS).toISOString();
34 const row = db
35 .query<Omit<Vitals, "conflicted">, [string, string, string, number]>(
36 `SELECT
37 (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id
38 WHERE (?4 = 0 OR r.visibility = 'public') AND i.state = 'open' AND p.merged_at IS NULL) AS open,
39 (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id
40 WHERE (?4 = 0 OR r.visibility = 'public') AND i.created_at >= ?1 AND i.created_at <= ?3) AS opened1h,
41 (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id
42 WHERE (?4 = 0 OR r.visibility = 'public') AND i.created_at >= ?2 AND i.created_at <= ?3) AS opened6h,
43 (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id
44 WHERE (?4 = 0 OR r.visibility = 'public') AND p.base_branch = r.default_branch AND p.merged_at >= ?1 AND p.merged_at <= ?3) AS landed1h,
45 (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id
46 WHERE (?4 = 0 OR r.visibility = 'public') AND p.base_branch = r.default_branch AND p.merged_at >= ?2 AND p.merged_at <= ?3) AS landed6h`,
47 )
48 .get(h1, h6, to, publicOnly ? 1 : 0);
49 return {
50 open: row?.open ?? 0,
51 opened1h: row?.opened1h ?? 0,
52 landed1h: row?.landed1h ?? 0,
53 opened6h: row?.opened6h ?? 0,
54 landed6h: row?.landed6h ?? 0,
55 conflicted: conflictedPulls(db, publicOnly).length,
56 };
57 }
58
59 /** Which targets the reading misses, in words; empty when it meets them all. */
60 export function vitalsMisses(v: Vitals): string[] {
61 const out: string[] = [];
62 if (v.open >= OPEN_TARGET) out.push(`open ${v.open} ≥ ${OPEN_TARGET}`);
63 if (v.conflicted >= CONFLICTED_TARGET) out.push(`conflicted ${v.conflicted} ≥ ${CONFLICTED_TARGET}`);
64 if (v.landed6h < v.opened6h) out.push(`landed ${v.landed6h} < opened ${v.opened6h} over 6 h`);
65 return out;
66 }
67
68 /** The tick line's one value: `open 12, opened/landed 3/2 in 1 h, 9/11 in 6 h, conflicted 1`. */
69 export function vitalsWords(v: Vitals): string {
70 return `open ${v.open}, opened/landed ${v.opened1h}/${v.landed1h} in 1 h, ${v.opened6h}/${v.landed6h} in 6 h, conflicted ${v.conflicted}`;
71 }
72
73 /** Home's line for an admin; the counts are the whole of it. */
74 export function vitalsLine(v: Vitals): string {
75 return `${v.open} open pulls · ${v.opened1h} opened, ${v.landed1h} landed in 1 h · ${v.opened6h} opened, ${v.landed6h} landed in 6 h · ${v.conflicted} conflicted`;
76 }
src/system/telemetry.ts +4
@@ -8,6 +8,7 @@
88 import type { App } from "../app.ts";
99 import { SOURCES } from "../boards/sources.ts";
1010 import { conflictedPulls, conflictedSays, rebaseLane, rebaseLaneSays } from "../flow/conflicted.ts";
11 import { readVitals, vitalsLine, vitalsMisses } from "../flow/vitals.ts";
1112 import { readPipeline, verdict } from "../flow/pipeline.ts";
1213 import { type QueueReading, readQueues } from "../flow/queue.ts";
1314 import { reviewersBusy, reviewingSays } from "../flow/reviewing.ts";
@@ -30,8 +31,11 @@ export function flowFor(app: App, reader: Reader, now = new Date()) {
3031 const lane = rebaseLane(app.db, conflicted);
3132 const requested = requestedWaits(app.db, reader.kind === "agent");
3233 const busy = reviewersBusy(app.db, reader.kind === "agent");
34 const vitals = readVitals(app.db, now, reader.kind === "agent");
outdated

Unscoped forge-wide counts handed to every reader, including agents and guests; private-repo pulls leak into open/opened/landed. Cut to public repos for non-admin readers like reviewing/requested above, and test it.

3335 return {
3436 says: verdict(stages, now.getTime()),
37 // Open pulls, opened and landed in 1 h and 6 h, and conflicted, against #5924's targets (#5935).
38 vitals: { says: vitalsLine(vitals), misses: vitalsMisses(vitals), ...vitals },
3539 // Review runs in flight per reviewer, cut to public repositories for an agent (#4733).
3640 reviewing: { says: reviewingSays(busy), reviewers: busy },
3741 conflicted: { says: conflictedSays(conflicted, now.getTime()), pulls: conflicted },
src/tui/screens/dashboard.ts +5 −1
@@ -88,6 +88,7 @@ import {
8888 plural,
8989 linkScreen,
9090 } from "./common.ts";
91 import { readVitals, vitalsLine } from "../../flow/vitals.ts";
9192 import { readKpis } from "../../flow/kpis.ts";
9293 import { kpiLines } from "../../flow/kpi_words.ts";
9394 import { BOOTED } from "../../release/gate.ts";
@@ -265,7 +266,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
265266 figures =
266267 f.user?.isAdmin !== true
267268 ? []
268 : kpiLines(readKpis(f.db, now, kpiForge(f.app)), commit === null ? null : { sha: commit, since: ago(BOOTED) });
269 : [
270 ...kpiLines(readKpis(f.db, now, kpiForge(f.app)), commit === null ? null : { sha: commit, since: ago(BOOTED) }),
271 vitalsLine(readVitals(f.db, now)),
272 ];
269273 // "Yours" is ownership plus the orgs you are on a team of; typing in the
270274 // filter reaches every repository you can see.
271275 const orgs = f.user
src/tui/screens/flow.ts +3
@@ -18,6 +18,7 @@ import { HOLDS } from "../../crew/held.ts";
1818 import type { HeldRow } from "../../flow/held.ts";
1919 import { conflictedPulls, conflictedSays, rebaseLane, rebaseLaneSays } from "../../flow/conflicted.ts";
2020 import { readPipeline, STAGE_LABEL, verdict } from "../../flow/pipeline.ts";
21 import { readVitals, vitalsLine } from "../../flow/vitals.ts";
2122 import { readQueues, redBatchWords } from "../../flow/queue.ts";
2223 import { reviewersBusy, reviewingSays } from "../../flow/reviewing.ts";
2324 import { requestedSays, requestedWaits } from "../../flow/requested.ts";
@@ -59,6 +60,8 @@ export function flowLines(
5960 return [
6061 ...wrap(verdict(stages, now.getTime()), inner),
6162 "",
63 ...wrap(vitalsLine(readVitals(db, now, agent !== undefined)), inner),
64 "",
6265 ...wrap(throughputSays(flow), inner),
6366 "",
6467 `${pad("Out", 12)} ${pad("last hour", 9, "right")} ${pad("24 h", 5, "right")}`,
src/web/studio/home.ts +3 −2
@@ -52,7 +52,7 @@ import { writableAppsOf } from "./asks.ts";
5252 import { needsYouSection } from "./asks_page.ts";
5353 import { flowCards } from "./flow_card.ts";
5454 import { composer, feedJs, feedNote, feedRegion } from "./feed.ts";
55 import { kpiRow, waitingLine } from "./kpi_row.ts";
55 import { kpiRow, vitalsRow, waitingLine } from "./kpi_row.ts";
5656 import { feedCandidateCache } from "../../feed/feed.ts";
5757 import { notePhase, notePhaseRest, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
5858 import { kpiCache, panelCache, waitingCache } from "./home_cache.ts";
@@ -261,6 +261,7 @@ export async function studioHome(
261261 ${ask} ${address} ${figures
262262 ? timePhase(ctx.request, "kpis", () => kpiRow(ctx.app.db, kpiOpts), () => kpiCache(ctx.app.db))
263263 : liveLine(ctx)}
264 ${figures ? timePhase(ctx.request, "vitals", () => vitalsRow(ctx.app.db)) : null}
264265 ${timePhase(ctx.request, "waiting", () => waitingLine(ctx.app.db, user), () => waitingCache(ctx.app.db))}
265266 ${figures
266267 ? await timePhaseAsync(ctx.request, "panels", () => pinnedPanels(ctx.app, user), () => panelCache(ctx.app.db))
@@ -301,7 +302,7 @@ export async function studioHome(
301302 body,
302303 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${feedJs}${ask ? `\n${passkeyJs}` : ""}`,
303304 });
304 notePhaseRest(ctx.request, "render", rendering, ["kpis", "waiting", "panels", "feed"]);
305 notePhaseRest(ctx.request, "render", rendering, ["kpis", "vitals", "waiting", "panels", "feed"]);
305306 return res;
306307 }
307308
src/web/studio/home_cache.ts +12
@@ -11,12 +11,14 @@ import type { Database } from "bun:sqlite";
1111 import type { Answer } from "../../boards/query.ts";
1212 import { writeStamp } from "../../core/write_clock.ts";
1313 import { readKpis, type Kpis } from "../../flow/kpis.ts";
14 import { readVitals, type Vitals } from "../../flow/vitals.ts";
1415 import { Stamped } from "../../kernel/stamped.ts";
1516
1617 export const HOME_MAX_AGE_MS = 30_000;
1718
1819 const kpiCaches = new WeakMap<Database, Stamped<Kpis>>();
1920 const panelCaches = new WeakMap<Database, Stamped<Answer>>();
21 const vitalsCaches = new WeakMap<Database, Stamped<Vitals>>();
2022 const waitingCaches = new WeakMap<Database, Stamped<Waiting>>();
2123
2224 /** What the waiting line keeps of the viewer's asks: how many, and the oldest's time. */
@@ -39,6 +41,9 @@ export const kpiCache = (db: Database): Stamped<Kpis> => cacheOf(kpiCaches, db,
3941 /** The pinned-panel cache of `db`: for tests, which count its hits. */
4042 export const panelCache = (db: Database): Stamped<Answer> => cacheOf(panelCaches, db, 64);
4143
44 /** The vitals cache of `db`: for tests, which count its hits. */
45 export const vitalsCache = (db: Database): Stamped<Vitals> => cacheOf(vitalsCaches, db, 2);
46
4247 /** The waiting-line cache of `db`: for tests, which count its hits. */
4348 export const waitingCache = (db: Database): Stamped<Waiting> => cacheOf(waitingCaches, db, 64);
4449
@@ -73,3 +78,10 @@ export function cachedPanel(db: Database, identity: unknown, compute: () => Answ
7378 if (stamp === undefined) return compute();
7479 return panelCache(db).get(`panel:${JSON.stringify(identity)}`, stamp, compute);
7580 }
81
82 /** Admin only: the caller has checked. The forge-wide flow vitals, valid like the KPIs: while the write clock holds, 30 seconds at most. */
83 export function cachedVitals(db: Database, now: Date): Vitals {
84 const stamp = writeStamp(db);
85 if (stamp === undefined) return readVitals(db, now);
86 return vitalsCache(db).get("vitals", stamp, () => readVitals(db, now));
87 }
src/web/studio/kpi_row.ts +9 −1
@@ -10,7 +10,8 @@
1010 import type { Database } from "bun:sqlite";
1111 import { backupAge, backupFull, backupOverdue, buildersValue, checkTime, reactNote, speedWords, spendCeilingWords, spendFull, spendValue, usersValue, visitorsNote, visitorsValue, waitingWords } from "../../flow/kpi_words.ts";
1212 import type { Kpis } from "../../flow/kpis.ts";
13 import { cachedKpis, cachedWaiting } from "./home_cache.ts";
13 import { vitalsLine } from "../../flow/vitals.ts";
14 import { cachedKpis, cachedVitals, cachedWaiting } from "./home_cache.ts";
1415 import { escapeHtml, html, raw, type Html } from "../html.ts";
1516 import { BOOTED } from "../../release/gate.ts";
1617 import { running } from "../../kernel/version.ts";
@@ -183,3 +184,10 @@ export function waitingLine(db: Database, user: User, now: Date = new Date()): H
183184 const words = waitingWords(count, oldest, now);
184185 return words === null ? html`` : html`<p class="small muted"><a href="/review">${words}</a></p>`;
185186 }
187
188 /** Home's flow vitals for an admin (#5935): one muted line of counts; the open count links to the boards. */
189 export function vitalsRow(db: Database, now: Date = new Date()): Html {
190 const v = cachedVitals(db, now);
191 const rest = vitalsLine(v).split(" · ").slice(1).join(" · ");
192 return html`<p class="small muted"><a href="/boards">${v.open} open pulls</a> · ${rest}</p>`;
193 }

Splitting the sentence on " · " couples home to the wording of vitalsLine; export the tail from vitals.ts instead.

test/flow-vitals.test.ts added +129
@@ -0,0 +1,129 @@
1 /** The flow vitals (#5935): home, the tick line and the MCP `flow` tool read the same three numbers from seeded pulls. */
2 import { describe, expect, test } from "bun:test";
3 import { mkdtempSync, rmSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp } from "../src/app.ts";
7 import { createRepo } from "../src/core/repos.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { serve } from "../src/http/server.ts";
10 import { id } from "../src/kernel/id.ts";
11 import { flowVitals } from "../src/crew/autonomy.ts";
12 import { flowFor } from "../src/system/telemetry.ts";
13 import { flowLines } from "../src/tui/screens/flow.ts";
14 import { vitalsLine, readVitals, vitalsMisses, vitalsWords } from "../src/flow/vitals.ts";
15
16 const NOW = new Date();
17 const ago = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString();
18
19 describe("flow vitals", () => {
20 test("home, the tick line and the MCP flow tool say the same numbers", async () => {
21 const root = mkdtempSync(join(tmpdir(), "forge-vitals-"));
22 const app = createApp({ dataDir: root, port: 0 });
23 const servers = await serve(app);
24 try {
25 const made = await createUser(app.db, { username: "alice", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true, adminIfFirst: true });
26 if (!made.ok) throw new Error(made.error);
27 const repo = await createRepo(app.db, app.reposRoot, { ownerId: made.value.id, name: "v", visibility: "public", description: "v" });
28 if (!repo.ok) throw new Error(repo.error);
29 const branch = repo.value.defaultBranch;
30 let n = 0;
31 const pull = (opened: string, merged: string | null, state: string) => {
32 const issueId = id();
33 n++;
34 app.db
35 .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', ?4, '', ?5, ?6, ?7)`)
36 .run(issueId, repo.value.id, n, `P${n}`, made.value.id, state, opened);
37 app.db
38 .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at) VALUES (?1, ?2, ?3, '', '', ?4, ?5)`)
39 .run(issueId, branch, `b/${n}`, repo.value.id, merged);
40 return issueId;
41 };
42 pull(ago(0.5), null, "open"); // open, opened in the hour
43 const clash = pull(ago(3), null, "open"); // open, opened in 6 h, conflicted
44 pull(ago(20), null, "open"); // open, old
45 pull(ago(4), ago(0.2), "closed"); // landed in the hour
46 pull(ago(30), ago(5), "closed"); // landed in 6 h only
47 pull(ago(30), ago(10), "closed"); // landed long ago
48 app.db
49 .query(`INSERT INTO run (id, repo_id, kind, issue_id, state, reason, created_at) VALUES (?1, ?2, 'agent', ?3, 'queued', 'conflict', ?4)`)
50 .run(id(), repo.value.id, clash, ago(1));
51
52 const v = readVitals(app.db, NOW);
53 expect(v).toEqual({ open: 3, opened1h: 1, landed1h: 1, opened6h: 3, landed6h: 2, conflicted: 1 });
54 expect(vitalsMisses(v)).toEqual(["landed 2 < opened 3 over 6 h"]);
55
56 const tick = flowVitals(app.db, NOW);
57 expect(tick.pulls).toBe("open 3, opened/landed 1/1 in 1 h, 3/2 in 6 h, conflicted 1");
58 expect(tick.pulls).toBe(vitalsWords(v));
59 expect(tick.offTarget).toBe("landed 2 < opened 3 over 6 h");
60
61 const mcp = flowFor(app, { kind: "owner" } as Parameters<typeof flowFor>[1], NOW).vitals;
62 expect(mcp).toMatchObject(v);
63 expect(mcp.says).toContain("3 open pulls");
64
65 const signin = await fetch(`${servers.url}/signin`, {
66 method: "POST",
67 headers: { origin: servers.url },
68 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
69 redirect: "manual",
70 });
71 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
72 const home = await (await fetch(`${servers.url}/`, { headers: { cookie } })).text();
73 expect(home).toContain(">3 open pulls</a> · 1 opened, 1 landed in 1 h · 3 opened, 2 landed in 6 h · 1 conflicted");
74 } finally {
75 await servers.stop();
76 app.close();
77 rmSync(root, { recursive: true, force: true });
78 }
79 }, 30_000);
80
81 test("an agent reads public repositories alone; the owner reads all", async () => {
82 const root = mkdtempSync(join(tmpdir(), "forge-vitals-"));
83 const app = createApp({ dataDir: root, port: 0 });
84 try {
85 const made = await createUser(app.db, { username: "alice", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true, adminIfFirst: true });
86 if (!made.ok) throw new Error(made.error);
87 let n = 0;
88 for (const visibility of ["public", "private"] as const) {
89 const repo = await createRepo(app.db, app.reposRoot, { ownerId: made.value.id, name: visibility, visibility, description: "v" });
90 if (!repo.ok) throw new Error(repo.error);
91 for (const merged of [null, ago(0.2)]) {
92 const issueId = id();
93 n++;
94 app.db
95 .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', ?4, '', ?5, ?6, ?7)`)
96 .run(issueId, repo.value.id, n, `P${n}`, made.value.id, merged === null ? "open" : "closed", ago(0.5));
97 app.db
98 .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at) VALUES (?1, ?2, ?3, '', '', ?4, ?5)`)
99 .run(issueId, repo.value.defaultBranch, `b/${n}`, repo.value.id, merged);
100 }
101 }
102 const owner = flowFor(app, { kind: "owner" } as Parameters<typeof flowFor>[1], NOW).vitals;
103 const agent = flowFor(app, { kind: "agent" } as Parameters<typeof flowFor>[1], NOW).vitals;
104 expect(owner).toMatchObject({ open: 2, opened1h: 4, landed1h: 2 });
105 expect(agent).toMatchObject({ open: 1, opened1h: 2, landed1h: 1 });
106 // The terminal's Flow dialog says the same: whole for the owner, public alone for an agent.
107 expect(flowLines(app.db, NOW).join(" ").replace(/\s+/g, " ")).toContain(vitalsLine(owner));
108 const cut = flowLines(app.db, NOW, [], { queues: [] }).join(" ").replace(/\s+/g, " ");
109 expect(cut).toContain(vitalsLine(agent));
110 expect(cut).not.toContain(vitalsLine(owner));
111 } finally {
112 app.close();
113 rmSync(root, { recursive: true, force: true });
114 }
115 }, 30_000);
116
117 test("an empty forge reads zeros and misses nothing", async () => {
118 const root = mkdtempSync(join(tmpdir(), "forge-vitals-"));
119 const app = createApp({ dataDir: root, port: 0 });
120 try {
121 const v = readVitals(app.db, NOW);
122 expect(Object.values(v)).toEqual([0, 0, 0, 0, 0, 0]);
123 expect(vitalsMisses(v)).toEqual([]);
124 } finally {
125 app.close();
126 rmSync(root, { recursive: true, force: true });
127 }
128 });
129 });
test/tui-kpis.test.ts +7
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 import { createApp, type App as Forge } from "../src/app.ts";
77 import { createUser, type User } from "../src/core/users.ts";
8 import { readVitals, vitalsLine } from "../src/flow/vitals.ts";
89 import { createForgeApp } from "../src/tui/forge.ts";
910 import { headlessTerminal } from "../src/tui/term/terminal.ts";
1011 import { quietLogs } from "./helpers/globals.ts";
@@ -95,3 +96,9 @@ test("past the forge cap the terminal and the web tile read the same words", asy
9596 const w = spendCeilingWords({ todayUsd: 601, ceilingUsd: 600, hitAt: "2026-10-25T14:03:20.000Z" });
9697 expect(w).toEqual({ note: "ceiling hit 14:03Z, builders idle", title: "ceiling hit 14:03Z, builders idle", attn: true });
9798 });
99
100 test("an admin's first screen carries the flow vitals line; a non-admin's does not (#5935)", async () => {
101 const text = await withCommit(undefined, () => firstScreen(alice));
102 expect(text).toContain(vitalsLine(readVitals(forge.db)));
103 expect(await firstScreen(bob)).not.toContain("open pulls");
104 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs