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

Subscription throughput: a governor that keeps every model subscription's window full, with usage as the forge's efficiency metric #379

Merged opened this in answer to

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

Merged

torque/forge:torque/341-subscription-throughput-a-governor-that → main 7 commits fork

  • Merged. torque/341-subscription-throughput-a-governor-that was merged into main as b249761.

Review

Sign in to review this change.
15 files changed +1053 −11
CLAUDE.md +3 −1
@@ -104,7 +104,9 @@ src/
104104 (issue|pull, grouped `labelsForIssues`/`commentCounts`), pulls,
105105 reviews, orgs/teams, webhooks, keys (ssh public keys), search
106106 (repos/issues/`git grep`, all bounded), events (outbox+bus),
107 reconcile, schema — pure data, no authz, no events
107 reconcile, schema, usage (what each agent run spent: tokens,
108 cost, wall time, by subscription/app/agent/issue) — pure
109 data, no authz, no events
108110 actions/ one function per platform mutation: authorize (access.ts tiers),
109111 call core, `bus.append`. API, web, and TUI are adapters over
110112 these. branches.ts also owns `originPush` — how a mirror's ref
docs/MCP.md +1
@@ -38,6 +38,7 @@ for a tunnel and a forge that sleeps.
3838 | `take_updates` | a fork takes its parent's updates when git can do it alone; a conflict is refused | yes |
3939 | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes |
4040 | `map` | an app's plan: ready now, waiting on what, the critical path, where the crew is (`app`) | |
41 | `usage` | `{ repo, days? }`: what the crew's runs spent — tokens, cost, wall time — by subscription, agent, issue; a writer's | |
4142 | `link` | `{ app, issue, blockedBy }`: the issue waits for another; loops and cross-repository are refused | yes |
4243 | `unlink` | `{ app, issue, blockedBy }`: the issue stops waiting for it | yes |
4344 | `plan` | `{ app, issues }`: open up to 12 linked issues, all or none (see below) | yes |
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",
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",
1010 "timeout": 1800
1111 }
1212 }
src/actions/usage.ts added +142
@@ -0,0 +1,142 @@
1 /**
2 * Reading the usage ledger (`core/usage.ts`) for one repository: what its
3 * agent runs spent over a window, summed by subscription, agent and issue,
4 * with the newest lines. Spend is the owner's own fact (`canSeeOwnerFacts`,
5 * the predicate the run pages use for cost), so a reader who cannot write
6 * the repository is refused. The API route and the MCP `usage` tool both
7 * answer from here, so they never disagree.
8 */
9
10 import type { App } from "../app.ts";
11 import { getIssuesByIds } from "../core/issues.ts";
12 import { canSeeOwnerFacts, type Repo } from "../core/repos.ts";
13 import { listSubscriptions } from "../core/subscriptions.ts";
14 import {
15 recentUsage,
16 usageTotals,
17 type UsageEntry,
18 type UsageTotals,
19 } from "../core/usage.ts";
20 import { getUsersByIds, type User } from "../core/users.ts";
21 import { err, ok, type Result } from "../kernel/result.ts";
22
23 /** The window a read covers, in whole days back from now. */
24 export const USAGE_DAYS_DEFAULT = 7;
25 export const USAGE_DAYS_MAX = 90;
26 const RECENT = 20;
27
28 /** A window in days from text: a whole number 1–90, or the default when absent. */
29 export function usageDays(text: string | null | undefined): number | undefined {
30 if (text === null || text === undefined || text === "")
31 return USAGE_DAYS_DEFAULT;
32 if (!/^\d{1,3}$/.test(text)) return undefined;
33 const n = Number(text);
34 return n >= 1 && n <= USAGE_DAYS_MAX ? n : undefined;
35 }
36
37 type Named<K extends string> = Omit<UsageTotals, "key"> & {
38 readonly [P in K]: string | null;
39 };
40
41 export interface UsageReport {
42 readonly app: string;
43 readonly days: number;
44 readonly since: string;
45 /** Every run in the window, summed; zeros when there were none. */
46 readonly total: Omit<UsageTotals, "key">;
47 /** By account; `subscription: null` is the forge's own token. */
48 readonly bySubscription: readonly Named<"subscription">[];
49 readonly byAgent: readonly Named<"agent">[];
50 /** By issue number; `issue: null` is a run about no issue. */
51 readonly byIssue: readonly (Omit<UsageTotals, "key"> & {
52 readonly issue: number | null;
53 })[];
54 readonly recent: readonly (Omit<UsageEntry, "repoId"> & {
55 readonly subscription: string | null;
56 readonly agent: string | null;
57 readonly issue: number | null;
58 })[];
59 }
60
61 const ZERO: Omit<UsageTotals, "key"> = {
62 runs: 0,
63 ok: 0,
64 failed: 0,
65 paused: 0,
66 tokensIn: 0,
67 tokensOut: 0,
68 costUsd: 0,
69 wallMs: 0,
70 };
71
72 const strip = ({ key: _, ...rest }: UsageTotals): Omit<UsageTotals, "key"> =>
73 rest;
74
75 export function readUsage(
76 app: App,
77 user: User | undefined,
78 repo: Repo,
79 days: number,
80 now: Date = new Date(),
81 ): Result<UsageReport, "forbidden"> {
82 if (!canSeeOwnerFacts(app.db, repo, user)) return err("forbidden");
83 const since = new Date(now.getTime() - days * 86_400_000).toISOString();
84 const filter = { since, repoId: repo.id };
85 const db = app.db;
86 const bySubscription = usageTotals(db, "subscription", filter);
87 const byAgent = usageTotals(db, "agent", filter);
88 const byIssue = usageTotals(db, "issue", filter);
89 const recent = recentUsage(db, filter, RECENT);
90
91 // Names, each looked up once for the whole report.
92 const subscriptions = new Map(
93 listSubscriptions(db).map((s) => [s.id, s.name]),
94 );
95 const agentIds = new Set<string>();
96 const issueIds = new Set<string>();
97 for (const row of [...byAgent, ...recent.map((r) => ({ key: r.agentId }))])
98 if (row.key !== null) agentIds.add(row.key);
99 for (const row of [...byIssue, ...recent.map((r) => ({ key: r.issueId }))])
100 if (row.key !== null) issueIds.add(row.key);
101 const agents = getUsersByIds(db, [...agentIds]);
102 const issues = getIssuesByIds(db, [...issueIds]);
103 const subscriptionName = (id: string | null): string | null =>
104 id === null ? null : (subscriptions.get(id) ?? "a removed subscription");
105 const agentName = (id: string | null): string | null =>
106 id === null ? null : (agents.get(id)?.username ?? "a removed agent");
107 const issueNumber = (id: string | null): number | null =>
108 id === null ? null : (issues.get(id)?.number ?? null);
109
110 const total = bySubscription.reduce<Omit<UsageTotals, "key">>(
111 (sum, row) => ({
112 runs: sum.runs + row.runs,
113 ok: sum.ok + row.ok,
114 failed: sum.failed + row.failed,
115 paused: sum.paused + row.paused,
116 tokensIn: sum.tokensIn + row.tokensIn,
117 tokensOut: sum.tokensOut + row.tokensOut,
118 costUsd: sum.costUsd + row.costUsd,
119 wallMs: sum.wallMs + row.wallMs,
120 }),
121 ZERO,
122 );
123
124 return ok({
125 app: `${repo.ownerName}/${repo.name}`,
126 days,
127 since,
128 total,
129 bySubscription: bySubscription.map((r) => ({
130 subscription: subscriptionName(r.key),
131 ...strip(r),
132 })),
133 byAgent: byAgent.map((r) => ({ agent: agentName(r.key), ...strip(r) })),
134 byIssue: byIssue.map((r) => ({ issue: issueNumber(r.key), ...strip(r) })),
135 recent: recent.map(({ repoId: _, ...r }) => ({
136 ...r,
137 subscription: subscriptionName(r.subscriptionId),
138 agent: agentName(r.agentId),
139 issue: issueNumber(r.issueId),
140 })),
141 });
142 }
src/core/schema.ts +45
@@ -980,4 +980,49 @@ export const migrations: readonly string[] = [
980980 CREATE INDEX share_recipient ON share(recipient_id, state);
981981 CREATE INDEX share_sender ON share(sender_id, state);
982982 `,
983
984 // 37 — the usage ledger (`core/usage.ts`): what each agent run spent,
985 // attributed to subscription, app, agent and issue. `subscription_id` is
986 // text, not a reference: a removed account's spend stays counted.
987 //
988 // Written to survive one ordering accident: for a while this migration
989 // was numbered 36, and a preview's database migrated then has the ledger
990 // at 36 and never ran `share`. So both are created only if missing —
991 // a no-op everywhere 36 was `share`, the repair where it was not. The
992 // `share` DDL is migration 36's, verbatim.
993 `
994 CREATE TABLE IF NOT EXISTS share (
995 id TEXT PRIMARY KEY,
996 item_id TEXT NOT NULL,
997 sender_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
998 recipient_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
999 repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
1000 kind TEXT NOT NULL,
1001 link TEXT NOT NULL,
1002 what TEXT NOT NULL,
1003 state TEXT NOT NULL CHECK (state IN ('delivered','queued','dropped')),
1004 created_at TEXT NOT NULL,
1005 delivered_at TEXT,
1006 UNIQUE (item_id, recipient_id)
1007 );
1008 CREATE INDEX IF NOT EXISTS share_recipient ON share(recipient_id, state);
1009 CREATE INDEX IF NOT EXISTS share_sender ON share(sender_id, state);
1010
1011 CREATE TABLE IF NOT EXISTS usage (
1012 run_id TEXT PRIMARY KEY REFERENCES run(id) ON DELETE CASCADE,
1013 repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
1014 issue_id TEXT,
1015 agent_id TEXT,
1016 subscription_id TEXT,
1017 tokens_in INTEGER NOT NULL DEFAULT 0,
1018 tokens_out INTEGER NOT NULL DEFAULT 0,
1019 cost_usd REAL NOT NULL DEFAULT 0,
1020 wall_ms INTEGER NOT NULL DEFAULT 0,
1021 outcome TEXT NOT NULL CHECK (outcome IN ('ok','failed','paused')),
1022 at TEXT NOT NULL
1023 );
1024 CREATE INDEX IF NOT EXISTS usage_at ON usage(at);
1025 CREATE INDEX IF NOT EXISTS usage_repo ON usage(repo_id, at);
1026 CREATE INDEX IF NOT EXISTS usage_subscription ON usage(subscription_id, at);
1027 `,
9831028 ];
src/core/usage.ts added +212
@@ -0,0 +1,212 @@
1 /**
2 * Usage — the ledger of what each agent run spent: tokens in and out, the
3 * provider's reported cost, wall time and outcome, attributed to the
4 * subscription it ran on, the app (repository), the agent and the issue.
5 * One row per run, written once as the run ends (`crew/runner.ts`); the
6 * governor reads it to pace the crew, the board to show efficiency.
7 *
8 * Units, stated once:
9 * - `tokensIn` is every input token the model read: fresh input plus cache
10 * writes plus cache reads, as Claude Code's `result.usage` reports them.
11 * `tokensOut` is output tokens. Both are whole tokens.
12 * - `costUsd` is the provider's `total_cost_usd` for the run, in US dollars
13 * (on a subscription it is the list-price equivalent, not money charged).
14 * - `wallMs` is milliseconds from claim to end, measured by the forge.
15 *
16 * Pure data: no authz, no events. A subscription's id is kept as text, not
17 * a reference, so removing an account does not erase what it spent.
18 */
19
20 import type { Database } from "bun:sqlite";
21
22 /** How a run ended, as far as spend is concerned. */
23 export type UsageOutcome = "ok" | "failed" | "paused";
24 export const USAGE_OUTCOMES: readonly UsageOutcome[] = ["ok", "failed", "paused"];
25
26 export interface UsageEntry {
27 readonly runId: string;
28 readonly repoId: string;
29 readonly issueId: string | null;
30 readonly agentId: string | null;
31 /** The account the run worked on; null is the forge's own token. */
32 readonly subscriptionId: string | null;
33 readonly tokensIn: number;
34 readonly tokensOut: number;
35 readonly costUsd: number;
36 readonly wallMs: number;
37 readonly outcome: UsageOutcome;
38 /** When the run ended (ISO 8601, UTC). */
39 readonly at: string;
40 }
41
42 /** A count that must be a whole, non-negative number; anything else is 0. */
43 const whole = (n: number): number =>
44 Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
45 const nonNegative = (n: number): number =>
46 Number.isFinite(n) && n > 0 ? n : 0;
47
48 /**
49 * Write a run's line in the ledger. A run is written once: a second write
50 * for the same run (a retried finish) replaces the first rather than
51 * counting the spend twice.
52 */
53 export function recordUsage(db: Database, entry: UsageEntry): void {
54 db.query(
55 `INSERT OR REPLACE INTO usage
56 (run_id, repo_id, issue_id, agent_id, subscription_id,
57 tokens_in, tokens_out, cost_usd, wall_ms, outcome, at)
58 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)`,
59 ).run(
60 entry.runId,
61 entry.repoId,
62 entry.issueId,
63 entry.agentId,
64 entry.subscriptionId,
65 whole(entry.tokensIn),
66 whole(entry.tokensOut),
67 nonNegative(entry.costUsd),
68 whole(entry.wallMs),
69 entry.outcome,
70 entry.at,
71 );
72 }
73
74 interface UsageRow {
75 run_id: string;
76 repo_id: string;
77 issue_id: string | null;
78 agent_id: string | null;
79 subscription_id: string | null;
80 tokens_in: number;
81 tokens_out: number;
82 cost_usd: number;
83 wall_ms: number;
84 outcome: UsageOutcome;
85 at: string;
86 }
87
88 const toEntry = (r: UsageRow): UsageEntry => ({
89 runId: r.run_id,
90 repoId: r.repo_id,
91 issueId: r.issue_id,
92 agentId: r.agent_id,
93 subscriptionId: r.subscription_id,
94 tokensIn: r.tokens_in,
95 tokensOut: r.tokens_out,
96 costUsd: r.cost_usd,
97 wallMs: r.wall_ms,
98 outcome: r.outcome,
99 at: r.at,
100 });
101
102 export function getUsage(db: Database, runId: string): UsageEntry | undefined {
103 const row = db
104 .query<UsageRow, [string]>("SELECT * FROM usage WHERE run_id = ?1")
105 .get(runId);
106 return row ? toEntry(row) : undefined;
107 }
108
109 /** What the ledger can be summed by. Each maps to one fixed column. */
110 export type UsageBy = "subscription" | "repo" | "agent" | "issue";
111 export const USAGE_BY: readonly UsageBy[] = [
112 "subscription",
113 "repo",
114 "agent",
115 "issue",
116 ];
117 const COLUMN: Readonly<Record<UsageBy, string>> = {
118 subscription: "subscription_id",
119 repo: "repo_id",
120 agent: "agent_id",
121 issue: "issue_id",
122 };
123
124 export interface UsageTotals {
125 /** The id summed by; null for the forge's own token, or no issue/agent. */
126 readonly key: string | null;
127 readonly runs: number;
128 readonly ok: number;
129 readonly failed: number;
130 readonly paused: number;
131 readonly tokensIn: number;
132 readonly tokensOut: number;
133 readonly costUsd: number;
134 readonly wallMs: number;
135 }
136
137 export interface UsageFilter {
138 /** Rows at or after this instant (ISO 8601). */
139 readonly since: string;
140 /** Only this repository's runs, when given. */
141 readonly repoId?: string | undefined;
142 }
143
144 /**
145 * The ledger summed by one attribution since an instant, in one query,
146 * most spent first. `SUM` over no rows is `NULL`; no rows is no groups, so
147 * a quiet window is an empty list rather than a row of zeros.
148 */
149 export function usageTotals(
150 db: Database,
151 by: UsageBy,
152 filter: UsageFilter,
153 ): UsageTotals[] {
154 const column = COLUMN[by];
155 const rows = db
156 .query<
157 {
158 key: string | null;
159 runs: number;
160 ok: number;
161 failed: number;
162 paused: number;
163 tokens_in: number;
164 tokens_out: number;
165 cost_usd: number;
166 wall_ms: number;
167 },
168 [string, string | null]
169 >(
170 `SELECT ${column} AS key,
171 COUNT(*) AS runs,
172 SUM(outcome = 'ok') AS ok,
173 SUM(outcome = 'failed') AS failed,
174 SUM(outcome = 'paused') AS paused,
175 SUM(tokens_in) AS tokens_in,
176 SUM(tokens_out) AS tokens_out,
177 SUM(cost_usd) AS cost_usd,
178 SUM(wall_ms) AS wall_ms
179 FROM usage
180 WHERE at >= ?1 AND (?2 IS NULL OR repo_id = ?2)
181 GROUP BY ${column}
182 ORDER BY cost_usd DESC, runs DESC, key`,
183 )
184 .all(filter.since, filter.repoId ?? null);
185 return rows.map((r) => ({
186 key: r.key,
187 runs: r.runs,
188 ok: r.ok,
189 failed: r.failed,
190 paused: r.paused,
191 tokensIn: r.tokens_in,
192 tokensOut: r.tokens_out,
193 costUsd: r.cost_usd,
194 wallMs: r.wall_ms,
195 }));
196 }
197
198 /** The newest ledger lines, newest first, bounded. */
199 export function recentUsage(
200 db: Database,
201 filter: UsageFilter,
202 limit: number,
203 ): UsageEntry[] {
204 return db
205 .query<UsageRow, [string, string | null, number]>(
206 `SELECT * FROM usage
207 WHERE at >= ?1 AND (?2 IS NULL OR repo_id = ?2)
208 ORDER BY at DESC, run_id DESC LIMIT ?3`,
209 )
210 .all(filter.since, filter.repoId ?? null, Math.min(Math.max(limit, 1), 200))
211 .map(toEntry);
212 }
src/crew/claude.ts +50 −2
@@ -46,6 +46,12 @@ export interface ClaudeOutcome {
4646 readonly result: string;
4747 readonly costUsd: number;
4848 readonly turns: number;
49 /**
50 * Tokens the model read (fresh input, cache writes and cache reads) and
51 * wrote, from the result's `usage`; 0 when the run reported none.
52 */
53 readonly tokensIn: number;
54 readonly tokensOut: number;
4955 /** `completed`, `max_budget_reached`, `timeout`, `cancelled`, `error`, `exit <n>`. */
5056 readonly reason: string;
5157 }
@@ -186,6 +192,8 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> {
186192 let result = "";
187193 let costUsd = 0;
188194 let turns = 0;
195 let tokensIn = 0;
196 let tokensOut = 0;
189197 let reason = "";
190198 let ok = false;
191199 let sawResult = false;
@@ -220,6 +228,7 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> {
220228 costUsd =
221229 typeof e["total_cost_usd"] === "number" ? e["total_cost_usd"] : 0;
222230 turns = typeof e["num_turns"] === "number" ? e["num_turns"] : 0;
231 ({ tokensIn, tokensOut } = tokensOf(e["usage"]));
223232 const terminal = e["terminal_reason"];
224233 // Claude Code names the budget ending twice (`error_max_budget_usd`
225234 // as the subtype, `budget_exhausted` as the terminal reason); the
@@ -293,7 +302,16 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> {
293302 input.signal.removeEventListener("abort", onAbort);
294303
295304 if (why !== undefined) {
296 return { ok: false, sessionId, result, costUsd, turns, reason: why };
305 return {
306 ok: false,
307 sessionId,
308 result,
309 costUsd,
310 turns,
311 tokensIn,
312 tokensOut,
313 reason: why,
314 };
297315 }
298316 if (!sawResult) {
299317 const tail = stderr.slice(-3).join(" | ");
@@ -303,10 +321,40 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> {
303321 result: tail,
304322 costUsd,
305323 turns,
324 tokensIn,
325 tokensOut,
306326 reason: `exit ${code}`,
307327 };
308328 }
309 return { ok, sessionId, result, costUsd, turns, reason };
329 return { ok, sessionId, result, costUsd, turns, tokensIn, tokensOut, reason };
330 }
331
332 /**
333 * The token counts of a result's `usage` object. Input is every token the
334 * model read: `input_tokens + cache_creation_input_tokens +
335 * cache_read_input_tokens`; output is `output_tokens`. A missing or odd
336 * field counts 0, never NaN.
337 */
338 export function tokensOf(usage: unknown): {
339 tokensIn: number;
340 tokensOut: number;
341 } {
342 if (typeof usage !== "object" || usage === null)
343 return { tokensIn: 0, tokensOut: 0 };
344 const u = usage as Record<string, unknown>;
345 const n = (key: string): number => {
346 const v = u[key];
347 return typeof v === "number" && Number.isFinite(v) && v > 0
348 ? Math.round(v)
349 : 0;
350 };
351 return {
352 tokensIn:
353 n("input_tokens") +
354 n("cache_creation_input_tokens") +
355 n("cache_read_input_tokens"),
356 tokensOut: n("output_tokens"),
357 };
310358 }
311359
312360 /** The lines a person wants from an assistant turn: its words and its tool calls. */
src/crew/runner.ts +59 −5
@@ -66,13 +66,14 @@ import {
6666 type RunReason,
6767 } from "./brief.ts";
6868 import { policyBrief } from "../contract/audit.ts";
69 import { BUDGET_REACHED, runClaude } from "./claude.ts";
69 import { BUDGET_REACHED, runClaude, type ClaudeOutcome } from "./claude.ts";
7070 import { isRateLimited, MAX_PAUSES, resumeAt, whenText } from "./limits.ts";
7171 import {
7272 pickSubscription,
7373 restSubscription,
7474 type Pick,
7575 } from "../core/subscriptions.ts";
76 import { recordUsage, type UsageOutcome } from "../core/usage.ts";
7677 import { trackAgentRun, untrackAgentRun } from "./running.ts";
7778 import { sandboxPolicy } from "./sandbox.ts";
7879 import { grantSecrets, parseDeclaration, type Grant } from "./grant.ts";
@@ -202,9 +203,11 @@ export async function executeAgentRun(
202203 const write = (line: string): void =>
203204 appendRunLog(app.config.dataDir, run.id, line);
204205 announceRun(app, claimed);
206 const started = Date.now();
207 const meter: Meter = { tokensIn: 0, tokensOut: 0, subscriptionId: null };
205208 let outcome: Outcome;
206209 try {
207 outcome = await work(app, claimed, options, write, controller.signal);
210 outcome = await work(app, claimed, options, write, controller.signal, meter);
208211 } catch (error) {
209212 log.error("agent run threw", { run: run.id, error: String(error) });
210213 outcome = {
@@ -221,6 +224,7 @@ export async function executeAgentRun(
221224 // forge does the tidying, by the directory the process is working in.
222225 await reapOrphans(app, run, write);
223226 forgetLandedClone(app, run);
227 meterUsage(app, claimed, outcome, meter, started);
224228 if (outcome.state === "paused") {
225229 write(outcome.summary);
226230 noteRunProgress(app.db, run.id, {
@@ -338,6 +342,48 @@ type Outcome =
338342 readonly costUsd: number;
339343 };
340344
345 /**
346 * What a run spent that its outcome does not carry: the tokens of every
347 * model invocation it made, and the account it made them on. Filled by
348 * `work` as it goes, read once the run ends.
349 */
350 interface Meter {
351 tokensIn: number;
352 tokensOut: number;
353 subscriptionId: string | null;
354 }
355
356 /** The run's line in the usage ledger (`core/usage.ts`), written once it ends. */
357 function meterUsage(
358 app: App,
359 run: Run,
360 outcome: Outcome,
361 meter: Meter,
362 started: number,
363 ): void {
364 const ended = new Date();
365 const result: UsageOutcome = outcome.state;
366 // The ledger is bookkeeping: a line it cannot write (the repository
367 // deleted under the run) is logged, never the run's failure.
368 try {
369 recordUsage(app.db, {
370 runId: run.id,
371 repoId: run.repoId,
372 issueId: run.issueId,
373 agentId: run.actorId,
374 subscriptionId: meter.subscriptionId,
375 tokensIn: meter.tokensIn,
376 tokensOut: meter.tokensOut,
377 costUsd: outcome.costUsd ?? 0,
378 wallMs: ended.getTime() - started,
379 outcome: result,
380 at: ended.toISOString(),
381 });
382 } catch (error) {
383 log.warn("usage not recorded", { run: run.id, error: String(error) });
384 }
385 }
386
341387 /** What a paused run hands back: when its task may start again, and the count. */
342388 export interface Pause {
343389 readonly resumeAt: string;
@@ -350,6 +396,7 @@ async function work(
350396 options: CrewOptions,
351397 write: (line: string) => void,
352398 signal: AbortSignal,
399 meter: Meter,
353400 ): Promise<Outcome> {
354401 const repo = getRepoById(app.db, run.repoId);
355402 const issue =
@@ -613,6 +660,7 @@ async function work(
613660 noteRunProgress(app.db, run.id, {
614661 subscriptionId: picked.subscription.id,
615662 });
663 meter.subscriptionId = picked.subscription.id;
616664 }
617665 const env: Record<string, string> = {
618666 ...gitAuth,
@@ -644,8 +692,9 @@ async function work(
644692 prompt: string,
645693 budgetUsd: number,
646694 resume: string | undefined,
647 ) =>
648 redactResult(await runClaude({
695 ): Promise<ClaudeOutcome> => {
696 const spent = redactResult(
697 await runClaude({
649698 binary: options.binary,
650699 cwd: dir,
651700 prompt,
@@ -659,7 +708,12 @@ async function work(
659708 log: write,
660709 signal,
661710 onSteer: (send) => steering.set(run.id, send),
662 }));
711 }),
712 );
713 meter.tokensIn += spent.tokensIn;
714 meter.tokensOut += spent.tokensOut;
715 return spent;
716 };
663717 write(
664718 `claude: budget $${budget.toFixed(2)}, $${reserve.toFixed(2)} of it kept for the wrap-up${brief.resuming ? `, resuming ${previous?.sessionId}` : ""}`,
665719 );
src/http/api.ts +19
@@ -215,6 +215,7 @@ import {
215215 } from "../core/runs.ts";
216216 import { resolveOid } from "../git/merge.ts";
217217 import { vitals } from "../kernel/vitals.ts";
218 import { readUsage, usageDays, USAGE_DAYS_MAX } from "../actions/usage.ts";
218219 import { readRunLog, subscribeRunLog } from "../runs/log.ts";
219220 import { blobForRender, commitLog, listRefs, listTree } from "../git/read.ts";
220221 import { repoDir } from "../git/repos.ts";
@@ -3147,6 +3148,24 @@ export function apiRoutes(router: Router<Handler>): void {
31473148 ? (r: Run): Run => r
31483149 : (r: Run): Run => ({ ...r, costUsd: 0, turns: 0 });
31493150
3151 // What the crew's runs here spent over a window (`core/usage.ts`):
3152 // tokens, cost, wall time and outcome, by subscription, agent and issue.
3153 // Spend is the owner's own fact, so a reader who cannot write gets 403.
3154 router.add("GET", "/api/v1/repos/:owner/:name/usage", (ctx) => {
3155 const repo = loadRepo(ctx);
3156 if (!repo) return notFound();
3157 const days = usageDays(ctx.url.searchParams.get("days"));
3158 if (days === undefined)
3159 return apiError(
3160 "invalid_days",
3161 422,
3162 `days is a whole number from 1 to ${USAGE_DAYS_MAX}`,
3163 );
3164 const report = readUsage(ctx.app, ctx.auth.user, repo, days);
3165 if (!report.ok) return forbidden();
3166 return json(report.value);
3167 });
3168
31503169 router.add("GET", "/api/v1/repos/:owner/:name/runs", (ctx) => {
31513170 const repo = loadRepo(ctx);
31523171 if (!repo) return notFound();
src/http/mcp.ts +37
@@ -27,6 +27,12 @@ import {
2727 import { openPlan, parsePlan, PLAN_MAX } from "../actions/plan.ts";
2828 import { cancelRepoRun } from "../actions/runs.ts";
2929 import { tell, toldWords } from "../actions/tell.ts";
30 import {
31 readUsage,
32 usageDays,
33 USAGE_DAYS_DEFAULT,
34 USAGE_DAYS_MAX,
35 } from "../actions/usage.ts";
3036 import { ITEM_ID } from "../core/interactions.ts";
3137 import { share } from "../feed/share.ts";
3238 import { parseTargets } from "./feed.ts";
@@ -504,6 +510,37 @@ const TOOLS: readonly Tool[] = [
504510 );
505511 },
506512 },
513 {
514 name: "usage",
515 description:
516 "What the crew's runs on an app spent over the last days (1–90, default 7): tokens in and out, cost in US dollars, wall time and outcome, by subscription, agent and issue, with the newest runs. The same data as /api/v1/repos/:owner/:name/usage; refused unless you can write the app.",
517 inputSchema: {
518 type: "object",
519 properties: {
520 repo: REPO_ARG,
521 days: { type: "integer", minimum: 1, maximum: USAGE_DAYS_MAX },
522 },
523 required: ["repo"],
524 },
525 writes: false,
526 run(ctx, user, args) {
527 const repo = repoArg(ctx, user, args);
528 if (isAnswer(repo)) return repo;
529 const raw = args["days"];
530 const days =
531 raw === undefined
532 ? USAGE_DAYS_DEFAULT
533 : typeof raw === "number"
534 ? usageDays(String(raw))
535 : undefined;
536 if (days === undefined)
537 return bad("invalid_days", `days is a whole number from 1 to ${USAGE_DAYS_MAX}`);
538 const report = readUsage(ctx.app, user, repo, days);
539 return report.ok
540 ? good(report.value)
541 : bad("forbidden", "spend is for those who can write this app");
542 },
543 },
507544 {
508545 name: "link",
509546 description:
test/apps-adversarial.test.ts +4 −2
@@ -716,12 +716,14 @@ describe("rule 6: a run token dies with the run, and only with the run", () => {
716716 });
717717
718718 test("a token expiring one millisecond from now is refused once that instant passes", async () => {
719 const soon = new Date(Date.now() + 5).toISOString();
719 // Half a second, not a few milliseconds: on a loaded test shard the
720 // first verify could otherwise land after the instant and read red.
721 const soon = new Date(Date.now() + 500).toISOString();
720722 const minted = createToken(app.db, alice.id, "soon", "rw", {
721723 expiresAt: soon,
722724 });
723725 expect(verifyToken(app.db, minted.token).ok).toBe(true);
724 await Bun.sleep(30);
726 await Bun.sleep(600);
725727 expect(verifyToken(app.db, minted.token).ok).toBe(false);
726728 });
727729 });
test/crew-limits.test.ts +2
@@ -13,6 +13,8 @@ const outcome = (over: Partial<ClaudeOutcome>): ClaudeOutcome => ({
1313 result: "",
1414 costUsd: 0,
1515 turns: 1,
16 tokensIn: 0,
17 tokensOut: 0,
1618 reason: "error",
1719 ...over,
1820 });
test/mcp.test.ts +2
@@ -156,6 +156,7 @@ describe("the forge over MCP", () => {
156156 expect(names).toEqual([
157157 "apps",
158158 "tell",
159 "share",
159160 "say",
160161 "now",
161162 "stop",
@@ -165,6 +166,7 @@ describe("the forge over MCP", () => {
165166 "take_updates",
166167 "converge",
167168 "map",
169 "usage",
168170 "link",
169171 "unlink",
170172 "plan",
test/subscriptions.test.ts +2
@@ -192,6 +192,8 @@ describe("the provider's reset time", () => {
192192 result: "You've hit your weekly limit · resets Sep 26 at 9pm (UTC)",
193193 costUsd: 0,
194194 turns: 0,
195 tokensIn: 0,
196 tokensOut: 0,
195197 reason: "error_during_execution",
196198 }),
197199 ).toBe(true);
test/usage.test.ts added +474
@@ -0,0 +1,474 @@
1 /**
2 * The usage ledger: a run's tokens, cost, wall time and outcome, attributed
3 * to subscription, app, agent and issue — summed the way a hand would, and
4 * read back over the API and MCP only by those who may see spend.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { chmodSync, writeFileSync } from "node:fs";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { addToCrew, createAgent } from "../src/actions/crew.ts";
13 import { assignIssue, openIssue } from "../src/actions/issues.ts";
14 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { usageDays } from "../src/actions/usage.ts";
16 import { createApp, type App } from "../src/app.ts";
17 import type { Repo } from "../src/core/repos.ts";
18 import { createRun, listRuns } from "../src/core/runs.ts";
19 import {
20 addSubscription,
21 EVERYONE,
22 setGrants,
23 } from "../src/core/subscriptions.ts";
24 import { migrations } from "../src/core/schema.ts";
25 import { createToken } from "../src/core/tokens.ts";
26 import {
27 getUsage,
28 recentUsage,
29 recordUsage,
30 usageTotals,
31 } from "../src/core/usage.ts";
32 import { createUser, type User } from "../src/core/users.ts";
33 import { tokensOf } from "../src/crew/claude.ts";
34 import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts";
35 import { openDb } from "../src/kernel/db.ts";
36 import { serve, type Servers } from "../src/http/server.ts";
37
38 const OAT = "sk-ant-oat01-personal-token-for-the-usage-test";
39 const KEY = "sk-ant-api03-work-key-for-the-usage-test";
40
41 describe("a result's token counts", () => {
42 test("input is fresh + cache writes + cache reads; output is output", () => {
43 // 1200 + 300 + 45000 = 46500 read; 800 written.
44 expect(
45 tokensOf({
46 input_tokens: 1200,
47 cache_creation_input_tokens: 300,
48 cache_read_input_tokens: 45000,
49 output_tokens: 800,
50 }),
51 ).toEqual({ tokensIn: 46500, tokensOut: 800 });
52 });
53 test("a missing or odd field counts nothing, never NaN", () => {
54 expect(tokensOf(undefined)).toEqual({ tokensIn: 0, tokensOut: 0 });
55 expect(
56 tokensOf({ input_tokens: "12", output_tokens: -5, cache_read_input_tokens: NaN }),
57 ).toEqual({ tokensIn: 0, tokensOut: 0 });
58 });
59 });
60
61 describe("the ledger's migration", () => {
62 test("repairs a database that took the ledger as migration 36 and never ran share", async () => {
63 const dir = await mkdtemp(join(tmpdir(), "forge-usage-mig-"));
64 try {
65 const path = join(dir, "forge.db");
66 // What a preview migrated before the renumbering holds: 1–35, then
67 // the ledger alone as 36 — no share table.
68 const ledger = migrations[36]!.slice(
69 migrations[36]!.indexOf("CREATE TABLE IF NOT EXISTS usage"),
70 );
71 const before = openDb(path, [...migrations.slice(0, 35), ledger]);
72 const has = (db: typeof before, name: string): boolean =>
73 db
74 .query<{ n: number }, [string]>(
75 "SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = ?1",
76 )
77 .get(name)!.n === 1;
78 expect(has(before, "usage")).toBe(true);
79 expect(has(before, "share")).toBe(false);
80 before.close();
81 // Today's list boots it: 36 is skipped as applied, 37 fills the gap.
82 const after = openDb(path, migrations);
83 expect(has(after, "usage")).toBe(true);
84 expect(has(after, "share")).toBe(true);
85 after.close();
86 } finally {
87 await rm(dir, { recursive: true, force: true });
88 }
89 });
90 });
91
92 describe("the window a read covers", () => {
93 test("is 1–90 whole days, 7 when unsaid, refused otherwise", () => {
94 expect(usageDays(null)).toBe(7);
95 expect(usageDays("")).toBe(7);
96 expect(usageDays("1")).toBe(1);
97 expect(usageDays("90")).toBe(90);
98 for (const bad of ["0", "91", "-1", "1.5", "7d", "1e2", " 7"])
99 expect(usageDays(bad)).toBeUndefined();
100 });
101 });
102
103 describe("the ledger", () => {
104 let dataDir: string;
105 let app: App;
106 let alice: User;
107 let repo: Repo;
108 let other: Repo;
109
110 beforeAll(async () => {
111 dataDir = await mkdtemp(join(tmpdir(), "forge-usage-"));
112 app = createApp({ dataDir, port: 0 });
113 const a = await createUser(app.db, {
114 username: "alice",
115 email: "[email protected]",
116 password: "a-good-passphrase",
117 adminIfFirst: true,
118 });
119 if (!a.ok) throw new Error(a.error);
120 alice = a.value;
121 const one = await createRepo(app, alice, { name: "one", visibility: "private" });
122 const two = await createRepo(app, alice, { name: "two", visibility: "private" });
123 if (!one.ok || !two.ok) throw new Error("repos");
124 repo = one.value;
125 other = two.value;
126 });
127 afterAll(async () => {
128 app.close();
129 await rm(dataDir, { recursive: true, force: true });
130 });
131
132 const run = (r: Repo) => createRun(app.db, { repoId: r.id, kind: "agent" }).id;
133
134 test("sums by subscription, app and outcome as a hand would", () => {
135 const a = run(repo);
136 const b = run(repo);
137 const c = run(repo);
138 const d = run(other);
139 const old = run(repo);
140 const line = {
141 issueId: null,
142 agentId: alice.id,
143 at: "2026-10-01T12:00:00.000Z",
144 };
145 recordUsage(app.db, {
146 ...line,
147 runId: a,
148 repoId: repo.id,
149 subscriptionId: "sub-work",
150 tokensIn: 1000,
151 tokensOut: 100,
152 costUsd: 0.25,
153 wallMs: 60_000,
154 outcome: "ok",
155 });
156 recordUsage(app.db, {
157 ...line,
158 runId: b,
159 repoId: repo.id,
160 subscriptionId: "sub-work",
161 tokensIn: 3000,
162 tokensOut: 300,
163 costUsd: 0.5,
164 wallMs: 120_000,
165 outcome: "paused",
166 });
167 recordUsage(app.db, {
168 ...line,
169 runId: c,
170 repoId: repo.id,
171 subscriptionId: null,
172 tokensIn: 10,
173 tokensOut: 1,
174 costUsd: 0.01,
175 wallMs: 1_000,
176 outcome: "failed",
177 });
178 recordUsage(app.db, {
179 ...line,
180 runId: d,
181 repoId: other.id,
182 subscriptionId: "sub-work",
183 tokensIn: 5,
184 tokensOut: 5,
185 costUsd: 2,
186 wallMs: 5,
187 outcome: "ok",
188 });
189 // Outside the window: never counted.
190 recordUsage(app.db, {
191 ...line,
192 runId: old,
193 repoId: repo.id,
194 subscriptionId: "sub-work",
195 tokensIn: 99_999,
196 tokensOut: 99_999,
197 costUsd: 99,
198 wallMs: 99,
199 outcome: "ok",
200 at: "2026-09-01T00:00:00.000Z",
201 });
202
203 const since = "2026-09-30T00:00:00.000Z";
204 const bySub = usageTotals(app.db, "subscription", { since, repoId: repo.id });
205 // sub-work: 1000+3000 in, 100+300 out, $0.25+$0.50, 60s+120s, 1 ok + 1 paused.
206 expect(bySub).toEqual([
207 {
208 key: "sub-work",
209 runs: 2,
210 ok: 1,
211 failed: 0,
212 paused: 1,
213 tokensIn: 4000,
214 tokensOut: 400,
215 costUsd: 0.75,
216 wallMs: 180_000,
217 },
218 {
219 key: null,
220 runs: 1,
221 ok: 0,
222 failed: 1,
223 paused: 0,
224 tokensIn: 10,
225 tokensOut: 1,
226 costUsd: 0.01,
227 wallMs: 1_000,
228 },
229 ]);
230 // Across every app, by app: two's $2 first, then one's $0.76.
231 const byRepo = usageTotals(app.db, "repo", { since });
232 expect(byRepo.map((r) => [r.key, r.runs, r.costUsd])).toEqual([
233 [other.id, 1, 2],
234 [repo.id, 3, 0.76],
235 ]);
236 expect(
237 recentUsage(app.db, { since, repoId: other.id }, 10).map((u) => u.runId),
238 ).toEqual([d]);
239 });
240
241 test("a run is one line: writing it again replaces, and nonsense counts as zero", () => {
242 const a = run(repo);
243 const base = {
244 runId: a,
245 repoId: repo.id,
246 issueId: null,
247 agentId: null,
248 subscriptionId: null,
249 at: "2026-10-02T00:00:00.000Z",
250 } as const;
251 recordUsage(app.db, {
252 ...base,
253 tokensIn: 7,
254 tokensOut: 7,
255 costUsd: 1,
256 wallMs: 7,
257 outcome: "failed",
258 });
259 recordUsage(app.db, {
260 ...base,
261 tokensIn: Number.NaN,
262 tokensOut: -3,
263 costUsd: Number.POSITIVE_INFINITY,
264 wallMs: 2.6,
265 outcome: "ok",
266 });
267 expect(getUsage(app.db, a)).toEqual({
268 ...base,
269 tokensIn: 0,
270 tokensOut: 0,
271 costUsd: 0,
272 wallMs: 3,
273 outcome: "ok",
274 });
275 expect(
276 app.db
277 .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM usage WHERE run_id = ?1")
278 .get(a)!.n,
279 ).toBe(1);
280 });
281 });
282
283 describe("a crew run's line, and who may read it", () => {
284 let dataDir: string;
285 let app: App;
286 let servers: Servers;
287 let crew: CrewHandle;
288 let alice: User;
289 let bob: User;
290 let bot: User;
291 let repo: Repo;
292
293 beforeAll(async () => {
294 dataDir = await mkdtemp(join(tmpdir(), "forge-usage-run-"));
295 app = createApp({ dataDir, port: 0 });
296 servers = await serve(app);
297 // On the personal token the provider is out for the week; on the work
298 // key the stand-in does the work and reports what it read and wrote.
299 const fake = join(dataDir, "fake-claude");
300 writeFileSync(
301 fake,
302 `#!/bin/sh
303 if [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi
304 IFS= read -r first
305 echo '{"type":"system","subtype":"init","session_id":"fake-1","model":"fake"}'
306 if [ "$CLAUDE_CODE_OAUTH_TOKEN" = "${OAT}" ]; then
307 echo '{"type":"result","subtype":"error_during_execution","is_error":true,"result":"You'"'"'ve hit your weekly limit · resets Sep 26 at 9pm (UTC)","total_cost_usd":0,"num_turns":0,"session_id":"fake-1"}'
308 exit 1
309 fi
310 echo "work by the crew" >> CREW.md
311 git add -A && git commit -qm "crew work" && git push -q -u origin HEAD
312 echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW.md.","total_cost_usd":0.0375,"num_turns":3,"session_id":"fake-1","terminal_reason":"completed","usage":{"input_tokens":120,"cache_creation_input_tokens":2000,"cache_read_input_tokens":30000,"output_tokens":640}}'
313 `,
314 );
315 chmodSync(fake, 0o755);
316 crew = await startCrew(app, {
317 forgeUrl: servers.url,
318 localUrl: servers.url,
319 binary: fake,
320 concurrency: 1,
321 budgetUsd: 2,
322 runTimeoutMs: 60_000,
323 });
324 const a = await createUser(app.db, {
325 username: "alice",
326 email: "[email protected]",
327 password: "a-good-passphrase",
328 adminIfFirst: true,
329 });
330 const b = await createUser(app.db, {
331 username: "bob",
332 email: "[email protected]",
333 password: "another-good-passphrase",
334 });
335 if (!a.ok || !b.ok) throw new Error("users");
336 alice = a.value;
337 bob = b.value;
338 const made = await createAgent(app, alice, { username: "bot" });
339 if (!made.ok) throw new Error(String(made.error));
340 bot = made.value.user;
341 const created = await createRepo(app, alice, {
342 name: "garden",
343 visibility: "public",
344 });
345 if (!created.ok) throw new Error(String(created.error));
346 repo = created.value;
347 const seeded = await commitToBranch(app, alice, repo, {
348 message: "Start",
349 files: [{ path: "README.md", content: "# garden\n" }],
350 });
351 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
352 expect(addToCrew(app, alice, repo, "bot").ok).toBe(true);
353 }, 30_000);
354
355 afterAll(async () => {
356 await crew.stop();
357 servers.stop();
358 app.close();
359 await rm(dataDir, { recursive: true, force: true });
360 });
361
362 async function until<T>(read: () => T | undefined): Promise<T> {
363 const deadline = Date.now() + 30_000;
364 while (Date.now() < deadline) {
365 const value = read();
366 if (value !== undefined) return value;
367 await Bun.sleep(100);
368 }
369 throw new Error("did not happen in time");
370 }
371
372 test("each run is attributed to the account it ran on, the app, the agent and the issue", async () => {
373 const personal = addSubscription(app.db, dataDir, { name: "personal", token: OAT });
374 const work = addSubscription(app.db, dataDir, { name: "work", token: KEY });
375 if (!personal.ok || !work.ok) throw new Error("pool");
376 setGrants(app.db, repo.id, EVERYONE, [personal.value.id, work.value.id]);
377 const issue = openIssue(app, alice, repo, { title: "Plant tulips", body: "" });
378 if (!issue.ok) throw new Error(String(issue.error));
379 expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true);
380 const done = await until(() =>
381 listRuns(app.db, repo.id, { kind: "agent" }).find(
382 (r) => r.issueId === issue.value.id && r.state === "ok",
383 ),
384 );
385 const refused = listRuns(app.db, repo.id, { kind: "agent" }).find((r) =>
386 /takes over/.test(r.summary),
387 )!;
388
389 const line = await until(() => getUsage(app.db, done.id));
390 // 120 + 2000 + 30000 read, 640 written, the provider's $0.0375.
391 expect(line).toMatchObject({
392 repoId: repo.id,
393 issueId: issue.value.id,
394 agentId: bot.id,
395 subscriptionId: work.value.id,
396 tokensIn: 32_120,
397 tokensOut: 640,
398 costUsd: 0.0375,
399 outcome: "ok",
400 });
401 expect(line.wallMs).toBeGreaterThan(0);
402 expect(getUsage(app.db, refused.id)).toMatchObject({
403 subscriptionId: personal.value.id,
404 tokensIn: 0,
405 tokensOut: 0,
406 outcome: "paused",
407 });
408
409 // The API: the owner reads it, summed and named.
410 const owner = createToken(app.db, alice.id, "ro", "ro").token;
411 const url = `${servers.url}/api/v1/repos/alice/garden/usage`;
412 const got = await fetch(url, { headers: { authorization: `Bearer ${owner}` } });
413 expect(got.status).toBe(200);
414 const body = (await got.json()) as any;
415 expect(body.app).toBe("alice/garden");
416 expect(body.days).toBe(7);
417 expect(body.total).toMatchObject({ runs: 2, ok: 1, paused: 1, tokensIn: 32_120 });
418 expect(
419 body.bySubscription.map((r: any) => [r.subscription, r.runs, r.tokensOut]),
420 ).toEqual([
421 ["work", 1, 640],
422 ["personal", 1, 0],
423 ]);
424 expect(body.byAgent).toEqual([expect.objectContaining({ agent: "bot", runs: 2 })]);
425 expect(body.byIssue).toEqual([
426 expect.objectContaining({ issue: issue.value.number, runs: 2 }),
427 ]);
428 expect(body.recent[0].subscription).toBe("work");
429 expect(JSON.stringify(body)).not.toContain(KEY);
430
431 // A window that is not 1–90 days is refused, not guessed at.
432 const bad = await fetch(`${url}?days=365`, {
433 headers: { authorization: `Bearer ${owner}` },
434 });
435 expect(bad.status).toBe(422);
436 expect(((await bad.json()) as any).error).toBe("invalid_days");
437
438 // Spend is the owner's fact: a reader of the public app is refused,
439 // and so is nobody at all.
440 const reader = createToken(app.db, bob.id, "ro", "ro").token;
441 const refusedRead = await fetch(url, {
442 headers: { authorization: `Bearer ${reader}` },
443 });
444 expect(refusedRead.status).toBe(403);
445 expect((await fetch(url)).status).toBe(403);
446
447 // MCP says the same, and refuses the same.
448 const mcp = async (token: string, args: Record<string, unknown>) => {
449 const res = await fetch(`${servers.url}/mcp`, {
450 method: "POST",
451 headers: {
452 authorization: `Bearer ${token}`,
453 "content-type": "application/json",
454 accept: "application/json, text/event-stream",
455 },
456 body: JSON.stringify({
457 jsonrpc: "2.0",
458 id: 1,
459 method: "tools/call",
460 params: { name: "usage", arguments: args },
461 }),
462 });
463 return (await res.json()) as any;
464 };
465 const told = await mcp(owner, { repo: "alice/garden", days: 7 });
466 const value = JSON.parse(told.result.content[0].text);
467 expect(value.total).toEqual(body.total);
468 const no = await mcp(reader, { repo: "alice/garden" });
469 expect(no.result.isError).toBe(true);
470 expect(no.result.content[0].text).toContain("forbidden");
471 const odd = await mcp(owner, { repo: "alice/garden", days: "7" });
472 expect(odd.result.content[0].text).toContain("invalid_days");
473 }, 60_000);
474 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs