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

Home's KPI row shows crew builders running of slots, linking to the crew page #2972

Merged opened this

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

Merged

scout/forge:scout/2967-home-s-kpi-row-shows-crew-builders-runni → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/2967-home-s-kpi-row-shows-crew-builders-runni was merged into main as 8195f99.

Review

Sign in to review this change.
6 files changed +84 −22
src/flow/kpi_words.ts +7 −1
@@ -26,8 +26,13 @@ export function sparkText(hours: readonly number[]): string {
2626 .join("");
2727 }
2828
29 /** Builders running of their slots: `2 of 4`, or an em dash when no slots are set. */
30 export function buildersValue(b: Kpis["builders"]): string {
31 return b.slots === 0 ? "—" : `${b.running} of ${b.slots}`;
32 }
33
2934 /**
30 * The terminal's row: the same three figures in the same words, as plain
35 * The terminal's row: the same four figures in the same words, as plain
3136 * lines. Forge-wide like the web's, so the caller shows them to an admin only.
3237 */
3338 export function kpiLines(k: Kpis, live: { sha: string; since: string } | null): string[] {
@@ -35,5 +40,6 @@ export function kpiLines(k: Kpis, live: { sha: string; since: string } | null):
3540 `Shipped per hour ${(k.landed / k.landedHours.length).toFixed(1)} ${sparkText(k.landedHours)} ${k.landed} ${k.landed === 1 ? "change" : "changes"} in 24 h`,
3641 `Live version ${live === null ? "— nothing live yet" : `${live.sha.slice(0, 7)}, live ${live.since}`}`,
3742 `Median check time ${checkTime(k.medianCheckMs)}`,
43 `Builders running ${buildersValue(k.builders)}${k.builders.slots === 0 ? ", no slots set" : ""}`,
3844 ];
3945 }
src/flow/kpis.ts +10
@@ -27,6 +27,8 @@ export interface Kpis {
2727 readonly live: { readonly sha: string; readonly at: string } | null;
2828 /** Median check duration in milliseconds over checks that finished in 24 h; null for none. */
2929 readonly medianCheckMs: number | null;
30 /** Crew builders running now, of the slots the autonomous repositories are set to. */
31 readonly builders: { readonly running: number; readonly slots: number };
3032 }
3133
3234 export function readKpis(db: Database, now: Date): Kpis {
@@ -70,7 +72,15 @@ export function readKpis(db: Database, now: Date): Kpis {
7072 .all(since, to)
7173 .map((r) => Math.round(r.ms));
7274
75 const builders = db
76 .query<{ running: number; slots: number }, []>(
77 `SELECT (SELECT COUNT(*) FROM run WHERE kind = 'agent' AND state = 'running') AS running,
78 (SELECT COALESCE(SUM(autonomy_slots), 0) FROM repo WHERE autonomy != 'off') AS slots`,
79 )
80 .get() ?? { running: 0, slots: 0 };

kpi_words.ts kpiLines does not render builders, so the terminal never shows this figure. Add it there.

81
7382 return {
83 builders,
7484 landedHours,
7585 landed: landedHours.reduce((a, b) => a + b, 0),
7686 live: live ?? null,
src/web/studio/kpi_row.ts +14 −5
@@ -1,12 +1,13 @@
11 /**
2 * Home's row of three figures (#2871), from `flow/kpis.ts`: shipped per hour
3 * with a day's sparkline, the live version and since when, and the median
4 * check time. Server-rendered, one fixed-height card each, so nothing moves
5 * when it paints. Only the studio's words: shipped, change, check time.
2 * Home's row of four figures (#2871), from `flow/kpis.ts`: shipped per hour
3 * with a day's sparkline, the live version and since when, the median check
4 * time, and the crew builders running of their slots. Server-rendered, one
5 * fixed-height card each, so nothing moves when it paints. Only the studio's
6 * words: shipped, change, check time.
67 */
78
89 import type { Database } from "bun:sqlite";
9 import { checkTime } from "../../flow/kpi_words.ts";
10 import { buildersValue, checkTime } from "../../flow/kpi_words.ts";
1011 import { readKpis, type Kpis } from "../../flow/kpis.ts";
1112 import { html, raw, type Html } from "../html.ts";
1213 import { BOOTED } from "../../release/gate.ts";
@@ -72,6 +73,14 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null): Ht
7273 k.medianCheckMs === null ? "No check finished in 24 h" : "over the last 24 h",
7374 k.medianCheckMs === null ? "No check finished in 24 h" : "over the last 24 h",
7475 )}
76 ${tile(
77 "Builders running",
78 "/settings/crew",
79 buildersValue(k.builders),
80 buildersValue(k.builders),
outdated

A note of "slots in use now" under "2 of 4" repeats the value. Use "running now" and "No slots set" for zero. Also show an em dash for the value when slots is 0.

81 k.builders.slots === 0 ? "No slots set" : "running now",
82 k.builders.slots === 0 ? "No slots set" : `Crew builders running now, of ${k.builders.slots} ${k.builders.slots === 1 ? "slot" : "slots"}`,
83 )}
7584 </section>`;
7685 }
7786
test/home-kpi-page.test.ts +3 −3
@@ -25,7 +25,7 @@ afterAll(async () => {
2525 await rm(dataDir, { recursive: true, force: true });
2626 });
2727
28 test("home renders the three tiles; only an admin sees them", async () => {
28 test("home renders the four tiles; only an admin sees them", async () => {
2929 const mk = async (username: string, first = false): Promise<User> => {
3030 const r = await createUser(app.db, {
3131 origin: "seed",
@@ -50,8 +50,8 @@ test("home renders the three tiles; only an admin sees them", async () => {
5050
5151 const a = await home(admin);
5252 expect(a).toContain('class="kpis"');
53 expect((a.match(/class="card kpi"/g) ?? []).length).toBe(3);
54 for (const href of ["/board", "/releases", "/boards"]) expect(a).toContain(`class="card kpi" href="${href}"`);
53 expect((a.match(/class="card kpi"/g) ?? []).length).toBe(4);
54 for (const href of ["/board", "/releases", "/boards", "/settings/crew"]) expect(a).toContain(`class="card kpi" href="${href}"`);
5555
5656 // The figures count every repository, so a non-admin sees none.
5757 expect(await home(bob)).not.toContain('class="kpis"');
test/home-kpi-row.test.ts +47 −10
@@ -1,4 +1,4 @@
1 /** Home's figures row (#2871): the three tiles, their links, studio words only, a sparkline of 24 points. */
1 /** Home's figures row (#2871): the four tiles, their links, studio words only, a sparkline of 24 points. */
22 import { describe, expect, test } from "bun:test";
33 import { checkTime, kpiLines, sparkText } from "../src/flow/kpi_words.ts";
44 import { running } from "../src/kernel/version.ts";
@@ -8,9 +8,9 @@ import { kpiRowFor, liveNow, sparkline } from "../src/web/studio/kpi_row.ts";
88 const hours = (n: number) => Array.from({ length: 24 }, (_, i) => (i === 3 ? n : 0));
99
1010 describe("home kpi row", () => {
11 test("three tiles with their links and figures", () => {
11 test("four tiles with their links and figures", () => {
1212 const out = kpiRowFor(
13 { landedHours: hours(6), landed: 6, live: null, medianCheckMs: 185_000 },
13 { landedHours: hours(6), landed: 6, live: null, builders: { running: 2, slots: 4 }, medianCheckMs: 185_000 },
1414 { sha: "abcdef0123456", at: "2026-10-19T11:00:00.000Z" },
1515 ).toString();
1616 expect(out).toContain('<span class="sha">abcdef0</span>');
@@ -25,7 +25,7 @@ describe("home kpi row", () => {
2525
2626 test("the whole figure is in the title and the link's label, rounded and in studio words", () => {
2727 const out = kpiRowFor(
28 { landedHours: hours(7), landed: 7, live: null, medianCheckMs: 185_000 },
28 { landedHours: hours(7), landed: 7, live: null, builders: { running: 2, slots: 4 }, medianCheckMs: 185_000 },
2929 { sha: "abcdef0123456", at: "2026-10-19T11:00:00.000Z" },
3030 ).toString();
3131 expect(out).toContain('title="0.29"');
@@ -37,13 +37,13 @@ describe("home kpi row", () => {
3737 });
3838
3939 test("empty figures say so", () => {
40 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, medianCheckMs: null }, null).toString();
40 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, builders: { running: 2, slots: 4 }, medianCheckMs: null }, null).toString();
4141 expect(out).toContain("Nothing live yet");
4242 expect(out).toContain("No check finished in 24 h");
4343 });
4444
4545 test("no git words render, not even in a title or label", () => {
46 const out = kpiRowFor({ landedHours: hours(1), landed: 1, live: null, medianCheckMs: 1000 }, null).toString();
46 const out = kpiRowFor({ landedHours: hours(1), landed: 1, live: null, builders: { running: 2, slots: 4 }, medianCheckMs: 1000 }, null).toString();
4747 expect(out).not.toMatch(/commit|merge|branch|repositor|pull request|checks (passed|failed)/i);
4848 });
4949
@@ -67,7 +67,7 @@ describe("home kpi row", () => {
6767 });
6868
6969 test("a truncated note carries its whole text as a title", () => {
70 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, medianCheckMs: null }, null).toString();
70 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, builders: { running: 2, slots: 4 }, medianCheckMs: null }, null).toString();
7171 expect(out).toContain('title="No check finished in 24 h"');
7272 });
7373
@@ -79,6 +79,26 @@ describe("home kpi row", () => {
7979 expect(rule(".kpi")).toContain("grid-template-rows: 20px 32px 40px 24px");
8080 });
8181
82 test("the builders tile says running of slots, titles it, and links to the crew", () => {
83 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, medianCheckMs: null, builders: { running: 2, slots: 4 } }, null).toString();
84 expect(out).toContain('href="/settings/crew"');
85 expect(out).toContain('class="kpi-value num" title="2 of 4">2 of 4<');
86 expect(out).toContain('title="Crew builders running now, of 4 slots"');
87 expect(out).toContain('aria-label="Builders running: 2 of 4, Crew builders running now, of 4 slots"');
88 });
89
90 test("with no slots set the builders tile shows a dash and says so", () => {
91 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, medianCheckMs: null, builders: { running: 0, slots: 0 } }, null).toString();
92 expect(out).toContain('class="kpi-value num" title="—">—<');
93 expect(out).toContain('title="No slots set">No slots set<');
94 expect(out).not.toContain("0 of 0");
95 });
96
97 test("the builders note does not repeat the value", () => {
98 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, medianCheckMs: null, builders: { running: 2, slots: 4 } }, null).toString();
99 expect(out).toContain(">running now<");
100 });
101
82102 test("check time reads as a person would", () => {
83103 expect(checkTime(null)).toBe("—");
84104 expect(checkTime(45_000)).toBe("45s");
@@ -86,11 +106,28 @@ describe("home kpi row", () => {
86106 });
87107 });
88108
109 describe("the builders figure from the database", () => {
110 test("counts running agent runs against the autonomous repositories' slots", async () => {
111 const { Database } = await import("bun:sqlite");
112 const db = new Database(":memory:");
113 db.run("CREATE TABLE repo (autonomy TEXT, autonomy_slots INTEGER, default_branch TEXT, id TEXT)");
114 db.run("CREATE TABLE run (id TEXT, kind TEXT, state TEXT, started_at TEXT, finished_at TEXT, serving_at TEXT, env TEXT, commit_oid TEXT)");
115 db.run("CREATE TABLE pull (merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)");
116 db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)");
117 db.run("INSERT INTO repo VALUES ('autonomous', 3, 'main', 'a'), ('autonomous', 1, 'main', 'b'), ('off', 8, 'main', 'c')");
118 db.run("INSERT INTO run (id, kind, state) VALUES ('1','agent','running'), ('2','agent','running'), ('3','agent','queued'), ('4','check','running')");
119 const { readKpis } = await import("../src/flow/kpis.ts");
120 expect(readKpis(db, new Date()).builders).toEqual({ running: 2, slots: 4 });
121 });
122 });
123
89124 describe("the terminal's figures", () => {
90 const k = { landedHours: hours(6), landed: 6, live: { sha: "abcdef0123456", at: new Date().toISOString() }, medianCheckMs: 185_000 };
91 test("the same three figures in the same words", () => {
125 const k = { landedHours: hours(6), landed: 6, live: { sha: "abcdef0123456", at: new Date().toISOString() }, builders: { running: 2, slots: 4 }, medianCheckMs: 185_000 };
126 test("the same four figures in the same words", () => {
92127 const lines = kpiLines(k, { sha: "abcdef0123456", since: "5m ago" });
93 expect(lines).toHaveLength(3);
128 expect(lines).toHaveLength(4);
129 expect(lines[3]).toBe("Builders running 2 of 4");
130 expect(kpiLines({ ...k, builders: { running: 0, slots: 0 } }, null)[3]).toBe("Builders running —, no slots set");
94131 expect(lines[0]).toContain("Shipped per hour 0.3");
95132 expect(lines[0]).toContain("6 changes in 24 h");
96133 expect(lines[1]).toContain("Live version abcdef0");
test/home-kpis.test.ts +3 −3
@@ -131,7 +131,7 @@ describe("home kpis", () => {
131131
132132 describe("home kpi row titles", () => {
133133 const full = kpiRowFor(
134 { landedHours: Array.from({ length: 24 }, (_, i) => (i === 0 ? 6 : 0)), landed: 6, live: null, medianCheckMs: 185_000 },
134 { landedHours: Array.from({ length: 24 }, (_, i) => (i === 0 ? 6 : 0)), landed: 6, live: null, builders: { running: 2, slots: 4 }, medianCheckMs: 185_000 },
135135 { sha: "abcdef0123456789", at: "2026-10-19T11:00:00.000Z" },
136136 ).toString();
137137
@@ -144,8 +144,8 @@ describe("home kpi row titles", () => {
144144 });
145145
146146 test("no value or note lacks a title, and none brings back a git word", () => {
147 expect(full.match(/class="kpi-(value num|note small muted)"/g)).toHaveLength(6);
148 expect(full.match(/class="kpi-(value num|note small muted)" title=/g)).toHaveLength(6);
147 expect(full.match(/class="kpi-(value num|note small muted)"/g)).toHaveLength(8);
148 expect(full.match(/class="kpi-(value num|note small muted)" title=/g)).toHaveLength(8);
149149 expect(full).not.toMatch(/commit|merge|branch|repositor|pull request/i);
150150 });
151151 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs