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

Home's spend tile shows the forge's own ceiling and the time it was hit, and the terminal dashboard says the same #5818

Merged opened this

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

Merged

scout/forge:scout/5798-home-s-spend-tile-shows-the-forge-s-own → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/5798-home-s-spend-tile-shows-the-forge-s-own was merged into main as f8f951c.

Review

Sign in to review this change.
10 files changed +128 −22
src/crew/autonomy.ts +3 −3
@@ -14,6 +14,7 @@
1414
1515 import type { Database } from "bun:sqlite";
1616 import type { App } from "../app.ts";
17 import { ceilingReached, forgeCeiling } from "../flow/ceiling.ts";
1718 import {
1819 addComment,
1920 assignIssue,
@@ -279,7 +280,7 @@ export function autonomyStatus(
279280 ): AutonomyStatus {
280281 const spent = spentToday(app.db, repo.id, now);
281282 const crew = listCrewRoles(app.db, repo.id);
282 const forgeCap = app.config.crewCeilingUsd;
283 const forgeCap = forgeCeiling(app.config);
283284 const paused: Paused | null =
284285 repo.autonomy === "off"
285286 ? null
@@ -289,8 +290,7 @@ export function autonomyStatus(
289290 ? "no crew"
290291 : spent >= repo.autonomyCeilingUsd
291292 ? "ceiling"
292 : forgeCap !== undefined &&
293 spentTodayEverywhere(app.db, now) >= forgeCap
293 : ceilingReached(spentTodayEverywhere(app.db, now), forgeCap)
294294 ? "forge ceiling"
295295 : null;
296296 const tomorrow = startOfTodayUtc(now);
src/flow/ceiling.ts added +39
@@ -0,0 +1,39 @@
1 /**
2 * The forge's own daily crew ceiling (#5798): the one rule for reading the cap
3 * and for when it was hit. `crew/autonomy.ts`, the tiles and the terminal all
4 * ask here, so they cannot disagree. Per-account ceilings are a different
5 * thing and never folded into this number.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import { startOfTodayUtc } from "../crew/autonomy.ts";

Non-blocking: this closes an import cycle with crew/autonomy.ts (which now imports this module). Consider moving startOfTodayUtc here or into its own small module.

10
11 /** The forge cap in dollars, or null when none is set. */
12 export function forgeCeiling(config: { crewCeilingUsd?: number }): number | null {
13 return config.crewCeilingUsd ?? null;
14 }
15
16 /** Spend has reached the cap: the very test that pauses the builders. */
17 export function ceilingReached(spentUsd: number, cap: number | null): boolean {
18 return cap !== null && spentUsd >= cap;
19 }
20
21 /**
22 * When the run that carried today's crew spend across the cap was created
23 * (ISO), or null while under it or with no cap.
24 */
25 export function ceilingHitAt(db: Database, now: Date, cap: number | null): string | null {
26 if (cap === null) return null;
27 const rows = db
28 .query<{ at: string; cost: number | null }, [string]>(
29 `SELECT created_at AS at, cost_usd AS cost FROM run
30 WHERE kind = 'agent' AND created_at >= ?1 ORDER BY created_at, rowid`,
31 )
32 .all(startOfTodayUtc(now).toISOString());
33 let sum = 0;
34 for (const r of rows) {
35 sum += r.cost ?? 0;
36 if (ceilingReached(sum, cap)) return r.at;
37 }
38 return null;
39 }
src/flow/kpi_words.ts +20 −1
@@ -50,6 +50,25 @@ export function spendFull(s: Kpis["spend"]): string {
5050 return s.ceilingUsd === null ? cents(s.todayUsd) : `${cents(s.todayUsd)} of ${cents(s.ceilingUsd)}`;
5151 }
5252
53 /** The `HH:MMZ` of an ISO time. */
54 const hhmmZ = (iso: string): string => `${iso.slice(11, 16)}Z`;
55
56 /** The forge ceiling's state in words, shared by the tile and the terminal row. */
57 export function spendCeilingWords(s: Kpis["spend"]): { note: string; title: string; attn: boolean } {
58 if (s.ceilingUsd === null) return { note: "no forge ceiling", title: "No forge ceiling is set", attn: false };
59 if (s.todayUsd >= s.ceilingUsd) {
60 const note = s.hitAt ? `ceiling hit ${hhmmZ(s.hitAt)}, builders idle` : "ceiling hit, builders idle";
61 return { note, title: note, attn: true };
62 }
63 return { note: "of the forge ceiling", title: "Crew spend today (UTC), of the forge ceiling", attn: false };
64 }
65
66 /** The terminal's tail on the spend line: only the two states worth a word. */
67 function spendLineNote(s: Kpis["spend"]): string {
68 const w = spendCeilingWords(s);
69 return w.attn || s.ceilingUsd === null ? `, ${w.note}` : "";
70 }
71
5372 const DAY_MS = 24 * 3_600_000;
5473
5574 /** Past a day, or with no backup at all, the last off-box backup is overdue. */
@@ -118,7 +137,7 @@ export function kpiLines(k: Kpis, live: { sha: string; since: string } | null, n
118137 `Live version ${live === null ? "— nothing live yet" : `${live.sha.slice(0, 7)}, live ${live.since}`}`,
119138 `Median check time ${checkTime(k.medianCheckMs)}`,
120139 `Builders running ${buildersValue(k.builders)}${k.builders.slots === 0 ? ", no slots set" : ""}`,
121 `Crew spend today ${spendValue(k.spend)}${k.spend.ceilingUsd === null ? ", no ceiling" : ""}`,
140 `Crew spend today ${spendValue(k.spend)}${spendLineNote(k.spend)}`,
122141 `Last off-box backup ${backupAge(k.backupAt, now)}${backupOverdue(k.backupAt, now) ? ", overdue" : ""}`,
123142 `Visitors ${visitorsValue(k.reach)}, ${visitorsNote(k.reach)} ${trendText(k.reach.visitorDays)} 30 days`,
124143 `Accounts ${usersValue(k.reach)} signed in today`,
src/flow/kpis.ts +3 −2
@@ -15,6 +15,7 @@
1515
1616 import type { Database } from "bun:sqlite";
1717 import { spentTodayEverywhere } from "../crew/autonomy.ts";
18 import { ceilingHitAt, ceilingReached } from "./ceiling.ts";
1819 import { lastOffBox } from "../snapshot/off_box.ts";
1920 import { readReach, type Reach } from "./reach.ts";
2021 import { HOURS, median } from "./throughput.ts";
@@ -33,7 +34,7 @@ export interface Kpis {
3334 /** Crew builders running now, of the slots the autonomous repositories are set to. */
3435 readonly builders: { readonly running: number; readonly slots: number };
3536 /** What the crew's runs cost since UTC midnight, and the forge's daily ceiling (null: none set). */
36 readonly spend: { readonly todayUsd: number; readonly ceilingUsd: number | null };
37 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 };
3738 /** When a snapshot was last known off the machine (a bucket destination or the mirror, `off_box.ts`); null when never. */
3839 readonly backupAt: string | null;
3940 /** Accounts, visitors and page speed (`reach.ts`). */
@@ -100,7 +101,7 @@ export function readKpis(
100101 backupAt,
101102 builders,
102103 reach: readReach(db, now),
103 spend: { todayUsd, ceilingUsd: forge.ceilingUsd },
104 spend: { todayUsd, ceilingUsd: forge.ceilingUsd, hitAt: ceilingReached(todayUsd, forge.ceilingUsd) ? ceilingHitAt(db, now, forge.ceilingUsd) : null },
104105 landedHours,
105106 landed: landedHours.reduce((a, b) => a + b, 0),
106107 live: live ?? null,
src/tui/screens/dashboard.ts +2 −1
@@ -97,6 +97,7 @@ import { issueScreen } from "./issue.ts";
9797 import { listPinned } from "../../actions/board-pins.ts";
9898 import { migrateScreen } from "./migrate.ts";
9999 import { newRepoScreen } from "./newrepo.ts";
100 import { forgeCeiling } from "../../flow/ceiling.ts";
100101 import { pullScreen } from "./pull.ts";
101102 import { repoScreen } from "./repo.ts";
102103 import { local } from "./where.ts";
@@ -107,7 +108,7 @@ const FIGURES_ROOM = 6;
107108
108109 /** The forge-wide daily ceiling, from config, for the spend figure; the data directory, for the backup's age. */
109110 export const kpiForge = (app: { config: { crewCeilingUsd?: number; dataDir: string } }) => ({
110 ceilingUsd: app.config.crewCeilingUsd ?? null,
111 ceilingUsd: forgeCeiling(app.config),
111112 dataDir: app.config.dataDir,
112113 });
113114
src/web/studio/home.ts +3 −2
@@ -6,6 +6,7 @@
66 * best of what is public (`actions/front.ts`).
77 */
88
9 import { forgeCeiling } from "../../flow/ceiling.ts";
910 import { liveLine } from "../pages/releases.ts";
1011 import {
1112 frontPage,
@@ -253,8 +254,8 @@ export async function studioHome(
253254 const rendering = performance.now();
254255 const ask = firstSteps ?? everydayCard(ctx, user);
255256 const figures = user.isAdmin && steps === undefined;
256 const ceilingUsd = ctx.app.config.crewCeilingUsd;
257 const kpiOpts = { dataDir: ctx.app.config.dataDir, ...(ceilingUsd === undefined ? {} : { ceilingUsd }) };
257 const ceilingUsd = forgeCeiling(ctx.app.config);
258 const kpiOpts = { dataDir: ctx.app.config.dataDir, ...(ceilingUsd === null ? {} : { ceilingUsd }) };
258259 const body = html`<h1 class="sr-only">Home</h1>
259260 ${ask} ${address} ${figures
260261 ? timePhase(ctx.request, "kpis", () => kpiRow(ctx.app.db, kpiOpts), () => kpiCache(ctx.app.db))
src/web/studio/kpi_row.ts +6 −3
@@ -8,7 +8,7 @@
88 */
99
1010 import type { Database } from "bun:sqlite";
11 import { backupAge, backupFull, backupOverdue, buildersValue, checkTime, reactNote, speedWords, spendFull, spendValue, usersValue, visitorsNote, visitorsValue, waitingWords } from "../../flow/kpi_words.ts";
11 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";
1313 import { cachedKpis } from "./home_cache.ts";
1414 import { escapeHtml, html, raw, type Html } from "../html.ts";
@@ -91,6 +91,7 @@ export function liveNow(): { sha: string; at: string } | null {
9191
9292 export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now: Date = new Date()): Html {
9393 const overdue = backupOverdue(k.backupAt, now);
94 const spendWords = spendCeilingWords(k.spend);
9495 const rate = k.landed / k.landedHours.length;
9596 return html`<section class="stats mb" aria-label="The last 24 hours, as of this page load">
9697 ${tile(
@@ -131,8 +132,10 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now
131132 "/settings/crew",
132133 spendValue(k.spend),
133134 spendFull(k.spend),
134 k.spend.ceilingUsd === null ? "no ceiling" : "of the daily ceiling",
135 k.spend.ceilingUsd === null ? "No daily ceiling is set" : "Crew spend today (UTC), of the daily ceiling",
135 spendWords.note,
136 spendWords.title,
137 null,
138 spendWords.attn ? "attn" : null,
136139 )}
137140 ${tile(
138141 "Last off-box backup",
test/home-kpi-page.test.ts +3 −3
@@ -76,7 +76,7 @@ test("home renders the nine tiles; only an admin sees them", async () => {
7676 expect((a.match(/class="stat" href="\/boards\/reach"/g) ?? []).length).toBe(3);
7777
7878 // The figures count every repository, so a non-admin sees none.
79 expect(a).toContain("no ceiling");
79 expect(a).toContain("no forge ceiling");
8080 expect(a).toContain("last 30 days");
8181 expect(await home(bob)).not.toContain('class="stats mb"');
8282 expect(await home(bob)).not.toContain("last 30 days");
@@ -104,8 +104,8 @@ test("home passes the forge's daily ceiling to the spend tile", async () => {
104104 });
105105 const body = await res.text();
106106 expect(body).toContain("of $40");
107 expect(body).toContain("of the daily ceiling");
108 expect(body).not.toContain("no ceiling");
107 expect(body).toContain("of the forge ceiling");
108 expect(body).not.toContain("no forge ceiling");
109109 } finally {
110110 s.stop();
111111 capped.close();
test/home-kpi-row.test.ts +42 −6
@@ -141,7 +141,7 @@ describe("the terminal's figures", () => {
141141 expect(lines).toHaveLength(9);
142142 expect(lines).toContain("Last off-box backup never, overdue");
143143 expect(lines[4]).toBe("Crew spend today $12 of $40");
144 expect(kpiLines({ ...k, spend: { todayUsd: 0.4, ceilingUsd: null } }, null)[4]).toBe("Crew spend today $0.40, no ceiling");
144 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");
146146 expect(kpiLines({ ...k, builders: { running: 0, slots: 0 } }, null)[3]).toBe("Builders running —, no slots set");
147147 expect(lines[0]).toContain("Shipped per hour 0.3");
@@ -164,8 +164,8 @@ describe("the spend tile", () => {
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 });
166166 expect(out).toContain('class="stat-value num" title="$12.34 of $40.00">$12 of $40<');
167 expect(out).toContain('aria-label="Crew spend today: $12.34 of $40.00, Crew spend today (UTC), of the daily ceiling"');
168 expect(out).toContain(">of the daily ceiling<");
167 expect(out).toContain('aria-label="Crew spend today: $12.34 of $40.00, Crew spend today (UTC), of the forge ceiling"');
168 expect(out).toContain(">of the forge ceiling<");
169169 });
170170
171171 test("a small spend shows cents, not $0", () => {
@@ -175,7 +175,7 @@ describe("the spend tile", () => {
175175 test("without a ceiling it shows the spend alone and says no ceiling", () => {
176176 const out = row({ todayUsd: 12.34, ceilingUsd: null });
177177 expect(out).toContain('title="$12.34">$12<');
178 expect(out).toContain(">no ceiling<");
178 expect(out).toContain(">no forge ceiling<");
179179 expect(out).not.toContain(" of $");
180180 });
181181 });
@@ -196,8 +196,8 @@ describe("the spend figure from the database", () => {
196196 ('1','agent',5,'2026-10-19T00:00:00.000Z'), ('2','agent',7.5,'2026-10-19T14:00:00.000Z'),
197197 ('3','agent',100,'2026-10-18T23:59:59.000Z'), ('4','check',9,'2026-10-19T10:00:00.000Z')`,
198198 );
199 expect(readKpis(db, now, { ceilingUsd: 40 }).spend).toEqual({ todayUsd: 12.5, ceilingUsd: 40 });
200 expect(readKpis(db, now).spend).toEqual({ todayUsd: 12.5, ceilingUsd: null });
199 expect(readKpis(db, now, { ceilingUsd: 40 }).spend).toEqual({ todayUsd: 12.5, ceilingUsd: 40, hitAt: null });
200 expect(readKpis(db, now).spend).toEqual({ todayUsd: 12.5, ceilingUsd: null, hitAt: null });
201201 });
202202 });
203203
@@ -398,3 +398,39 @@ describe("home waiting line", () => {
398398 expect(body).not.toContain('href="/review"');
399399 }, 30_000);
400400 });
401
402 describe("the forge ceiling hit (#5798)", () => {
403 const base = { landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: NO_REACH, medianCheckMs: null, builders: { running: 0, slots: 0 } };
404 const over = { todayUsd: 601, ceilingUsd: 600, hitAt: "2026-10-25T14:03:20.000Z" };
405
406 test("past the cap the tile is attention-toned and names the time; the terminal says the same words", () => {
407 const out = kpiRowFor({ ...base, spend: over }, null).toString();
408 expect(out).toContain("ceiling hit 14:03Z, builders idle");
409 expect(out).toContain("attn");
410 expect(kpiLines({ ...base, spend: over }, null)[4]).toBe("Crew spend today $601 of $600, ceiling hit 14:03Z, builders idle");
411 });
412
413 test("under the cap there is no hit; with no cap both doors say no forge ceiling", () => {
414 expect(kpiRowFor({ ...base, spend: { todayUsd: 5, ceilingUsd: 600, hitAt: null } }, null).toString()).not.toContain("ceiling hit");
415 expect(kpiRowFor({ ...base, spend: { todayUsd: 5, ceilingUsd: null } }, null).toString()).toContain("no forge ceiling");
416 expect(kpiLines({ ...base, spend: { todayUsd: 5, ceilingUsd: null } }, null)[4]).toContain("no forge ceiling");
417 });
418
419 test("the hit time is when the run that crossed the cap was created", async () => {
420 const { readKpis } = await import("../src/flow/kpis.ts");
421 const db = new Database(":memory:");
422 db.run("CREATE TABLE repo (autonomy TEXT, autonomy_slots INTEGER, default_branch TEXT, id TEXT)");
423 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, cost_usd REAL, created_at TEXT)");
424 db.run("CREATE TABLE pull (merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)");
425 db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)");
426 reachTables(db);
427 db.run(
428 `INSERT INTO run (id, kind, cost_usd, created_at) VALUES
429 ('a','agent',300,'2026-10-25T10:00:00.000Z'), ('b','agent',300,'2026-10-25T12:30:00.000Z'), ('c','agent',1,'2026-10-25T15:00:00.000Z')`,
430 );
431 const now = new Date("2026-10-25T20:00:00Z");
432 expect(readKpis(db, now, { ceilingUsd: 600 }).spend.hitAt).toBe("2026-10-25T12:30:00.000Z");
433 expect(readKpis(db, now, { ceilingUsd: 700 }).spend.hitAt).toBeNull();
434 expect(readKpis(db, now).spend.hitAt).toBeNull();
435 });
436 });
test/tui-kpis.test.ts +7 −1
@@ -86,6 +86,12 @@ test("the live figure is the running commit, as /releases shows it", async () =>
8686 test("the admin's figures carry the configured daily ceiling, and say so when there is none", async () => {
8787 const capped = { ...forge, config: { ...forge.config, crewCeilingUsd: 40 } };
8888 expect(await firstScreen(alice, capped)).toContain("Crew spend today $0.00 of $40");
89 expect(await firstScreen(alice)).toContain("Crew spend today $0.00, no ceiling");
89 expect(await firstScreen(alice)).toContain("Crew spend today $0.00, no forge ceiling");
9090 expect(await firstScreen(alice)).toContain("Visitors 0 today, 0 in 7 days");
9191 });
92
93 test("past the forge cap the terminal and the web tile read the same words", async () => {
94 const { spendCeilingWords } = await import("../src/flow/kpi_words.ts");
95 const w = spendCeilingWords({ todayUsd: 601, ceilingUsd: 600, hitAt: "2026-10-25T14:03:20.000Z" });
96 expect(w).toEqual({ note: "ceiling hit 14:03Z, builders idle", title: "ceiling hit 14:03Z, builders idle", attn: true });
97 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs