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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7664-home-s-builders-tile-says-why-builders-a → main 3 commits fork
9 files
changed +123
−62
src/flow/kpi_words.ts +2 −2
| @@ -32,7 +32,7 @@ export function trendText(series: readonly number[]): string { | ||
| 32 | 32 | } |
| 33 | 33 | |
| 34 | 34 | /** 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 { | |
| 36 | 36 | return b.slots === 0 ? "—" : `${b.running} of ${b.slots}`; |
| 37 | 37 | } |
| 38 | 38 | |
| @@ -141,7 +141,7 @@ export function kpiLines(k: Kpis, live: { sha: string; since: string } | null, n | ||
| 141 | 141 | `Shipped per hour ${(k.landed / k.landedHours.length).toFixed(1)} ${sparkText(k.landedHours)} ${k.landed} ${k.landed === 1 ? "change" : "changes"} in 24 h`, |
| 142 | 142 | `Live version ${live === null ? "— nothing live yet" : `${live.sha.slice(0, 7)}, live ${live.since}`}`, |
| 143 | 143 | `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}`}`, | |
| 145 | 145 | `Crew spend today ${spendValue(k.spend)}${spendLineNote(k.spend)}`, |
| 146 | 146 | `Last off-box backup ${backupAge(k.backupAt, now)}${backupOverdue(k.backupAt, now) ? ", overdue" : ""}`, |
| 147 | 147 | `Visitors ${visitorsValue(k.reach)}, ${visitorsNote(k.reach)} ${trendText(k.reach.visitorDays)} 30 days`, |
src/flow/kpis.ts +27 −8
| @@ -19,6 +19,7 @@ | ||
| 19 | 19 | import type { Database } from "bun:sqlite"; |
| 20 | 20 | import { spentTodayEverywhere } from "../crew/autonomy.ts"; |
| 21 | 21 | import { ceilingHitAt, ceilingReached } from "./ceiling.ts"; |
| 22 | import type { IdleReason } from "../crew/idle_reason.ts"; | |
| 22 | 23 | import { lastOffBox } from "../snapshot/off_box.ts"; |
| 23 | 24 | import { readReach, type Reach } from "./reach.ts"; |
| 24 | 25 | import { HOURS, median } from "./throughput.ts"; |
| @@ -35,7 +36,12 @@ export interface Kpis { | ||
| 35 | 36 | /** Median check duration in milliseconds over checks that finished in 24 h; null for none. */ |
| 36 | 37 | readonly medianCheckMs: number | null; |
| 37 | 38 | /** 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 | }; | |
| 39 | 45 | /** What the crew's runs cost since UTC midnight, and the forge's daily ceiling (null: none set). */ |
| 40 | 46 | 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 }; |
| 41 | 47 | /** 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; | ||
| 51 | 57 | |
| 52 | 58 | const untimed: KpiTimer = (_name, fn) => fn(); |
| 53 | 59 | |
| 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 = | |
| 57 | 68 | db |
| 58 | 69 | .query<{ running: number; slots: number }, []>( |
| 59 | 70 | `SELECT (SELECT COUNT(*) FROM run WHERE kind = 'agent' AND state = 'running') AS running, |
| 60 | 71 | (SELECT COALESCE(SUM(autonomy_slots), 0) FROM repo WHERE autonomy != 'off') AS slots`, |
| 61 | 72 | ) |
| 62 | .get() ?? { running: 0, slots: 0 } | |
| 63 | ); | |
| 73 | .get() ?? { running: 0, slots: 0 }; | |
| 74 | return { ...counts, idle }; | |
| 64 | 75 | } |
| 65 | 76 | |
| 66 | 77 | /** |
| @@ -104,7 +115,12 @@ function firstLandings(db: Database, since: string, to: string): string[] { | ||
| 104 | 115 | export function readKpis( |
| 105 | 116 | db: Database, |
| 106 | 117 | 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 }, | |
| 108 | 124 | time: KpiTimer = untimed, |
| 109 | 125 | ): Kpis { |
| 110 | 126 | const end = now.getTime(); |
| @@ -135,7 +151,10 @@ export function readKpis( | ||
| 135 | 151 | .all(since, to) |
| 136 | 152 | .map((r) => Math.round(r.ms))); |
| 137 | 153 | |
| 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 | }); | |
| 139 | 158 | |
| 140 | 159 | const spend = time("kpis.spend", () => { |
| 141 | 160 | const todayUsd = spentTodayEverywhere(db, now); |
src/tui/screens/dashboard.ts +2 −11
| @@ -113,7 +113,7 @@ import { firedAlarms } from "../../flow/alarms.ts"; | ||
| 113 | 113 | import { lastClearedNote } from "../../flow/clog_tick.ts"; |
| 114 | 114 | import { forgeCeiling } from "../../flow/ceiling.ts"; |
| 115 | 115 | 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"; | |
| 117 | 117 | import { noReleaseAlarm } from "../../flow/alarm_no_release.ts"; |
| 118 | 118 | import { redMainAlarm } from "../../flow/alarm_red_main.ts"; |
| 119 | 119 | import { alarmStuck } from "../../flow/alarm_stuck.ts"; |
| @@ -351,10 +351,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 351 | 351 | ? [] |
| 352 | 352 | : [ |
| 353 | 353 | ...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) }), | |
| 358 | 355 | vitalsLine(readVitals(f.db, now)), |
| 359 | 356 | ...firedAlarms(f.db, now, { ceilingUsd: forgeCeiling(f.app.config) }).map((a) => a.says), |
| 360 | 357 | ...(cleared === undefined ? [] : [cleared]), |
| @@ -1443,12 +1440,6 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1443 | 1440 | return screen; |
| 1444 | 1441 | } |
| 1445 | 1442 | |
| 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 | ||
| 1452 | 1443 | /** Why the builders are idle, as the one line an admin's home reads; none while they are not. */ |
| 1453 | 1444 | function idleLines(app: App, now: Date): string[] { |
| 1454 | 1445 | 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 | ||
| 235 | 235 | kpis: () => { |
| 236 | 236 | const ceilingUsd = forgeCeiling(ctx.app.config); |
| 237 | 237 | 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 }) }; | |
| 239 | 239 | return html`${timePhase(request, "kpis", () => kpiRow(db, kpiOpts), () => kpiCache(db))} |
| 240 | 240 | ${timePhase(request, "vitals", () => vitalsRow(db))} ${clogRow(db)} |
| 241 | 241 | ${keeperRow(db, true)} |
src/web/studio/home_cache.ts +22 −10
| @@ -8,14 +8,17 @@ | ||
| 8 | 8 | * every second, so a write-stamped entry never hit. Past 60 s the old answer |
| 9 | 9 | * is still served at once, and one refresh per key runs after the response |
| 10 | 10 | * (#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. | |
| 14 | 16 | */ |
| 15 | 17 | import type { Database } from "bun:sqlite"; |
| 16 | 18 | import type { Answer } from "../../boards/query.ts"; |
| 17 | 19 | 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"; | |
| 19 | 22 | import { readVitals, type Vitals } from "../../flow/vitals.ts"; |
| 20 | 23 | import { createLog } from "../../kernel/log.ts"; |
| 21 | 24 | import { Stamped } from "../../kernel/stamped.ts"; |
| @@ -136,14 +139,18 @@ export function cachedWaiting(db: Database, viewerId: string, compute: () => Wai | ||
| 136 | 139 | /** |
| 137 | 140 | * Admin only: the caller has checked. Keyed by UTC hour, ceiling and data |
| 138 | 141 | * 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. | |
| 142 | 145 | */ |
| 143 | 146 | export function cachedKpis( |
| 144 | 147 | db: Database, |
| 145 | 148 | 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 | }, | |
| 147 | 154 | time?: KpiTimer, |
| 148 | 155 | ): Kpis { |
| 149 | 156 | if (db.inTransaction) return readKpis(db, now, forge, time); |
| @@ -159,8 +166,13 @@ export function cachedKpis( | ||
| 159 | 166 | // request in the next hour asks for a new key. |
| 160 | 167 | const kept = servedStale(db, kpiCache(db), key, compute, () => readKpis(db, now, forge)); |
| 161 | 168 | 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) }; | |
| 164 | 176 | } |
| 165 | 177 | |
| 166 | 178 | /** |
src/web/studio/kpi_row.ts +5 −5
| @@ -92,8 +92,8 @@ export function liveNow(): { sha: string; at: string } | null { | ||
| 92 | 92 | return commit === null ? null : { sha: commit, at: BOOTED }; |
| 93 | 93 | } |
| 94 | 94 | |
| 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; | |
| 97 | 97 | const overdue = backupOverdue(k.backupAt, now); |
| 98 | 98 | const spendWords = spendCeilingWords(k.spend); |
| 99 | 99 | const rate = k.landed / k.landedHours.length; |
| @@ -165,10 +165,10 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now | ||
| 165 | 165 | } |
| 166 | 166 | |
| 167 | 167 | /** 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 { | |
| 169 | 169 | 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); | |
| 172 | 172 | } |
| 173 | 173 | |
| 174 | 174 | /** |
test/home-builders-idle.test.ts +45 −6
| @@ -11,7 +11,10 @@ import { getUserByName } from "../src/core/users.ts"; | ||
| 11 | 11 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; |
| 12 | 12 | import { idleReason } from "../src/crew/idle_reason.ts"; |
| 13 | 13 | 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"; | |
| 15 | 18 | import { createSession } from "../src/core/sessions.ts"; |
| 16 | 19 | import { noteLimit } from "../src/core/standing.ts"; |
| 17 | 20 | import { addSubscription, EVERYONE, setGrants } from "../src/core/subscriptions.ts"; |
| @@ -66,8 +69,11 @@ afterAll(async () => { | ||
| 66 | 69 | await rm(dir, { recursive: true, force: true }); |
| 67 | 70 | }); |
| 68 | 71 | |
| 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 | }; | |
| 71 | 77 | |
| 72 | 78 | async function terminal(): Promise<string> { |
| 73 | 79 | const t = headlessTerminal(200, 40); |
| @@ -114,8 +120,8 @@ test("no subscription with headroom: the granted account's window is full", asyn | ||
| 114 | 120 | const later = new Date(now + 2 * 3_600_000); |
| 115 | 121 | expect(idleReason(app, new Date(now))?.word).toBe("no subscription with headroom"); |
| 116 | 122 | 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"); | |
| 119 | 125 | expect(writeStamp(app.db)).toBe(stampBefore); |
| 120 | 126 | expect(tile(page)).toContain("no subscription with headroom"); |
| 121 | 127 | 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 | ||
| 147 | 153 | expect(tile(busy)).toContain("1 of 1"); |
| 148 | 154 | for (const r of app.db.query<{ id: string }, []>("SELECT id FROM run WHERE kind = 'agent' AND state = 'running'").all()) |
| 149 | 155 | 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); | |
| 151 | 158 | expect(tile(page)).toContain("0 of 1"); |
| 152 | 159 | expect(tile(page)).toContain("ceiling reached"); |
| 153 | 160 | expect(await terminal()).toContain("Builders running 0 of 1 · ceiling reached"); |
| 154 | 161 | }); |
| 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 : | ||
| 21 | 21 | describe("home kpi row", () => { |
| 22 | 22 | test("five tiles with their links and figures", () => { |
| 23 | 23 | 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 }, | |
| 25 | 25 | { sha: "abcdef0123456", at: "2026-10-19T11:00:00.000Z" }, |
| 26 | 26 | ).toString(); |
| 27 | 27 | expect(out).toContain('<span class="sha">abcdef0</span>'); |
| @@ -36,7 +36,7 @@ describe("home kpi row", () => { | ||
| 36 | 36 | |
| 37 | 37 | test("the whole figure is in the title and the link's label, rounded and in studio words", () => { |
| 38 | 38 | 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 }, | |
| 40 | 40 | { sha: "abcdef0123456", at: "2026-10-19T11:00:00.000Z" }, |
| 41 | 41 | ).toString(); |
| 42 | 42 | expect(out).toContain('title="0.29"'); |
| @@ -48,13 +48,13 @@ describe("home kpi row", () => { | ||
| 48 | 48 | }); |
| 49 | 49 | |
| 50 | 50 | 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(); | |
| 52 | 52 | expect(out).toContain("Nothing live yet"); |
| 53 | 53 | expect(out).toContain("No check finished in 24 h"); |
| 54 | 54 | }); |
| 55 | 55 | |
| 56 | 56 | 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(); | |
| 58 | 58 | expect(out).not.toMatch(/commit|merge|branch|repositor|pull request|checks (passed|failed)/i); |
| 59 | 59 | }); |
| 60 | 60 | |
| @@ -78,7 +78,7 @@ describe("home kpi row", () => { | ||
| 78 | 78 | }); |
| 79 | 79 | |
| 80 | 80 | 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(); | |
| 82 | 82 | expect(out).toContain('title="No check finished in 24 h"'); |
| 83 | 83 | }); |
| 84 | 84 | |
| @@ -91,7 +91,7 @@ describe("home kpi row", () => { | ||
| 91 | 91 | }); |
| 92 | 92 | |
| 93 | 93 | 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(); | |
| 95 | 95 | expect(out).toContain('href="/settings/crew"'); |
| 96 | 96 | expect(out).toContain('class="stat-value num" title="2 of 4">2 of 4<'); |
| 97 | 97 | expect(out).toContain('title="Crew builders running now, of 4 slots"'); |
| @@ -99,7 +99,7 @@ describe("home kpi row", () => { | ||
| 99 | 99 | }); |
| 100 | 100 | |
| 101 | 101 | 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(); | |
| 103 | 103 | expect(out).toContain('class="stat-value num" title="—">—<'); |
| 104 | 104 | expect(out).toContain('title="No slots set">No slots set<'); |
| 105 | 105 | expect(out).toContain(`aria-label="Builders running: —, No slots set"`); |
| @@ -107,7 +107,7 @@ describe("home kpi row", () => { | ||
| 107 | 107 | }); |
| 108 | 108 | |
| 109 | 109 | 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(); | |
| 111 | 111 | expect(out).toContain(">running now<"); |
| 112 | 112 | }); |
| 113 | 113 | |
| @@ -130,12 +130,12 @@ describe("the builders figure from the database", () => { | ||
| 130 | 130 | db.run("INSERT INTO repo VALUES ('autonomous', 3, 'main', 'a'), ('autonomous', 1, 'main', 'b'), ('off', 8, 'main', 'c')"); |
| 131 | 131 | db.run("INSERT INTO run (id, kind, state) VALUES ('1','agent','running'), ('2','agent','running'), ('3','agent','queued'), ('4','check','running')"); |
| 132 | 132 | 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 }); | |
| 134 | 134 | }); |
| 135 | 135 | }); |
| 136 | 136 | |
| 137 | 137 | 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 }; | |
| 139 | 139 | test("the same nine figures in the same words", () => { |
| 140 | 140 | const lines = kpiLines(k, { sha: "abcdef0123456", since: "5m ago" }); |
| 141 | 141 | expect(lines).toHaveLength(9); |
| @@ -143,7 +143,7 @@ describe("the terminal's figures", () => { | ||
| 143 | 143 | expect(lines[4]).toBe("Crew spend today $12 of $40"); |
| 144 | 144 | expect(kpiLines({ ...k, spend: { todayUsd: 0.4, ceilingUsd: null } }, null)[4]).toBe("Crew spend today $0.40, no forge ceiling"); |
| 145 | 145 | 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"); | |
| 147 | 147 | expect(lines[0]).toContain("Shipped per hour 0.3"); |
| 148 | 148 | expect(lines[0]).toContain("6 changes in 24 h"); |
| 149 | 149 | expect(lines[1]).toContain("Live version abcdef0"); |
| @@ -159,7 +159,7 @@ describe("the terminal's figures", () => { | ||
| 159 | 159 | |
| 160 | 160 | describe("the spend tile", () => { |
| 161 | 161 | 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(); | |
| 163 | 163 | |
| 164 | 164 | test("shows today's spend of the ceiling, the cents in the title, linking to the crew page", () => { |
| 165 | 165 | const out = row({ todayUsd: 12.34, ceilingUsd: 40 }); |
| @@ -205,7 +205,7 @@ describe("home kpi row: the last off-box backup", () => { | ||
| 205 | 205 | const NOW = new Date("2026-10-20T12:00:00.000Z"); |
| 206 | 206 | const hoursAgo = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString(); |
| 207 | 207 | 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(); | |
| 209 | 209 | const tile = (out: string) => /<a class="stat"[^>]*href="\/settings\/backups"[^>]*>/.exec(out)![0]; |
| 210 | 210 | |
| 211 | 211 | 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", () => { | ||
| 252 | 252 | describe("the reach tiles", () => { |
| 253 | 253 | test("say what they count in words, each linking to the reach board", () => { |
| 254 | 254 | 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(); | |
| 256 | 256 | 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}<`); |
| 257 | 257 | expect((out.match(/class="stat" href="\/boards\/reach"/g) ?? []).length).toBe(3); |
| 258 | 258 | expect(out).not.toContain("INP"); |
| @@ -262,12 +262,12 @@ describe("the reach tiles", () => { | ||
| 262 | 262 | test("the terminal draws the 30 days as one block per day", () => { |
| 263 | 263 | const days = Array.from({ length: 30 }, (_, i) => (i === 29 ? 8 : 0)); |
| 264 | 264 | 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"))!; | |
| 266 | 266 | expect(line).toContain("▁".repeat(29) + "█"); |
| 267 | 267 | }); |
| 268 | 268 | |
| 269 | 269 | 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); | |
| 271 | 271 | expect(lines.find((l) => l.startsWith("Visitors"))).toContain("Visitors 5 today, 31 in 7 days"); |
| 272 | 272 | expect(lines).toContain("Accounts 3 of 12 signed in today"); |
| 273 | 273 | expect(lines.find((l) => l.startsWith("Visitors"))).toContain("▁".repeat(30)); |
| @@ -286,7 +286,7 @@ describe("the visitors sparkline", () => { | ||
| 286 | 286 | expect(r.visitorDays[29]).toBe(2); |
| 287 | 287 | expect(r.visitorDays[0]).toBe(1); // 2026-09-20 is 29 days back; 09-19 is out |
| 288 | 288 | 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(); | |
| 290 | 290 | const svg = /<svg[^>]*last 30 days[^>]*><polyline points="([^"]+)"/.exec(out)!; |
| 291 | 291 | expect(svg[1]!.split(" ")).toHaveLength(30); |
| 292 | 292 | expect(out).toContain('href="/boards/reach"'); |
| @@ -409,7 +409,7 @@ describe("home waiting line", () => { | ||
| 409 | 409 | }); |
| 410 | 410 | |
| 411 | 411 | 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 } }; | |
| 413 | 413 | const over = { todayUsd: 601, ceilingUsd: 600, hitAt: "2026-10-25T14:03:20.000Z" }; |
| 414 | 414 | |
| 415 | 415 | 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", () => { | ||
| 132 | 132 | |
| 133 | 133 | describe("home kpi row titles", () => { |
| 134 | 134 | 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 }, | |
| 136 | 136 | { sha: "abcdef0123456789", at: "2026-10-19T11:00:00.000Z" }, |
| 137 | 137 | ).toString(); |
| 138 | 138 |
new above
offline