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

Merged opened this

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

Merged

scout/forge:scout/5799-home-s-builders-tile-says-why-builders-a → main 7 commits fork 3 threads · 3 unresolved

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

Review

Sign in to review this change.
3 files changed +151 −6
src/web/studio/home.ts +2 −1
@@ -57,6 +57,7 @@ import { alarmLines, cachedAlarms, flowOnlyOffer } from "./alarm_lines.ts";
5757 import { clogRow, kpiRow, vitalsRow, waitingLine } from "./kpi_row.ts";
5858 import { feedCandidateCache } from "../../feed/feed.ts";
5959 import { notePhase, notePhaseRest, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
60 import { idleReason } from "../../crew/idle_reason.ts";
6061 import { kpiCache, panelCache, waitingCache } from "./home_cache.ts";
6162 import { archivedAppCount } from "../pages/apps.ts";
6263 import { pinnedPanels } from "./pinned.ts";
@@ -285,7 +286,7 @@ export async function studioHome(
285286 const ask = firstSteps ?? everydayCard(ctx, user);
286287 const figures = user.isAdmin && steps === undefined;
287288 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 }) };
289290 const body = html`<h1 class="sr-only">Home</h1>
290291 ${ask} ${address} ${figures
291292 ? 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 @@
99
1010 import type { Database } from "bun:sqlite";
1111 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";
1213 import type { Kpis } from "../../flow/kpis.ts";
1314 import { clogLines, readClog } from "../../flow/clog_read.ts";
1415 import { vitalsLine } from "../../flow/vitals.ts";
@@ -91,7 +92,8 @@ export function liveNow(): { sha: string; at: string } | null {
9192 return commit === null ? null : { sha: commit, at: BOOTED };
9293 }
9394
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 {
9597 const overdue = backupOverdue(k.backupAt, now);
9698 const spendWords = spendCeilingWords(k.spend);
9799 const rate = k.landed / k.landedHours.length;
@@ -123,10 +125,10 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now
123125 )}
124126 ${tile(
125127 "Builders running",
126 "/settings/crew",
128 idle?.href ?? "/settings/crew",
127129 buildersValue(k.builders),
128130 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",
130132 k.builders.slots === 0 ? "No slots set" : `Crew builders running now, of ${k.builders.slots} ${k.builders.slots === 1 ? "slot" : "slots"}`,
131133 )}
132134 ${tile(
@@ -163,10 +165,10 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now
163165 }
164166
165167 /** 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 {
167169 const now = opts.now ?? new Date();
168170 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);
170172 }
171173
172174 /**
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs