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

The terminal's Usage screen, opened from the dashboard with no repository, shows the forge's spend today, in the last 24 h and each subscription's headroom #8341

Merged opened this

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

Merged

scout/forge:scout/8308-the-terminal-s-usage-screen-opened-from → main 2 commits fork 4 threads · 4 unresolved

  • Merged. scout/8308-the-terminal-s-usage-screen-opened-from was merged into main as f5160fa.

Review

Sign in to review this change.
6 files changed +191 −9
docs/features/forge-usage-terminal.md added +11
@@ -0,0 +1,11 @@
1 # The forge's spend in the terminal
2
3 Press `:` (or Ctrl-K), type `Usage` and press enter, with no repository named. Any signed-in person or agent gets the same lines the MCP `usage` tool answers without a repo:
4
5 - **Today** — dollars and runs since midnight.
6 - **Last 24 h** — dollars and runs over the last 24 hours.
7 - **Headroom** — one line per subscription, each window in percent free (`no limit named` when the account never said its limit).
8
9 The spend is only of the apps you write: a member sees their own, an admin sees the forge's. A signed-out visitor is not offered the entry.
10
11 Below the spend, an admin who is not an agent also sees every route, unused ones first; anyone else is told that list is an admin's. `[` and `]` move the Returns window, `f` opens the fleet graph. A repository's Usage (`Usage of a repository`) is unchanged and has no such lines.
src/tui/forge.ts +3 −3
@@ -414,12 +414,12 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
414414 hint: "internal hosts an origin may reach",
415415 run: () => ctx.push(trustedHostsPrompt(session, ctx, blank("Trusted hosts"))),
416416 });
417 // Settings → Usage, as the page has it: the same gate as Flags (#1565).
418 if (mayManageFlags(session.user))
417 // Usage: the forge's spend for any signed-in user (#8308); the route list stays the page's gate, the same as Flags (#1565).
418 if (session.user !== undefined)
419419 commands.push({
420420 id: "usage",
421421 title: "Usage",
422 hint: "every route, unused ones first",
422 hint: "the forge's spend and headroom; an admin also sees every route",
423423 run: () => ctx.push(usageScreen(session, ctx)),
424424 });
425425 // What the forge spent across the apps you write, as GET /api/v1/usage and MCP `usage` answer.
src/tui/screens/usage.ts +19 −3
@@ -2,8 +2,8 @@
22 * Usage in the terminal, a forge admin's (#1565): every route pattern with
33 * its requests over the last 30 days by role and by surface, unused ones
44 * first with their last use or `never`, as Settings → Usage lists them
5 * (`routeUsage`). The gate is the page's: an admin who is not an agent;
6 * anyone else sees nothing.
5 * (`routeUsage`). The routes are the page's gate: an admin who is not an agent;
6 * anyone else is told so, and still sees the spend block below.
77 *
88 * Above it, what the crew's tokens bought (#7271), as MCP `returns` reads
99 * it (`readReturns`): dollars, runs, changes landed and cost per change over
@@ -12,6 +12,7 @@
1212 */
1313
1414 import { USAGE_ADMIN_ONLY } from "../../actions/admin_limits_words.ts";
15 import { readForgeUsage, type ForgeUsage } from "../../actions/usage.ts";
1516 import { readReturns } from "../../actions/returns.ts";
1617 import { mayManageFlags } from "../../core/flags.ts";
1718 import { ROLES, SURFACES } from "../../core/usage_count.ts";
@@ -55,6 +56,18 @@ export function returnsLine(r: Returns, days: number): string {
5556 }`;
5657 }
5758
59 /** The forge-wide block: today, the last 24 h, then each subscription's headroom in percent on its own line. */
60 export function forgeSpendLines(r: ForgeUsage): string[] {
61 const span = (x: { costUsd: number; runs: number }) => `${usd(x.costUsd)} over ${plural(x.runs, "run", "runs")}`;
62 const room = r.subscriptions.map((s) => {
63 const w = s.headroom.map((h) => `${h.window} ${h.pct === null ? "no limit named" : `${h.pct}%`}`).join(", ");
64 return `${s.subscription} ${w === "" ? "no window yet" : w}`;
65 });
66 // One subscription a line, so a narrow terminal truncates one, never drops the later ones.
67 const head = room.length === 0 ? ["no subscription"] : room;
68 return [`Today: ${span(r.today)}`, `Last 24 h: ${span(r.last24h)}`, ...head.map((x, i) => `${i === 0 ? "Headroom: " : " "}${x}`)];
69 }
70
5871 export function usageScreen(f: Session, ctx: Ctx, repo?: Repo): Screen {
5972 const state = new ListState();
6073 const allowed = repo === undefined && mayManageFlags(f.user);
@@ -69,6 +82,8 @@ export function usageScreen(f: Session, ctx: Ctx, repo?: Repo): Screen {
6982 return r.ok ? returnsLine(r.value, days) : undefined;
7083 };
7184 let returns = read();
85 // Forge-wide only, read once when the screen opens; readForgeUsage narrows to what this user can see.
86 const spend = repo === undefined && f.user !== undefined ? forgeSpendLines(readForgeUsage(f.app, f.user)) : [];

Non-blocking: spend is read once at open and never refreshed, so a terminal left open shows stale dollars. Re-read it where returns is re-read.

7287 return {
7388 title: repo === undefined ? "Usage" : `Usage · ${repo.ownerName}/${repo.name}`,
7489 keys: [
@@ -78,7 +93,8 @@ export function usageScreen(f: Session, ctx: Ctx, repo?: Repo): Screen {
7893 ],
7994 render(area, s) {
8095 const c = ctx.theme.c;
81 const [top, head, list] = split(area, "v", [len(returns === undefined ? 0 : 1), len(1), fill(1)]);
96 const [spendArea, top, head, list] = split(area, "v", [len(spend.length), len(returns === undefined ? 0 : 1), len(1), fill(1)]);
97 spend.forEach((line, i) => s.text(spendArea!.x, spendArea!.y + i, truncate(line, spendArea!.w), style(c.fg, c.bg), spendArea!.x + spendArea!.w));
8298 if (returns !== undefined)
8399 s.text(top!.x, top!.y, truncate(returns, top!.w), style(c.fg, c.bg), top!.x + top!.w);
84100 const all = rows;
outdated

Each line is truncated to the width; with several subscriptions the Headroom line loses the later ones silently on a narrow terminal. One subscription per line, or wrap.

test/palette-roster.test.ts +3 −1
@@ -88,7 +88,7 @@ test("a site-admin person is offered the whole roster", () => {
8888 ["flags", "Flags", "declared flags and their live values"],
8989 ["secrets", "Secrets", "sealed secrets by name; set or remove one"],
9090 ["trusted-hosts", "Trusted hosts", "internal hosts an origin may reach"],
91 ["usage", "Usage", "every route, unused ones first"],
91 ["usage", "Usage", "the forge's spend and headroom; an admin also sees every route"],
9292 ["crew-scorecard", "Crew scorecard", "agent runs per model and per prompt version"],
9393 ["crew-handover", "Crew hand-overs", "issues parked twice, and the last restore drill"],
9494 ["scorecard", "Crew scorecard per agent", "each agent's pulls landed, review rounds, red checks, reverts, time to land"],
@@ -153,6 +153,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
153153 ["stalled", "Stalled steps", "pulls whose check is red or was never asked for; enter re-runs"],
154154 ["file-holds", "Held files", "hold new work on a file, release it, or list the holds"],
155155 ["secrets", "Secrets", "sealed secrets by name; set or remove one"],
156 ["usage", "Usage", "the forge's spend and headroom; an admin also sees every route"],
156157 ["crew-scorecard", "Crew scorecard", "agent runs per model and per prompt version"],
157158 ["crew-handover", "Crew hand-overs", "issues parked twice, and the last restore drill"],
158159 ["scorecard", "Crew scorecard per agent", "each agent's pulls landed, review rounds, red checks, reverts, time to land"],
@@ -205,6 +206,7 @@ test("a member person is offered the signed-in roster without the admin entries"
205206 ["changelog", "Changelog", "the last 30 releases and their pulls"],
206207 ["stalled", "Stalled steps", "pulls whose check is red or was never asked for; enter re-runs"],
207208 ["file-holds", "Held files", "hold new work on a file, release it, or list the holds"],
209 ["usage", "Usage", "the forge's spend and headroom; an admin also sees every route"],
208210 ["forge-spend", "Forge spend", "today and the last 24 hours, headroom per subscription"],
209211 ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"],
210212 ["model", "Model budget", "what your crew may spend, and add your own subscription"],
test/tui-usage-forge.test.ts added +152
@@ -0,0 +1,152 @@
1 /**
2 * The terminal's Usage screen with no repository says the forge's spend
3 * (#8308), as MCP `usage` without a repo answers it: today, the last 24 h
4 * and each subscription's headroom in percent. A repository's Usage is
5 * unchanged: it has no such lines.
6 */
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { readForgeUsage } from "../src/actions/usage.ts";
12 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { createApp, type App as Forge } from "../src/app.ts";
14 import type { Repo } from "../src/core/repos.ts";
15 import { noteLimit } from "../src/core/standing.ts";
16 import { addSubscription, EVERYONE, setGrants } from "../src/core/subscriptions.ts";
17 import { createRun } from "../src/core/runs.ts";
18 import { recordUsage } from "../src/core/usage.ts";
19 import { createUser, getUserByName } from "../src/core/users.ts";
20 import { forgeSpendLines } from "../src/tui/screens/usage.ts";
21 import { createForgeApp } from "../src/tui/forge.ts";
22 import { headlessTerminal } from "../src/tui/term/terminal.ts";
23 import { quietLogs } from "./helpers/globals.ts";
24 import { settleApp } from "./helpers/tui.ts";
25
26 quietLogs();
27
28 let dataDir: string;
29 let forge: Forge;
30
31 function boot(username: string) {
32 const user = getUserByName(forge.db, username)!;
33 const { app } = createForgeApp(
34 forge,
35 { sshUsername: "t", remoteAddress: "127.0.0.1", user },
36 { terminal: headlessTerminal(160, 32), exitHooks: false },
37 );
38 app.start();
39 const key = (k: string, ctrl = false) => app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
40 return {
41 text: () => app.screen.toText(),
42 open: async (command: string, spec?: string) => {
43 key("k", true);
44 for (const ch of command) key(ch);
45 await settleApp(app);
46 key("enter");
47 await settleApp(app);
48 if (spec !== undefined) {
49 for (const ch of spec) key(ch);
50 await settleApp(app);
51 key("enter");
52 await settleApp(app);
53 }
54 },
55 stop: () => app.stop(),
56 };
57 }
58
59 function runCost(repo: Repo, cost: number, sub: string): void {
60 recordUsage(forge.db, {
61 runId: createRun(forge.db, { repoId: repo.id, kind: "agent" }).id, repoId: repo.id, issueId: null, agentId: null,
62 subscriptionId: sub, tokensIn: 100, tokensOut: 10, costUsd: cost, wallMs: 1000, outcome: "ok", at: new Date().toISOString(),
63 });
64 }
65
66 beforeAll(async () => {
67 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-usage-forge-"));
68 forge = createApp({ dataDir, port: 0 });
69 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
70 const made = await createUser(forge.db, {
71 origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst,
72 });
73 if (!made.ok) throw new Error(made.error);
74 }
75 const alice = getUserByName(forge.db, "alice")!;
76 const sub = addSubscription(forge.db, dataDir, { name: "work", token: "sk-ant-api03-work-key-for-the-forge-test" });
77 if (!sub.ok) throw new Error("subscription");
78 noteLimit(forge.db, sub.value.id, {
79 window: "5h", resetsAt: new Date(Date.now() + 3_600_000).toISOString(), observedAt: new Date().toISOString(), said: "limit",
80 });
81 for (const [name, cost] of [["mill", 1.25], ["kiln", 2]] as const) {
82 const made = await createRepo(forge, alice, { name, visibility: "public" });
83 if (!made.ok) throw new Error(String(made.error));
84 await commitToBranch(forge, alice, made.value, { message: "seed", files: [{ path: "README.md", content: "# x\n" }] });
85 setGrants(forge.db, made.value.id, EVERYONE, [sub.value.id]);
86 runCost(made.value, cost, sub.value.id);
87 }
88 }, 30_000);
89 afterAll(async () => {
90 forge.close();
91 await rm(dataDir, { recursive: true, force: true });
92 });
93
94 test("Usage with no repository draws today, the last 24 h and each subscription's headroom", async () => {
95 const alice = getUserByName(forge.db, "alice")!;
96 const report = readForgeUsage(forge, alice);
97 expect(report.apps).toBe(2);
98 expect(report.subscriptions.length).toBeGreaterThan(0);
99 const h = boot("alice");
100 await h.open("Usage");
101 const text = h.text();
102 expect(text).toContain("Today: $3.25 over 2 runs");
103 expect(text).toContain("Last 24 h: $3.25 over 2 runs");
104 expect(text).toContain("Headroom: ");
105 for (const s of report.subscriptions) expect(text).toContain(s.subscription);
106 expect(text).toContain("work 5h 0%");
107 h.stop();
108 });
109
110 test("a repository's Usage has none of those lines", async () => {
111 const h = boot("alice");
112 await h.open("Usage of a repository", "alice/mill");
113 const text = h.text();
114 expect(text).not.toContain("Headroom:");
115 expect(text).not.toContain("Last 24 h:");
116 h.stop();
117 });
118
119 test("a signed-in member reaches the lines, and not the admin's spend", async () => {
120 const h = boot("bob");
121 await h.open("Usage");
122 const text = h.text();
123 expect(text).toContain("Today: $0.00 over 0 runs");
124 expect(text).not.toContain("$3.25");
125 expect(text).toContain("Headroom: ");
126 expect(text).not.toContain("routes had no request");
127 h.stop();
128 });
129
130 test("a guest is offered no Usage and the screen draws no spend lines", () => {
131 const { app } = createForgeApp(
132 forge,
133 { sshUsername: "git", remoteAddress: "127.0.0.1" },
134 { terminal: headlessTerminal(160, 32), exitHooks: false },
135 );
136 app.start();
137 app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false });
138 for (const ch of "Usage") app.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false });
139 const text = app.screen.toText();
140 expect(text).not.toContain("Today:");
141 expect(text).not.toContain("the forge's spend and headroom");
142 app.stop();
143 });
144
145 test("each subscription's headroom has its own line", () => {
146 const w = { window: "5h", pct: 40 };
147 const sub = (subscription: string) => ({ subscription, headroom: [w] });
148 const lines = forgeSpendLines({
149 today: { costUsd: 0, runs: 0 }, last24h: { costUsd: 0, runs: 0 }, subscriptions: [sub("a"), sub("b"), sub("c")],
150 } as never);
151 expect(lines.slice(2)).toEqual(["Headroom: a 5h 40%", " b 5h 40%", " c 5h 40%"]);
152 });
test/tui-usage.test.ts +3 −2
@@ -72,11 +72,12 @@ test("an admin's terminal lists unused routes first, with never, and the count l
7272 h.stop();
7373 });
7474
75 test("a non-admin and an admin agent have no command", async () => {
75 test("a non-admin and an admin agent see the spend and are told the routes are an admin's", async () => {
7676 for (const who of ["bob", "carol"]) {
7777 const h = boot(who);
7878 await h.palette("Usage");
79 expect(h.text()).not.toContain("every route, unused ones first");
79 await h.enter();
80 expect(h.text()).not.toContain("routes had no request");

Non-blocking: also expect(h.text()).toContain(USAGE_ADMIN_ONLY) so the member-facing explanation is pinned, not just the absence of the route count.

8081 h.stop();
8182 }
8283 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs