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

Home's builders tile says why builders are idle, read inside the cached KPI answer #7954

Merged opened this

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

Merged

scout/forge:scout/7664-home-s-builders-tile-says-why-builders-a → main 3 commits fork

  • Merged. scout/7664-home-s-builders-tile-says-why-builders-a was merged into main as aa25b13.

Review

Sign in to review this change.
9 files changed +123 −62
src/flow/kpi_words.ts +2 −2
@@ -32,7 +32,7 @@ export function trendText(series: readonly number[]): string {
3232 }
3333
3434 /** Builders running of their slots: `2 of 4`, or an em dash when no slots are set. */
35 export function buildersValue(b: Kpis["builders"]): string {
35 export function buildersValue(b: Pick<Kpis["builders"], "running" | "slots">): string {
3636 return b.slots === 0 ? "—" : `${b.running} of ${b.slots}`;
3737 }
3838
@@ -141,7 +141,7 @@ export function kpiLines(k: Kpis, live: { sha: string; since: string } | null, n
141141 `Shipped per hour ${(k.landed / k.landedHours.length).toFixed(1)} ${sparkText(k.landedHours)} ${k.landed} ${k.landed === 1 ? "change" : "changes"} in 24 h`,
142142 `Live version ${live === null ? "— nothing live yet" : `${live.sha.slice(0, 7)}, live ${live.since}`}`,
143143 `Median check time ${checkTime(k.medianCheckMs)}`,
144 `Builders running ${buildersValue(k.builders)}${k.builders.slots === 0 ? ", no slots set" : ""}`,
144 `Builders running ${buildersValue(k.builders)}${k.builders.slots === 0 ? ", no slots set" : ""}${k.builders.idle === null ? "" : ` · ${k.builders.idle.word}`}`,
145145 `Crew spend today ${spendValue(k.spend)}${spendLineNote(k.spend)}`,
146146 `Last off-box backup ${backupAge(k.backupAt, now)}${backupOverdue(k.backupAt, now) ? ", overdue" : ""}`,
147147 `Visitors ${visitorsValue(k.reach)}, ${visitorsNote(k.reach)} ${trendText(k.reach.visitorDays)} 30 days`,
src/flow/kpis.ts +27 −8
@@ -19,6 +19,7 @@
1919 import type { Database } from "bun:sqlite";
2020 import { spentTodayEverywhere } from "../crew/autonomy.ts";
2121 import { ceilingHitAt, ceilingReached } from "./ceiling.ts";
22 import type { IdleReason } from "../crew/idle_reason.ts";
2223 import { lastOffBox } from "../snapshot/off_box.ts";
2324 import { readReach, type Reach } from "./reach.ts";
2425 import { HOURS, median } from "./throughput.ts";
@@ -35,7 +36,12 @@ export interface Kpis {
3536 /** Median check duration in milliseconds over checks that finished in 24 h; null for none. */
3637 readonly medianCheckMs: number | null;
3738 /** Crew builders running now, of the slots the autonomous repositories are set to. */
38 readonly builders: { readonly running: number; readonly slots: number };
39 readonly builders: {
40 readonly running: number;
41 readonly slots: number;
42 /** Why none is starting anything now (`idleReason`); null while every slot is busy or no reason is known. */
43 readonly idle: IdleReason | null;
44 };
3945 /** What the crew's runs cost since UTC midnight, and the forge's daily ceiling (null: none set). */
4046 readonly spend: { readonly todayUsd: number; readonly ceilingUsd: number | null; /** When the run that crossed the cap was created (ISO); null while under it. */ readonly hitAt?: string | null };
4147 /** When a snapshot was last known off the machine (a bucket destination or the mirror, `off_box.ts`); null when never. */
@@ -51,16 +57,21 @@ export type KpiTimer = <T>(name: KpiPart, fn: () => T) => T;
5157
5258 const untimed: KpiTimer = (_name, fn) => fn();
5359
54 /** Crew builders running now, of the autonomous repositories' slots: one cheap query, so home reads it fresh beside the idle reason (#7624). */
55 export function readBuilders(db: Database): Kpis["builders"] {
56 return (
60 /** Every slot is taken: no reason to name. One rule, for the fresh read and home's cached one. */
61 export function slotsBusy(b: { readonly running: number; readonly slots: number }): boolean {
62 return b.slots > 0 && b.running >= b.slots;
63 }
64
65 /** Crew builders running now, of the autonomous repositories' slots: one cheap query, so home reads the counts fresh (#7624). `idle` is the reason the caller already has. */
66 export function readBuilders(db: Database, idle: IdleReason | null = null): Kpis["builders"] {
67 const counts =
5768 db
5869 .query<{ running: number; slots: number }, []>(
5970 `SELECT (SELECT COUNT(*) FROM run WHERE kind = 'agent' AND state = 'running') AS running,
6071 (SELECT COALESCE(SUM(autonomy_slots), 0) FROM repo WHERE autonomy != 'off') AS slots`,
6172 )
62 .get() ?? { running: 0, slots: 0 }
63 );
73 .get() ?? { running: 0, slots: 0 };
74 return { ...counts, idle };
6475 }
6576
6677 /**
@@ -104,7 +115,12 @@ function firstLandings(db: Database, since: string, to: string): string[] {
104115 export function readKpis(
105116 db: Database,
106117 now: Date,
107 forge: { readonly ceilingUsd: number | null; readonly dataDir?: string } = { ceilingUsd: null },
118 forge: {
119 readonly ceilingUsd: number | null;
120 readonly dataDir?: string;
121 /** Why builders are idle, asked inside the builders part; left out, no reason is named. */
122 readonly idle?: () => IdleReason | null;
123 } = { ceilingUsd: null },
108124 time: KpiTimer = untimed,
109125 ): Kpis {
110126 const end = now.getTime();
@@ -135,7 +151,10 @@ export function readKpis(
135151 .all(since, to)
136152 .map((r) => Math.round(r.ms)));
137153
138 const builders = time("kpis.builders", () => readBuilders(db));
154 const builders = time("kpis.builders", () => {
155 const counts = readBuilders(db);
156 return slotsBusy(counts) ? counts : { ...counts, idle: forge.idle?.() ?? null };
157 });
139158
140159 const spend = time("kpis.spend", () => {
141160 const todayUsd = spentTodayEverywhere(db, now);
src/tui/screens/dashboard.ts +2 −11
@@ -113,7 +113,7 @@ import { firedAlarms } from "../../flow/alarms.ts";
113113 import { lastClearedNote } from "../../flow/clog_tick.ts";
114114 import { forgeCeiling } from "../../flow/ceiling.ts";
115115 import { alarmIdleCrew } from "../../flow/alarm_idle_crew.ts";
116 import { idleLine, idleReason, type IdleReason } from "../../crew/idle_reason.ts";
116 import { idleLine, idleReason } from "../../crew/idle_reason.ts";
117117 import { noReleaseAlarm } from "../../flow/alarm_no_release.ts";
118118 import { redMainAlarm } from "../../flow/alarm_red_main.ts";
119119 import { alarmStuck } from "../../flow/alarm_stuck.ts";
@@ -351,10 +351,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
351351 ? []
352352 : [
353353 ...idleLines(f.app, now),
354 ...withIdleWord(
355 kpiLines(readKpis(f.db, now, kpiForge(f.app)), commit === null ? null : { sha: commit, since: ago(BOOTED) }),
356 idleReason(f.app, now),
357 ),
354 ...kpiLines(readKpis(f.db, now, { ...kpiForge(f.app), idle: () => idleReason(f.app, now) }), commit === null ? null : { sha: commit, since: ago(BOOTED) }),
358355 vitalsLine(readVitals(f.db, now)),
359356 ...firedAlarms(f.db, now, { ceilingUsd: forgeCeiling(f.app.config) }).map((a) => a.says),
360357 ...(cleared === undefined ? [] : [cleared]),
@@ -1443,12 +1440,6 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
14431440 return screen;
14441441 }
14451442
1446 /** The builders line ends ` · <word>` while they are idle, the word home prints (`idleReason`). */
1447 function withIdleWord(lines: string[], reason: IdleReason | null): string[] {
1448 if (reason === null) return lines;
1449 return lines.map((l) => (l.startsWith("Builders running ") ? `${l} · ${reason.word}` : l));
1450 }
1451
14521443 /** Why the builders are idle, as the one line an admin's home reads; none while they are not. */
14531444 function idleLines(app: App, now: Date): string[] {
14541445 const reason = idleReason(app, now);
src/web/studio/home.ts +1 −1
@@ -235,7 +235,7 @@ function homeDraws(ctx: Ctx, user: User): Record<HomeRegion, () => Html | Promis
235235 kpis: () => {
236236 const ceilingUsd = forgeCeiling(ctx.app.config);
237237 const kpiTime: KpiTimer = (name, fn) => timePhase(request, name, fn);
238 const kpiOpts = { idle: idleReason(ctx.app, new Date()), dataDir: ctx.app.config.dataDir, time: kpiTime, ...(ceilingUsd === null ? {} : { ceilingUsd }) };
238 const kpiOpts = { idle: () => idleReason(ctx.app, new Date()), dataDir: ctx.app.config.dataDir, time: kpiTime, ...(ceilingUsd === null ? {} : { ceilingUsd }) };
239239 return html`${timePhase(request, "kpis", () => kpiRow(db, kpiOpts), () => kpiCache(db))}
240240 ${timePhase(request, "vitals", () => vitalsRow(db))} ${clogRow(db)}
241241 ${keeperRow(db, true)}
src/web/studio/home_cache.ts +22 −10
@@ -8,14 +8,17 @@
88 * every second, so a write-stamped entry never hit. Past 60 s the old answer
99 * is still served at once, and one refresh per key runs after the response
1010 * (#7625); only a cold start, or an answer past the 10 min ceiling, computes
11 * inline. The builders figure is the exception: it is read fresh on every
12 * request, so it always agrees with the idle reason home prints beside it.
13 * The waiting line stays on the write clock: the asks count must be exact.
11 * inline. The builders figure is the exception: the counts are read fresh on
12 * every request, and the idle reason rides in the cached answer only while
13 * the counts are the ones it was computed with; when they differ it is asked
14 * again. The waiting line stays on the write clock: the asks count must be
15 * exact.
1416 */
1517 import type { Database } from "bun:sqlite";
1618 import type { Answer } from "../../boards/query.ts";
1719 import { writeStamp } from "../../core/write_clock.ts";
18 import { readBuilders, readKpis, type KpiTimer, type Kpis } from "../../flow/kpis.ts";
20 import type { IdleReason } from "../../crew/idle_reason.ts";
21 import { readBuilders, readKpis, slotsBusy, type KpiTimer, type Kpis } from "../../flow/kpis.ts";
1922 import { readVitals, type Vitals } from "../../flow/vitals.ts";
2023 import { createLog } from "../../kernel/log.ts";
2124 import { Stamped } from "../../kernel/stamped.ts";
@@ -136,14 +139,18 @@ export function cachedWaiting(db: Database, viewerId: string, compute: () => Wai
136139 /**
137140 * Admin only: the caller has checked. Keyed by UTC hour, ceiling and data
138141 * directory; kept 60 s and refreshed after (`servedStale`), all but the
139 * builders, read fresh each time so they agree with the idle reason. Nothing
140 * is kept inside a transaction, whose rows may roll back. `time` names
141 * readKpis' parts for Server-Timing.
142 * builders' counts, read fresh each time (and the reason with them when they
143 * changed). Nothing is kept inside a transaction, whose rows may roll back.
144 * `time` names readKpis' parts for Server-Timing.
142145 */
143146 export function cachedKpis(
144147 db: Database,
145148 now: Date,
146 forge: { readonly ceilingUsd: number | null; readonly dataDir?: string },
149 forge: {
150 readonly ceilingUsd: number | null;
151 readonly dataDir?: string;
152 readonly idle?: () => IdleReason | null;
153 },
147154 time?: KpiTimer,
148155 ): Kpis {
149156 if (db.inTransaction) return readKpis(db, now, forge, time);
@@ -159,8 +166,13 @@ export function cachedKpis(
159166 // request in the next hour asks for a new key.
160167 const kept = servedStale(db, kpiCache(db), key, compute, () => readKpis(db, now, forge));
161168 if (computed) return kept;
162 const builders = time === undefined ? readBuilders(db) : time("kpis.builders", () => readBuilders(db));
163 return { ...kept, builders };
169 const read = (): Kpis["builders"] => {
170 const counts = readBuilders(db);
171 if (slotsBusy(counts)) return counts;
172 const same = counts.running === kept.builders.running && counts.slots === kept.builders.slots;
173 return { ...counts, idle: same ? kept.builders.idle : forge.idle?.() ?? null };
174 };
175 return { ...kept, builders: time === undefined ? read() : time("kpis.builders", read) };
164176 }
165177
166178 /**
src/web/studio/kpi_row.ts +5 −5
@@ -92,8 +92,8 @@ export function liveNow(): { sha: string; at: string } | null {
9292 return commit === null ? null : { sha: commit, at: BOOTED };
9393 }
9494
95 /** `idle` is why the builders are idle now (`idleReason`), asked at render time: it moves with the clock, which the cached figures do not. */
96 export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now: Date = new Date(), idle: IdleReason | null = null): Html {
95 export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now: Date = new Date()): Html {
96 const idle = k.builders.idle;
9797 const overdue = backupOverdue(k.backupAt, now);
9898 const spendWords = spendCeilingWords(k.spend);
9999 const rate = k.landed / k.landedHours.length;
@@ -165,10 +165,10 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now
165165 }
166166
167167 /** The figures count every repository, so the caller shows this to an admin only. */
168 export function kpiRow(db: Database, opts: { ceilingUsd?: number; dataDir?: string; idle?: IdleReason | null; now?: Date; time?: KpiTimer } = {}): Html {
168 export function kpiRow(db: Database, opts: { ceilingUsd?: number; dataDir?: string; idle?: () => IdleReason | null; now?: Date; time?: KpiTimer } = {}): Html {
169169 const now = opts.now ?? new Date();
170 const forge = { ceilingUsd: opts.ceilingUsd ?? null, ...(opts.dataDir === undefined ? {} : { dataDir: opts.dataDir }) };
171 return kpiRowFor(cachedKpis(db, now, forge, opts.time), liveNow(), now, opts.idle ?? null);
170 const forge = { ceilingUsd: opts.ceilingUsd ?? null, ...(opts.dataDir === undefined ? {} : { dataDir: opts.dataDir }), ...(opts.idle === undefined ? {} : { idle: opts.idle }) };
171 return kpiRowFor(cachedKpis(db, now, forge, opts.time), liveNow(), now);
172172 }
173173
174174 /**
test/home-builders-idle.test.ts +45 −6
@@ -11,7 +11,10 @@ import { getUserByName } from "../src/core/users.ts";
1111 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
1212 import { idleReason } from "../src/crew/idle_reason.ts";
1313 import { writeStamp } from "../src/core/write_clock.ts";
14 import { kpiRow } from "../src/web/studio/kpi_row.ts";
14 import { kpiRow, kpiRowFor } from "../src/web/studio/kpi_row.ts";
15 import { cachedKpis, kpiCache } from "../src/web/studio/home_cache.ts";
16 import { readKpis } from "../src/flow/kpis.ts";
17 import { kpiLines } from "../src/flow/kpi_words.ts";
1518 import { createSession } from "../src/core/sessions.ts";
1619 import { noteLimit } from "../src/core/standing.ts";
1720 import { addSubscription, EVERYONE, setGrants } from "../src/core/subscriptions.ts";
@@ -66,8 +69,11 @@ afterAll(async () => {
6669 await rm(dir, { recursive: true, force: true });
6770 });
6871
69 const home = async (): Promise<string> =>
70 freshHome(servers.url, `forge_session=${createSession(app.db, admin.id).token}; forge_mode=studio`);
72 /** The reason rides in home's 60 s KPI answer (#7664): a test that changed the state forgets the answer first. */
73 const home = async (keep = false): Promise<string> => {
74 if (!keep) kpiCache(app.db).clear();
75 return freshHome(servers.url, `forge_session=${createSession(app.db, admin.id).token}; forge_mode=studio`);
76 };
7177
7278 async function terminal(): Promise<string> {
7379 const t = headlessTerminal(200, 40);
@@ -114,8 +120,8 @@ test("no subscription with headroom: the granted account's window is full", asyn
114120 const later = new Date(now + 2 * 3_600_000);
115121 expect(idleReason(app, new Date(now))?.word).toBe("no subscription with headroom");
116122 expect(idleReason(app, later)?.word).not.toBe("no subscription with headroom");
117 expect(String(kpiRow(app.db, { now: new Date(now), idle: idleReason(app, new Date(now)) }))).toContain("no subscription with headroom");
118 expect(String(kpiRow(app.db, { now: later, idle: idleReason(app, later) }))).not.toContain("no subscription with headroom");
123 expect(String(kpiRow(app.db, { now: new Date(now), idle: () => idleReason(app, new Date(now)) }))).toContain("no subscription with headroom");
124 expect(String(kpiRow(app.db, { now: later, idle: () => idleReason(app, later) }))).not.toContain("no subscription with headroom");
119125 expect(writeStamp(app.db)).toBe(stampBefore);
120126 expect(tile(page)).toContain("no subscription with headroom");
121127 expect(tile(page)).toContain('href="/subscriptions"');
@@ -147,8 +153,41 @@ test("both doors agree within home's 60 s KPI entry: the run ends, each says 0 o
147153 expect(tile(busy)).toContain("1 of 1");
148154 for (const r of app.db.query<{ id: string }, []>("SELECT id FROM run WHERE kind = 'agent' AND state = 'running'").all())
149155 finishRun(app.db, r.id, { state: "ok", summary: "done" });
150 const page = await home();
156 // Within the entry the counts changed, so the reason is asked again rather than left silent.
157 const page = await home(true);
151158 expect(tile(page)).toContain("0 of 1");
152159 expect(tile(page)).toContain("ceiling reached");
153160 expect(await terminal()).toContain("Builders running 0 of 1 · ceiling reached");
154161 });
162
163 test("the reason rides in the cached KPI answer: readKpis carries it, the tile and line print it (#7664)", async () => {
164 for (const r of app.db.query<{ id: string }, []>("SELECT id FROM run WHERE kind = 'agent' AND state = 'running'").all())
165 finishRun(app.db, r.id, { state: "ok", summary: "done" });
166 const now = new Date();
167 const k = readKpis(app.db, now, { ceilingUsd: null, idle: () => idleReason(app, now) });
168 expect(k.builders.idle).toEqual({ word: "ceiling reached", href: "/meter" });
169 expect(kpiLines(k, null)[3]).toBe("Builders running 0 of 1 · ceiling reached");
170 expect(tile(String(kpiRowFor(k, null, now)))).toContain('href="/meter"');
171 // Every slot busy: nothing is added, and the reason is not asked.
172 claimRun(app.db, createRun(app.db, { repoId: repo.id, kind: "agent", actorId: builder.id, reason: "assigned" }).id);
173 const busy = readKpis(app.db, now, { ceilingUsd: null, idle: () => { throw new Error("asked"); } });
174 expect(busy.builders.idle).toBeNull();
175 });
176
177 test("a cached reason is dropped when the counts change: one of two slots running names no stale reason (#7664)", () => {
178 const run = (id: string) => finishRun(app.db, id, { state: "ok", summary: "done" });
179 for (const r of app.db.query<{ id: string }, []>("SELECT id FROM run WHERE kind = 'agent' AND state = 'running'").all()) run(r.id);
180 app.db.query("UPDATE repo SET autonomy_slots = 2 WHERE autonomy != 'off'").run();
181 kpiCache(app.db).clear();
182 const now = new Date();
183 const ceiling = { word: "ceiling reached", href: "/meter" };
184 let reason: typeof ceiling | null = ceiling;
185 const forge = { ceilingUsd: null, idle: () => reason };
186 expect(cachedKpis(app.db, now, forge).builders).toEqual({ running: 0, slots: 2, idle: ceiling });
187 // Same counts: the entry's reason is kept without asking.
188 expect(cachedKpis(app.db, now, { ceilingUsd: null, idle: () => { throw new Error("asked"); } }).builders.idle).toEqual(ceiling);
189 // One of two starts: the entry is still warm, but its reason was for 0 of 2.
190 claimRun(app.db, createRun(app.db, { repoId: repo.id, kind: "agent", actorId: builder.id, reason: "assigned" }).id);
191 reason = null;
192 expect(cachedKpis(app.db, now, forge).builders).toEqual({ running: 1, slots: 2, idle: null });
193 });
test/home-kpi-row.test.ts +18 −18
@@ -21,7 +21,7 @@ const hours = (n: number) => Array.from({ length: 24 }, (_, i) => (i === 3 ? n :
2121 describe("home kpi row", () => {
2222 test("five tiles with their links and figures", () => {
2323 const out = kpiRowFor(
24 { landedHours: hours(6), landed: 6, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000, backupAt: null, reach: NO_REACH },
24 { landedHours: hours(6), landed: 6, live: null, builders: { running: 2, slots: 4, idle: null }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000, backupAt: null, reach: NO_REACH },
2525 { sha: "abcdef0123456", at: "2026-10-19T11:00:00.000Z" },
2626 ).toString();
2727 expect(out).toContain('<span class="sha">abcdef0</span>');
@@ -36,7 +36,7 @@ describe("home kpi row", () => {
3636
3737 test("the whole figure is in the title and the link's label, rounded and in studio words", () => {
3838 const out = kpiRowFor(
39 { landedHours: hours(7), landed: 7, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000, backupAt: null, reach: NO_REACH },
39 { landedHours: hours(7), landed: 7, live: null, builders: { running: 2, slots: 4, idle: null }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000, backupAt: null, reach: NO_REACH },
4040 { sha: "abcdef0123456", at: "2026-10-19T11:00:00.000Z" },
4141 ).toString();
4242 expect(out).toContain('title="0.29"');
@@ -48,13 +48,13 @@ describe("home kpi row", () => {
4848 });
4949
5050 test("empty figures say so", () => {
51 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null, backupAt: null, reach: NO_REACH }, null).toString();
51 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, builders: { running: 2, slots: 4, idle: null }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null, backupAt: null, reach: NO_REACH }, null).toString();
5252 expect(out).toContain("Nothing live yet");
5353 expect(out).toContain("No check finished in 24 h");
5454 });
5555
5656 test("no git words render, not even in a title or label", () => {
57 const out = kpiRowFor({ landedHours: hours(1), landed: 1, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 1000, backupAt: null, reach: NO_REACH }, null).toString();
57 const out = kpiRowFor({ landedHours: hours(1), landed: 1, live: null, builders: { running: 2, slots: 4, idle: null }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 1000, backupAt: null, reach: NO_REACH }, null).toString();
5858 expect(out).not.toMatch(/commit|merge|branch|repositor|pull request|checks (passed|failed)/i);
5959 });
6060
@@ -78,7 +78,7 @@ describe("home kpi row", () => {
7878 });
7979
8080 test("a truncated note carries its whole text as a title", () => {
81 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null, backupAt: null, reach: NO_REACH }, null).toString();
81 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, builders: { running: 2, slots: 4, idle: null }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null, backupAt: null, reach: NO_REACH }, null).toString();
8282 expect(out).toContain('title="No check finished in 24 h"');
8383 });
8484
@@ -91,7 +91,7 @@ describe("home kpi row", () => {
9191 });
9292
9393 test("the builders tile says running of slots, titles it, and links to the crew", () => {
94 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: NO_REACH, medianCheckMs: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString();
94 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: NO_REACH, medianCheckMs: null, builders: { running: 2, slots: 4, idle: null }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString();
9595 expect(out).toContain('href="/settings/crew"');
9696 expect(out).toContain('class="stat-value num" title="2 of 4">2 of 4<');
9797 expect(out).toContain('title="Crew builders running now, of 4 slots"');
@@ -99,7 +99,7 @@ describe("home kpi row", () => {
9999 });
100100
101101 test("with no slots set the builders tile shows a dash and says so", () => {
102 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: NO_REACH, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString();
102 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: NO_REACH, medianCheckMs: null, builders: { running: 0, slots: 0, idle: null }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString();
103103 expect(out).toContain('class="stat-value num" title="—">—<');
104104 expect(out).toContain('title="No slots set">No slots set<');
105105 expect(out).toContain(`aria-label="Builders running: —, No slots set"`);
@@ -107,7 +107,7 @@ describe("home kpi row", () => {
107107 });
108108
109109 test("the builders note does not repeat the value", () => {
110 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: NO_REACH, medianCheckMs: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString();
110 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: NO_REACH, medianCheckMs: null, builders: { running: 2, slots: 4, idle: null }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString();
111111 expect(out).toContain(">running now<");
112112 });
113113
@@ -130,12 +130,12 @@ describe("the builders figure from the database", () => {
130130 db.run("INSERT INTO repo VALUES ('autonomous', 3, 'main', 'a'), ('autonomous', 1, 'main', 'b'), ('off', 8, 'main', 'c')");
131131 db.run("INSERT INTO run (id, kind, state) VALUES ('1','agent','running'), ('2','agent','running'), ('3','agent','queued'), ('4','check','running')");
132132 const { readKpis } = await import("../src/flow/kpis.ts");
133 expect(readKpis(db, new Date()).builders).toEqual({ running: 2, slots: 4 });
133 expect(readKpis(db, new Date()).builders).toEqual({ running: 2, slots: 4, idle: null });
134134 });
135135 });
136136
137137 describe("the terminal's figures", () => {
138 const k = { landedHours: hours(6), landed: 6, live: { sha: "abcdef0123456", at: new Date().toISOString() }, builders: { running: 2, slots: 4 }, spend: { todayUsd: 12, ceilingUsd: 40 }, medianCheckMs: 185_000, backupAt: null, reach: NO_REACH };
138 const k = { landedHours: hours(6), landed: 6, live: { sha: "abcdef0123456", at: new Date().toISOString() }, builders: { running: 2, slots: 4, idle: null }, spend: { todayUsd: 12, ceilingUsd: 40 }, medianCheckMs: 185_000, backupAt: null, reach: NO_REACH };
139139 test("the same nine figures in the same words", () => {
140140 const lines = kpiLines(k, { sha: "abcdef0123456", since: "5m ago" });
141141 expect(lines).toHaveLength(9);
@@ -143,7 +143,7 @@ describe("the terminal's figures", () => {
143143 expect(lines[4]).toBe("Crew spend today $12 of $40");
144144 expect(kpiLines({ ...k, spend: { todayUsd: 0.4, ceilingUsd: null } }, null)[4]).toBe("Crew spend today $0.40, no forge ceiling");
145145 expect(lines[3]).toBe("Builders running 2 of 4");
146 expect(kpiLines({ ...k, builders: { running: 0, slots: 0 } }, null)[3]).toBe("Builders running —, no slots set");
146 expect(kpiLines({ ...k, builders: { running: 0, slots: 0, idle: null } }, null)[3]).toBe("Builders running —, no slots set");
147147 expect(lines[0]).toContain("Shipped per hour 0.3");
148148 expect(lines[0]).toContain("6 changes in 24 h");
149149 expect(lines[1]).toContain("Live version abcdef0");
@@ -159,7 +159,7 @@ describe("the terminal's figures", () => {
159159
160160 describe("the spend tile", () => {
161161 const row = (spend: { todayUsd: number; ceilingUsd: number | null }) =>
162 kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: NO_REACH, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend }, null).toString();
162 kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: NO_REACH, medianCheckMs: null, builders: { running: 0, slots: 0, idle: null }, spend }, null).toString();
163163
164164 test("shows today's spend of the ceiling, the cents in the title, linking to the crew page", () => {
165165 const out = row({ todayUsd: 12.34, ceilingUsd: 40 });
@@ -205,7 +205,7 @@ describe("home kpi row: the last off-box backup", () => {
205205 const NOW = new Date("2026-10-20T12:00:00.000Z");
206206 const hoursAgo = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString();
207207 const row = (backupAt: string | null) =>
208 kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt, reach: NO_REACH, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null }, null, NOW).toString();
208 kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt, reach: NO_REACH, builders: { running: 0, slots: 0, idle: null }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null }, null, NOW).toString();
209209 const tile = (out: string) => /<a class="stat"[^>]*href="\/settings\/backups"[^>]*>/.exec(out)![0];
210210
211211 test("a backup 2 h old reads its age, with the exact time, and is not flagged", () => {
@@ -252,7 +252,7 @@ describe("home kpi row: the last off-box backup", () => {
252252 describe("the reach tiles", () => {
253253 test("say what they count in words, each linking to the reach board", () => {
254254 const reach = { usersToday: 3, usersTotal: 12, visitorsToday: 5, visitors7d: 31, visitorDays: new Array<number>(30).fill(0), loadP75: 1250, inpP75: 180 };
255 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString();
255 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach, medianCheckMs: null, builders: { running: 0, slots: 0, idle: null }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString();
256256 for (const v of ["5 today", "31 in 7 days", "3 of 12", "signed in today", "1.3 s", "reacts in 180 ms"]) expect(out).toContain(`>${v}<`);
257257 expect((out.match(/class="stat" href="\/boards\/reach"/g) ?? []).length).toBe(3);
258258 expect(out).not.toContain("INP");
@@ -262,12 +262,12 @@ describe("the reach tiles", () => {
262262 test("the terminal draws the 30 days as one block per day", () => {
263263 const days = Array.from({ length: 30 }, (_, i) => (i === 29 ? 8 : 0));
264264 const reach = { usersToday: 0, usersTotal: 1, visitorsToday: 8, visitors7d: 8, visitorDays: days, loadP75: null, inpP75: null };
265 const line = kpiLines({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).find((l) => l.startsWith("Visitors"))!;
265 const line = kpiLines({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach, medianCheckMs: null, builders: { running: 0, slots: 0, idle: null }, spend: { todayUsd: 0, ceilingUsd: null } }, null).find((l) => l.startsWith("Visitors"))!;
266266 expect(line).toContain("▁".repeat(29) + "█");
267267 });
268268
269269 test("the terminal says the same", () => {
270 const lines = kpiLines({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: { usersToday: 3, usersTotal: 12, visitorsToday: 5, visitors7d: 31, visitorDays: new Array<number>(30).fill(0), loadP75: 1250, inpP75: 180 }, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null } }, null);
270 const lines = kpiLines({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: { usersToday: 3, usersTotal: 12, visitorsToday: 5, visitors7d: 31, visitorDays: new Array<number>(30).fill(0), loadP75: 1250, inpP75: 180 }, medianCheckMs: null, builders: { running: 0, slots: 0, idle: null }, spend: { todayUsd: 0, ceilingUsd: null } }, null);
271271 expect(lines.find((l) => l.startsWith("Visitors"))).toContain("Visitors 5 today, 31 in 7 days");
272272 expect(lines).toContain("Accounts 3 of 12 signed in today");
273273 expect(lines.find((l) => l.startsWith("Visitors"))).toContain("▁".repeat(30));
@@ -286,7 +286,7 @@ describe("the visitors sparkline", () => {
286286 expect(r.visitorDays[29]).toBe(2);
287287 expect(r.visitorDays[0]).toBe(1); // 2026-09-20 is 29 days back; 09-19 is out
288288 expect(r.visitorDays.reduce((a, b) => a + b, 0)).toBe(3);
289 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: r, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString();
289 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: r, medianCheckMs: null, builders: { running: 0, slots: 0, idle: null }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString();
290290 const svg = /<svg[^>]*last 30 days[^>]*><polyline points="([^"]+)"/.exec(out)!;
291291 expect(svg[1]!.split(" ")).toHaveLength(30);
292292 expect(out).toContain('href="/boards/reach"');
@@ -409,7 +409,7 @@ describe("home waiting line", () => {
409409 });
410410
411411 describe("the forge ceiling hit (#5798)", () => {
412 const base = { landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: NO_REACH, medianCheckMs: null, builders: { running: 0, slots: 0 } };
412 const base = { landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: NO_REACH, medianCheckMs: null, builders: { running: 0, slots: 0, idle: null } };
413413 const over = { todayUsd: 601, ceilingUsd: 600, hitAt: "2026-10-25T14:03:20.000Z" };
414414
415415 test("past the cap the tile is attention-toned and names the time; the terminal says the same words", () => {
test/home-kpis.test.ts +1 −1
@@ -132,7 +132,7 @@ describe("home kpis", () => {
132132
133133 describe("home kpi row titles", () => {
134134 const full = kpiRowFor(
135 { landedHours: Array.from({ length: 24 }, (_, i) => (i === 0 ? 6 : 0)), landed: 6, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000, backupAt: null, reach: NO_REACH },
135 { landedHours: Array.from({ length: 24 }, (_, i) => (i === 0 ? 6 : 0)), landed: 6, live: null, builders: { running: 2, slots: 4, idle: null }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000, backupAt: null, reach: NO_REACH },
136136 { sha: "abcdef0123456789", at: "2026-10-19T11:00:00.000Z" },
137137 ).toString();
138138

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs