Public: anyone can read this; the crew can change it.
Home's builders tile says why builders are idle: the ceiling, no ready issue, or no subscription with headroom #7008
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5799-home-s-builders-tile-says-why-builders-a → main 7 commits fork 3 threads · 3 unresolved
3 files
changed +151
−6
src/web/studio/home.ts +2 −1
| @@ -57,6 +57,7 @@ import { alarmLines, cachedAlarms, flowOnlyOffer } from "./alarm_lines.ts"; | ||
| 57 | 57 | import { clogRow, kpiRow, vitalsRow, waitingLine } from "./kpi_row.ts"; |
| 58 | 58 | import { feedCandidateCache } from "../../feed/feed.ts"; |
| 59 | 59 | import { notePhase, notePhaseRest, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 60 | import { idleReason } from "../../crew/idle_reason.ts"; | |
| 60 | 61 | import { kpiCache, panelCache, waitingCache } from "./home_cache.ts"; |
| 61 | 62 | import { archivedAppCount } from "../pages/apps.ts"; |
| 62 | 63 | import { pinnedPanels } from "./pinned.ts"; |
| @@ -285,7 +286,7 @@ export async function studioHome( | ||
| 285 | 286 | const ask = firstSteps ?? everydayCard(ctx, user); |
| 286 | 287 | const figures = user.isAdmin && steps === undefined; |
| 287 | 288 | const ceilingUsd = forgeCeiling(ctx.app.config); |
| 288 | const kpiOpts = { dataDir: ctx.app.config.dataDir, ...(ceilingUsd === null ? {} : { ceilingUsd }) }; | |
| 289 | const kpiOpts = { idle: figures ? idleReason(ctx.app, new Date()) : null, dataDir: ctx.app.config.dataDir, ...(ceilingUsd === null ? {} : { ceilingUsd }) }; | |
| 289 | 290 | const body = html`<h1 class="sr-only">Home</h1> |
| 290 | 291 | ${ask} ${address} ${figures |
| 291 | 292 | ? timePhase(ctx.request, "kpis", () => kpiRow(ctx.app.db, kpiOpts), () => kpiCache(ctx.app.db)) |
src/web/studio/kpi_row.ts +7 −5
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | |
| 10 | 10 | import type { Database } from "bun:sqlite"; |
| 11 | 11 | import { backupAge, backupFull, backupOverdue, buildersValue, checkTime, reactNote, speedWords, spendCeilingWords, spendFull, spendValue, usersValue, visitorsNote, visitorsValue, waitingWords } from "../../flow/kpi_words.ts"; |
| 12 | import type { IdleReason } from "../../crew/idle_reason.ts"; | |
| 12 | 13 | import type { Kpis } from "../../flow/kpis.ts"; |
| 13 | 14 | import { clogLines, readClog } from "../../flow/clog_read.ts"; |
| 14 | 15 | import { vitalsLine } from "../../flow/vitals.ts"; |
| @@ -91,7 +92,8 @@ export function liveNow(): { sha: string; at: string } | null { | ||
| 91 | 92 | return commit === null ? null : { sha: commit, at: BOOTED }; |
| 92 | 93 | } |
| 93 | 94 | |
| 94 | export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now: Date = new Date()): Html { | |
| 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 | 97 | const overdue = backupOverdue(k.backupAt, now); |
| 96 | 98 | const spendWords = spendCeilingWords(k.spend); |
| 97 | 99 | const rate = k.landed / k.landedHours.length; |
| @@ -123,10 +125,10 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now | ||
| 123 | 125 | )} |
| 124 | 126 | ${tile( |
| 125 | 127 | "Builders running", |
| 126 | "/settings/crew", | |
| 128 | idle?.href ?? "/settings/crew", | |
| 127 | 129 | buildersValue(k.builders), |
| 128 | 130 | buildersValue(k.builders), |
| 129 | k.builders.slots === 0 ? "No slots set" : "running now", | |
| 131 | k.builders.slots === 0 ? "No slots set" : idle?.word ?? "running now", | |
| 130 | 132 | k.builders.slots === 0 ? "No slots set" : `Crew builders running now, of ${k.builders.slots} ${k.builders.slots === 1 ? "slot" : "slots"}`, |
| 131 | 133 | )} |
| 132 | 134 | ${tile( |
| @@ -163,10 +165,10 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now | ||
| 163 | 165 | } |
| 164 | 166 | |
| 165 | 167 | /** The figures count every repository, so the caller shows this to an admin only. */ |
| 166 | export function kpiRow(db: Database, opts: { ceilingUsd?: number; dataDir?: string; now?: Date } = {}): Html { | |
| 168 | export function kpiRow(db: Database, opts: { ceilingUsd?: number; dataDir?: string; idle?: IdleReason | null; now?: Date } = {}): Html { | |
| 167 | 169 | const now = opts.now ?? new Date(); |
| 168 | 170 | const forge = { ceilingUsd: opts.ceilingUsd ?? null, ...(opts.dataDir === undefined ? {} : { dataDir: opts.dataDir }) }; |
| 169 | return kpiRowFor(cachedKpis(db, now, forge), liveNow(), now); | |
| 171 | return kpiRowFor(cachedKpis(db, now, forge), liveNow(), now, opts.idle ?? null); | |
| 170 | 172 | } |
| 171 | 173 | |
| 172 | 174 | /** |
test/home-builders-idle.test.ts added +142
| @@ -0,0 +1,142 @@ | ||
| 1 | /** Why builders sit idle (#5799): the first reason that holds, on home and in the terminal, from the scheduler's own checks. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 8 | import { openIssue } from "../src/actions/issues.ts"; | |
| 9 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 10 | import { getUserByName } from "../src/core/users.ts"; | |
| 11 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 12 | import { idleReason } from "../src/crew/idle_reason.ts"; | |
| 13 | import { writeStamp } from "../src/core/write_clock.ts"; | |
| 14 | import { kpiRow } from "../src/web/studio/kpi_row.ts"; | |
| 15 | import { createSession } from "../src/core/sessions.ts"; | |
| 16 | import { noteLimit } from "../src/core/standing.ts"; | |
| 17 | import { addSubscription, EVERYONE, setGrants } from "../src/core/subscriptions.ts"; | |
| 18 | import { type User } from "../src/core/users.ts"; | |
| 19 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 20 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 21 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 22 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 23 | import { quietLogs } from "./helpers/globals.ts"; | |
| 24 | import { settleApp } from "./helpers/tui.ts"; | |
| 25 | import type { Repo } from "../src/core/repos.ts"; | |
| 26 | ||
| 27 | quietLogs(); | |
| 28 | ||
| 29 | let dir: string; | |
| 30 | let app: App; | |
| 31 | let servers: Servers; | |
| 32 | let admin: User; | |
| 33 | let repo: Repo; | |
| 34 | let builder: User; | |
| 35 | ||
| 36 | beforeAll(async () => { | |
| 37 | dir = await mkdtemp(join(tmpdir(), "forge-builders-idle-")); | |
| 38 | app = createApp({ dataDir: dir, port: 0 }); | |
| 39 | servers = await serve(app); | |
| 40 | const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 41 | if (!a.ok) throw new Error("signup failed"); | |
| 42 | admin = a.value; | |
| 43 | const made = await createRepo(app, admin, { name: "engine", visibility: "private" }); | |
| 44 | if (!made.ok) throw new Error(String(made.error)); | |
| 45 | repo = made.value; | |
| 46 | const seeded = await commitToBranch(app, admin, repo, { message: "Start", files: [{ path: "README.md", content: "# engine\n" }] }); | |
| 47 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 48 | const agent = await createAgent(app, admin, { username: "mason" }); | |
| 49 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 50 | expect(addToCrew(app, admin, repo, "mason").ok).toBe(true); | |
| 51 | builder = getUserByName(app.db, "mason")!; | |
| 52 | const on = await updateRepoSettings(app, admin, repo, { | |
| 53 | autonomy: "autonomous", | |
| 54 | autonomyDirection: "Build.", | |
| 55 | autonomyCeilingUsd: 10, | |
| 56 | autonomySlots: 1, | |
| 57 | autonomyBy: admin.id, | |
| 58 | }); | |
| 59 | expect(on.ok).toBe(true); | |
| 60 | }); | |
| 61 | ||
| 62 | afterAll(async () => { | |
| 63 | servers.stop(); | |
| 64 | app.close(); | |
| 65 | await rm(dir, { recursive: true, force: true }); | |
| 66 | }); | |
| 67 | ||
| 68 | const home = async (): Promise<string> => | |
| 69 | (await fetch(`${servers.url}/`, { headers: { cookie: `forge_session=${createSession(app.db, admin.id).token}; forge_mode=studio` } })).text(); | |
| 70 | ||
| 71 | async function terminal(): Promise<string> { | |
| 72 | const t = headlessTerminal(200, 40); | |
| 73 | const { app: tui } = createForgeApp( | |
| 74 | app, | |
| 75 | { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: admin }, | |
| 76 | { terminal: t, exitHooks: false }, | |
| 77 | ); | |
| 78 | tui.start(); | |
| 79 | await settleApp(tui); | |
| 80 | const text = tui.screen.toText(); | |
| 81 | tui.stop(); | |
| 82 | return text; | |
| 83 | } | |
| 84 | ||
| 85 | /** The builders tile's anchor on home. */ | |
| 86 | const tile = (page: string): string => /<a class="stat" href="[^"]*"[^>]*><span class="stat-label">Builders running.*?<\/a>/s.exec(page)?.[0] ?? ""; | |
| 87 | ||
| 88 | test("nothing ready: the map has nothing a builder may start", async () => { | |
| 89 | const page = await home(); | |
| 90 | expect(tile(page)).toContain("nothing ready"); | |
| 91 | expect(tile(page)).toContain('href="/map"'); | |
| 92 | expect(await terminal()).toContain("Builders running 0 of 1 · nothing ready"); | |
| 93 | }); | |
| 94 | ||
| 95 | test("a ready issue and a builder with room: no reason is named", async () => { | |
| 96 | const opened = openIssue(app, admin, repo, { title: "Renderer" }); | |
| 97 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 98 | const page = await home(); | |
| 99 | expect(tile(page)).not.toContain("nothing ready"); | |
| 100 | expect(tile(page)).toContain("running now"); | |
| 101 | expect(await terminal()).not.toMatch(/Builders running 0 of 1 · (nothing|no sub|ceiling)/); | |
| 102 | }); | |
| 103 | ||
| 104 | test("no subscription with headroom: the granted account's window is full", async () => { | |
| 105 | const sub = addSubscription(app.db, dir, { name: "espo", token: "sk-ant-oat01-" + "x".repeat(24) }); | |
| 106 | if (!sub.ok) throw new Error(sub.error); | |
| 107 | setGrants(app.db, repo.id, EVERYONE, [sub.value.id]); | |
| 108 | const now = Date.now(); | |
| 109 | noteLimit(app.db, sub.value.id, { window: "5h", resetsAt: new Date(now + 3_600_000).toISOString(), observedAt: new Date(now).toISOString(), said: "limit" }); | |
| 110 | const page = await home(); | |
| 111 | // The write stamp is the same at both clocks; only the window's reset moves. | |
| 112 | const stampBefore = writeStamp(app.db); | |
| 113 | const later = new Date(now + 2 * 3_600_000); | |
| 114 | expect(idleReason(app, new Date(now))?.word).toBe("no subscription with headroom"); | |
| 115 | expect(idleReason(app, later)?.word).not.toBe("no subscription with headroom"); | |
| 116 | expect(String(kpiRow(app.db, { now: new Date(now), idle: idleReason(app, new Date(now)) }))).toContain("no subscription with headroom"); | |
| 117 | expect(String(kpiRow(app.db, { now: later, idle: idleReason(app, later) }))).not.toContain("no subscription with headroom"); | |
| 118 | expect(writeStamp(app.db)).toBe(stampBefore); | |
| 119 | expect(tile(page)).toContain("no subscription with headroom"); | |
| 120 | expect(tile(page)).toContain('href="/subscriptions"'); | |
| 121 | expect(await terminal()).toContain("Builders running 0 of 1 · no subscription with headroom"); | |
| 122 | }); | |
| 123 | ||
| 124 | test("ceiling reached outranks the others", async () => { | |
| 125 | const spent = createRun(app.db, { repoId: repo.id, kind: "agent", actorId: builder.id, reason: "assigned" }); | |
| 126 | claimRun(app.db, spent.id); | |
| 127 | finishRun(app.db, spent.id, { state: "ok", summary: "done", costUsd: 12 }); | |
| 128 | const page = await home(); | |
| 129 | expect(tile(page)).toContain("ceiling reached"); | |
| 130 | expect(tile(page)).toContain('href="/meter"'); | |
| 131 | expect(await terminal()).toContain("Builders running 0 of 1 · ceiling reached"); | |
| 132 | }); | |
| 133 | ||
| 134 | test("every slot busy: no reason is shown", async () => { | |
| 135 | const run = createRun(app.db, { repoId: repo.id, kind: "agent", actorId: builder.id, reason: "assigned" }); | |
| 136 | claimRun(app.db, run.id); | |
| 137 | const page = await home(); | |
| 138 | expect(tile(page)).toContain("1 of 1"); | |
| 139 | expect(tile(page)).not.toContain("ceiling reached"); | |
| 140 | expect(await terminal()).toContain("Builders running 1 of 1"); | |
| 141 | expect(await terminal()).not.toContain("ceiling reached"); | |
| 142 | }); |
new above
offline