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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/341-subscription-throughput-a-governor-that → main 7 commits fork
15 files
changed +1053
−11
CLAUDE.md +3 −1
| @@ -104,7 +104,9 @@ src/ | ||
| 104 | 104 | (issue|pull, grouped `labelsForIssues`/`commentCounts`), pulls, |
| 105 | 105 | reviews, orgs/teams, webhooks, keys (ssh public keys), search |
| 106 | 106 | (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 | |
| 108 | 110 | actions/ one function per platform mutation: authorize (access.ts tiers), |
| 109 | 111 | call core, `bus.append`. API, web, and TUI are adapters over |
| 110 | 112 | 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. | ||
| 38 | 38 | | `take_updates` | a fork takes its parent's updates when git can do it alone; a conflict is refused | yes | |
| 39 | 39 | | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes | |
| 40 | 40 | | `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 | | | |
| 41 | 42 | | `link` | `{ app, issue, blockedBy }`: the issue waits for another; loops and cross-repository are refused | yes | |
| 42 | 43 | | `unlink` | `{ app, issue, blockedBy }`: the issue stops waiting for it | yes | |
| 43 | 44 | | `plan` | `{ app, issues }`: open up to 12 linked issues, all or none (see below) | yes | |
forge.json +1 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | "ready": 90 |
| 7 | 7 | }, |
| 8 | 8 | "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", | |
| 10 | 10 | "timeout": 1800 |
| 11 | 11 | } |
| 12 | 12 | } |
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[] = [ | ||
| 980 | 980 | CREATE INDEX share_recipient ON share(recipient_id, state); |
| 981 | 981 | CREATE INDEX share_sender ON share(sender_id, state); |
| 982 | 982 | `, |
| 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 | `, | |
| 983 | 1028 | ]; |
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 { | ||
| 46 | 46 | readonly result: string; |
| 47 | 47 | readonly costUsd: number; |
| 48 | 48 | 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; | |
| 49 | 55 | /** `completed`, `max_budget_reached`, `timeout`, `cancelled`, `error`, `exit <n>`. */ |
| 50 | 56 | readonly reason: string; |
| 51 | 57 | } |
| @@ -186,6 +192,8 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> { | ||
| 186 | 192 | let result = ""; |
| 187 | 193 | let costUsd = 0; |
| 188 | 194 | let turns = 0; |
| 195 | let tokensIn = 0; | |
| 196 | let tokensOut = 0; | |
| 189 | 197 | let reason = ""; |
| 190 | 198 | let ok = false; |
| 191 | 199 | let sawResult = false; |
| @@ -220,6 +228,7 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> { | ||
| 220 | 228 | costUsd = |
| 221 | 229 | typeof e["total_cost_usd"] === "number" ? e["total_cost_usd"] : 0; |
| 222 | 230 | turns = typeof e["num_turns"] === "number" ? e["num_turns"] : 0; |
| 231 | ({ tokensIn, tokensOut } = tokensOf(e["usage"])); | |
| 223 | 232 | const terminal = e["terminal_reason"]; |
| 224 | 233 | // Claude Code names the budget ending twice (`error_max_budget_usd` |
| 225 | 234 | // as the subtype, `budget_exhausted` as the terminal reason); the |
| @@ -293,7 +302,16 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> { | ||
| 293 | 302 | input.signal.removeEventListener("abort", onAbort); |
| 294 | 303 | |
| 295 | 304 | 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 | }; | |
| 297 | 315 | } |
| 298 | 316 | if (!sawResult) { |
| 299 | 317 | const tail = stderr.slice(-3).join(" | "); |
| @@ -303,10 +321,40 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> { | ||
| 303 | 321 | result: tail, |
| 304 | 322 | costUsd, |
| 305 | 323 | turns, |
| 324 | tokensIn, | |
| 325 | tokensOut, | |
| 306 | 326 | reason: `exit ${code}`, |
| 307 | 327 | }; |
| 308 | 328 | } |
| 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 | }; | |
| 310 | 358 | } |
| 311 | 359 | |
| 312 | 360 | /** 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 { | ||
| 66 | 66 | type RunReason, |
| 67 | 67 | } from "./brief.ts"; |
| 68 | 68 | import { policyBrief } from "../contract/audit.ts"; |
| 69 | import { BUDGET_REACHED, runClaude } from "./claude.ts"; | |
| 69 | import { BUDGET_REACHED, runClaude, type ClaudeOutcome } from "./claude.ts"; | |
| 70 | 70 | import { isRateLimited, MAX_PAUSES, resumeAt, whenText } from "./limits.ts"; |
| 71 | 71 | import { |
| 72 | 72 | pickSubscription, |
| 73 | 73 | restSubscription, |
| 74 | 74 | type Pick, |
| 75 | 75 | } from "../core/subscriptions.ts"; |
| 76 | import { recordUsage, type UsageOutcome } from "../core/usage.ts"; | |
| 76 | 77 | import { trackAgentRun, untrackAgentRun } from "./running.ts"; |
| 77 | 78 | import { sandboxPolicy } from "./sandbox.ts"; |
| 78 | 79 | import { grantSecrets, parseDeclaration, type Grant } from "./grant.ts"; |
| @@ -202,9 +203,11 @@ export async function executeAgentRun( | ||
| 202 | 203 | const write = (line: string): void => |
| 203 | 204 | appendRunLog(app.config.dataDir, run.id, line); |
| 204 | 205 | announceRun(app, claimed); |
| 206 | const started = Date.now(); | |
| 207 | const meter: Meter = { tokensIn: 0, tokensOut: 0, subscriptionId: null }; | |
| 205 | 208 | let outcome: Outcome; |
| 206 | 209 | try { |
| 207 | outcome = await work(app, claimed, options, write, controller.signal); | |
| 210 | outcome = await work(app, claimed, options, write, controller.signal, meter); | |
| 208 | 211 | } catch (error) { |
| 209 | 212 | log.error("agent run threw", { run: run.id, error: String(error) }); |
| 210 | 213 | outcome = { |
| @@ -221,6 +224,7 @@ export async function executeAgentRun( | ||
| 221 | 224 | // forge does the tidying, by the directory the process is working in. |
| 222 | 225 | await reapOrphans(app, run, write); |
| 223 | 226 | forgetLandedClone(app, run); |
| 227 | meterUsage(app, claimed, outcome, meter, started); | |
| 224 | 228 | if (outcome.state === "paused") { |
| 225 | 229 | write(outcome.summary); |
| 226 | 230 | noteRunProgress(app.db, run.id, { |
| @@ -338,6 +342,48 @@ type Outcome = | ||
| 338 | 342 | readonly costUsd: number; |
| 339 | 343 | }; |
| 340 | 344 | |
| 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 | ||
| 341 | 387 | /** What a paused run hands back: when its task may start again, and the count. */ |
| 342 | 388 | export interface Pause { |
| 343 | 389 | readonly resumeAt: string; |
| @@ -350,6 +396,7 @@ async function work( | ||
| 350 | 396 | options: CrewOptions, |
| 351 | 397 | write: (line: string) => void, |
| 352 | 398 | signal: AbortSignal, |
| 399 | meter: Meter, | |
| 353 | 400 | ): Promise<Outcome> { |
| 354 | 401 | const repo = getRepoById(app.db, run.repoId); |
| 355 | 402 | const issue = |
| @@ -613,6 +660,7 @@ async function work( | ||
| 613 | 660 | noteRunProgress(app.db, run.id, { |
| 614 | 661 | subscriptionId: picked.subscription.id, |
| 615 | 662 | }); |
| 663 | meter.subscriptionId = picked.subscription.id; | |
| 616 | 664 | } |
| 617 | 665 | const env: Record<string, string> = { |
| 618 | 666 | ...gitAuth, |
| @@ -644,8 +692,9 @@ async function work( | ||
| 644 | 692 | prompt: string, |
| 645 | 693 | budgetUsd: number, |
| 646 | 694 | resume: string | undefined, |
| 647 | ) => | |
| 648 | redactResult(await runClaude({ | |
| 695 | ): Promise<ClaudeOutcome> => { | |
| 696 | const spent = redactResult( | |
| 697 | await runClaude({ | |
| 649 | 698 | binary: options.binary, |
| 650 | 699 | cwd: dir, |
| 651 | 700 | prompt, |
| @@ -659,7 +708,12 @@ async function work( | ||
| 659 | 708 | log: write, |
| 660 | 709 | signal, |
| 661 | 710 | onSteer: (send) => steering.set(run.id, send), |
| 662 | })); | |
| 711 | }), | |
| 712 | ); | |
| 713 | meter.tokensIn += spent.tokensIn; | |
| 714 | meter.tokensOut += spent.tokensOut; | |
| 715 | return spent; | |
| 716 | }; | |
| 663 | 717 | write( |
| 664 | 718 | `claude: budget $${budget.toFixed(2)}, $${reserve.toFixed(2)} of it kept for the wrap-up${brief.resuming ? `, resuming ${previous?.sessionId}` : ""}`, |
| 665 | 719 | ); |
src/http/api.ts +19
| @@ -215,6 +215,7 @@ import { | ||
| 215 | 215 | } from "../core/runs.ts"; |
| 216 | 216 | import { resolveOid } from "../git/merge.ts"; |
| 217 | 217 | import { vitals } from "../kernel/vitals.ts"; |
| 218 | import { readUsage, usageDays, USAGE_DAYS_MAX } from "../actions/usage.ts"; | |
| 218 | 219 | import { readRunLog, subscribeRunLog } from "../runs/log.ts"; |
| 219 | 220 | import { blobForRender, commitLog, listRefs, listTree } from "../git/read.ts"; |
| 220 | 221 | import { repoDir } from "../git/repos.ts"; |
| @@ -3147,6 +3148,24 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3147 | 3148 | ? (r: Run): Run => r |
| 3148 | 3149 | : (r: Run): Run => ({ ...r, costUsd: 0, turns: 0 }); |
| 3149 | 3150 | |
| 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 | ||
| 3150 | 3169 | router.add("GET", "/api/v1/repos/:owner/:name/runs", (ctx) => { |
| 3151 | 3170 | const repo = loadRepo(ctx); |
| 3152 | 3171 | if (!repo) return notFound(); |
src/http/mcp.ts +37
| @@ -27,6 +27,12 @@ import { | ||
| 27 | 27 | import { openPlan, parsePlan, PLAN_MAX } from "../actions/plan.ts"; |
| 28 | 28 | import { cancelRepoRun } from "../actions/runs.ts"; |
| 29 | 29 | 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"; | |
| 30 | 36 | import { ITEM_ID } from "../core/interactions.ts"; |
| 31 | 37 | import { share } from "../feed/share.ts"; |
| 32 | 38 | import { parseTargets } from "./feed.ts"; |
| @@ -504,6 +510,37 @@ const TOOLS: readonly Tool[] = [ | ||
| 504 | 510 | ); |
| 505 | 511 | }, |
| 506 | 512 | }, |
| 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 | }, | |
| 507 | 544 | { |
| 508 | 545 | name: "link", |
| 509 | 546 | 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", () => { | ||
| 716 | 716 | }); |
| 717 | 717 | |
| 718 | 718 | 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(); | |
| 720 | 722 | const minted = createToken(app.db, alice.id, "soon", "rw", { |
| 721 | 723 | expiresAt: soon, |
| 722 | 724 | }); |
| 723 | 725 | expect(verifyToken(app.db, minted.token).ok).toBe(true); |
| 724 | await Bun.sleep(30); | |
| 726 | await Bun.sleep(600); | |
| 725 | 727 | expect(verifyToken(app.db, minted.token).ok).toBe(false); |
| 726 | 728 | }); |
| 727 | 729 | }); |
test/crew-limits.test.ts +2
| @@ -13,6 +13,8 @@ const outcome = (over: Partial<ClaudeOutcome>): ClaudeOutcome => ({ | ||
| 13 | 13 | result: "", |
| 14 | 14 | costUsd: 0, |
| 15 | 15 | turns: 1, |
| 16 | tokensIn: 0, | |
| 17 | tokensOut: 0, | |
| 16 | 18 | reason: "error", |
| 17 | 19 | ...over, |
| 18 | 20 | }); |
test/mcp.test.ts +2
| @@ -156,6 +156,7 @@ describe("the forge over MCP", () => { | ||
| 156 | 156 | expect(names).toEqual([ |
| 157 | 157 | "apps", |
| 158 | 158 | "tell", |
| 159 | "share", | |
| 159 | 160 | "say", |
| 160 | 161 | "now", |
| 161 | 162 | "stop", |
| @@ -165,6 +166,7 @@ describe("the forge over MCP", () => { | ||
| 165 | 166 | "take_updates", |
| 166 | 167 | "converge", |
| 167 | 168 | "map", |
| 169 | "usage", | |
| 168 | 170 | "link", |
| 169 | 171 | "unlink", |
| 170 | 172 | "plan", |
test/subscriptions.test.ts +2
| @@ -192,6 +192,8 @@ describe("the provider's reset time", () => { | ||
| 192 | 192 | result: "You've hit your weekly limit · resets Sep 26 at 9pm (UTC)", |
| 193 | 193 | costUsd: 0, |
| 194 | 194 | turns: 0, |
| 195 | tokensIn: 0, | |
| 196 | tokensOut: 0, | |
| 195 | 197 | reason: "error_during_execution", |
| 196 | 198 | }), |
| 197 | 199 | ).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 | }); |
new above
offline