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

The board shows each subscription's window and each app's units per shipped change #381

Merged opened this

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

Merged

torque/forge:torque/374-the-board-shows-each-subscription-s-wind → main 1 commit fork

  • Merged. torque/374-the-board-shows-each-subscription-s-wind was merged into main as ae77fcc.

Review

Sign in to review this change.
9 files changed +704 −67
docs/STUDIO.md +10 −1
@@ -374,7 +374,16 @@ changes across your apps, each with the app's production look on a phone.
374374 **Decided** is the last dozen entries in the decision ledger: which issue
375375 next, ship or show, try again — the answer, how sure the decider was, and
376376 what came of it. **Today** is one row per app that runs on its own: spend
377 against the ceiling, runs, shipped, and whether it is paused. Every region
377 against the ceiling, runs, shipped, and whether it is paused — plus any
378 other app the crew spent on this week — and beneath it the efficiency
379 signal: spend per shipped change over the last 7 days (the usage ledger's
380 `cost_usd` ÷ pulls merged, `core/efficiency.ts`, every app in one grouped
381 query), or "$4.10 spent, nothing shipped" when there is no ratio. A site
382 admin also gets a strip of the subscriptions above the regions: each
383 account's 5-hour and weekly window — used, left, resets — in the words
384 `windowWords` (`studio/words.ts`) gives them, with _inferred_ beside every
385 figure the provider did not say (`core/standing.ts`; all the accounts in
386 three statements, `standings`). Every region
378387 is live — it swaps itself when a matching event lands, and the dot in the
379388 corner says whether the stream is connected. `?apps=a,b` narrows it to some
380389 apps. Keys: `f` fullscreen, `1`–`4` one region full-bleed, `0` all.
forge.json +1 −1
@@ -6,7 +6,7 @@
66 "ready": 90
77 },
88 "check": {
9 "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts test/feed.test.ts test/feed-page.test.ts test/offline.test.ts test/share.test.ts test/cloud.test.ts test/run-secrets.test.ts test/forge-cloudflare.test.ts test/usage.test.ts test/standing.test.ts",
9 "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts test/feed.test.ts test/feed-page.test.ts test/offline.test.ts test/share.test.ts test/cloud.test.ts test/run-secrets.test.ts test/forge-cloudflare.test.ts test/usage.test.ts test/standing.test.ts test/board-windows.test.ts",
1010 "timeout": 1800
1111 }
1212 }
src/actions/usage.ts +5 −4
@@ -14,7 +14,7 @@
1414 import type { App } from "../app.ts";
1515 import { getIssuesByIds } from "../core/issues.ts";
1616 import { canSeeOwnerFacts, type Repo } from "../core/repos.ts";
17 import { standing, type Standing } from "../core/standing.ts";
17 import { standings, type Standing } from "../core/standing.ts";
1818 import { listGrants, listSubscriptions } from "../core/subscriptions.ts";
1919 import {
2020 recentUsage,
@@ -136,9 +136,10 @@ export function readUsage(
136136 accounts.add(g.subscriptionId);
137137 for (const row of bySubscription)
138138 if (row.key !== null && subscriptions.has(row.key)) accounts.add(row.key);
139 const standings = [...accounts]
139 const read = standings(db, [...accounts], now);
140 const standingsOut = [...accounts]
140141 .flatMap((id) => {
141 const s = standing(db, id, now);
142 const s = read.get(id);
142143 if (s === undefined) return [];
143144 const { subscriptionId: _, ...rest } = s;
144145 return [{ subscription: subscriptions.get(id) ?? id, ...rest }];
@@ -162,6 +163,6 @@ export function readUsage(
162163 agent: agentName(r.agentId),
163164 issue: issueNumber(r.issueId),
164165 })),
165 standing: standings,
166 standing: standingsOut,
166167 });
167168 }
src/core/efficiency.ts added +79
@@ -0,0 +1,79 @@
1 /**
2 * Efficiency — what an app's crew spent per change it shipped, over a
3 * window: the forge's efficiency signal. Spend is the usage ledger's
4 * (`core/usage.ts`), so it counts every agent run that ended in the window,
5 * shipped or not; a shipped change is a pull merged in the window.
6 *
7 * Units: US dollars of the provider's list-price equivalent
8 * (`usage.cost_usd`), rounded to 1/10 000 of a dollar.
9 *
10 * perShippedUsd = Σ usage.cost_usd (at ≥ since) ÷ count(merged_at ≥ since)
11 *
12 * and null when nothing shipped — a ratio over zero changes is not a
13 * number, and "all of it" is said in words by the page, not as infinity.
14 *
15 * Pure data: no authz, no events.
16 */
17
18 import type { Database } from "bun:sqlite";
19
20 export interface Efficiency {
21 readonly repoId: string;
22 /** Ledger lines (agent runs) that ended in the window. */
23 readonly runs: number;
24 readonly spentUsd: number;
25 /** Pulls merged in the window. */
26 readonly shipped: number;
27 /** spentUsd ÷ shipped; null when nothing shipped. */
28 readonly perShippedUsd: number | null;
29 }
30
31 const money = (n: number): number => Math.round(n * 10_000) / 10_000;
32
33 /**
34 * Each repository's spend and shipped changes since an instant, in one
35 * grouped query. A repository with neither is absent: a quiet app is no
36 * row, not a row of zeros.
37 */
38 export function efficiencyForRepos(
39 db: Database,
40 repoIds: readonly string[],
41 since: string,
42 ): Map<string, Efficiency> {
43 const ids = [...new Set(repoIds)].slice(0, 400);
44 const out = new Map<string, Efficiency>();
45 if (ids.length === 0) return out;
46 const list = ids.map((_, n) => `?${n + 2}`).join(",");
47 const rows = db
48 .query<
49 { repo_id: string; runs: number; spent: number; shipped: number },
50 string[]
51 >(
52 `SELECT repo_id, SUM(runs) AS runs, SUM(spent) AS spent, SUM(shipped) AS shipped
53 FROM (
54 SELECT repo_id, COUNT(*) AS runs, SUM(cost_usd) AS spent, 0 AS shipped
55 FROM usage
56 WHERE at >= ?1 AND repo_id IN (${list})
57 GROUP BY repo_id
58 UNION ALL
59 SELECT issue.repo_id, 0, 0, COUNT(*)
60 FROM issue JOIN pull ON pull.issue_id = issue.id
61 WHERE pull.merged_at IS NOT NULL AND pull.merged_at >= ?1
62 AND issue.repo_id IN (${list})
63 GROUP BY issue.repo_id
64 )
65 GROUP BY repo_id`,
66 )
67 .all(since, ...ids);
68 for (const r of rows) {
69 const spentUsd = money(r.spent);
70 out.set(r.repo_id, {
71 repoId: r.repo_id,
72 runs: r.runs,
73 spentUsd,
74 shipped: r.shipped,
75 perShippedUsd: r.shipped > 0 ? money(r.spent / r.shipped) : null,
76 });
77 }
78 return out;
79 }
src/core/standing.ts +101 −53
@@ -118,32 +118,6 @@ export function noteLimit(
118118 );
119119 }
120120
121 function limitsOf(
122 db: Database,
123 subscriptionId: string,
124 ): Map<LimitWindow, Limit> {
125 const rows = db
126 .query<
127 { span: LimitWindow; resets_at: string; observed_at: string; said: string },
128 [string]
129 >(
130 `SELECT span, resets_at, observed_at, said FROM subscription_limit
131 WHERE subscription_id = ?1`,
132 )
133 .all(subscriptionId);
134 return new Map(
135 rows.map((r) => [
136 r.span,
137 {
138 window: r.span,
139 resetsAt: r.resets_at,
140 observedAt: r.observed_at,
141 said: r.said,
142 },
143 ]),
144 );
145 }
146
147121 /** A whole percent 0–90 from text; undefined for anything else. */
148122 export function headroomPct(text: string): number | undefined {
149123 if (!/^\d{1,2}$/.test(text.trim())) return undefined;
@@ -196,45 +170,119 @@ export function standing(
196170 subscriptionId: string,
197171 now: Date,
198172 ): Standing | undefined {
199 const pct = getHeadroom(db, subscriptionId);
200 if (pct === undefined) return undefined;
201 const limits = limitsOf(db, subscriptionId);
173 return standings(db, [subscriptionId], now).get(subscriptionId);
174 }
175
176 const placeholders = (n: number, from = 1): string =>
177 Array.from({ length: n }, (_, i) => `?${i + from}`).join(",");
178
179 /**
180 * Several accounts' standing at `now`, keyed by id, in three statements
181 * whatever their number: the headroom, the provider's last words, and one
182 * ledger read from the earliest any of them needs. An unknown id is absent.
183 */
184 export function standings(
185 db: Database,
186 subscriptionIds: readonly string[],
187 now: Date,
188 ): Map<string, Standing> {
189 const ids = [...new Set(subscriptionIds)].slice(0, 400);
190 const out = new Map<string, Standing>();
191 if (ids.length === 0) return out;
192 const pcts = new Map(
193 db
194 .query<{ id: string; headroom_pct: number }, string[]>(
195 `SELECT id, headroom_pct FROM subscription WHERE id IN (${placeholders(ids.length)})`,
196 )
197 .all(...ids)
198 .map((r) => [r.id, r.headroom_pct]),
199 );
200 if (pcts.size === 0) return out;
201 const known = [...pcts.keys()];
202
203 const limits = new Map<string, Map<LimitWindow, Limit>>();
204 for (const r of db
205 .query<
206 {
207 subscription_id: string;
208 span: LimitWindow;
209 resets_at: string;
210 observed_at: string;
211 said: string;
212 },
213 string[]
214 >(
215 `SELECT subscription_id, span, resets_at, observed_at, said FROM subscription_limit
216 WHERE subscription_id IN (${placeholders(known.length)})`,
217 )
218 .all(...known)) {
219 const of = limits.get(r.subscription_id) ?? new Map<LimitWindow, Limit>();
220 of.set(r.span, {
221 window: r.span,
222 resetsAt: r.resets_at,
223 observedAt: r.observed_at,
224 said: r.said,
225 });
226 limits.set(r.subscription_id, of);
227 }
228
202229 const t = now.getTime();
203230 // Two weeks back holds every window's chain; a refusal's window is read
204231 // too, so long as it opened within the last five weeks.
205232 const floor = t - 5 * WINDOW_MS.week;
206 const since = Math.max(
207 floor,
208 Math.min(
209 t - 2 * WINDOW_MS.week,
210 ...[...limits.values()].map(
211 (l) => Date.parse(l.resetsAt) - WINDOW_MS[l.window],
233 const sinceOf = (id: string): number =>
234 Math.max(
235 floor,
236 Math.min(
237 t - 2 * WINDOW_MS.week,
238 ...[...(limits.get(id)?.values() ?? [])].map(
239 (l) => Date.parse(l.resetsAt) - WINDOW_MS[l.window],
240 ),
212241 ),
213 ),
214 );
215 const spends: Spend[] = db
242 );
243 const since = new Map(known.map((id) => [id, sinceOf(id)]));
244 const earliest = Math.min(...since.values());
245
246 const spends = new Map<string, Spend[]>(known.map((id) => [id, []]));
247 for (const r of db
216248 .query<
217 { at: string; wall_ms: number; cost_usd: number; tokens_in: number; tokens_out: number },
218 [string, string]
249 {
250 subscription_id: string;
251 at: string;
252 wall_ms: number;
253 cost_usd: number;
254 tokens_in: number;
255 tokens_out: number;
256 },
257 string[]
219258 >(
220 `SELECT at, wall_ms, cost_usd, tokens_in, tokens_out FROM usage
221 WHERE subscription_id = ?1 AND at >= ?2`,
259 `SELECT subscription_id, at, wall_ms, cost_usd, tokens_in, tokens_out FROM usage
260 WHERE subscription_id IN (${placeholders(known.length, 2)}) AND at >= ?1`,
222261 )
223 .all(subscriptionId, iso(since))
224 .map((r) => ({
262 .all(iso(earliest), ...known)) {
263 // Each account reads from its own floor, as if read alone.
264 if (r.at < iso(since.get(r.subscription_id)!)) continue;
265 spends.get(r.subscription_id)!.push({
225266 start: Date.parse(r.at) - r.wall_ms,
226267 costUsd: r.cost_usd,
227268 tokensIn: r.tokens_in,
228269 tokensOut: r.tokens_out,
229 }))
230 .sort((a, b) => a.start - b.start);
231 return {
232 subscriptionId,
233 headroomPct: pct,
234 windows: WINDOWS.map((w) =>
235 windowStanding(w, limits.get(w), spends, pct, t, since),
236 ),
237 };
270 });
271 }
272
273 for (const id of known) {
274 const pct = pcts.get(id)!;
275 const mine = spends.get(id)!.sort((a, b) => a.start - b.start);
276 const of = limits.get(id);
277 out.set(id, {
278 subscriptionId: id,
279 headroomPct: pct,
280 windows: WINDOWS.map((w) =>
281 windowStanding(w, of?.get(w), mine, pct, t, since.get(id)!),
282 ),
283 });
284 }
285 return out;
238286 }
239287
240288 function windowStanding(
src/web/pages/board.ts +106 −6
@@ -1,8 +1,10 @@
11 /**
22 * The board: a live wall of the crews at work, for a second monitor. Four
33 * regions — what is being worked on now, what landed, what the decider
4 * decided, and today's spend against each app's ceiling — each a live
5 * region that swaps itself when a matching event lands. Nothing on it
4 * decided, and today's spend against each app's ceiling with what a shipped
5 * change cost over the week — each a live region that swaps itself when a
6 * matching event lands. A site admin also gets two strips above them: the
7 * forge's health, and each subscription's windows. Nothing on it
68 * needs a click, and everything on it links somewhere. It reads the same
79 * tables as the studio and adds no state of its own.
810 */
@@ -14,6 +16,9 @@ import {
1416 type DecisionKind,
1517 } from "../../core/decisions.ts";
1618 import { appsRuntime } from "../../apps/runtime.ts";
19 import { efficiencyForRepos } from "../../core/efficiency.ts";
20 import { standings, WINDOW_MS } from "../../core/standing.ts";
21 import { listSubscriptions } from "../../core/subscriptions.ts";
1722 import { listApps } from "../../core/apps.ts";
1823 import { vitals, type Vitals } from "../../kernel/vitals.ts";
1924 import { getIssue, getIssuesByIds, type Issue } from "../../core/issues.ts";
@@ -34,6 +39,11 @@ import { html, when, type Html } from "../html.ts";
3439 import { page } from "../layout.ts";
3540 import { avatar, icon, time } from "../ui.ts";
3641 import { voiceFor } from "../studio/voice.ts";
42 import {
43 perShippedWords,
44 windowWords,
45 type FigureWords,
46 } from "../studio/words.ts";
3747 import { lookUrl, thumbLook } from "./looks.ts";
3848
3949 // ---- words ----
@@ -175,7 +185,7 @@ export function boardPage(ctx: Ctx): Response {
175185 ></span>
176186 <time class="board-clock" id="board-clock"></time>
177187 </header>
178 ${forgeStrip(ctx)}
188 ${forgeStrip(ctx)} ${windowsStrip(ctx)}
179189 <div class="board-grid">
180190 ${working(ctx, scope)} ${landed(ctx, scope)} ${decided(ctx, scope)}
181191 ${today(ctx, scope)}
@@ -264,6 +274,70 @@ function forgeStrip(ctx: Ctx): Html | null {
264274 </div>`;
265275 }
266276
277 // ---- the subscriptions' windows ----
278
279 /** Figures in a row, `inferred` said once when every one is, else on each. */
280 function figures(parts: readonly FigureWords[]): Html {
281 const all = parts.every((p) => p.inferred);
282 const tag = html`<i class="board-inferred">inferred</i>`;
283 return html`${parts.map(
284 (p, n) =>
285 html`${n > 0 ? " · " : null}<span
286 >${p.text}${when(!all && p.inferred, () => html` ${tag}`)}</span
287 >`,
288 )}${when(all, () => html` ${tag}`)}`;
289 }
290
291 /**
292 * Each subscription's 5-hour and weekly window — used, left, resets — for a
293 * site admin only: the pool is theirs (`/settings/subscriptions`). Three
294 * statements however many accounts (`standings`). A live region of its own.
295 */
296 function windowsStrip(ctx: Ctx): Html | null {
297 if (!ctx.auth.user?.isAdmin) return null;
298 const db = ctx.app.db;
299 const pool = listSubscriptions(db);
300 if (pool.length === 0) return null;
301 const now = new Date();
302 const all = standings(
303 db,
304 pool.map((s) => s.id),
305 now,
306 );
307 return html`<div
308 class="board-windows"
309 id="windows"
310 data-live="run.*"
311 aria-label="Subscription windows"
312 >
313 ${pool.map((s) => {
314 const st = all.get(s.id);
315 if (st === undefined) return null;
316 return html`<a
317 class="board-sub"
318 href="/settings/subscriptions"
319 data-key="sub:${s.id}"
320 >
321 <span class="board-sub-name">${s.name}</span>
322 ${st.windows.map((w) => {
323 const words = windowWords(w);
324 return html`<span class="board-window" data-window="${w.window}"
325 ><span class="board-window-name">${words.name}</span>
326 ${words.usedPct === null
327 ? null
328 : html`<span class="board-bar"
329 ><span class="board-fill" data-w="${words.usedPct}"></span
330 ></span>`}
331 <span class="board-window-nums"
332 >${figures([words.used, words.remaining, words.resets])}</span
333 ></span
334 >`;
335 })}
336 </a>`;
337 })}
338 </div>`;
339 }
340
267341 // ---- working now ----
268342
269343 function working(ctx: Ctx, scope: BoardScope): Html {
@@ -479,10 +553,21 @@ function decided(ctx: Ctx, scope: BoardScope): Html {
479553
480554 function today(ctx: Ctx, scope: BoardScope): Html {
481555 const db = ctx.app.db;
482 const since = startOfTodayUtc(new Date()).toISOString();
483 // Today's spend against the ceiling is the owner's own dashboard.
556 const now = new Date();
557 const since = startOfTodayUtc(now).toISOString();
558 // Spend per shipped change over the weekly window, every app in one read.
559 const week = efficiencyForRepos(
560 db,
561 [...scope.ownerFacts],
562 new Date(now.getTime() - WINDOW_MS.week).toISOString(),
563 );
564 // Today's spend against the ceiling is the owner's own dashboard: each
565 // app that runs on its own, and any other the crew spent on this week.
484566 const rows = scope.repos
485 .filter((r) => r.autonomy !== "off" && scope.ownerFacts.has(r.id))
567 .filter(
568 (r) =>
569 scope.ownerFacts.has(r.id) && (r.autonomy !== "off" || week.has(r.id)),
570 )
486571 .map((repo) => {
487572 const s = autonomyStatus(ctx.app, repo);
488573 const runs = listRuns(db, repo.id, { kind: "agent", limit: 200 }).filter(
@@ -519,6 +604,9 @@ function today(ctx: Ctx, scope: BoardScope): Html {
519604 <span><b>${runs}</b> run${runs === 1 ? "" : "s"}</span>
520605 <span><b>${shipped}</b> shipped</span>
521606 </span>
607 <span class="board-app-week"
608 >${perShippedWords(week.get(repo.id))} this week</span
609 >
522610 <span class="board-spend"
523611 ><span class="board-fill" data-w="${pct(share)}"></span
524612 ></span>
@@ -643,6 +731,18 @@ ${widths}
643731 .board-spend { grid-column: 1 / -1; display: block; height: 6px; border-radius: 999px; background: var(--muted); overflow: hidden; }
644732 .board-app[data-paused="ceiling"] .board-fill { background: var(--attn); }
645733
734 /* Windows: each subscription's 5-hour and weekly window, admin only. */
735 .board-windows { display: flex; flex-wrap: wrap; gap: 8px 12px; padding: 8px var(--pad) 0; font-size: 13px; }
736 .board-sub { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 16px; padding: 6px 12px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); color: var(--muted-fg); }
737 .board-sub:hover { border-color: var(--border-strong); color: var(--muted-fg); }
738 .board-sub-name { font-weight: 600; color: var(--fg); }
739 .board-window { display: inline-flex; align-items: center; gap: 8px; font-variant-numeric: tabular-nums; }
740 .board-window-name { color: var(--faint); }
741 .board-window .board-bar { width: 56px; }
742 .board-window[data-window] .board-fill[data-w="100"] { background: var(--attn); }
743 .board-inferred { font-style: normal; font-size: .85em; color: var(--faint); border: 1px dashed var(--border); border-radius: 4px; padding: 0 4px; }
744 .board-app-week { grid-column: 1 / -1; font-size: .875em; color: var(--muted-fg); font-variant-numeric: tabular-nums; }
745
646746 /* Wide: four regions fill the window, each scrolling on its own. */
647747 @media (min-width: 1100px) {
648748 .board { flex: none; height: 100dvh; min-height: 0; }
src/web/pages/subscriptions.ts +7 −2
@@ -22,7 +22,7 @@ import {
2222 HEADROOM_MAX,
2323 headroomPct,
2424 setHeadroom,
25 standing,
25 standings,
2626 type Figure,
2727 type WindowStanding,
2828 } from "../../core/standing.ts";
@@ -99,6 +99,11 @@ export function subscriptionsPage(ctx: Ctx): Response {
9999 const now = new Date();
100100 const pool = listSubscriptions(ctx.app.db);
101101 const places = listGrantPlaces(ctx.app.db);
102 const standingOf = standings(
103 ctx.app.db,
104 pool.map((s) => s.id),
105 now,
106 );
102107
103108 const list =
104109 pool.length === 0
@@ -110,7 +115,7 @@ export function subscriptionsPage(ctx: Ctx): Response {
110115 : listCard(
111116 pool.map((s) => {
112117 const rests = resting(s, now);
113 const st = standing(ctx.app.db, s.id, now);
118 const st = standingOf.get(s.id);
114119 const by =
115120 s.createdBy === null
116121 ? undefined
src/web/studio/words.ts +68
@@ -12,6 +12,9 @@ import type { Database } from "bun:sqlite";
1212 import { ancestorsOf } from "../../git/merge.ts";
1313 import type { AppsRuntime, EnvStatus } from "../../apps/runtime.ts";
1414 import type { AppRecord } from "../../core/apps.ts";
15 import type { Efficiency } from "../../core/efficiency.ts";
16 import type { WindowStanding } from "../../core/standing.ts";
17 import { whenText } from "../../crew/limits.ts";
1518 import { getIssueById } from "../../core/issues.ts";
1619 import { getPull, pullForBranch, type Pull } from "../../core/pulls.ts";
1720 import {
@@ -346,3 +349,68 @@ export function wordBadge(word: Word, id?: string): Html {
346349 >${word === "working" ? icon("loader") : null}${word}</span
347350 >`;
348351 }
352
353 // ---- what a subscription has left, and what a change costs ----
354
355 /** One figure as a person reads it, and whether the forge worked it out
356 * (from the usage ledger) rather than the provider saying it. */
357 export interface FigureWords {
358 readonly text: string;
359 readonly inferred: boolean;
360 }
361
362 /** A subscription's window (`core/standing.ts`) in words: what is used,
363 * what is left, when it resets. Money in US dollars of list price. */
364 export interface WindowWords {
365 readonly name: string;
366 readonly used: FigureWords;
367 readonly remaining: FigureWords;
368 readonly resets: FigureWords;
369 /** Whole percent of the cap used, 0–100; null while no cap is known. */
370 readonly usedPct: number | null;
371 }
372
373 const dollars = (n: number): string => `$${n.toFixed(2)}`;
374
375 export function windowWords(w: WindowStanding): WindowWords {
376 const full = w.remaining?.source === "read" && w.remaining.value === 0;
377 const usedPct = full
378 ? 100
379 : w.cap === null || w.cap.value <= 0
380 ? null
381 : Math.max(0, Math.min(100, Math.round((w.used.value / w.cap.value) * 100)));
382 return {
383 name: w.window === "5h" ? "5-hour window" : "weekly window",
384 used: {
385 text: `${dollars(w.used.value)} used`,
386 inferred: w.used.source === "inferred",
387 },
388 remaining:
389 w.remaining === null
390 ? { text: "cap not known yet", inferred: false }
391 : full
392 ? { text: "full", inferred: false }
393 : {
394 text: `${dollars(w.remaining.value)} left`,
395 inferred: w.remaining.source === "inferred",
396 },
397 resets:
398 w.resetsAt === null
399 ? { text: "opens on next use", inferred: false }
400 : {
401 text: `resets ${whenText(new Date(w.resetsAt.value))}`,
402 inferred: w.resetsAt.source === "inferred",
403 },
404 usedPct,
405 };
406 }
407
408 /** An app's spend per shipped change (`core/efficiency.ts`) in a few words:
409 * `$1.37 per shipped change`, or what is true when there is no ratio. */
410 export function perShippedWords(e: Efficiency | undefined): string {
411 if (e === undefined || (e.spentUsd === 0 && e.shipped === 0))
412 return "nothing spent or shipped";
413 if (e.perShippedUsd === null)
414 return `${dollars(e.spentUsd)} spent, nothing shipped`;
415 return `${dollars(e.perShippedUsd)} per shipped change`;
416 }
test/board-windows.test.ts added +327
@@ -0,0 +1,327 @@
1 /**
2 * The board's two new readings: each subscription's windows (used, left,
3 * resets — and *inferred* wherever the provider did not say), and each
4 * app's spend per shipped change over the week. Numbers are checked against
5 * a hand calculation; statements are counted, so neither grows a query per
6 * row (`test/counts.test.ts`'s rule).
7 */
8
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import type { Database } from "bun:sqlite";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { createApp, type App } from "../src/app.ts";
15 import { efficiencyForRepos } from "../src/core/efficiency.ts";
16 import { createRun } from "../src/core/runs.ts";
17 import {
18 noteLimit,
19 standing,
20 standings,
21 type WindowStanding,
22 } from "../src/core/standing.ts";
23 import { addSubscription } from "../src/core/subscriptions.ts";
24 import { recordUsage } from "../src/core/usage.ts";
25 import { createUser, type User } from "../src/core/users.ts";
26 import { serve, type Servers } from "../src/http/server.ts";
27 import { id } from "../src/kernel/id.ts";
28 import { perShippedWords, windowWords } from "../src/web/studio/words.ts";
29
30 /** Count the statements a block of work runs, through `db.query`. */
31 async function statements(db: Database, work: () => Promise<void> | void) {
32 const original = db.query.bind(db);
33 let count = 0;
34 (db as unknown as { query: unknown }).query = (sql: string) => {
35 count++;
36 return original(sql);
37 };
38 try {
39 await work();
40 } finally {
41 (db as unknown as { query: unknown }).query = original;
42 }
43 return count;
44 }
45
46 describe("the words for a window", () => {
47 const base: WindowStanding = {
48 window: "5h",
49 startsAt: { value: "2026-10-02T10:00:00.000Z", source: "inferred" },
50 resetsAt: { value: "2026-10-02T15:00:00.000Z", source: "inferred" },
51 cap: { value: 10, source: "inferred" },
52 used: { value: 3.2, source: "inferred" },
53 remaining: { value: 6.8, source: "inferred" },
54 kept: { value: 0, source: "inferred" },
55 crewRemaining: { value: 6.8, source: "inferred" },
56 runs: 2,
57 tokensIn: 0,
58 tokensOut: 0,
59 said: null,
60 };
61
62 test("worked out from the ledger, every figure says inferred", () => {
63 const w = windowWords(base);
64 expect(w.name).toBe("5-hour window");
65 expect(w.used).toEqual({ text: "$3.20 used", inferred: true });
66 expect(w.remaining).toEqual({ text: "$6.80 left", inferred: true });
67 expect(w.resets).toEqual({ text: "resets 15:00 UTC", inferred: true });
68 // 3.20 ÷ 10.00 = 32 %.
69 expect(w.usedPct).toBe(32);
70 });
71
72 test("a window the provider called full reads full, its reset as said", () => {
73 const w = windowWords({
74 ...base,
75 window: "week",
76 resetsAt: { value: "2026-10-05T03:00:00.000Z", source: "read" },
77 remaining: { value: 0, source: "read" },
78 });
79 expect(w.name).toBe("weekly window");
80 expect(w.remaining).toEqual({ text: "full", inferred: false });
81 expect(w.resets).toEqual({ text: "resets 03:00 UTC", inferred: false });
82 expect(w.usedPct).toBe(100);
83 });
84
85 test("before the first refusal there is no cap, and no bar", () => {
86 const w = windowWords({ ...base, cap: null, remaining: null });
87 expect(w.remaining).toEqual({ text: "cap not known yet", inferred: false });
88 expect(w.usedPct).toBeNull();
89 const closed = windowWords({ ...base, startsAt: null, resetsAt: null });
90 expect(closed.resets.text).toBe("opens on next use");
91 });
92
93 test("spend per shipped change, and what is true without a ratio", () => {
94 const e = { repoId: "r", runs: 2, spentUsd: 3.5, shipped: 2 };
95 expect(perShippedWords({ ...e, perShippedUsd: 1.75 })).toBe(
96 "$1.75 per shipped change",
97 );
98 expect(perShippedWords({ ...e, shipped: 0, perShippedUsd: null })).toBe(
99 "$3.50 spent, nothing shipped",
100 );
101 expect(perShippedWords(undefined)).toBe("nothing spent or shipped");
102 });
103 });
104
105 describe("efficiency and standing, grouped", () => {
106 let dataDir: string;
107 let app: App;
108 let alice: User;
109 let servers: Servers;
110 let cookie = "";
111 let bobCookie = "";
112 const repos: Record<string, string> = {};
113
114 const signIn = async (username: string) =>
115 (
116 (
117 await fetch(`${servers.url}/signin`, {
118 method: "POST",
119 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
120 redirect: "manual",
121 })
122 ).headers.get("set-cookie") ?? ""
123 ).split(";")[0]!;
124
125 const seedRepo = (name: string, autonomy = "off") => {
126 const repoId = id();
127 app.db
128 .query(
129 `INSERT INTO repo (id, owner_id, name, visibility, description, default_branch)
130 VALUES (?1, ?2, ?3, 'private', '', 'main')`,
131 )
132 .run(repoId, alice.id, name);
133 app.db
134 .query(
135 `INSERT INTO app (id, repo_id, slug, access) VALUES (?1, ?2, ?3, 'private')`,
136 )
137 .run(id(), repoId, name);
138 app.db.query("UPDATE repo SET autonomy = ?2 WHERE id = ?1").run(repoId, autonomy);
139 repos[name] = repoId;
140 return repoId;
141 };
142 let number = 0;
143 const merged = (repoId: string, at: string | null) => {
144 const issueId = id();
145 app.db
146 .query(
147 `INSERT INTO issue (id, repo_id, number, kind, title, author_id, state)
148 VALUES (?1, ?2, ?3, 'pull', 'a change', ?4, ?5)`,
149 )
150 .run(issueId, repoId, ++number, alice.id, at === null ? "open" : "closed");
151 app.db
152 .query(
153 `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at)
154 VALUES (?1, 'main', 'b', 'x', 'y', ?2)`,
155 )
156 .run(issueId, at);
157 };
158 const spend = (repoId: string, at: string, usd: number, sub: string | null = null) =>
159 recordUsage(app.db, {
160 runId: createRun(app.db, { repoId, kind: "agent" }).id,
161 repoId,
162 issueId: null,
163 agentId: null,
164 subscriptionId: sub,
165 tokensIn: 100,
166 tokensOut: 10,
167 costUsd: usd,
168 wallMs: 60_000,
169 outcome: "ok",
170 at,
171 });
172 let subs = 0;
173 const account = () => {
174 const added = addSubscription(app.db, dataDir, {
175 name: `account ${++subs}`,
176 token: `sk-ant-oat01-board-windows-${subs}`,
177 });
178 if (!added.ok) throw new Error(added.error);
179 return added.value.id;
180 };
181
182 beforeAll(async () => {
183 dataDir = await mkdtemp(join(tmpdir(), "forge-board-windows-"));
184 app = createApp({ dataDir, port: 0 });
185 servers = await serve(app);
186 for (const [username, adminIfFirst] of [
187 ["alice", true],
188 ["bob", false],
189 ] as const) {
190 const u = await createUser(app.db, {
191 username,
192 email: `${username}@forge.local`,
193 password: "a-good-passphrase",
194 adminIfFirst,
195 });
196 if (!u.ok) throw new Error(u.error);
197 if (username === "alice") alice = u.value;
198 }
199 cookie = await signIn("alice");
200 bobCookie = await signIn("bob");
201 });
202 afterAll(async () => {
203 servers.stop();
204 app.close();
205 await rm(dataDir, { recursive: true, force: true });
206 });
207
208 test("spend per shipped change matches the hand sum, in one statement", async () => {
209 const one = seedRepo("one");
210 const two = seedRepo("two");
211 const three = seedRepo("three");
212 const quiet = seedRepo("quiet");
213 const since = "2026-09-25T00:00:00.000Z";
214 // one: $1.00 + $2.50 in the window ($10 before it is not); two merges
215 // in it, one before, one still open. 3.50 ÷ 2 = 1.75.
216 spend(one, "2026-09-26T12:00:00.000Z", 1);
217 spend(one, "2026-09-30T12:00:00.000Z", 2.5);
218 spend(one, "2026-09-20T12:00:00.000Z", 10);
219 merged(one, "2026-09-27T00:00:00.000Z");
220 merged(one, "2026-10-01T00:00:00.000Z");
221 merged(one, "2026-09-01T00:00:00.000Z");
222 merged(one, null);
223 // two: spent, shipped nothing. three: shipped on no ledger spend.
224 spend(two, "2026-09-28T00:00:00.000Z", 0.4);
225 merged(three, "2026-09-29T00:00:00.000Z");
226
227 let got = new Map();
228 const n = await statements(app.db, () => {
229 got = efficiencyForRepos(app.db, [one, two, three, quiet], since);
230 });
231 expect(n).toBe(1);
232 expect(got.get(one)).toEqual({
233 repoId: one,
234 runs: 2,
235 spentUsd: 3.5,
236 shipped: 2,
237 perShippedUsd: 1.75,
238 });
239 expect(got.get(two)).toEqual({
240 repoId: two,
241 runs: 1,
242 spentUsd: 0.4,
243 shipped: 0,
244 perShippedUsd: null,
245 });
246 expect(got.get(three)?.perShippedUsd).toBe(0);
247 expect(got.has(quiet)).toBe(false);
248 expect(efficiencyForRepos(app.db, [], since).size).toBe(0);
249 });
250
251 test("the batch agrees with each account read alone, in three statements however many", async () => {
252 const repoId = repos.one!;
253 const now = new Date("2026-10-02T12:00:00.000Z");
254 const a = account();
255 const b = account();
256 const c = account();
257 spend(repoId, "2026-10-02T10:00:00.000Z", 1.25, a);
258 spend(repoId, "2026-09-30T10:00:00.000Z", 4, a);
259 spend(repoId, "2026-10-02T11:00:00.000Z", 0.75, b);
260 noteLimit(app.db, b, {
261 window: "5h",
262 resetsAt: "2026-10-02T14:00:00.000Z",
263 observedAt: "2026-10-02T11:30:00.000Z",
264 said: "You've hit your session limit",
265 });
266 for (const ids of [[a], [a, b, c, "nope"]]) {
267 const n = await statements(app.db, () => {
268 standings(app.db, ids, now);
269 });
270 expect(n).toBe(3);
271 }
272 const all = standings(app.db, [a, b, c, "nope"], now);
273 expect(all.has("nope")).toBe(false);
274 for (const s of [a, b, c]) expect(all.get(s)).toEqual(standing(app.db, s, now)!);
275 // a: the 5-hour window opened with the 10:00 run — $1.25; the week, the
276 // chain from 30 Sep — $1.25 + $4.00.
277 expect(all.get(a)!.windows.map((w) => w.used.value)).toEqual([1.25, 5.25]);
278 expect(all.get(b)!.windows[0]!.remaining).toEqual({ value: 0, source: "read" });
279 });
280
281 test("the board shows each window to an admin, and each app's efficiency", async () => {
282 // Fresh, relative to now: the board reads the week back from the clock.
283 const now = Date.now();
284 const ago = (h: number) => new Date(now - h * 3_600_000).toISOString();
285 const live = seedRepo("live", "autonomous");
286 spend(live, ago(30), 3);
287 spend(live, ago(2), 1.5);
288 merged(live, ago(20));
289 merged(live, ago(1));
290 merged(live, ago(9 * 24));
291 const full = account();
292 spend(live, ago(1), 0.5, full);
293 noteLimit(app.db, full, {
294 window: "5h",
295 resetsAt: new Date(now + 2 * 3_600_000).toISOString(),
296 observedAt: ago(0.5),
297 said: "You've hit your session limit",
298 });
299
300 const board = async (c: string) =>
301 (await fetch(`${servers.url}/board`, { headers: { cookie: c } })).text();
302 const page = await board(cookie);
303 expect(page).toContain('id="windows"');
304 expect(page).toContain("account 1");
305 expect(page).toContain("5-hour window");
306 expect(page).toContain("weekly window");
307 expect(page).toContain('class="board-inferred">inferred</i>');
308 expect(page).toContain(">full<");
309 // ($3.00 + $1.50 + $0.50) ÷ 2 merged this week = $2.50.
310 expect(page).toContain("$2.50 per shipped change this week");
311 // An app the crew spent on with autonomy off still reads its ratio.
312 expect(page).toContain("$0.40 spent, nothing shipped this week");
313
314 // The pool is an admin's; nobody else gets the strip, or the reads.
315 expect(await board(bobCookie)).not.toContain('id="windows"');
316
317 // More accounts cost the board no more statements.
318 const before = await statements(app.db, async () => {
319 await board(cookie);
320 });
321 for (let i = 0; i < 4; i++) spend(live, ago(3), 0.1, account());
322 const after = await statements(app.db, async () => {
323 await board(cookie);
324 });
325 expect(after).toBe(before);
326 });
327 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs