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

Every credential is a subscription — the platform token included — with a live spend breakdown by credential, app, agent and issue #539

Merged opened this in answer to

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

Merged

torque/forge:torque/460-every-credential-is-a-subscription-the-p → main 8 commits fork

  • Merged. torque/460-every-credential-is-a-subscription-the-p was merged into main as dbb6401.

Review

Sign in to review this change.
15 files changed +1112 −70
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 -- crew crew-wrapup crew-limits crew-sandbox steer landing decide ledger checks dependencies autonomy studio studio-words forks fork-flow cross-repo-pull pulls apps apps-adversarial apps-busy apps-token web git-trust helpers shards migrate vitals guard asks feed feed-page offline share cloud run-secrets forge-cloudflare usage standing board-windows spawn-plan spawn web-apps fleet-ops meter",
9 "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 -- crew crew-wrapup crew-limits crew-sandbox steer landing decide ledger checks dependencies autonomy studio studio-words forks fork-flow cross-repo-pull pulls apps apps-adversarial apps-busy apps-token web git-trust helpers shards migrate vitals guard asks feed feed-page offline share cloud run-secrets forge-cloudflare usage credentials standing board-windows spawn-plan spawn web-apps fleet-ops meter",
1010 "timeout": 1800
1111 }
1212 }
src/actions/spend.ts added +206
@@ -0,0 +1,206 @@
1 /**
2 * Spend — what the crew spent over three spans, the last 5 hours, today
3 * (since 00:00 UTC) and the last 7 days: by credential, by app, by agent,
4 * by issue and by run kind, with the costliest runs of the week. Read from
5 * the usage ledger (`core/usage.ts`), which a running session writes to as
6 * it reports, so a run at work is counted already.
7 *
8 * Units, stated once: tokens are whole tokens (in = fresh + cache writes +
9 * cache reads); `costUsd` is US dollars of the provider's list price, as
10 * Claude Code reports it; `units` is ledger lines (runs).
11 *
12 * The 5-hour span is the last five hours, not any one account's window:
13 * each credential's own window is its standing (`core/standing.ts`).
14 *
15 * Plain data, names resolved once per read; who may read it is the
16 * caller's to decide (`readUsage` for one app, a site admin forge-wide).
17 */
18
19 import type { App } from "../app.ts";
20 import { getIssuesByIds } from "../core/issues.ts";
21 import { getReposByIds } from "../core/repos.ts";
22 import { listCredentials, PLATFORM } from "../core/subscriptions.ts";
23 import {
24 topUsage,
25 usageTotals,
26 type UsageBy,
27 type UsageEntry,
28 type UsageFilter,
29 type UsageKind,
30 type UsageOutcome,
31 } from "../core/usage.ts";
32 import { getUsersByIds } from "../core/users.ts";
33
34 export type SpanName = "5h" | "today" | "week";
35 export const SPANS: readonly SpanName[] = ["5h", "today", "week"];
36 const TOP = 10;
37
38 export interface SpendLine {
39 /** The credential, `owner/name`, agent, `owner/name#n` or kind; `—` for none. */
40 readonly name: string;
41 readonly units: number;
42 readonly tokensIn: number;
43 readonly tokensOut: number;
44 readonly costUsd: number;
45 }
46
47 export interface SpendSpan {
48 readonly span: SpanName;
49 readonly since: string;
50 readonly total: Omit<SpendLine, "name">;
51 readonly byCredential: readonly SpendLine[];
52 readonly byApp: readonly SpendLine[];
53 readonly byAgent: readonly SpendLine[];
54 readonly byIssue: readonly SpendLine[];
55 readonly byKind: readonly SpendLine[];
56 }
57
58 export interface TopRun {
59 readonly runId: string;
60 readonly app: string;
61 readonly credential: string;
62 readonly agent: string;
63 readonly issue: string;
64 readonly kind: UsageKind;
65 readonly outcome: UsageOutcome;
66 readonly tokensIn: number;
67 readonly tokensOut: number;
68 readonly costUsd: number;
69 readonly at: string;
70 }
71
72 export interface Spend {
73 readonly at: string;
74 readonly spans: readonly SpendSpan[];
75 /** The ten costliest runs of the last 7 days. */
76 readonly top: readonly TopRun[];
77 }
78
79 /** Where each span begins at `now`. */
80 export function spanSince(span: SpanName, now: Date): string {
81 const t = now.getTime();
82 if (span === "5h") return new Date(t - 5 * 3_600_000).toISOString();
83 if (span === "week") return new Date(t - 7 * 86_400_000).toISOString();
84 const day = new Date(now);
85 day.setUTCHours(0, 0, 0, 0);
86 return day.toISOString();
87 }
88
89 /** The spend at `now`, forge-wide or for one app. */
90 export function readSpend(
91 app: App,
92 now: Date = new Date(),
93 repoId?: string | undefined,
94 ): Spend {
95 const db = app.db;
96 const read = SPANS.map((span) => {
97 const filter: UsageFilter = {
98 since: spanSince(span, now),
99 until: now.toISOString(),
100 repoId,
101 };
102 const by = (k: UsageBy) => usageTotals(db, k, filter);
103 return {
104 span,
105 filter,
106 credential: by("subscription"),
107 repo: by("repo"),
108 agent: by("agent"),
109 issue: by("issue"),
110 kind: by("kind"),
111 };
112 });
113 const top = topUsage(
114 db,
115 { since: spanSince("week", now), until: now.toISOString(), repoId },
116 TOP,
117 );
118
119 // Every name, looked up once.
120 const ids = (pick: (s: (typeof read)[number]) => { key: string | null }[], more: (string | null)[]) =>
121 [...new Set([...read.flatMap((s) => pick(s).map((r) => r.key)), ...more])].filter(
122 (k): k is string => k !== null,
123 );
124 const credentials = new Map(listCredentials(db).map((c) => [c.id, c.name]));
125 const issues = getIssuesByIds(db, ids((s) => s.issue, top.map((t) => t.issueId)));
126 const repos = getReposByIds(db, [
127 ...ids((s) => s.repo, top.map((t) => t.repoId)),
128 ...[...issues.values()].map((i) => i.repoId),
129 ]);
130 const agents = getUsersByIds(db, ids((s) => s.agent, top.map((t) => t.agentId)));
131
132 const platform = credentials.get(PLATFORM) ?? "platform (env)";
133 const credentialName = (k: string | null): string =>
134 k === null || k === PLATFORM
135 ? platform
136 : (credentials.get(k) ?? "a removed subscription");
137 const appName = (k: string | null): string => {
138 const r = k === null ? undefined : repos.get(k);
139 return r === undefined ? "a removed app" : `${r.ownerName}/${r.name}`;
140 };
141 const agentName = (k: string | null): string =>
142 k === null ? "—" : (agents.get(k)?.username ?? "a removed agent");
143 const issueName = (k: string | null): string => {
144 const i = k === null ? undefined : issues.get(k);
145 if (i === undefined) return "—";
146 return `${appName(i.repoId)}#${i.number}`;
147 };
148
149 const lines = (
150 rows: ReturnType<typeof usageTotals>,
151 name: (k: string | null) => string,
152 ): SpendLine[] => {
153 // Two keys can share a name (null and the platform): fold them.
154 const out = new Map<string, SpendLine>();
155 for (const r of rows) {
156 const n = name(r.key);
157 const was = out.get(n);
158 out.set(n, {
159 name: n,
160 units: (was?.units ?? 0) + r.runs,
161 tokensIn: (was?.tokensIn ?? 0) + r.tokensIn,
162 tokensOut: (was?.tokensOut ?? 0) + r.tokensOut,
163 costUsd: (was?.costUsd ?? 0) + r.costUsd,
164 });
165 }
166 return [...out.values()].sort((a, b) => b.costUsd - a.costUsd || b.units - a.units);
167 };
168
169 return {
170 at: now.toISOString(),
171 spans: read.map((s) => {
172 const byCredential = lines(s.credential, credentialName);
173 return {
174 span: s.span,
175 since: s.filter.since,
176 total: byCredential.reduce(
177 (sum, l) => ({
178 units: sum.units + l.units,
179 tokensIn: sum.tokensIn + l.tokensIn,
180 tokensOut: sum.tokensOut + l.tokensOut,
181 costUsd: sum.costUsd + l.costUsd,
182 }),
183 { units: 0, tokensIn: 0, tokensOut: 0, costUsd: 0 },
184 ),
185 byCredential,
186 byApp: lines(s.repo, appName),
187 byAgent: lines(s.agent, agentName),
188 byIssue: lines(s.issue, issueName),
189 byKind: lines(s.kind, (k) => k ?? "build"),
190 };
191 }),
192 top: top.map((t: UsageEntry) => ({
193 runId: t.runId,
194 app: appName(t.repoId),
195 credential: credentialName(t.subscriptionId),
196 agent: agentName(t.agentId),
197 issue: issueName(t.issueId),
198 kind: t.kind ?? "build",
199 outcome: t.outcome,
200 tokensIn: t.tokensIn,
201 tokensOut: t.tokensOut,
202 costUsd: t.costUsd,
203 at: t.at,
204 })),
205 };
206 }
src/actions/usage.ts +17 −5
@@ -15,7 +15,13 @@ import type { App } from "../app.ts";
1515 import { getIssuesByIds } from "../core/issues.ts";
1616 import { canSeeOwnerFacts, type Repo } from "../core/repos.ts";
1717 import { standings, type Standing } from "../core/standing.ts";
18 import { listGrants, listSubscriptions } from "../core/subscriptions.ts";
18 import {
19 grantedAccounts,
20 listCredentials,
21 listGrants,
22 PLATFORM,
23 } from "../core/subscriptions.ts";
24 import { readSpend, type Spend } from "./spend.ts";
1925 import {
2026 recentUsage,
2127 usageTotals,
@@ -49,7 +55,7 @@ export interface UsageReport {
4955 readonly since: string;
5056 /** Every run in the window, summed; zeros when there were none. */
5157 readonly total: Omit<UsageTotals, "key">;
52 /** By account; `subscription: null` is the forge's own token. */
58 /** By credential; `platform (env)` is the forge's own token. */
5359 readonly bySubscription: readonly Named<"subscription">[];
5460 readonly byAgent: readonly Named<"agent">[];
5561 /** By issue number; `issue: null` is a run about no issue. */
@@ -65,6 +71,8 @@ export interface UsageReport {
6571 readonly standing: readonly (Omit<Standing, "subscriptionId"> & {
6672 readonly subscription: string;
6773 })[];
74 /** The last 5 hours, today and the week, broken down, with the top runs. */
75 readonly spend: Spend;
6876 }
6977
7078 const ZERO: Omit<UsageTotals, "key"> = {
@@ -99,7 +107,7 @@ export function readUsage(
99107
100108 // Names, each looked up once for the whole report.
101109 const subscriptions = new Map(
102 listSubscriptions(db).map((s) => [s.id, s.name]),
110 listCredentials(db).map((s) => [s.id, s.name]),
103111 );
104112 const agentIds = new Set<string>();
105113 const issueIds = new Set<string>();
@@ -110,7 +118,7 @@ export function readUsage(
110118 const agents = getUsersByIds(db, [...agentIds]);
111119 const issues = getIssuesByIds(db, [...issueIds]);
112120 const subscriptionName = (id: string | null): string | null =>
113 id === null ? null : (subscriptions.get(id) ?? "a removed subscription");
121 subscriptions.get(id ?? PLATFORM) ?? "a removed subscription";
114122 const agentName = (id: string | null): string | null =>
115123 id === null ? null : (agents.get(id)?.username ?? "a removed agent");
116124 const issueNumber = (id: string | null): number | null =>
@@ -134,8 +142,11 @@ export function readUsage(
134142 const accounts = new Set<string>();
135143 for (const g of [...listGrants(db, repo.id), ...listGrants(db, repo.ownerId)])
136144 accounts.add(g.subscriptionId);
145 // Nothing granted: the crew here works on the platform's token.
146 if (grantedAccounts(db, repo.id, repo.ownerId).length === 0)
147 accounts.add(PLATFORM);
137148 for (const row of bySubscription)
138 if (row.key !== null && subscriptions.has(row.key)) accounts.add(row.key);
149 if (subscriptions.has(row.key ?? PLATFORM)) accounts.add(row.key ?? PLATFORM);
139150 const read = standings(db, [...accounts], now);
140151 const standingsOut = [...accounts]
141152 .flatMap((id) => {
@@ -164,5 +175,6 @@ export function readUsage(
164175 issue: issueNumber(r.issueId),
165176 })),
166177 standing: standingsOut,
178 spend: readSpend(app, now, repo.id),
167179 });
168180 }
src/core/schema.ts +40
@@ -1111,4 +1111,44 @@ export const migrations: readonly string[] = [
11111111 CREATE INDEX storage_repo ON storage(repo_id, at);
11121112 CREATE INDEX storage_at ON storage(at);
11131113 `,
1114 // 42 — every credential is a subscription (`core/subscriptions.ts`): the
1115 // forge's own token from its environment becomes the row `platform`, with
1116 // no sealed token, so its windows and spend read like any account's. The
1117 // ledger's runs on it, once `NULL`, are its. The ledger gains the run's
1118 // kind (build, plan, review, check) and a `running` line, written as a
1119 // session reports its spend and replaced when it ends. SQLite cannot widen
1120 // a CHECK in place, so the ledger is copied into a new table.
1121 `
1122 INSERT OR IGNORE INTO subscription (id, name, kind, token_enc)
1123 VALUES ('platform', 'platform (env)', 'oauth', '');
1124 CREATE TABLE usage_next (
1125 run_id TEXT PRIMARY KEY REFERENCES run(id) ON DELETE CASCADE,
1126 repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
1127 issue_id TEXT,
1128 agent_id TEXT,
1129 subscription_id TEXT,
1130 kind TEXT NOT NULL DEFAULT 'build'
1131 CHECK (kind IN ('build','plan','review','check')),
1132 tokens_in INTEGER NOT NULL DEFAULT 0,
1133 tokens_out INTEGER NOT NULL DEFAULT 0,
1134 cost_usd REAL NOT NULL DEFAULT 0,
1135 wall_ms INTEGER NOT NULL DEFAULT 0,
1136 outcome TEXT NOT NULL CHECK (outcome IN ('ok','failed','paused','running')),
1137 at TEXT NOT NULL
1138 );
1139 INSERT INTO usage_next
1140 SELECT u.run_id, u.repo_id, u.issue_id, u.agent_id,
1141 COALESCE(u.subscription_id, 'platform'),
1142 CASE r.reason WHEN 'review' THEN 'review'
1143 WHEN 'check_failed' THEN 'check' ELSE 'build' END,
1144 u.tokens_in, u.tokens_out, u.cost_usd, u.wall_ms, u.outcome, u.at
1145 FROM usage u LEFT JOIN run r ON r.id = u.run_id;
1146 DROP TABLE usage;
1147 ALTER TABLE usage_next RENAME TO usage;
1148 CREATE INDEX usage_at ON usage(at);
1149 CREATE INDEX usage_repo ON usage(repo_id, at);
1150 CREATE INDEX usage_subscription ON usage(subscription_id, at);
1151 UPDATE run SET subscription_id = 'platform'
1152 WHERE kind = 'agent' AND subscription_id IS NULL;
1153 `,
11141154 ];
src/core/subscriptions.ts +51 −7
@@ -15,6 +15,13 @@
1515 *
1616 * A token is opened only for the run's environment, never listed and never
1717 * shown again once added.
18 *
19 * The forge's own token is a subscription too: the row `PLATFORM`, shown as
20 * `platform (env)`, with no sealed token — its credential is the forge's
21 * environment. It is never granted; it is what a run gets when no grant
22 * reaches it, and the ledger and the windows count it like any other. The
23 * grants are each sealed account's scope, an allow-list: an account with
24 * none is never offered to the crew (a person's own plan, kept apart).
1825 */
1926
2027 import type { Database } from "bun:sqlite";
@@ -52,6 +59,9 @@ interface Row {
5259 export const NAME_MAX = 40;
5360 const TOKEN_MAX = 512;
5461
62 /** The forge's own token, from its environment: a row with no sealed token. */
63 export const PLATFORM = "platform";
64
5565 /** Every crew member at a scope, as the grant's `agent_id`. */
5666 export const EVERYONE = "";
5767
@@ -149,20 +159,31 @@ export function listSubscriptions(db: Database): Subscription[] {
149159 return db
150160 .query<Row, []>(
151161 `SELECT id, name, kind, resting_until, resting_said, last_used_at, created_by, created_at
152 FROM subscription ORDER BY name COLLATE NOCASE`,
162 FROM subscription WHERE id <> 'platform' ORDER BY name COLLATE NOCASE`,
153163 )
154164 .all()
155165 .map(toSubscription);
156166 }
157167
168 /**
169 * Every credential a run can use: the platform's first, then the sealed
170 * pool. `listSubscriptions` is the pool alone — what can be granted,
171 * removed or given a token.
172 */
173 export function listCredentials(db: Database): Subscription[] {
174 const platform = getSubscription(db, PLATFORM);
175 return [...(platform === undefined ? [] : [platform]), ...listSubscriptions(db)];
176 }
177
158178 /** Its grants go with it; runs that used it keep the id as history. */
159179 export function deleteSubscription(
160180 db: Database,
161181 subscriptionId: string,
162182 ): boolean {
163183 return (
164 db.query("DELETE FROM subscription WHERE id = ?1").run(subscriptionId)
165 .changes > 0
184 db
185 .query("DELETE FROM subscription WHERE id = ?1 AND id <> 'platform'")
186 .run(subscriptionId).changes > 0
166187 );
167188 }
168189
@@ -262,6 +283,20 @@ export function setGrants(
262283 })();
263284 }
264285
286 /**
287 * Where each sealed account may be used, by id: `owner/name` for a
288 * repository, `@org` for an organization, with ` (agent)` when the grant
289 * names one. An account absent here has an empty scope.
290 */
291 export function scopesOf(db: Database): Map<string, string[]> {
292 const out = new Map<string, string[]>();
293 for (const g of listGrantPlaceRows(db)) {
294 const at = g.agent === undefined ? g.place : `${g.place} (${g.agent})`;
295 out.set(g.subscriptionId, [...(out.get(g.subscriptionId) ?? []), at]);
296 }
297 return out;
298 }
299
265300 export interface GrantPlace {
266301 readonly subscription: string;
267302 /** `owner/name` for a repository, `@org` for an organization. */
@@ -273,9 +308,16 @@ export interface GrantPlace {
273308
274309 /** Every grant with its names on, for the page that says where each goes. */
275310 export function listGrantPlaces(db: Database): GrantPlace[] {
311 return listGrantPlaceRows(db).map(({ subscriptionId: _, ...g }) => g);
312 }
313
314 function listGrantPlaceRows(
315 db: Database,
316 ): (GrantPlace & { subscriptionId: string })[] {
276317 return db
277318 .query<
278319 {
320 subscription_id: string;
279321 subscription: string;
280322 agent: string | null;
281323 repo_name: string | null;
@@ -284,7 +326,7 @@ export function listGrantPlaces(db: Database): GrantPlace[] {
284326 },
285327 []
286328 >(
287 `SELECT s.name AS subscription, a.username AS agent,
329 `SELECT s.id AS subscription_id, s.name AS subscription, a.username AS agent,
288330 r.name AS repo_name, ro.username AS repo_owner, o.username AS org
289331 FROM subscription_grant g
290332 JOIN subscription s ON s.id = g.subscription_id
@@ -296,6 +338,7 @@ export function listGrantPlaces(db: Database): GrantPlace[] {
296338 )
297339 .all()
298340 .map((r) => ({
341 subscriptionId: r.subscription_id,
299342 subscription: r.subscription,
300343 place:
301344 r.repo_name !== null
@@ -316,7 +359,7 @@ export function listGrantPlaces(db: Database): GrantPlace[] {
316359 // ---- the pick ----
317360
318361 export type Pick =
319 /** No grant reaches this run: the forge's own token, from its environment. */
362 /** No grant reaches this run: the forge's own token, `PLATFORM`, from its environment. */
320363 | { readonly kind: "env" }
321364 | {
322365 readonly kind: "ready";
@@ -359,7 +402,7 @@ export function pickSubscription(
359402 `SELECT g.scope_id, g.agent_id, s.id, s.name, s.kind, s.token_enc, s.resting_until,
360403 s.resting_said, s.last_used_at, s.created_by, s.created_at
361404 FROM subscription_grant g JOIN subscription s ON s.id = g.subscription_id
362 WHERE g.scope_id IN (?1, ?2) AND g.agent_id IN (?3, '')
405 WHERE g.scope_id IN (?1, ?2) AND g.agent_id IN (?3, '') AND s.id <> 'platform'
363406 ORDER BY (g.scope_id = ?1) DESC, (g.agent_id = ?3) DESC,
364407 s.last_used_at IS NOT NULL, s.last_used_at, s.name COLLATE NOCASE`,
365408 )
@@ -420,7 +463,8 @@ export function grantedAccounts(
420463 .query<{ id: string; name: string; scope_id: string }, [string, string]>(
421464 `SELECT DISTINCT s.id, s.name, g.scope_id
422465 FROM subscription_grant g JOIN subscription s ON s.id = g.subscription_id
423 WHERE g.scope_id IN (?1, ?2) ORDER BY s.name COLLATE NOCASE`,
466 WHERE g.scope_id IN (?1, ?2) AND s.id <> 'platform'
467 ORDER BY s.name COLLATE NOCASE`,
424468 )
425469 .all(repoId, ownerId);
426470 const own = rows.filter((r) => r.scope_id === repoId);
src/core/usage.ts +56 −11
@@ -2,7 +2,9 @@
22 * Usage — the ledger of what each agent run spent: tokens in and out, the
33 * provider's reported cost, wall time and outcome, attributed to the
44 * 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
5 * One row per run (`crew/runner.ts`): written as `running` each time the
6 * session reports its spend, then replaced by its ending — so the totals
7 * move while the run works, and the run is never counted twice. The
68 * governor reads it to pace the crew, the board to show efficiency.
79 *
810 * Units, stated once:
@@ -20,22 +22,37 @@
2022 import type { Database } from "bun:sqlite";
2123
2224 /** 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 export type UsageOutcome = "ok" | "failed" | "paused" | "running";
26 export const USAGE_OUTCOMES: readonly UsageOutcome[] = [
27 "ok",
28 "failed",
29 "paused",
30 "running",
31 ];
32
33 /**
34 * What a run was for: `build` (an issue or a pull's feedback), `plan` (a
35 * planner drawing the map), `review` (a pull read), `check` (a failed
36 * check put right).
37 */
38 export type UsageKind = "build" | "plan" | "review" | "check";
39 export const USAGE_KINDS: readonly UsageKind[] = ["build", "plan", "review", "check"];
2540
2641 export interface UsageEntry {
2742 readonly runId: string;
2843 readonly repoId: string;
2944 readonly issueId: string | null;
3045 readonly agentId: string | null;
31 /** The account the run worked on; null is the forge's own token. */
46 /** The account the run worked on (`PLATFORM` the forge's own token); null on lines that predate it. */
3247 readonly subscriptionId: string | null;
48 /** What the run was for; `build` when unsaid. */
49 readonly kind?: UsageKind | undefined;
3350 readonly tokensIn: number;
3451 readonly tokensOut: number;
3552 readonly costUsd: number;
3653 readonly wallMs: number;
3754 readonly outcome: UsageOutcome;
38 /** When the run ended (ISO 8601, UTC). */
55 /** When the run ended, or last reported while `running` (ISO 8601, UTC). */
3956 readonly at: string;
4057 }
4158
@@ -54,8 +71,8 @@ export function recordUsage(db: Database, entry: UsageEntry): void {
5471 db.query(
5572 `INSERT OR REPLACE INTO usage
5673 (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)`,
74 tokens_in, tokens_out, cost_usd, wall_ms, outcome, at, kind)
75 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)`,
5976 ).run(
6077 entry.runId,
6178 entry.repoId,
@@ -68,6 +85,7 @@ export function recordUsage(db: Database, entry: UsageEntry): void {
6885 whole(entry.wallMs),
6986 entry.outcome,
7087 entry.at,
88 USAGE_KINDS.includes(entry.kind ?? "build") ? (entry.kind ?? "build") : "build",
7189 );
7290 }
7391
@@ -77,6 +95,7 @@ interface UsageRow {
7795 issue_id: string | null;
7896 agent_id: string | null;
7997 subscription_id: string | null;
98 kind: UsageKind;
8099 tokens_in: number;
81100 tokens_out: number;
82101 cost_usd: number;
@@ -91,6 +110,7 @@ const toEntry = (r: UsageRow): UsageEntry => ({
91110 issueId: r.issue_id,
92111 agentId: r.agent_id,
93112 subscriptionId: r.subscription_id,
113 kind: r.kind,
94114 tokensIn: r.tokens_in,
95115 tokensOut: r.tokens_out,
96116 costUsd: r.cost_usd,
@@ -107,18 +127,20 @@ export function getUsage(db: Database, runId: string): UsageEntry | undefined {
107127 }
108128
109129 /** What the ledger can be summed by. Each maps to one fixed column. */
110 export type UsageBy = "subscription" | "repo" | "agent" | "issue";
130 export type UsageBy = "subscription" | "repo" | "agent" | "issue" | "kind";
111131 export const USAGE_BY: readonly UsageBy[] = [
112132 "subscription",
113133 "repo",
114134 "agent",
115135 "issue",
136 "kind",
116137 ];
117138 const COLUMN: Readonly<Record<UsageBy, string>> = {
118139 subscription: "subscription_id",
119140 repo: "repo_id",
120141 agent: "agent_id",
121142 issue: "issue_id",
143 kind: "kind",
122144 };
123145
124146 export interface UsageTotals {
@@ -139,6 +161,8 @@ export interface UsageFilter {
139161 readonly since: string;
140162 /** Only this repository's runs, when given. */
141163 readonly repoId?: string | undefined;
164 /** Rows at or before this instant (ISO 8601), when given. */
165 readonly until?: string | undefined;
142166 }
143167
144168 /**
@@ -165,7 +189,7 @@ export function usageTotals(
165189 cost_usd: number;
166190 wall_ms: number;
167191 },
168 [string, string | null]
192 [string, string | null, string | null]
169193 >(
170194 `SELECT ${column} AS key,
171195 COUNT(*) AS runs,
@@ -177,11 +201,11 @@ export function usageTotals(
177201 SUM(cost_usd) AS cost_usd,
178202 SUM(wall_ms) AS wall_ms
179203 FROM usage
180 WHERE at >= ?1 AND (?2 IS NULL OR repo_id = ?2)
204 WHERE at >= ?1 AND (?2 IS NULL OR repo_id = ?2) AND (?3 IS NULL OR at <= ?3)
181205 GROUP BY ${column}
182206 ORDER BY cost_usd DESC, runs DESC, key`,
183207 )
184 .all(filter.since, filter.repoId ?? null);
208 .all(filter.since, filter.repoId ?? null, filter.until ?? null);
185209 return rows.map((r) => ({
186210 key: r.key,
187211 runs: r.runs,
@@ -210,3 +234,24 @@ export function recentUsage(
210234 .all(filter.since, filter.repoId ?? null, Math.min(Math.max(limit, 1), 200))
211235 .map(toEntry);
212236 }
237
238 /** The costliest ledger lines since an instant, most spent first, bounded. */
239 export function topUsage(
240 db: Database,
241 filter: UsageFilter,
242 limit: number,
243 ): UsageEntry[] {
244 return db
245 .query<UsageRow, [string, string | null, number, string | null]>(
246 `SELECT * FROM usage
247 WHERE at >= ?1 AND (?2 IS NULL OR repo_id = ?2) AND (?4 IS NULL OR at <= ?4)
248 ORDER BY cost_usd DESC, tokens_in + tokens_out DESC, run_id LIMIT ?3`,
249 )
250 .all(
251 filter.since,
252 filter.repoId ?? null,
253 Math.min(Math.max(limit, 1), 200),
254 filter.until ?? null,
255 )
256 .map(toEntry);
257 }
src/crew/claude.ts +32
@@ -36,6 +36,19 @@ export interface ClaudeInput {
3636 * send after the session ended returns false.
3737 */
3838 readonly onSteer?: ((send: (text: string) => boolean) => void) | undefined;
39 /**
40 * The session's spend so far, each time it reports some: tokens as each
41 * assistant message's `usage` arrives, the cost once the result names it
42 * (Claude Code reports `total_cost_usd` only at the end). Running totals
43 * for this invocation, never deltas.
44 */
45 readonly onUsage?: ((spent: Spent) => void) | undefined;
46 }
47
48 export interface Spent {
49 readonly tokensIn: number;
50 readonly tokensOut: number;
51 readonly costUsd: number;
3952 }
4053
4154 export interface ClaudeOutcome {
@@ -197,6 +210,9 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> {
197210 let reason = "";
198211 let ok = false;
199212 let sawResult = false;
213 // A message's usage repeats on every content block of it, growing; the
214 // last word per message id is the message's spend.
215 const perMessage = new Map<string, { tokensIn: number; tokensOut: number }>();
200216
201217 const onLine = (line: string): void => {
202218 if (line.trim() === "") return;
@@ -219,6 +235,21 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> {
219235 }
220236 if (type === "assistant") {
221237 for (const text of assistantLines(e, input.cwd)) input.log(text);
238 const message = e["message"];
239 if (input.onUsage && typeof message === "object" && message !== null) {
240 const m = message as Record<string, unknown>;
241 if (typeof m["usage"] === "object" && m["usage"] !== null) {
242 const key = typeof m["id"] === "string" ? m["id"] : `#${perMessage.size}`;
243 perMessage.set(key, tokensOf(m["usage"]));
244 let tokensIn = 0;
245 let tokensOut = 0;
246 for (const t of perMessage.values()) {
247 tokensIn += t.tokensIn;
248 tokensOut += t.tokensOut;
249 }
250 input.onUsage({ tokensIn, tokensOut, costUsd: 0 });
251 }
252 }
222253 return;
223254 }
224255 if (type === "result") {
@@ -229,6 +260,7 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> {
229260 typeof e["total_cost_usd"] === "number" ? e["total_cost_usd"] : 0;
230261 turns = typeof e["num_turns"] === "number" ? e["num_turns"] : 0;
231262 ({ tokensIn, tokensOut } = tokensOf(e["usage"]));
263 input.onUsage?.({ tokensIn, tokensOut, costUsd });
232264 const terminal = e["terminal_reason"];
233265 // Claude Code names the budget ending twice (`error_max_budget_usd`
234266 // as the subtype, `budget_exhausted` as the terminal reason); the
src/crew/runner.ts +109 −2
@@ -78,10 +78,15 @@ import { noteLimit } from "../core/standing.ts";
7878 import { pauseReason } from "./pacing.ts";
7979 import {
8080 pickSubscription,
81 PLATFORM,
8182 restSubscription,
8283 type Pick,
8384 } from "../core/subscriptions.ts";
84 import { recordUsage, type UsageOutcome } from "../core/usage.ts";
85 import {
86 recordUsage,
87 type UsageKind,
88 type UsageOutcome,
89 } from "../core/usage.ts";
8590 import { trackAgentRun, untrackAgentRun } from "./running.ts";
8691 import { sandboxPolicy } from "./sandbox.ts";
8792 import { grantSecrets, parseDeclaration, type Grant } from "./grant.ts";
@@ -212,7 +217,14 @@ export async function executeAgentRun(
212217 appendRunLog(app.config.dataDir, run.id, line);
213218 announceRun(app, claimed);
214219 const started = Date.now();
215 const meter: Meter = { tokensIn: 0, tokensOut: 0, subscriptionId: null };
220 const meter: Meter = {
221 tokensIn: 0,
222 tokensOut: 0,
223 costUsd: 0,
224 subscriptionId: null,
225 kind: usageKind(app, claimed),
226 reportedAt: 0,
227 };
216228 let outcome: Outcome;
217229 try {
218230 outcome = await work(app, claimed, options, write, controller.signal, meter);
@@ -358,7 +370,70 @@ type Outcome =
358370 interface Meter {
359371 tokensIn: number;
360372 tokensOut: number;
373 /** US dollars reported by invocations that have ended. */
374 costUsd: number;
361375 subscriptionId: string | null;
376 kind: UsageKind;
377 /** When a `running` line was last written (epoch ms); 0 for never. */
378 reportedAt: number;
379 }
380
381 /** What a run is for, as the ledger sums it: its reason, or its agent's role. */
382 function usageKind(app: App, run: Run): UsageKind {
383 if (run.reason === "review") return "review";
384 if (run.reason === "check_failed") return "check";
385 if (
386 run.actorId !== null &&
387 crewRole(app.db, run.repoId, run.actorId) === "planner"
388 )
389 return "plan";
390 return "build";
391 }
392
393 /** How often a running session's spend is written and announced, at most. */
394 const LIVE_MS = 2_000;
395
396 /**
397 * A running session's spend so far, as its `running` line in the ledger,
398 * and a `usage.delta` on the outbox so the board and the Subscriptions page
399 * move within seconds. At most once per `LIVE_MS` unless `force`; the
400 * event carries only the run's id — spend is the owner's fact, and a
401 * reader who may see it reads it from the page.
402 */
403 function meterLive(
404 app: App,
405 run: Run,
406 meter: Meter,
407 live: { tokensIn: number; tokensOut: number; costUsd: number },
408 started: number,
409 force: boolean,
410 ): void {
411 const now = Date.now();
412 if (!force && now - meter.reportedAt < LIVE_MS) return;
413 meter.reportedAt = now;
414 try {
415 recordUsage(app.db, {
416 runId: run.id,
417 repoId: run.repoId,
418 issueId: run.issueId,
419 agentId: run.actorId,
420 subscriptionId: meter.subscriptionId,
421 kind: meter.kind,
422 tokensIn: live.tokensIn,
423 tokensOut: live.tokensOut,
424 costUsd: live.costUsd,
425 wallMs: now - started,
426 outcome: "running",
427 at: new Date(now).toISOString(),
428 });
429 app.bus.append({
430 kind: "usage.delta",
431 repoId: run.repoId,
432 payload: { runId: run.id },
433 });
434 } catch (error) {
435 log.warn("live usage not recorded", { run: run.id, error: String(error) });
436 }
362437 }
363438
364439 /** The run's line in the usage ledger (`core/usage.ts`), written once it ends. */
@@ -380,6 +455,7 @@ function meterUsage(
380455 issueId: run.issueId,
381456 agentId: run.actorId,
382457 subscriptionId: meter.subscriptionId,
458 kind: meter.kind,
383459 tokensIn: meter.tokensIn,
384460 tokensOut: meter.tokensOut,
385461 costUsd: outcome.costUsd ?? 0,
@@ -669,7 +745,12 @@ async function work(
669745 subscriptionId: picked.subscription.id,
670746 });
671747 meter.subscriptionId = picked.subscription.id;
748 } else {
749 write("subscription: platform (env)");
750 noteRunProgress(app.db, run.id, { subscriptionId: PLATFORM });
751 meter.subscriptionId = PLATFORM;
672752 }
753 const started = Date.now();
673754 const env: Record<string, string> = {
674755 ...gitAuth,
675756 ...identity,
@@ -716,10 +797,24 @@ async function work(
716797 log: write,
717798 signal,
718799 onSteer: (send) => steering.set(run.id, send),
800 onUsage: (so) =>
801 meterLive(
802 app,
803 run,
804 meter,
805 {
806 tokensIn: meter.tokensIn + so.tokensIn,
807 tokensOut: meter.tokensOut + so.tokensOut,
808 costUsd: meter.costUsd + so.costUsd,
809 },
810 started,
811 so.costUsd > 0,
812 ),
719813 }),
720814 );
721815 meter.tokensIn += spent.tokensIn;
722816 meter.tokensOut += spent.tokensOut;
817 meter.costUsd += spent.costUsd;
723818 return spent;
724819 };
725820 write(
@@ -808,6 +903,18 @@ async function work(
808903 costUsd: result.costUsd,
809904 };
810905 }
906 } else {
907 // The platform's token is not rested — nothing else would take the
908 // run — but its window is noted, so its standing reads true.
909 const now = new Date();
910 const limit = limitOf(result.result, now);
911 if (limit !== undefined)
912 noteLimit(app.db, PLATFORM, {
913 window: limit.window,
914 resetsAt: limit.resetsAt.toISOString(),
915 observedAt: now.toISOString(),
916 said,
917 });
811918 }
812919 if (run.attempt < MAX_PAUSES) {
813920 const at = resumeAt(result.result, new Date());
src/web/client.ts +3
@@ -2052,7 +2052,10 @@ export const liveRegionsJs = /* js */ `
20522052 var active = document.activeElement;
20532053 if (active && mine.contains(active) && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return;
20542054 if (same(mine.innerHTML, theirs.innerHTML)) return;
2055 // A disclosure the reader opened stays open across the swap.
2056 var opened = Array.prototype.map.call(mine.querySelectorAll('details[id][open]'), function (d) { return d.id; });
20552057 mine.innerHTML = theirs.innerHTML;
2058 opened.forEach(function (i) { var d = document.getElementById(i); if (d) d.open = true; });
20562059 Array.prototype.forEach.call(theirs.attributes, function (a) { if (a.name !== 'id') mine.setAttribute(a.name, a.value); });
20572060 mine.dispatchEvent(new CustomEvent('forge:swapped', { bubbles: true }));
20582061 });
src/web/pages/board.ts +80 −33
@@ -22,7 +22,14 @@ import {
2222 import { appsRuntime } from "../../apps/runtime.ts";
2323 import { efficiencyForRepos } from "../../core/efficiency.ts";
2424 import { standings, WINDOW_MS } from "../../core/standing.ts";
25 import { listSubscriptions } from "../../core/subscriptions.ts";
25 import {
26 listCredentials,
27 PLATFORM,
28 scopesOf,
29 type Subscription,
30 } from "../../core/subscriptions.ts";
31 import { readSpend } from "../../actions/spend.ts";
32 import { spendView } from "./spend.ts";
2633 import { listApps } from "../../core/apps.ts";
2734 import { forgeHealth, rolloutLine } from "../../kernel/guard.ts";
2835 import { vitals, type Vitals } from "../../kernel/vitals.ts";
@@ -315,14 +322,18 @@ function figures(parts: readonly FigureWords[]): Html {
315322 }
316323
317324 /**
318 * Each subscription's 5-hour and weekly window — used, left, resets — for a
319 * site admin only: the pool is theirs (`/settings/subscriptions`). Three
320 * statements however many accounts (`standings`). A live region of its own.
325 * Each credential's 5-hour and weekly window — used, left, resets — and the
326 * crew's spend broken down, for a site admin only: the pool is theirs
327 * (`/settings/subscriptions`). The platform's token leads: it is the crew's.
328 * Then the accounts with a scope, then those with none — a person's own
329 * plan, never offered to the crew, its numbers kept apart so they never
330 * read as idle crew capacity. A fixed number of statements however many
331 * accounts (`standings`, `scopesOf`). A live region of its own.
321332 */
322333 function windowsStrip(ctx: Ctx): Html | null {
323334 if (!ctx.auth.user?.isAdmin) return null;
324335 const db = ctx.app.db;
325 const pool = listSubscriptions(db);
336 const pool = listCredentials(db);
326337 if (pool.length === 0) return null;
327338 const now = new Date();
328339 const all = standings(
@@ -330,39 +341,66 @@ function windowsStrip(ctx: Ctx): Html | null {
330341 pool.map((s) => s.id),
331342 now,
332343 );
344 const scopes = scopesOf(db);
345 const scopeWords = (id: string): string =>
346 id === PLATFORM
347 ? "every app no subscription is granted to"
348 : (scopes.get(id) ?? []).join(", ");
349 const card = (s: Subscription): Html | null => {
350 const st = all.get(s.id);
351 if (st === undefined) return null;
352 return html`<a
353 class="board-sub"
354 href="/settings/subscriptions"
355 data-key="sub:${s.id}"
356 >
357 <span class="board-sub-name">${s.name}</span>
358 ${st.windows.map((w) => {
359 const words = windowWords(w);
360 return html`<span class="board-window" data-window="${w.window}"
361 ><span class="board-window-name">${words.name}</span>
362 ${
363 words.usedPct === null
364 ? null
365 : html`<span class="board-bar"
366 ><span class="board-fill" data-w="${words.usedPct}"></span
367 ></span>`
368 }
369 <span class="board-window-nums"
370 >${figures([words.used, words.remaining, words.resets])}</span
371 ></span
372 >`;
373 })}
374 ${when(
375 scopeWords(s.id) !== "",
376 () => html`<span class="board-sub-scope">for ${scopeWords(s.id)}</span>`,
377 )}
378 </a>`;
379 };
380 const crew = pool.filter((s) => s.id === PLATFORM || scopes.has(s.id));
381 const apart = pool.filter((s) => s.id !== PLATFORM && !scopes.has(s.id));
333382 return html`<div
334383 class="board-windows"
335384 id="windows"
336 data-live="run.*"
385 data-live="run.*,usage.*"
337386 aria-label="Subscription windows"
338387 >
339 ${pool.map((s) => {
340 const st = all.get(s.id);
341 if (st === undefined) return null;
342 return html`<a
343 class="board-sub"
344 href="/settings/subscriptions"
345 data-key="sub:${s.id}"
346 >
347 <span class="board-sub-name">${s.name}</span>
348 ${st.windows.map((w) => {
349 const words = windowWords(w);
350 return html`<span class="board-window" data-window="${w.window}"
351 ><span class="board-window-name">${words.name}</span>
352 ${
353 words.usedPct === null
354 ? null
355 : html`<span class="board-bar"
356 ><span class="board-fill" data-w="${words.usedPct}"></span
357 ></span>`
358 }
359 <span class="board-window-nums"
360 >${figures([words.used, words.remaining, words.resets])}</span
361 ></span
362 >`;
363 })}
364 </a>`;
365 })}
388 <div class="board-subs" data-group="crew">
389 <span class="board-group">The crew's</span>${crew.map(card)}
390 </div>
391 ${when(
392 apart.length > 0,
393 () =>
394 html`<div class="board-subs" data-group="apart">
395 <span class="board-group">Not offered to the crew</span>${apart.map(
396 card,
397 )}
398 </div>`,
399 )}
400 <details class="board-spend-detail" id="spend-detail">
401 <summary>Spend by credential, app, agent, issue and kind</summary>
402 ${spendView(readSpend(ctx.app, now))}
403 </details>
366404 </div>`;
367405 }
368406
@@ -766,6 +804,15 @@ ${widths}
766804 .board-sub { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 16px; padding: 6px 12px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); color: var(--muted-fg); }
767805 .board-sub:hover { border-color: var(--border-strong); color: var(--muted-fg); }
768806 .board-sub-name { font-weight: 600; color: var(--fg); }
807 .board-subs { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
808 .board-group { font-size: .85em; color: var(--faint); text-transform: uppercase; letter-spacing: .04em; }
809 .board-subs[data-group="apart"] .board-sub { border-style: dashed; }
810 .board-sub-scope { color: var(--faint); }
811 .board-spend-detail { flex-basis: 100%; }
812 .board-spend-detail summary { cursor: pointer; color: var(--muted-fg); }
813 .spend-table { margin-top: 8px; }
814 .spend-table caption { text-align: left; color: var(--muted-fg); }
815 .spend-totals { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 8px 0 0; }
769816 .board-window { display: inline-flex; align-items: center; gap: 8px; font-variant-numeric: tabular-nums; }
770817 .board-window-name { color: var(--faint); }
771818 .board-window .board-bar { width: 56px; }
src/web/pages/spend.ts added +144
@@ -0,0 +1,144 @@
1 /**
2 * The spend breakdown (`actions/spend.ts`) as tables: one per attribution —
3 * credential, app, agent, issue, run kind — each row the last 5 hours,
4 * today and the week side by side, then the week's ten costliest runs.
5 * Shared by the board and the Subscriptions page, so they never disagree.
6 * Every name is escaped by `html`; numbers are formatted here, once.
7 */
8
9 import type { Spend, SpendLine, SpanName } from "../../actions/spend.ts";
10 import { html, type Html } from "../html.ts";
11
12 const usd = (n: number): string => `$${n.toFixed(2)}`;
13 /** Whole tokens, shortened past a thousand: 32 120 → 32.1k. */
14 export const tokens = (n: number): string =>
15 n >= 1_000_000
16 ? `${(n / 1_000_000).toFixed(1)}M`
17 : n >= 1_000
18 ? `${(n / 1_000).toFixed(1)}k`
19 : String(n);
20
21 const SPAN_WORDS: Readonly<Record<SpanName, string>> = {
22 "5h": "last 5 h",
23 today: "today",
24 week: "7 days",
25 };
26
27 type Dimension = "byCredential" | "byApp" | "byAgent" | "byIssue" | "byKind";
28 const DIMENSIONS: readonly [Dimension, string][] = [
29 ["byCredential", "Credential"],
30 ["byApp", "App"],
31 ["byAgent", "Agent"],
32 ["byIssue", "Issue"],
33 ["byKind", "Run kind"],
34 ];
35
36 /** One attribution's rows, each span's cost side by side, the week's tokens. */
37 function table(spend: Spend, dim: Dimension, title: string): Html {
38 const names: string[] = [];
39 const at = new Map<string, Map<SpanName, SpendLine>>();
40 for (const s of spend.spans)
41 for (const line of s[dim]) {
42 if (!at.has(line.name)) {
43 names.push(line.name);
44 at.set(line.name, new Map());
45 }
46 at.get(line.name)!.set(s.span, line);
47 }
48 // The week's order: most spent first.
49 const week = (n: string): number => at.get(n)!.get("week")?.costUsd ?? 0;
50 names.sort((a, b) => week(b) - week(a));
51 return html`<table class="table spend-table" data-by="${dim}">
52 <thead>
53 <tr>
54 <th scope="col">${title}</th>
55 ${spend.spans.map(
56 (s) => html`<th scope="col" class="num">${SPAN_WORDS[s.span]}</th>`,
57 )}
58 <th scope="col" class="num">runs, 7 d</th>
59 <th scope="col" class="num">tokens in / out, 7 d</th>
60 </tr>
61 </thead>
62 <tbody>
63 ${names.length === 0
64 ? html`<tr>
65 <td colspan="${spend.spans.length + 3}" class="muted">
66 Nothing spent in the last 7 days.
67 </td>
68 </tr>`
69 : names.map((n) => {
70 const of = at.get(n)!;
71 const w = of.get("week");
72 return html`<tr>
73 <th scope="row">${n}</th>
74 ${spend.spans.map(
75 (s) =>
76 html`<td class="num">${usd(of.get(s.span)?.costUsd ?? 0)}</td>`,
77 )}
78 <td class="num">${String(w?.units ?? 0)}</td>
79 <td class="num">
80 ${tokens(w?.tokensIn ?? 0)} / ${tokens(w?.tokensOut ?? 0)}
81 </td>
82 </tr>`;
83 })}
84 </tbody>
85 </table>`;
86 }
87
88 /** The whole breakdown: totals, five tables, the top runs. */
89 export function spendView(spend: Spend): Html {
90 return html`<div class="spend">
91 <p class="spend-totals num">
92 ${spend.spans.map(
93 (s) =>
94 html`<span
95 ><b>${usd(s.total.costUsd)}</b> ${SPAN_WORDS[s.span]} ·
96 ${String(s.total.units)} runs · ${tokens(s.total.tokensIn)} in /
97 ${tokens(s.total.tokensOut)} out</span
98 >`,
99 )}
100 </p>
101 ${DIMENSIONS.map(([dim, title]) => table(spend, dim, title))}
102 <table class="table spend-table" data-by="top">
103 <caption>
104 The 10 costliest runs, 7 days
105 </caption>
106 <thead>
107 <tr>
108 <th scope="col">Run</th>
109 <th scope="col">Credential</th>
110 <th scope="col">Agent</th>
111 <th scope="col">Issue</th>
112 <th scope="col">Kind</th>
113 <th scope="col" class="num">Cost</th>
114 <th scope="col" class="num">tokens in / out</th>
115 </tr>
116 </thead>
117 <tbody>
118 ${spend.top.map(
119 (t) =>
120 html`<tr data-outcome="${t.outcome}">
121 <th scope="row">
122 <a href="/${t.app}/runs/${t.runId}">${t.app}</a>${t.outcome ===
123 "running"
124 ? html` <i class="muted">running</i>`
125 : null}
126 </th>
127 <td>${t.credential}</td>
128 <td>${t.agent}</td>
129 <td>${t.issue}</td>
130 <td>${t.kind}</td>
131 <td class="num">${usd(t.costUsd)}</td>
132 <td class="num">${tokens(t.tokensIn)} / ${tokens(t.tokensOut)}</td>
133 </tr>`,
134 )}
135 </tbody>
136 </table>
137 <p class="muted small">
138 US dollars at list price, as Claude Code reports them; a running session's
139 tokens count as they arrive, its cost when it reports one. "Last 5 h" is
140 the last five hours, not any one account's window — each credential's own
141 windows are shown with it.
142 </p>
143 </div>`;
144 }
src/web/pages/subscriptions.ts +52 −10
@@ -12,8 +12,11 @@ import {
1212 EVERYONE,
1313 listGrantPlaces,
1414 listGrants,
15 listCredentials,
1516 listSubscriptions,
1617 NAME_MAX,
18 PLATFORM,
19 scopesOf,
1720 setGrants,
1821 wakeSubscription,
1922 type Subscription,
@@ -47,6 +50,9 @@ import {
4750 } from "../ui.ts";
4851 import { whenText } from "../../crew/limits.ts";
4952 import { accountSections } from "./origins.ts";
53 import { readSpend } from "../../actions/spend.ts";
54 import { liveRegionsJs } from "../client.ts";
55 import { spendView } from "./spend.ts";
5056
5157 const POOL = "/settings/subscriptions";
5258
@@ -57,7 +63,11 @@ function requireAdmin(ctx: Ctx): Response | undefined {
5763 }
5864
5965 const kindText = (s: Subscription): string =>
60 s.kind === "oauth" ? "Claude subscription" : "API key";
66 s.id === PLATFORM
67 ? "The forge's own token, from its environment"
68 : s.kind === "oauth"
69 ? "Claude subscription"
70 : "API key";
6171
6272 const usd = (n: number): string => `$${n.toFixed(2)}`;
6373 const mark = (f: Figure<unknown>): string => (f.source === "read" ? "" : "~");
@@ -89,6 +99,7 @@ function shell(ctx: Ctx, body: Html): Response {
8999 body,
90100 "Account settings",
91101 )}`,
102 scriptJs: liveRegionsJs,
92103 });
93104 }
94105
@@ -97,14 +108,30 @@ export function subscriptionsPage(ctx: Ctx): Response {
97108 const denied = requireAdmin(ctx);
98109 if (denied) return denied;
99110 const now = new Date();
100 const pool = listSubscriptions(ctx.app.db);
111 const pool = listCredentials(ctx.app.db);
101112 const places = listGrantPlaces(ctx.app.db);
113 const scopes = scopesOf(ctx.app.db);
114 const scopeText = (s: Subscription): string =>
115 s.id === PLATFORM
116 ? "the crew's: every app no subscription is granted to"
117 : scopes.has(s.id)
118 ? `for ${scopes.get(s.id)!.join(", ")}`
119 : "not offered to the crew — granted nowhere";
102120 const standingOf = standings(
103121 ctx.app.db,
104122 pool.map((s) => s.id),
105123 now,
106124 );
107125
126 const sealed = pool.filter((s) => s.id !== PLATFORM);
127 const none =
128 sealed.length > 0
129 ? null
130 : empty({
131 icon: "key",
132 title: "The crew works on the forge's own token",
133 note: "Add a subscription here and grant it to a repository or an organization; its runs spend that account instead. One granted nowhere is never offered to the crew.",
134 });
108135 const list =
109136 pool.length === 0
110137 ? empty({
@@ -129,8 +156,9 @@ export function subscriptionsPage(ctx: Ctx): Response {
129156 icon: "clock",
130157 }),
131158 )}`,
132 meta: html`${kindText(s)} · added
133 ${time(s.createdAt)}${by
159 meta: html`${kindText(s)} · ${scopeText(s)}${s.id === PLATFORM
160 ? null
161 : html` · added ${time(s.createdAt)}`}${by
134162 ? html` by ${userLink(by.username)}`
135163 : null}
136164 ·
@@ -179,11 +207,13 @@ export function subscriptionsPage(ctx: Ctx): Response {
179207 ${btn("Use again now", { size: "sm", icon: "play" })}
180208 </form>`,
181209 )}
182 ${confirmAction({
183 action: `${POOL}/${s.id}/delete`,
184 verb: "Remove",
185 size: "sm",
186 })}`,
210 ${when(s.id !== PLATFORM, () =>
211 confirmAction({
212 action: `${POOL}/${s.id}/delete`,
213 verb: "Remove",
214 size: "sm",
215 }),
216 )}`,
187217 });
188218 }),
189219 );
@@ -260,7 +290,7 @@ export function subscriptionsPage(ctx: Ctx): Response {
260290
261291 return shell(
262292 ctx,
263 html`${list}
293 html`${list} ${none === null ? null : html`<div class="mt">${none}</div>`}
264294 ${when(
265295 pool.length > 0,
266296 () =>
@@ -271,6 +301,18 @@ export function subscriptionsPage(ctx: Ctx): Response {
271301 and the share kept for people is never spent by the crew.
272302 </p>`,
273303 )}
304 <div class="mt" id="spend" data-live="run.*,usage.*">
305 ${card(
306 html`<details id="spend-detail" open>
307 <summary>By credential, app, agent, issue and run kind</summary>
308 ${spendView(readSpend(ctx.app, now))}
309 </details>`,
310 {
311 head: html`${icon("clock")}
312 <h2>Spend</h2>`,
313 },
314 )}
315 </div>
274316 <div class="mt">${add}</div>
275317 ${used === null ? null : html`<div class="mt">${used}</div>`}`,
276318 );
test/apps-busy.test.ts +3 −1
@@ -203,7 +203,9 @@ describe("apps that are busy", () => {
203203 "an app whose child is still working survives the sweep, then sleeps once it is done",
204204 async () => {
205205 await freshlyAwake();
206 expect(await (await viaHost("/work?s=2")).text()).toBe("working");
206 // Five seconds of work: long enough that a loaded machine still checks
207 // while the child is busy (two seconds raced the check under load).
208 expect(await (await viaHost("/work?s=5")).text()).toBe("working");
207209 // Well past the idle window, and still up: it was asked, and it is busy.
208210 await Bun.sleep(WINDOW + 700);
209211 expect(production().state).toBe("running");
test/credentials.test.ts added +316
@@ -0,0 +1,316 @@
1 /**
2 * Every credential is a subscription: the forge's own token is the row
3 * `platform (env)`, a run on it is counted under it, and a running
4 * session's spend reaches the ledger (and the outbox) before it ends. A
5 * sealed account carries a scope — the places it is granted — and one
6 * granted nowhere is never offered to the crew. The breakdown sums the way
7 * a hand would.
8 */
9
10 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11 import { chmodSync, writeFileSync } from "node:fs";
12 import { mkdtemp, rm } from "node:fs/promises";
13 import { tmpdir } from "node:os";
14 import { join } from "node:path";
15 import { addToCrew, createAgent } from "../src/actions/crew.ts";
16 import { assignIssue, openIssue } from "../src/actions/issues.ts";
17 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
18 import { readSpend, spanSince } from "../src/actions/spend.ts";
19 import { createApp, type App } from "../src/app.ts";
20 import { listEvents } from "../src/core/events.ts";
21 import type { Repo } from "../src/core/repos.ts";
22 import { listRuns } from "../src/core/runs.ts";
23 import { standing } from "../src/core/standing.ts";
24 import {
25 addSubscription,
26 deleteSubscription,
27 EVERYONE,
28 grantedAccounts,
29 listCredentials,
30 listGrants,
31 listSubscriptions,
32 pickSubscription,
33 PLATFORM,
34 scopesOf,
35 setGrants,
36 } from "../src/core/subscriptions.ts";
37 import { createToken } from "../src/core/tokens.ts";
38 import { getUsage, recordUsage } from "../src/core/usage.ts";
39 import { createUser, type User } from "../src/core/users.ts";
40 import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts";
41 import { serve, type Servers } from "../src/http/server.ts";
42
43 const WORK = "sk-ant-oat01-the-owners-work-account-never-for-the-crew";
44
45 let dataDir: string;
46 let gateDir: string;
47 let app: App;
48 let servers: Servers;
49 let crew: CrewHandle;
50 let alice: User;
51 let bot: User;
52 let repo: Repo;
53
54 async function until<T>(read: () => T | undefined): Promise<T> {
55 const deadline = Date.now() + 30_000;
56 while (Date.now() < deadline) {
57 const value = read();
58 if (value !== undefined) return value;
59 await Bun.sleep(50);
60 }
61 throw new Error("did not happen in time");
62 }
63
64 beforeAll(async () => {
65 dataDir = await mkdtemp(join(tmpdir(), "forge-credentials-"));
66 gateDir = await mkdtemp(join(tmpdir(), "forge-credentials-gate-"));
67 app = createApp({ dataDir, port: 0 });
68 servers = await serve(app);
69 // The stand-in reports one assistant message's usage, then — when told
70 // to hold — waits for the gate before it finishes, so a test can read
71 // the ledger mid-run. A run on the work account fails loudly: it must
72 // never be handed that token.
73 const fake = join(dataDir, "fake-claude");
74 writeFileSync(
75 fake,
76 `#!/bin/sh
77 if [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi
78 IFS= read -r first
79 if [ "$CLAUDE_CODE_OAUTH_TOKEN" = "${WORK}" ]; then echo "work token used" >&2; exit 3; fi
80 echo '{"type":"system","subtype":"init","session_id":"fake-1","model":"fake"}'
81 echo '{"type":"assistant","session_id":"fake-1","message":{"id":"msg_1","content":[{"type":"text","text":"working"}],"usage":{"input_tokens":100,"cache_read_input_tokens":900,"output_tokens":50}}}'
82 if [ -f "${gateDir}/hold" ]; then
83 while [ ! -f "${gateDir}/open" ]; do sleep 0.1; done
84 fi
85 echo "work by the crew" >> CREW.md
86 git add -A && git commit -qm "crew work" && git push -q -u origin HEAD
87 echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW.md.","total_cost_usd":0.25,"num_turns":2,"session_id":"fake-1","terminal_reason":"completed","usage":{"input_tokens":200,"cache_read_input_tokens":1800,"output_tokens":120}}'
88 `,
89 );
90 chmodSync(fake, 0o755);
91 crew = await startCrew(app, {
92 forgeUrl: servers.url,
93 localUrl: servers.url,
94 binary: fake,
95 concurrency: 1,
96 budgetUsd: 2,
97 runTimeoutMs: 60_000,
98 });
99 const a = await createUser(app.db, {
100 username: "alice",
101 email: "[email protected]",
102 password: "a-good-passphrase",
103 adminIfFirst: true,
104 });
105 if (!a.ok) throw new Error("user");
106 alice = a.value;
107 const made = await createAgent(app, alice, { username: "bot" });
108 if (!made.ok) throw new Error(String(made.error));
109 bot = made.value.user;
110 const created = await createRepo(app, alice, { name: "garden", visibility: "public" });
111 if (!created.ok) throw new Error(String(created.error));
112 repo = created.value;
113 const seeded = await commitToBranch(app, alice, repo, {
114 message: "Start",
115 files: [{ path: "README.md", content: "# garden\n" }],
116 });
117 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
118 expect(addToCrew(app, alice, repo, "bot").ok).toBe(true);
119 // The owner's work account: in the pool, granted nowhere.
120 expect(addSubscription(app.db, dataDir, { name: "trevato-espo", token: WORK }).ok).toBe(true);
121 }, 30_000);
122
123 afterAll(async () => {
124 await crew.stop();
125 servers.stop();
126 app.close();
127 await rm(dataDir, { recursive: true, force: true });
128 await rm(gateDir, { recursive: true, force: true });
129 });
130
131 describe("the platform credential", () => {
132 test("is a row of its own: listed first, never in the grantable pool, never removed", () => {
133 expect(listCredentials(app.db).map((c) => c.name)).toEqual([
134 "platform (env)",
135 "trevato-espo",
136 ]);
137 expect(listSubscriptions(app.db).map((c) => c.name)).toEqual(["trevato-espo"]);
138 expect(deleteSubscription(app.db, PLATFORM)).toBe(false);
139 // Granting it is a no-op: it is what applies when nothing is granted.
140 setGrants(app.db, repo.id, EVERYONE, [PLATFORM]);
141 expect(listGrants(app.db, repo.id)).toEqual([]);
142 expect(standing(app.db, PLATFORM, new Date())).toBeDefined();
143 });
144 });
145
146 describe("a subscription's scope", () => {
147 test("one granted nowhere is never picked; one granted elsewhere is not picked here", () => {
148 const espo = listSubscriptions(app.db)[0]!;
149 const scope = { repoId: repo.id, ownerId: alice.id, agentId: bot.id };
150 expect(pickSubscription(app.db, dataDir, scope, new Date()).kind).toBe("env");
151 expect(grantedAccounts(app.db, repo.id, alice.id)).toEqual([]);
152 expect(scopesOf(app.db).has(espo.id)).toBe(false);
153
154 setGrants(app.db, "another-repo", EVERYONE, [espo.id]);
155 expect(scopesOf(app.db).get(espo.id)).toEqual(["a place that is gone"]);
156 expect(pickSubscription(app.db, dataDir, scope, new Date()).kind).toBe("env");
157 expect(grantedAccounts(app.db, repo.id, alice.id)).toEqual([]);
158 setGrants(app.db, "another-repo", EVERYONE, []);
159
160 // Granted here, it is picked here — and only then.
161 setGrants(app.db, repo.id, EVERYONE, [espo.id]);
162 const picked = pickSubscription(app.db, dataDir, scope, new Date());
163 expect(picked.kind === "ready" && picked.subscription.id).toBe(espo.id);
164 expect(scopesOf(app.db).get(espo.id)).toEqual(["alice/garden"]);
165 setGrants(app.db, repo.id, EVERYONE, []);
166 });
167 });
168
169 describe("a crew run on the platform's token", () => {
170 test("is counted under platform (env), moving while it runs", async () => {
171 writeFileSync(join(gateDir, "hold"), "");
172 const issue = openIssue(app, alice, repo, { title: "Plant tulips", body: "" });
173 if (!issue.ok) throw new Error(String(issue.error));
174 expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true);
175 const run = await until(() =>
176 listRuns(app.db, repo.id, { kind: "agent" }).find((r) => r.issueId === issue.value.id),
177 );
178
179 // Mid-run: the assistant message's 100 + 900 read and 50 written are in
180 // the ledger as a running line, before any result; no cost reported yet.
181 const live = await until(() => {
182 const u = getUsage(app.db, run.id);
183 return u?.outcome === "running" ? u : undefined;
184 });
185 expect(live).toMatchObject({
186 subscriptionId: PLATFORM,
187 tokensIn: 1_000,
188 tokensOut: 50,
189 costUsd: 0,
190 kind: "build",
191 });
192 const mid = readSpend(app, new Date(), repo.id);
193 const fiveHours = mid.spans.find((s) => s.span === "5h")!;
194 expect(fiveHours.total).toEqual({ units: 1, tokensIn: 1_000, tokensOut: 50, costUsd: 0 });
195 expect(fiveHours.byCredential.map((l) => l.name)).toEqual(["platform (env)"]);
196 expect(mid.top[0]).toMatchObject({ runId: run.id, outcome: "running" });
197 // …and the outbox says so, naming the run and nothing it spent.
198 const delta = listEvents(app.db, { repoId: repo.id, kind: "usage.delta" })[0];
199 expect(delta?.payload).toEqual({ runId: run.id });
200
201 writeFileSync(join(gateDir, "open"), "");
202 const done = await until(() => {
203 const u = getUsage(app.db, run.id);
204 return u !== undefined && u.outcome !== "running" ? u : undefined;
205 });
206 // The result replaces the line: 200 + 1800 read, 120 written, $0.25.
207 expect(done).toMatchObject({
208 subscriptionId: PLATFORM,
209 tokensIn: 2_000,
210 tokensOut: 120,
211 costUsd: 0.25,
212 outcome: "ok",
213 });
214
215 const after = readSpend(app, new Date());
216 const week = after.spans.find((s) => s.span === "week")!;
217 expect(week.byCredential).toEqual([
218 { name: "platform (env)", units: 1, tokensIn: 2_000, tokensOut: 120, costUsd: 0.25 },
219 ]);
220 expect(week.byApp.map((l) => l.name)).toEqual(["alice/garden"]);
221 expect(week.byAgent.map((l) => l.name)).toEqual(["bot"]);
222 expect(week.byIssue.map((l) => l.name)).toEqual([`alice/garden#${issue.value.number}`]);
223 expect(week.byKind.map((l) => l.name)).toEqual(["build"]);
224
225 // The API and MCP read the same report: platform (env) by name, its
226 // windows with it, the breakdown alongside.
227 const owner = createToken(app.db, alice.id, "ro", "ro").token;
228 const got = await fetch(`${servers.url}/api/v1/repos/alice/garden/usage`, {
229 headers: { authorization: `Bearer ${owner}` },
230 });
231 const body = (await got.json()) as any;
232 expect(body.bySubscription[0].subscription).toBe("platform (env)");
233 expect(body.standing.map((s: any) => s.subscription)).toEqual(["platform (env)"]);
234 expect(body.spend.spans.map((s: any) => s.span)).toEqual(["5h", "today", "week"]);
235 expect(JSON.stringify(body)).not.toContain(WORK);
236 }, 60_000);
237 });
238
239 describe("the breakdown", () => {
240 test("sums by kind and span as a hand would, the costliest runs first", () => {
241 const now = new Date("2026-06-02T12:00:00.000Z");
242 const line = (runId: string, at: string, costUsd: number, kind: "plan" | "review") =>
243 recordUsage(app.db, {
244 runId,
245 repoId: repo.id,
246 issueId: null,
247 agentId: bot.id,
248 subscriptionId: PLATFORM,
249 kind,
250 tokensIn: 10,
251 tokensOut: 1,
252 costUsd,
253 wallMs: 1_000,
254 outcome: "ok",
255 at,
256 });
257 // Finished runs, so the crew never claims one and rewrites its line.
258 let n = 0;
259 const run = () => {
260 const runId = `done-${++n}`;
261 app.db
262 .query("INSERT INTO run (id, repo_id, kind, state) VALUES (?1, ?2, 'agent', 'ok')")
263 .run(runId, repo.id);
264 return runId;
265 };
266 // 11:00 is in the last 5 h, today and the week; 02:00 today and the
267 // week; three days back only the week; nine days back none.
268 line(run(), "2026-06-02T11:00:00.000Z", 1, "plan");
269 line(run(), "2026-06-02T02:00:00.000Z", 2, "review");
270 line(run(), "2026-05-30T12:00:00.000Z", 4, "review");
271 line(run(), "2026-05-24T12:00:00.000Z", 8, "plan");
272 expect(spanSince("today", now)).toBe("2026-06-02T00:00:00.000Z");
273 const spend = readSpend(app, now, repo.id);
274 const cost = (span: string) => spend.spans.find((s) => s.span === span)!.total.costUsd;
275 expect([cost("5h"), cost("today"), cost("week")]).toEqual([1, 3, 7]);
276 const week = spend.spans.find((s) => s.span === "week")!;
277 // review: $2 + $4 = $6 in 2 runs; plan: $1 in 1.
278 expect(week.byKind.map((l) => [l.name, l.units, l.costUsd])).toEqual([
279 ["review", 2, 6],
280 ["plan", 1, 1],
281 ]);
282 expect(spend.top.map((t) => t.costUsd)).toEqual([4, 2, 1]);
283 });
284 });
285
286 describe("the board", () => {
287 test("puts the crew's credentials first and the unscoped account apart, with the spend", async () => {
288 const cookie = (
289 (
290 await fetch(`${servers.url}/signin`, {
291 method: "POST",
292 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
293 redirect: "manual",
294 })
295 ).headers.get("set-cookie") ?? ""
296 ).split(";")[0]!;
297 const page = await (await fetch(`${servers.url}/board`, { headers: { cookie } })).text();
298 const crewAt = page.indexOf('data-group="crew"');
299 const apartAt = page.indexOf('data-group="apart"');
300 expect(crewAt).toBeGreaterThan(-1);
301 expect(apartAt).toBeGreaterThan(crewAt);
302 expect(page.slice(crewAt, apartAt)).toContain("platform (env)");
303 expect(page.slice(crewAt, apartAt)).not.toContain("trevato-espo");
304 expect(page.slice(apartAt)).toContain("trevato-espo");
305 expect(page).toContain('data-live="run.*,usage.*"');
306 expect(page).toContain('data-by="byKind"');
307 expect(page).toContain("The 10 costliest runs");
308
309 const subs = await (
310 await fetch(`${servers.url}/settings/subscriptions`, { headers: { cookie } })
311 ).text();
312 expect(subs).toContain("platform (env)");
313 expect(subs).toContain("not offered to the crew");
314 expect(subs).toContain('data-by="byCredential"');
315 });
316 });
test/usage.test.ts +2
@@ -271,6 +271,8 @@ describe("the ledger", () => {
271271 costUsd: 0,
272272 wallMs: 3,
273273 outcome: "ok",
274 // Unsaid, a run is a build.
275 kind: "build",
274276 });
275277 expect(
276278 app.db

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs