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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7656-mcp-usage-with-no-repo-answers-the-forge → main 6 commits fork
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 | ||
| 554 | 554 | - [](docs/DESIGN.md) — the design system's spine; its sections are under `docs/design/sections/`. |
| 555 | 555 | - [](docs/SPIKE-CLOUDFLARE.md), [](docs/AUDIT-MANY-HANDS.md), [](docs/BACKLOG.md) and [](docs/research/) — a spike, an audit, a backlog and research notes, kept as written. |
| 556 | 556 | |
| 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). | |
| 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). | |
| 558 | 558 | |
| 559 | 559 | 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`. |
| 560 | 560 |
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 | ||
| 1326 | 1326 | a floor). An operator keeps a share of every window for people |
| 1327 | 1327 | (Settings → Subscriptions, 0–90%); `crewRemaining` is what is left after |
| 1328 | 1328 | 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. | |
| 1330 | 1334 | |
| 1331 | 1335 | Across repositories, the _fleet graph_ (`GET /fleet/graph`) draws a |
| 1332 | 1336 | 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. | ||
| 191 | 191 | | `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 | | |
| 192 | 192 | | `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 | |
| 193 | 193 | | `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 | | | |
| 195 | 195 | | `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 | |
| 196 | 196 | | `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 | | |
| 197 | 197 | | `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 @@ | ||
| 13 | 13 | |
| 14 | 14 | import type { App } from "../app.ts"; |
| 15 | 15 | 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"; | |
| 18 | 24 | import { |
| 19 | 25 | budgetsOf, |
| 20 | 26 | grantedAccounts, |
| @@ -195,3 +201,96 @@ export function readUsage( | ||
| 195 | 201 | guard: guardCounts(app.db, repo.id, since), |
| 196 | 202 | }); |
| 197 | 203 | } |
| 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"; | ||
| 140 | 140 | export { route as getApiV1TelemetrySources } from "./getApiV1TelemetrySources.ts"; |
| 141 | 141 | export { route as getApiV1TelemetrySystem } from "./getApiV1TelemetrySystem.ts"; |
| 142 | 142 | export { route as getApiV1Tokens } from "./getApiV1Tokens.ts"; |
| 143 | export { route as getApiV1Usage } from "./getApiV1Usage.ts"; | |
| 143 | 144 | export { route as getApiV1User } from "./getApiV1User.ts"; |
| 144 | 145 | export { route as getApiV1UserAssigned } from "./getApiV1UserAssigned.ts"; |
| 145 | 146 | export { route as getApiV1UserKeys } from "./getApiV1UserKeys.ts"; |
src/core/standing.ts +6
| @@ -382,3 +382,9 @@ function windowStanding( | ||
| 382 | 382 | said: limit?.said ?? null, |
| 383 | 383 | }; |
| 384 | 384 | } |
| 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) | ||
| 54 | 54 | return b.total - a.total || a.pattern.localeCompare(b.pattern); |
| 55 | 55 | }); |
| 56 | 56 | } |
| 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 { | ||
| 250 | 250 | } from "../core/runs.ts"; |
| 251 | 251 | import { checkStale } from "../checks/stale.ts"; |
| 252 | 252 | 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"; | |
| 254 | 254 | import { readReturns, reportSpend, RETURNS_DAYS } from "../actions/returns.ts"; |
| 255 | 255 | import { fleetSelection, readFleetGraph } from "../actions/fleet-graph.ts"; |
| 256 | 256 | import { carriedBy } from "../core/carried.ts"; |
| @@ -3400,6 +3400,13 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3400 | 3400 | ); |
| 3401 | 3401 | }); |
| 3402 | 3402 | |
| 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 | ||
| 3403 | 3410 | router.add("GET", "/api/v1/repos/:owner/:name/usage", (ctx) => { |
| 3404 | 3411 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 3405 | 3412 | 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"; | |
| 2 | 2 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 3 | 3 | |
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | 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", | |
| 7 | 7 | 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.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
| 12 | 12 | repo: REPO_ARG, |
| 13 | 13 | days: { type: "integer", minimum: 1, maximum: USAGE_DAYS_MAX }, |
| 14 | 14 | }, |
| 15 | required: ["repo"], | |
| 16 | 15 | }, |
| 17 | 16 | writes: false, |
| 18 | 17 | 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 | } | |
| 19 | 23 | const repo = repoArg(ctx, user, args); |
| 20 | 24 | if (isAnswer(repo)) return repo; |
| 21 | 25 | const raw = args["days"]; |
src/tui/forge.ts +9
| @@ -44,6 +44,7 @@ import { mayManageFlags } from "../core/flags.ts"; | ||
| 44 | 44 | import { mayEditTrustedHosts } from "../origins/trusted-hosts.ts"; |
| 45 | 45 | import { trustedHostsPrompt } from "./screens/trustedhosts.ts"; |
| 46 | 46 | import { flagsScreen } from "./screens/flags.ts"; |
| 47 | import { forgeUsageScreen } from "./screens/forge_usage.ts"; | |
| 47 | 48 | import { usageScreen } from "./screens/usage.ts"; |
| 48 | 49 | import { canScreen } from "./screens/can.ts"; |
| 49 | 50 | import { modelScreen } from "./screens/model.ts"; |
| @@ -421,6 +422,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 421 | 422 | hint: "every route, unused ones first", |
| 422 | 423 | run: () => ctx.push(usageScreen(session, ctx)), |
| 423 | 424 | }); |
| 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 | }); | |
| 424 | 433 | // Proposal tokens for a repository you write, as the API and MCP have them (#2148). |
| 425 | 434 | if (session.user !== undefined) |
| 426 | 435 | 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", () => { | ||
| 92 | 92 | ["crew-scorecard", "Crew scorecard", "agent runs per model and per prompt version"], |
| 93 | 93 | ["crew-handover", "Crew hand-overs", "issues parked twice, and the last restore drill"], |
| 94 | 94 | ["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"], | |
| 95 | 96 | ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"], |
| 96 | 97 | ["model", "Model budget", "what your crew may spend, and add your own subscription"], |
| 97 | 98 | ["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", | ||
| 155 | 156 | ["crew-scorecard", "Crew scorecard", "agent runs per model and per prompt version"], |
| 156 | 157 | ["crew-handover", "Crew hand-overs", "issues parked twice, and the last restore drill"], |
| 157 | 158 | ["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"], | |
| 158 | 160 | ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"], |
| 159 | 161 | ["can", "What I can do", "your role and what it allows here"], |
| 160 | 162 | ["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" | ||
| 203 | 205 | ["changelog", "Changelog", "the last 30 releases and their pulls"], |
| 204 | 206 | ["stalled", "Stalled steps", "pulls whose check is red or was never asked for; enter re-runs"], |
| 205 | 207 | ["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"], | |
| 206 | 209 | ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"], |
| 207 | 210 | ["model", "Model budget", "what your crew may spend, and add your own subscription"], |
| 208 | 211 | ["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 () => { | ||
| 156 | 156 | expect(h.text()).toBe(before); |
| 157 | 157 | h.stop(); |
| 158 | 158 | }); |
| 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 | }); |
new above
offline