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

MCP `usage` with no repo answers the forge's spend: today, the last 24 h, and headroom as a share of each subscription's limit #7778

Merged opened this

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

Merged

scout/forge:scout/7656-mcp-usage-with-no-repo-answers-the-forge → main 6 commits fork

  • Merged. scout/7656-mcp-usage-with-no-repo-answers-the-forge was merged into main as 64c8cb7.

Review

Sign in to review this change.
15 files changed +445 −10
README.md +1 −1
@@ -554,7 +554,7 @@ This page is the front door. Everything else is a file of its own under
554554 - [`docs/DESIGN.md`](docs/DESIGN.md) — the design system's spine; its sections are under `docs/design/sections/`.
555555 - [`docs/SPIKE-CLOUDFLARE.md`](docs/SPIKE-CLOUDFLARE.md), [`docs/AUDIT-MANY-HANDS.md`](docs/AUDIT-MANY-HANDS.md), [`docs/BACKLOG.md`](docs/BACKLOG.md) and [`docs/research/`](docs/research/) — a spike, an audit, a backlog and research notes, kept as written.
556556
557 What the crew's tokens bought is on the board (`/board/returns`), in MCP `returns`, and in the terminal: the palette's Usage carries a Returns line (dollars, runs, changes landed, cost per landed change; `[`/`]` move between 1, 7, 30 and 90 days), and Usage of a repository (`owner/name`) narrows it to one app. On the forge-wide Usage, `f` opens the fleet graph (what `/fleet/graph` shows): one sparkline row per repository for runs and for spend, each with its total at the right, over 24 h by hour or 14 d by day (`w` toggles); past eight series the smallest share "N others", and a short terminal ends with a "+N more rows" line. Who may see spend is the action's rule (`src/actions/fleet-graph.ts`), not the screen's. Owner facts only (`src/actions/returns.ts`); see [`docs/OPERATING.md`](docs/OPERATING.md).
557 What the crew's tokens bought is on the board (`/board/returns`), in MCP `returns`, and in the terminal: the palette's Usage carries a Returns line (dollars, runs, changes landed, cost per landed change; `[`/`]` move between 1, 7, 30 and 90 days), and Usage of a repository (`owner/name`) narrows it to one app. On the forge-wide Usage, `f` opens the fleet graph (what `/fleet/graph` shows): one sparkline row per repository for runs and for spend, each with its total at the right, over 24 h by hour or 14 d by day (`w` toggles); past eight series the smallest share "N others", and a short terminal ends with a "+N more rows" line. Who may see spend is the action's rule (`src/actions/fleet-graph.ts`), not the screen's. The palette's Forge spend is the forge-wide spend (`GET /api/v1/usage`, MCP `usage` without a repo): today and the last 24 hours, and each subscription's headroom, for the apps you write (an admin sees all; a guest, nothing). Owner facts only (`src/actions/returns.ts`); see [`docs/OPERATING.md`](docs/OPERATING.md).
558558
559559 The terminal's palette has `Signed in` for anyone signed in: your browsers, tokens and this terminal's key, with when each was last seen; `d` signs the selected browser out after a confirm, as `/settings` and MCP `sessions` do, and on a token revokes it after a confirm (Keep is the default) through the same `deleteToken` action as Settings and the API, so it stops working at once (`docs/CREW.md`, "Where you are signed in"). `p` there changes your password: it asks the current one, the new one and the new one again, each masked and never echoed, and changes it through `changePassword`, the action behind Settings, so a wrong current password, a throttled account, a new one under 10 characters or over the cap is refused in Settings' words (the length is refused at the new-password prompt, before the confirm); an agent signs in with a key and is not offered `p`.
560560
docs/APPS.md +5 −1
@@ -1326,7 +1326,11 @@ called the window full (a person's own use is not in the ledger, so it is
13261326 a floor). An operator keeps a share of every window for people
13271327 (Settings → Subscriptions, 0–90%); `crewRemaining` is what is left after
13281328 it. Both read on `GET /api/v1/repos/:owner/:name/usage` and the MCP
1329 `usage` tool, under `standing`.
1329 `usage` tool, under `standing`. Without a repository, `GET /api/v1/usage` and
1330 the same tool answer for every app you can write: `today` (since 00:00 UTC)
1331 and `last24h` (rolling) named apart, per subscription, with each window's
1332 `headroom` as the percent of its limit still free (`null` until the provider
1333 has named a limit). Not in the terminal yet.
13301334
13311335 Across repositories, the _fleet graph_ (`GET /fleet/graph`) draws a
13321336 selection's spend and runs from the same ledger: the last 24 hours by UTC
docs/MCP.md +1 −1
@@ -191,7 +191,7 @@ for a tunnel and a forge that sleeps.
191191 | `tokens` | your own API tokens, as `GET /api/v1/tokens` lists them: id, name, scope, created and last used; never a token or its hash | |
192192 | `trail` | `{ repo, shown?, dryRun? }`: where a migrated repository came from (`provenance`: source, sourceUrl, parents, migratedAt) and whether it is shown; with `shown`, shows or hides the trail — planned first (`dryRun` is true unless `false`), only the repository's admin with an admin token, never an agent. Hiding keeps the record; a hidden one reads `null` to anyone but an admin | yes |
193193 | `upstream` | `{ repo, enabled?, url?, branch?, check? }`: the repository a fork follows (an admin reads it); with `enabled` sets it, with `check: true` checks it now; setting and checking need an admin token | yes |
194 | `usage` | `{ repo, days? }`: what the crew's runs spent — tokens, cost, wall time — by subscription, agent, issue, and each account's 5-hour and weekly standing; a writer's | |
194 | `usage` | `{ repo?, days? }`: no repo, the forge's spend today and last24h and each account's headroom %; else what the crew's runs spent — tokens, cost, wall time — by subscription, agent, issue, and each account's 5-hour and weekly standing; a writer's | |
195195 | `variant` | `{ repo, number, answer, reason? }`: like or dislike one pull among its variants (`answer` like or dislike); a later vote replaces the earlier; a dislike closes the pull with `reason` | yes |
196196 | `vision` | `{ repo, number? }`: `{ purpose, order: [{ n, name, text }], rules, direction, directionStall, directionParts, preview, updatedAt }` from docs/VISION.md on the default branch; without the document `purpose` is null and `order` empty; `directionStall` (why a saved direction has no direction issue, else null; admins only), `direction` (the autonomy direction) and `directionParts` (it cut into `{ kind: vision|owner, text }`: the fenced block that follows docs/VISION.md, and the owner's words) only for those who may read the repository's settings, else null; with `number` (an open vision pull) those same people get `preview`: `{ today, after }`, the direction as it stands and as an admin's merge will write it, or `{ why }` it would stay; else null | |
197197 | `wait_for` | `{ app, issue, blockedBy, stop? }`: the issue waits for another, or stops with `stop: true`; the page's rules, and the answer carries the issue's URL | yes |
src/actions/usage.ts +101 −2
@@ -13,8 +13,14 @@
1313
1414 import type { App } from "../app.ts";
1515 import { getIssuesByIds } from "../core/issues.ts";
16 import { canSeeOwnerFacts, type Repo } from "../core/repos.ts";
17 import { standings, type Standing } from "../core/standing.ts";
16 import {
17 canSeeOwnerFacts,
18 listReposVisibleTo,
19 ownerFactRepoIds,
20 type Repo,
21 } from "../core/repos.ts";
22 import { forgeSpend, type SpanSpend } from "../core/usage_report.ts";
23 import { freePct, standings, type Standing } from "../core/standing.ts";
1824 import {
1925 budgetsOf,
2026 grantedAccounts,
@@ -195,3 +201,96 @@ export function readUsage(
195201 guard: guardCounts(app.db, repo.id, since),
196202 });
197203 }
204
205 /** Repositories read per page; the core caps a page at 200. */
206 const PAGE = 200;
207
208 const NONE: SpanSpend = { runs: 0, tokensIn: 0, tokensOut: 0, costUsd: 0 };
209
210 export interface ForgeUsage {
211 readonly at: string;
212 /** `today` runs from `todaySince` (00:00 UTC); `last24h` is the rolling 24 hours: both are named so they never seem to disagree. */
213 readonly todaySince: string;
214 readonly last24hSince: string;
215 readonly apps: number;
216 readonly today: SpanSpend;
217 readonly last24h: SpanSpend;
218 readonly subscriptions: readonly {
219 readonly subscription: string;
220 readonly today: SpanSpend;
221 readonly last24h: SpanSpend;
222 /** Per window: the share of its limit still free, in whole percent; null until the provider has named a limit. */
223 readonly headroom: readonly {
224 readonly window: string;
225 readonly pct: number | null;
226 readonly resetsAt: string | null;
227 }[];
228 }[];
229 }
230
231 const sum = (a: SpanSpend, b: SpanSpend): SpanSpend => ({
232 runs: a.runs + b.runs,
233 tokensIn: a.tokensIn + b.tokensIn,
234 tokensOut: a.tokensOut + b.tokensOut,
235 costUsd: a.costUsd + b.costUsd,
236 });
237
238 /** Spend across the repositories whose owner facts `user` may see, never another's. */
239 export function readForgeUsage(
240 app: App,
241 user: User | undefined,
242 now: Date = new Date(),
243 ): ForgeUsage {
244 const db = app.db;
245 // Every visible repository, page by page (newest id first): a cap would undercount silently.
246 const visible: Repo[] = [];
247 for (let cursor: string | undefined; ; ) {
248 const page = listReposVisibleTo(db, user, cursor === undefined ? { limit: PAGE } : { limit: PAGE, cursor });
249 visible.push(...page);
250 if (page.length < PAGE) break;
251 cursor = page[page.length - 1]!.id;
252 }
253 const mine = ownerFactRepoIds(db, visible, user);
254 const spend = forgeSpend(db, mine, now);
255 const names = new Map(listCredentials(db).map((c) => [c.id, c.name]));
256 const accounts = new Set<string>();
257 for (const m of [spend.today, spend.last24h])
258 for (const k of m.keys()) accounts.add(k ?? PLATFORM);
259 for (const repo of visible) {
260 if (!mine.has(repo.id)) continue;
261 const granted = grantedAccounts(db, repo.id, repo.ownerId);
262 if (granted.length === 0) accounts.add(PLATFORM);
263 for (const g of [...listGrants(db, repo.id), ...listGrants(db, repo.ownerId)])
264 accounts.add(g.subscriptionId);
265 }
266 const read = standings(db, [...accounts], now);
267 const at = (m: Map<string | null, SpanSpend>, id: string) =>
268 id === PLATFORM
269 ? sum(m.get(null) ?? NONE, m.get(PLATFORM) ?? NONE)
270 : (m.get(id) ?? NONE);
271 // A subscription since removed still paid for runs: its own row, never dropped.
272 const subscriptions = [...accounts]
273 .map((id) => ({
274 subscription: names.get(id) ?? (id === PLATFORM ? id : "a removed subscription"),
275 today: at(spend.today, id),
276 last24h: at(spend.last24h, id),
277 headroom: (read.get(id)?.windows ?? []).map((w) => ({
278 window: w.window,
279 pct: freePct(w.cap?.value ?? null, w.used.value),
280 resetsAt: w.resetsAt?.value ?? null,
281 })),
282 }))
283 .sort((a, b) => a.subscription.localeCompare(b.subscription));
284 // Totals come from every row, so no spend can fall out of them.
285 const whole = (m: Map<string | null, SpanSpend>) =>
286 [...m.values()].reduce(sum, NONE);
287 return {
288 at: now.toISOString(),
289 todaySince: spend.todaySince,
290 last24hSince: spend.last24hSince,
291 apps: mine.size,
292 today: whole(spend.today),
293 last24h: whole(spend.last24h),
294 subscriptions,
295 };
296 }
src/contract/openapi/routes/getApiV1Usage.ts added +8
@@ -0,0 +1,8 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/usage",
6 summary: "What the forge spent across the apps the caller writes: today and last24h named apart, totals from every row, a removed subscription its own row, headroom per subscription window, percent in standing; 401 without a session",
7 auth: true,
8 };
src/contract/openapi/routes/index.ts +1
@@ -140,6 +140,7 @@ export { route as getApiV1TelemetryHome } from "./getApiV1TelemetryHome.ts";
140140 export { route as getApiV1TelemetrySources } from "./getApiV1TelemetrySources.ts";
141141 export { route as getApiV1TelemetrySystem } from "./getApiV1TelemetrySystem.ts";
142142 export { route as getApiV1Tokens } from "./getApiV1Tokens.ts";
143 export { route as getApiV1Usage } from "./getApiV1Usage.ts";
143144 export { route as getApiV1User } from "./getApiV1User.ts";
144145 export { route as getApiV1UserAssigned } from "./getApiV1UserAssigned.ts";
145146 export { route as getApiV1UserKeys } from "./getApiV1UserKeys.ts";
src/core/standing.ts +6
@@ -382,3 +382,9 @@ function windowStanding(
382382 said: limit?.said ?? null,
383383 };
384384 }
385
386 /** The share of a window's cap still free, in whole percent 0–100; null with no cap. */
387 export function freePct(cap: number | null, used: number): number | null {
388 if (cap === null || cap <= 0) return null;
389 return Math.max(0, Math.min(100, Math.round(((cap - used) / cap) * 100)));
390 }
src/core/usage_report.ts +60
@@ -54,3 +54,63 @@ export function routeUsage(db: Database, patterns: readonly string[], now: Date)
5454 return b.total - a.total || a.pattern.localeCompare(b.pattern);
5555 });
5656 }
57
58 /** What the ledger says was spent, in one span, by the account that paid. */
59 export interface SpanSpend {
60 readonly runs: number;
61 readonly tokensIn: number;
62 readonly tokensOut: number;
63 readonly costUsd: number;
64 }
65
66 export interface ForgeSpend {
67 /** Since 00:00 UTC, the forge's zone. */
68 readonly todaySince: string;
69 /** The 24 hours before `at`: a rolling span, not the calendar day. */
70 readonly last24hSince: string;
71 readonly today: Map<string | null, SpanSpend>;
72 readonly last24h: Map<string | null, SpanSpend>;
73 }
74
75 /** Spend since `since` across `repoIds`, by subscription id (null: unrecorded), summed in SQL. */
76 function spendSince(
77 db: Database,
78 repoIds: readonly string[],
79 since: string,
80 until: string,
81 ): Map<string | null, SpanSpend> {
82 const rows = db
83 .query(
84 `SELECT subscription_id AS sub, COUNT(*) AS runs, SUM(tokens_in) AS tin,
85 SUM(tokens_out) AS tout, SUM(cost_usd) AS cost
86 FROM usage
87 WHERE at >= ?1 AND at <= ?2 AND repo_id IN (SELECT value FROM json_each(?3))
88 GROUP BY subscription_id`,
89 )
90 .all(since, until, JSON.stringify(repoIds)) as {
91 sub: string | null;
92 runs: number;
93 tin: number;
94 tout: number;
95 cost: number;
96 }[];
97 return new Map(
98 rows.map((r) => [r.sub, { runs: r.runs, tokensIn: r.tin, tokensOut: r.tout, costUsd: r.cost }]),
99 );
100 }
101
102 /** Spend today and over the last 24 h across `repoIds`, by subscription id (null: unrecorded). */
103 export function forgeSpend(db: Database, repoIds: ReadonlySet<string>, now: Date): ForgeSpend {
104 const day = new Date(now);
105 day.setUTCHours(0, 0, 0, 0);
106 const todaySince = day.toISOString();
107 const last24hSince = new Date(now.getTime() - 86_400_000).toISOString();
108 const ids = [...repoIds];
109 const until = now.toISOString();
110 return {
111 todaySince,
112 last24hSince,
113 today: spendSince(db, ids, todaySince, until),
114 last24h: spendSince(db, ids, last24hSince, until),
115 };
116 }
src/http/api.ts +8 −1
@@ -250,7 +250,7 @@ import {
250250 } from "../core/runs.ts";
251251 import { checkStale } from "../checks/stale.ts";
252252 import { resolveOid } from "../git/merge.ts";
253 import { readUsage, usageDays, USAGE_DAYS_MAX } from "../actions/usage.ts";
253 import { readForgeUsage, readUsage, usageDays, USAGE_DAYS_MAX } from "../actions/usage.ts";
254254 import { readReturns, reportSpend, RETURNS_DAYS } from "../actions/returns.ts";
255255 import { fleetSelection, readFleetGraph } from "../actions/fleet-graph.ts";
256256 import { carriedBy } from "../core/carried.ts";
@@ -3400,6 +3400,13 @@ export function apiRoutes(router: Router<Handler>): void {
34003400 );
34013401 });
34023402
3403 // The forge-wide twin: today, last24h and headroom across the apps the caller writes.
3404 router.add("GET", "/api/v1/usage", (ctx) => {
3405 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
3406 if (!ctx.auth.user) return unauthorized();
3407 return json(readForgeUsage(ctx.app, ctx.auth.user));
3408 });
3409
34033410 router.add("GET", "/api/v1/repos/:owner/:name/usage", (ctx) => {
34043411 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
34053412 const repo = loadRepo(ctx);
src/http/mcp/tools/usage.ts +8 −4
@@ -1,21 +1,25 @@
1 import { readUsage, usageDays, USAGE_DAYS_DEFAULT, USAGE_DAYS_MAX } from "../../../actions/usage.ts";
1 import { readForgeUsage, readUsage, usageDays, USAGE_DAYS_DEFAULT, USAGE_DAYS_MAX } from "../../../actions/usage.ts";
22 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
33
44 export const tool: Tool = {
55 name: "usage",
6 doc: "`{ repo, days? }`: what the crew's runs spent — tokens, cost, wall time — by subscription, agent, issue, and each account's 5-hour and weekly standing; a writer's",
6 doc: "`{ repo?, days? }`: no repo, the forge's spend today and last24h and each account's headroom %; else what the crew's runs spent — tokens, cost, wall time — by subscription, agent, issue, and each account's 5-hour and weekly standing; a writer's",
77 description:
8 "What crew runs on an app spent in `days` (1–90, default 7), by agent, issue. Writers.",
8 "Crew spend: with `repo`, `days` (1–90) by agent, issue; without, today, last24h, headroom %. Writers.",
99 inputSchema: {
1010 type: "object",
1111 properties: {
1212 repo: REPO_ARG,
1313 days: { type: "integer", minimum: 1, maximum: USAGE_DAYS_MAX },
1414 },
15 required: ["repo"],
1615 },
1716 writes: false,
1817 run(ctx, user, args) {
18 if (args["repo"] === undefined) {
19 if (args["days"] !== undefined)
20 return bad("invalid_days", "days needs a repo; without one the answer is today and last24h");
21 return good(readForgeUsage(ctx.app, user));
22 }
1923 const repo = repoArg(ctx, user, args);
2024 if (isAnswer(repo)) return repo;
2125 const raw = args["days"];
src/tui/forge.ts +9
@@ -44,6 +44,7 @@ import { mayManageFlags } from "../core/flags.ts";
4444 import { mayEditTrustedHosts } from "../origins/trusted-hosts.ts";
4545 import { trustedHostsPrompt } from "./screens/trustedhosts.ts";
4646 import { flagsScreen } from "./screens/flags.ts";
47 import { forgeUsageScreen } from "./screens/forge_usage.ts";
4748 import { usageScreen } from "./screens/usage.ts";
4849 import { canScreen } from "./screens/can.ts";
4950 import { modelScreen } from "./screens/model.ts";
@@ -421,6 +422,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
421422 hint: "every route, unused ones first",
422423 run: () => ctx.push(usageScreen(session, ctx)),
423424 });
425 // What the forge spent across the apps you write, as GET /api/v1/usage and MCP `usage` answer.
426 if (session.user !== undefined)
427 commands.push({
428 id: "forge-spend",
429 title: "Forge spend",
430 hint: "today and the last 24 hours, headroom per subscription",
431 run: () => ctx.push(forgeUsageScreen(session, ctx)),
432 });
424433 // Proposal tokens for a repository you write, as the API and MCP have them (#2148).
425434 if (session.user !== undefined)
426435 commands.push({
src/tui/screens/forge_usage.ts added +52
@@ -0,0 +1,52 @@
1 /**
2 * Forge-wide spend in the terminal, as `GET /api/v1/usage` and MCP `usage`
3 * without a repo answer it (`readForgeUsage`): today (since 00:00 UTC) and
4 * the rolling last 24 hours named apart, then one row per subscription with
5 * each window's headroom. The gate is theirs: a guest sees nothing, a writer
6 * their own apps, an admin all. Read once when the screen opens.
7 */
8
9 import { readForgeUsage, type ForgeUsage } from "../../actions/usage.ts";
10 import { usd } from "../../core/money.ts";
11 import type { SpanSpend } from "../../core/usage_report.ts";
12 import type { Ctx, Screen } from "../app/app.ts";
13 import { style } from "../term/style.ts";
14 import { truncate } from "../term/width.ts";
15 import type { Session } from "../session.ts";
16 import { fill, len, split } from "../ui/layout.ts";
17 import { ListState, drawList, type ListRow } from "./common.ts";
18
19 const span = (s: SpanSpend) => `${usd(s.costUsd)} over ${s.runs} ${s.runs === 1 ? "run" : "runs"}`;
20
21 export function forgeUsageRows(r: ForgeUsage): ListRow[] {
22 return r.subscriptions.map((s) => ({
23 title: s.subscription,
24 secondary: `today ${span(s.today)} · 24h ${span(s.last24h)}`,
25 meta:
26 s.headroom.length === 0
27 ? "no window yet"
28 : s.headroom.map((h) => `${h.window} ${h.pct === null ? "no limit named" : `${h.pct}% free`}`).join(" "),
29 }));
30 }
31
32 export function forgeUsageLine(r: ForgeUsage): string {
33 return `${r.apps} ${r.apps === 1 ? "app" : "apps"} · today ${span(r.today)} · last 24 h ${span(r.last24h)}`;
34 }
35
36 export function forgeUsageScreen(f: Session, ctx: Ctx): Screen {
37 const state = new ListState();
38 const report = f.user === undefined ? undefined : readForgeUsage(f.app, f.user);
39 const rows = report === undefined ? [] : forgeUsageRows(report);
40 return {
41 title: "Forge spend",
42 keys: [],
43 render(area, s) {
44 const c = ctx.theme.c;
45 const [head, list] = split(area, "v", [len(1), fill(1)]);
46 const text = report === undefined ? "Sign in to see what the forge spent." : forgeUsageLine(report);
47 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
48 drawList(s, list!, c, state, rows);
49 },
50 key: (e) => state.key(e, rows.length),
51 };
52 }
test/mcp-usage-forge.test.ts added +169
@@ -0,0 +1,169 @@
1 /** `usage` with no repo: the forge's spend, today and last24h named apart, and headroom per subscription. */
2
3 import { afterAll, beforeAll, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { readForgeUsage } from "../src/actions/usage.ts";
9 import { createApp, type App } from "../src/app.ts";
10 import type { Repo } from "../src/core/repos.ts";
11 import { freePct, noteLimit } from "../src/core/standing.ts";
12 import { addSubscription, EVERYONE, setGrants } from "../src/core/subscriptions.ts";
13 import { createRun } from "../src/core/runs.ts";
14 import { recordUsage } from "../src/core/usage.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import { promote } from "./helpers/members.ts";
17 import { createToken } from "../src/core/tokens.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19 import { tool } from "../src/http/mcp/tools/usage.ts";
20
21 let dir: string;
22 let app: App;
23 let alice: User;
24 let bob: User;
25 let carol: User;
26 let mine: Repo;
27 let theirs: Repo;
28 let subId: string;
29 let servers: Servers;
30 const now = new Date("2026-10-16T02:00:00Z");
31 const iso = (s: string) => new Date(s).toISOString();
32
33 function spend(runId: string, repo: Repo, at: string, costUsd: number, sub: string | null = subId) {
34 recordUsage(app.db, {
35 runId: createRun(app.db, { repoId: repo.id, kind: "agent" }).id, repoId: repo.id, issueId: null, agentId: null, subscriptionId: sub,
36 tokensIn: 100, tokensOut: 10, costUsd, wallMs: 1000, outcome: "ok", at: iso(at),
37 });
38 }
39
40 beforeAll(async () => {
41 dir = await mkdtemp(join(tmpdir(), "forge-usage-forge-"));
42 app = createApp({ dataDir: dir, port: 0 });
43 servers = await serve(app);
44 const mk = async (username: string, adminIfFirst = false) => {
45 const r = await createUser(app.db, {
46 origin: "seed", trusted: true, username, email: `${username}@forge.local`,
47 password: "a-good-passphrase", ...(adminIfFirst ? { adminIfFirst } : {}),
48 });
49 if (!r.ok) throw new Error(r.error);
50 return r.value;
51 };
52 carol = await mk("carol", true);
53 alice = await mk("alice");
54 bob = await mk("bob");
55 promote(app.db, alice);
56 promote(app.db, bob);
57 const a = await createRepo(app, alice, { name: "mine", visibility: "public" });
58 const b = await createRepo(app, bob, { name: "theirs", visibility: "public" });
59 if (!a.ok || !b.ok) throw new Error(JSON.stringify([a, b]).slice(0, 300));
60 mine = a.value;
61 theirs = b.value;
62 const sub = addSubscription(app.db, dir, { name: "work", token: "sk-ant-api03-work-key-for-the-forge-test" });
63 if (!sub.ok) throw new Error("sub");
64 subId = sub.value.id;
65 setGrants(app.db, mine.id, EVERYONE, [subId]);
66 // 02:00 UTC now: 23:00 the day before is inside the last 24 h but not today.
67 spend("r-today", mine, "2026-10-16T01:00:00Z", 1);
68 spend("r-before-midnight", mine, "2026-10-15T23:00:00Z", 2);
69 spend("r-two-days", mine, "2026-10-15T01:00:00Z", 4);
70 spend("r-bobs", theirs, "2026-10-16T01:00:00Z", 50);
71 });
72
73 afterAll(async () => {
74 servers.stop();
75 app.close();
76 await rm(dir, { recursive: true, force: true });
77 });
78
79 test("today and last24h are named apart and differ across midnight", () => {
80 const r = readForgeUsage(app, alice, now);
81 expect(r.todaySince).toBe("2026-10-16T00:00:00.000Z");
82 expect(r.last24hSince).toBe("2026-10-15T02:00:00.000Z");
83 expect(r.today.costUsd).toBe(1);
84 expect(r.last24h.costUsd).toBe(3);
85 expect(r.last24h.runs).toBe(2);
86 });
87
88 test("a writer of one repository never sees another's spend", () => {
89 const r = readForgeUsage(app, alice, now);
90 expect(r.apps).toBe(1);
91 expect(r.today.costUsd).toBe(1);
92 expect(r.last24h.costUsd).toBe(3);
93 expect(r.subscriptions.reduce((t, x) => t + x.last24h.costUsd, 0)).toBe(3);
94 const b = readForgeUsage(app, bob, now);
95 expect(b.last24h.costUsd).toBe(50);
96 expect(readForgeUsage(app, undefined, now).last24h.runs).toBe(0);
97 });
98
99 test("each subscription carries headroom as a percent of its window limit", () => {
100 const before = readForgeUsage(app, alice, now).subscriptions.find((s) => s.subscription === "work")!;
101 expect(before.headroom.every((h) => h.pct === null)).toBe(true);
102 noteLimit(app.db, subId, {
103 window: "5h", resetsAt: iso("2026-10-16T05:00:00Z"), observedAt: iso("2026-10-16T01:30:00Z"), said: "limit",
104 });
105 const after = readForgeUsage(app, alice, now).subscriptions.find((s) => s.subscription === "work")!;
106 const five = after.headroom.find((h) => h.window === "5h")!;
107 expect(five.pct).toBe(0);
108 expect(five.resetsAt).toBe("2026-10-16T05:00:00.000Z");
109 });
110
111 test("the tool answers without repo, and still asks for one when named wrongly", () => {
112 const ok = tool.run({ app } as never, alice, {}) as { ok: true; value: any };
113 expect(ok.ok).toBe(true);
114 const body = ok.value;
115 expect(body.apps).toBe(1);
116 // The tool reads the real clock, long after the fixed spend: the shape is what it proves.
117 expect(body.today).toEqual({ runs: 0, tokensIn: 0, tokensOut: 0, costUsd: 0 });
118 expect(body.last24h).toEqual(body.today);
119 expect(body.todaySince).toMatch(/T00:00:00\.000Z$/);
120 const work = body.subscriptions.find((s: { subscription: string }) => s.subscription === "work");
121 expect(work.headroom.map((h: { window: string }) => h.window)).toContain("5h");
122 expect(JSON.stringify(tool.run({ app } as never, alice, { repo: "nobody/none" }))).toContain("not_found");
123 });
124
125 test("the percent free rounds, clamps to 0–100 and is null without a cap", () => {
126 expect(freePct(10, 2.5)).toBe(75);
127 expect(freePct(3, 1)).toBe(67);
128 expect(freePct(10, 25)).toBe(0);
129 expect(freePct(10, -5)).toBe(100);
130 expect(freePct(0, 1)).toBeNull();
131 expect(freePct(null, 1)).toBeNull();
132 });
133
134 test("spend paid by a removed subscription stays in the totals as its own row", () => {
135 spend("r-gone", mine, "2026-10-16T01:10:00Z", 7, "gone-subscription-id");
136 const r = readForgeUsage(app, alice, now);
137 expect(r.today.costUsd).toBe(8);
138 expect(r.last24h.costUsd).toBe(10);
139 const gone = r.subscriptions.find((s) => s.subscription === "a removed subscription")!;
140 expect(gone.today.costUsd).toBe(7);
141 });
142
143 test("a site admin sees every app's spend", () => {
144 const r = readForgeUsage(app, carol, now);
145 expect(r.apps).toBe(2);
146 expect(r.last24h.costUsd).toBe(60);
147 });
148
149 test("days without a repo is refused rather than ignored", () => {
150 expect(JSON.stringify(tool.run({ app } as never, alice, { days: 3 }))).toContain("invalid_days");
151 });
152
153 test("GET /api/v1/usage answers the caller's apps and refuses a guest", async () => {
154 const token = createToken(app.db, alice.id, "ro", "ro").token;
155 const got = await fetch(`${servers.url}/api/v1/usage`, { headers: { authorization: `Bearer ${token}` } });
156 expect(got.status).toBe(200);
157 const body = (await got.json()) as { apps: number; today: unknown; last24h: unknown };
158 expect(body.apps).toBe(1);
159 expect(body).toHaveProperty("today");
160 expect((await fetch(`${servers.url}/api/v1/usage`)).status).toBe(401);
161 });
162
163 test("an admin reads every visible app past one page, never a capped count", async () => {
164 for (let i = 0; i < 205; i++) {
165 const r = await createRepo(app, bob, { name: `bulk-${i}`, visibility: "public" });
166 if (!r.ok) throw new Error(String(r.error));
167 }
168 expect(readForgeUsage(app, carol, now).apps).toBe(207);
169 }, 120_000);
test/palette-roster.test.ts +3
@@ -92,6 +92,7 @@ test("a site-admin person is offered the whole roster", () => {
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"],
95 ["forge-spend", "Forge spend", "today and the last 24 hours, headroom per subscription"],
9596 ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"],
9697 ["model", "Model budget", "what your crew may spend, and add your own subscription"],
9798 ["model-grant", "Model grant", "give an account dollars a month, or take it back"],
@@ -155,6 +156,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
155156 ["crew-scorecard", "Crew scorecard", "agent runs per model and per prompt version"],
156157 ["crew-handover", "Crew hand-overs", "issues parked twice, and the last restore drill"],
157158 ["scorecard", "Crew scorecard per agent", "each agent's pulls landed, review rounds, red checks, reverts, time to land"],
159 ["forge-spend", "Forge spend", "today and the last 24 hours, headroom per subscription"],
158160 ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"],
159161 ["can", "What I can do", "your role and what it allows here"],
160162 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
@@ -203,6 +205,7 @@ test("a member person is offered the signed-in roster without the admin entries"
203205 ["changelog", "Changelog", "the last 30 releases and their pulls"],
204206 ["stalled", "Stalled steps", "pulls whose check is red or was never asked for; enter re-runs"],
205207 ["file-holds", "Held files", "hold new work on a file, release it, or list the holds"],
208 ["forge-spend", "Forge spend", "today and the last 24 hours, headroom per subscription"],
206209 ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"],
207210 ["model", "Model budget", "what your crew may spend, and add your own subscription"],
208211 ["can", "What I can do", "your role and what it allows here"],
test/tui-usage-returns.test.ts +13
@@ -156,3 +156,16 @@ test("a reader the returns tool refuses sees no returns block", async () => {
156156 expect(h.text()).toBe(before);
157157 h.stop();
158158 });
159
160 test("Forge spend is a palette entry: an admin reads every app, a writer of none reads none", async () => {
161 const h = boot("alice");
162 await h.open("Forge spend");
163 expect(h.text()).toContain("Forge spend");
164 expect(h.text()).toContain("3 apps · today $");
165 expect(h.text()).toContain("last 24 h $");
166 h.stop();
167 const b = boot("bob");
168 await b.open("Forge spend");
169 expect(b.text()).toContain("0 apps · today $0.00 over 0 runs");
170 b.stop();
171 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs