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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/460-every-credential-is-a-subscription-the-p → main 8 commits fork
15 files
changed +1112
−70
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 -- 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", | |
| 10 | 10 | "timeout": 1800 |
| 11 | 11 | } |
| 12 | 12 | } |
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"; | ||
| 15 | 15 | import { getIssuesByIds } from "../core/issues.ts"; |
| 16 | 16 | import { canSeeOwnerFacts, type Repo } from "../core/repos.ts"; |
| 17 | 17 | 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"; | |
| 19 | 25 | import { |
| 20 | 26 | recentUsage, |
| 21 | 27 | usageTotals, |
| @@ -49,7 +55,7 @@ export interface UsageReport { | ||
| 49 | 55 | readonly since: string; |
| 50 | 56 | /** Every run in the window, summed; zeros when there were none. */ |
| 51 | 57 | 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. */ | |
| 53 | 59 | readonly bySubscription: readonly Named<"subscription">[]; |
| 54 | 60 | readonly byAgent: readonly Named<"agent">[]; |
| 55 | 61 | /** By issue number; `issue: null` is a run about no issue. */ |
| @@ -65,6 +71,8 @@ export interface UsageReport { | ||
| 65 | 71 | readonly standing: readonly (Omit<Standing, "subscriptionId"> & { |
| 66 | 72 | readonly subscription: string; |
| 67 | 73 | })[]; |
| 74 | /** The last 5 hours, today and the week, broken down, with the top runs. */ | |
| 75 | readonly spend: Spend; | |
| 68 | 76 | } |
| 69 | 77 | |
| 70 | 78 | const ZERO: Omit<UsageTotals, "key"> = { |
| @@ -99,7 +107,7 @@ export function readUsage( | ||
| 99 | 107 | |
| 100 | 108 | // Names, each looked up once for the whole report. |
| 101 | 109 | const subscriptions = new Map( |
| 102 | listSubscriptions(db).map((s) => [s.id, s.name]), | |
| 110 | listCredentials(db).map((s) => [s.id, s.name]), | |
| 103 | 111 | ); |
| 104 | 112 | const agentIds = new Set<string>(); |
| 105 | 113 | const issueIds = new Set<string>(); |
| @@ -110,7 +118,7 @@ export function readUsage( | ||
| 110 | 118 | const agents = getUsersByIds(db, [...agentIds]); |
| 111 | 119 | const issues = getIssuesByIds(db, [...issueIds]); |
| 112 | 120 | 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"; | |
| 114 | 122 | const agentName = (id: string | null): string | null => |
| 115 | 123 | id === null ? null : (agents.get(id)?.username ?? "a removed agent"); |
| 116 | 124 | const issueNumber = (id: string | null): number | null => |
| @@ -134,8 +142,11 @@ export function readUsage( | ||
| 134 | 142 | const accounts = new Set<string>(); |
| 135 | 143 | for (const g of [...listGrants(db, repo.id), ...listGrants(db, repo.ownerId)]) |
| 136 | 144 | 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); | |
| 137 | 148 | 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); | |
| 139 | 150 | const read = standings(db, [...accounts], now); |
| 140 | 151 | const standingsOut = [...accounts] |
| 141 | 152 | .flatMap((id) => { |
| @@ -164,5 +175,6 @@ export function readUsage( | ||
| 164 | 175 | issue: issueNumber(r.issueId), |
| 165 | 176 | })), |
| 166 | 177 | standing: standingsOut, |
| 178 | spend: readSpend(app, now, repo.id), | |
| 167 | 179 | }); |
| 168 | 180 | } |
src/core/schema.ts +40
| @@ -1111,4 +1111,44 @@ export const migrations: readonly string[] = [ | ||
| 1111 | 1111 | CREATE INDEX storage_repo ON storage(repo_id, at); |
| 1112 | 1112 | CREATE INDEX storage_at ON storage(at); |
| 1113 | 1113 | `, |
| 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 | `, | |
| 1114 | 1154 | ]; |
src/core/subscriptions.ts +51 −7
| @@ -15,6 +15,13 @@ | ||
| 15 | 15 | * |
| 16 | 16 | * A token is opened only for the run's environment, never listed and never |
| 17 | 17 | * 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). | |
| 18 | 25 | */ |
| 19 | 26 | |
| 20 | 27 | import type { Database } from "bun:sqlite"; |
| @@ -52,6 +59,9 @@ interface Row { | ||
| 52 | 59 | export const NAME_MAX = 40; |
| 53 | 60 | const TOKEN_MAX = 512; |
| 54 | 61 | |
| 62 | /** The forge's own token, from its environment: a row with no sealed token. */ | |
| 63 | export const PLATFORM = "platform"; | |
| 64 | ||
| 55 | 65 | /** Every crew member at a scope, as the grant's `agent_id`. */ |
| 56 | 66 | export const EVERYONE = ""; |
| 57 | 67 | |
| @@ -149,20 +159,31 @@ export function listSubscriptions(db: Database): Subscription[] { | ||
| 149 | 159 | return db |
| 150 | 160 | .query<Row, []>( |
| 151 | 161 | `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`, | |
| 153 | 163 | ) |
| 154 | 164 | .all() |
| 155 | 165 | .map(toSubscription); |
| 156 | 166 | } |
| 157 | 167 | |
| 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 | ||
| 158 | 178 | /** Its grants go with it; runs that used it keep the id as history. */ |
| 159 | 179 | export function deleteSubscription( |
| 160 | 180 | db: Database, |
| 161 | 181 | subscriptionId: string, |
| 162 | 182 | ): boolean { |
| 163 | 183 | 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 | |
| 166 | 187 | ); |
| 167 | 188 | } |
| 168 | 189 | |
| @@ -262,6 +283,20 @@ export function setGrants( | ||
| 262 | 283 | })(); |
| 263 | 284 | } |
| 264 | 285 | |
| 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 | ||
| 265 | 300 | export interface GrantPlace { |
| 266 | 301 | readonly subscription: string; |
| 267 | 302 | /** `owner/name` for a repository, `@org` for an organization. */ |
| @@ -273,9 +308,16 @@ export interface GrantPlace { | ||
| 273 | 308 | |
| 274 | 309 | /** Every grant with its names on, for the page that says where each goes. */ |
| 275 | 310 | 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 })[] { | |
| 276 | 317 | return db |
| 277 | 318 | .query< |
| 278 | 319 | { |
| 320 | subscription_id: string; | |
| 279 | 321 | subscription: string; |
| 280 | 322 | agent: string | null; |
| 281 | 323 | repo_name: string | null; |
| @@ -284,7 +326,7 @@ export function listGrantPlaces(db: Database): GrantPlace[] { | ||
| 284 | 326 | }, |
| 285 | 327 | [] |
| 286 | 328 | >( |
| 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, | |
| 288 | 330 | r.name AS repo_name, ro.username AS repo_owner, o.username AS org |
| 289 | 331 | FROM subscription_grant g |
| 290 | 332 | JOIN subscription s ON s.id = g.subscription_id |
| @@ -296,6 +338,7 @@ export function listGrantPlaces(db: Database): GrantPlace[] { | ||
| 296 | 338 | ) |
| 297 | 339 | .all() |
| 298 | 340 | .map((r) => ({ |
| 341 | subscriptionId: r.subscription_id, | |
| 299 | 342 | subscription: r.subscription, |
| 300 | 343 | place: |
| 301 | 344 | r.repo_name !== null |
| @@ -316,7 +359,7 @@ export function listGrantPlaces(db: Database): GrantPlace[] { | ||
| 316 | 359 | // ---- the pick ---- |
| 317 | 360 | |
| 318 | 361 | 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. */ | |
| 320 | 363 | | { readonly kind: "env" } |
| 321 | 364 | | { |
| 322 | 365 | readonly kind: "ready"; |
| @@ -359,7 +402,7 @@ export function pickSubscription( | ||
| 359 | 402 | `SELECT g.scope_id, g.agent_id, s.id, s.name, s.kind, s.token_enc, s.resting_until, |
| 360 | 403 | s.resting_said, s.last_used_at, s.created_by, s.created_at |
| 361 | 404 | 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' | |
| 363 | 406 | ORDER BY (g.scope_id = ?1) DESC, (g.agent_id = ?3) DESC, |
| 364 | 407 | s.last_used_at IS NOT NULL, s.last_used_at, s.name COLLATE NOCASE`, |
| 365 | 408 | ) |
| @@ -420,7 +463,8 @@ export function grantedAccounts( | ||
| 420 | 463 | .query<{ id: string; name: string; scope_id: string }, [string, string]>( |
| 421 | 464 | `SELECT DISTINCT s.id, s.name, g.scope_id |
| 422 | 465 | 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`, | |
| 424 | 468 | ) |
| 425 | 469 | .all(repoId, ownerId); |
| 426 | 470 | const own = rows.filter((r) => r.scope_id === repoId); |
src/core/usage.ts +56 −11
| @@ -2,7 +2,9 @@ | ||
| 2 | 2 | * Usage — the ledger of what each agent run spent: tokens in and out, the |
| 3 | 3 | * provider's reported cost, wall time and outcome, attributed to the |
| 4 | 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 | |
| 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 | |
| 6 | 8 | * governor reads it to pace the crew, the board to show efficiency. |
| 7 | 9 | * |
| 8 | 10 | * Units, stated once: |
| @@ -20,22 +22,37 @@ | ||
| 20 | 22 | import type { Database } from "bun:sqlite"; |
| 21 | 23 | |
| 22 | 24 | /** 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"]; | |
| 25 | 40 | |
| 26 | 41 | export interface UsageEntry { |
| 27 | 42 | readonly runId: string; |
| 28 | 43 | readonly repoId: string; |
| 29 | 44 | readonly issueId: string | null; |
| 30 | 45 | 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. */ | |
| 32 | 47 | readonly subscriptionId: string | null; |
| 48 | /** What the run was for; `build` when unsaid. */ | |
| 49 | readonly kind?: UsageKind | undefined; | |
| 33 | 50 | readonly tokensIn: number; |
| 34 | 51 | readonly tokensOut: number; |
| 35 | 52 | readonly costUsd: number; |
| 36 | 53 | readonly wallMs: number; |
| 37 | 54 | readonly outcome: UsageOutcome; |
| 38 | /** When the run ended (ISO 8601, UTC). */ | |
| 55 | /** When the run ended, or last reported while `running` (ISO 8601, UTC). */ | |
| 39 | 56 | readonly at: string; |
| 40 | 57 | } |
| 41 | 58 | |
| @@ -54,8 +71,8 @@ export function recordUsage(db: Database, entry: UsageEntry): void { | ||
| 54 | 71 | db.query( |
| 55 | 72 | `INSERT OR REPLACE INTO usage |
| 56 | 73 | (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)`, | |
| 59 | 76 | ).run( |
| 60 | 77 | entry.runId, |
| 61 | 78 | entry.repoId, |
| @@ -68,6 +85,7 @@ export function recordUsage(db: Database, entry: UsageEntry): void { | ||
| 68 | 85 | whole(entry.wallMs), |
| 69 | 86 | entry.outcome, |
| 70 | 87 | entry.at, |
| 88 | USAGE_KINDS.includes(entry.kind ?? "build") ? (entry.kind ?? "build") : "build", | |
| 71 | 89 | ); |
| 72 | 90 | } |
| 73 | 91 | |
| @@ -77,6 +95,7 @@ interface UsageRow { | ||
| 77 | 95 | issue_id: string | null; |
| 78 | 96 | agent_id: string | null; |
| 79 | 97 | subscription_id: string | null; |
| 98 | kind: UsageKind; | |
| 80 | 99 | tokens_in: number; |
| 81 | 100 | tokens_out: number; |
| 82 | 101 | cost_usd: number; |
| @@ -91,6 +110,7 @@ const toEntry = (r: UsageRow): UsageEntry => ({ | ||
| 91 | 110 | issueId: r.issue_id, |
| 92 | 111 | agentId: r.agent_id, |
| 93 | 112 | subscriptionId: r.subscription_id, |
| 113 | kind: r.kind, | |
| 94 | 114 | tokensIn: r.tokens_in, |
| 95 | 115 | tokensOut: r.tokens_out, |
| 96 | 116 | costUsd: r.cost_usd, |
| @@ -107,18 +127,20 @@ export function getUsage(db: Database, runId: string): UsageEntry | undefined { | ||
| 107 | 127 | } |
| 108 | 128 | |
| 109 | 129 | /** 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"; | |
| 111 | 131 | export const USAGE_BY: readonly UsageBy[] = [ |
| 112 | 132 | "subscription", |
| 113 | 133 | "repo", |
| 114 | 134 | "agent", |
| 115 | 135 | "issue", |
| 136 | "kind", | |
| 116 | 137 | ]; |
| 117 | 138 | const COLUMN: Readonly<Record<UsageBy, string>> = { |
| 118 | 139 | subscription: "subscription_id", |
| 119 | 140 | repo: "repo_id", |
| 120 | 141 | agent: "agent_id", |
| 121 | 142 | issue: "issue_id", |
| 143 | kind: "kind", | |
| 122 | 144 | }; |
| 123 | 145 | |
| 124 | 146 | export interface UsageTotals { |
| @@ -139,6 +161,8 @@ export interface UsageFilter { | ||
| 139 | 161 | readonly since: string; |
| 140 | 162 | /** Only this repository's runs, when given. */ |
| 141 | 163 | readonly repoId?: string | undefined; |
| 164 | /** Rows at or before this instant (ISO 8601), when given. */ | |
| 165 | readonly until?: string | undefined; | |
| 142 | 166 | } |
| 143 | 167 | |
| 144 | 168 | /** |
| @@ -165,7 +189,7 @@ export function usageTotals( | ||
| 165 | 189 | cost_usd: number; |
| 166 | 190 | wall_ms: number; |
| 167 | 191 | }, |
| 168 | [string, string | null] | |
| 192 | [string, string | null, string | null] | |
| 169 | 193 | >( |
| 170 | 194 | `SELECT ${column} AS key, |
| 171 | 195 | COUNT(*) AS runs, |
| @@ -177,11 +201,11 @@ export function usageTotals( | ||
| 177 | 201 | SUM(cost_usd) AS cost_usd, |
| 178 | 202 | SUM(wall_ms) AS wall_ms |
| 179 | 203 | 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) | |
| 181 | 205 | GROUP BY ${column} |
| 182 | 206 | ORDER BY cost_usd DESC, runs DESC, key`, |
| 183 | 207 | ) |
| 184 | .all(filter.since, filter.repoId ?? null); | |
| 208 | .all(filter.since, filter.repoId ?? null, filter.until ?? null); | |
| 185 | 209 | return rows.map((r) => ({ |
| 186 | 210 | key: r.key, |
| 187 | 211 | runs: r.runs, |
| @@ -210,3 +234,24 @@ export function recentUsage( | ||
| 210 | 234 | .all(filter.since, filter.repoId ?? null, Math.min(Math.max(limit, 1), 200)) |
| 211 | 235 | .map(toEntry); |
| 212 | 236 | } |
| 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 { | ||
| 36 | 36 | * send after the session ended returns false. |
| 37 | 37 | */ |
| 38 | 38 | 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; | |
| 39 | 52 | } |
| 40 | 53 | |
| 41 | 54 | export interface ClaudeOutcome { |
| @@ -197,6 +210,9 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> { | ||
| 197 | 210 | let reason = ""; |
| 198 | 211 | let ok = false; |
| 199 | 212 | 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 }>(); | |
| 200 | 216 | |
| 201 | 217 | const onLine = (line: string): void => { |
| 202 | 218 | if (line.trim() === "") return; |
| @@ -219,6 +235,21 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> { | ||
| 219 | 235 | } |
| 220 | 236 | if (type === "assistant") { |
| 221 | 237 | 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 | } | |
| 222 | 253 | return; |
| 223 | 254 | } |
| 224 | 255 | if (type === "result") { |
| @@ -229,6 +260,7 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> { | ||
| 229 | 260 | typeof e["total_cost_usd"] === "number" ? e["total_cost_usd"] : 0; |
| 230 | 261 | turns = typeof e["num_turns"] === "number" ? e["num_turns"] : 0; |
| 231 | 262 | ({ tokensIn, tokensOut } = tokensOf(e["usage"])); |
| 263 | input.onUsage?.({ tokensIn, tokensOut, costUsd }); | |
| 232 | 264 | const terminal = e["terminal_reason"]; |
| 233 | 265 | // Claude Code names the budget ending twice (`error_max_budget_usd` |
| 234 | 266 | // 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"; | ||
| 78 | 78 | import { pauseReason } from "./pacing.ts"; |
| 79 | 79 | import { |
| 80 | 80 | pickSubscription, |
| 81 | PLATFORM, | |
| 81 | 82 | restSubscription, |
| 82 | 83 | type Pick, |
| 83 | 84 | } 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"; | |
| 85 | 90 | import { trackAgentRun, untrackAgentRun } from "./running.ts"; |
| 86 | 91 | import { sandboxPolicy } from "./sandbox.ts"; |
| 87 | 92 | import { grantSecrets, parseDeclaration, type Grant } from "./grant.ts"; |
| @@ -212,7 +217,14 @@ export async function executeAgentRun( | ||
| 212 | 217 | appendRunLog(app.config.dataDir, run.id, line); |
| 213 | 218 | announceRun(app, claimed); |
| 214 | 219 | 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 | }; | |
| 216 | 228 | let outcome: Outcome; |
| 217 | 229 | try { |
| 218 | 230 | outcome = await work(app, claimed, options, write, controller.signal, meter); |
| @@ -358,7 +370,70 @@ type Outcome = | ||
| 358 | 370 | interface Meter { |
| 359 | 371 | tokensIn: number; |
| 360 | 372 | tokensOut: number; |
| 373 | /** US dollars reported by invocations that have ended. */ | |
| 374 | costUsd: number; | |
| 361 | 375 | 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 | } | |
| 362 | 437 | } |
| 363 | 438 | |
| 364 | 439 | /** The run's line in the usage ledger (`core/usage.ts`), written once it ends. */ |
| @@ -380,6 +455,7 @@ function meterUsage( | ||
| 380 | 455 | issueId: run.issueId, |
| 381 | 456 | agentId: run.actorId, |
| 382 | 457 | subscriptionId: meter.subscriptionId, |
| 458 | kind: meter.kind, | |
| 383 | 459 | tokensIn: meter.tokensIn, |
| 384 | 460 | tokensOut: meter.tokensOut, |
| 385 | 461 | costUsd: outcome.costUsd ?? 0, |
| @@ -669,7 +745,12 @@ async function work( | ||
| 669 | 745 | subscriptionId: picked.subscription.id, |
| 670 | 746 | }); |
| 671 | 747 | meter.subscriptionId = picked.subscription.id; |
| 748 | } else { | |
| 749 | write("subscription: platform (env)"); | |
| 750 | noteRunProgress(app.db, run.id, { subscriptionId: PLATFORM }); | |
| 751 | meter.subscriptionId = PLATFORM; | |
| 672 | 752 | } |
| 753 | const started = Date.now(); | |
| 673 | 754 | const env: Record<string, string> = { |
| 674 | 755 | ...gitAuth, |
| 675 | 756 | ...identity, |
| @@ -716,10 +797,24 @@ async function work( | ||
| 716 | 797 | log: write, |
| 717 | 798 | signal, |
| 718 | 799 | 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 | ), | |
| 719 | 813 | }), |
| 720 | 814 | ); |
| 721 | 815 | meter.tokensIn += spent.tokensIn; |
| 722 | 816 | meter.tokensOut += spent.tokensOut; |
| 817 | meter.costUsd += spent.costUsd; | |
| 723 | 818 | return spent; |
| 724 | 819 | }; |
| 725 | 820 | write( |
| @@ -808,6 +903,18 @@ async function work( | ||
| 808 | 903 | costUsd: result.costUsd, |
| 809 | 904 | }; |
| 810 | 905 | } |
| 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 | }); | |
| 811 | 918 | } |
| 812 | 919 | if (run.attempt < MAX_PAUSES) { |
| 813 | 920 | const at = resumeAt(result.result, new Date()); |
src/web/client.ts +3
| @@ -2052,7 +2052,10 @@ export const liveRegionsJs = /* js */ ` | ||
| 2052 | 2052 | var active = document.activeElement; |
| 2053 | 2053 | if (active && mine.contains(active) && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return; |
| 2054 | 2054 | 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; }); | |
| 2055 | 2057 | mine.innerHTML = theirs.innerHTML; |
| 2058 | opened.forEach(function (i) { var d = document.getElementById(i); if (d) d.open = true; }); | |
| 2056 | 2059 | Array.prototype.forEach.call(theirs.attributes, function (a) { if (a.name !== 'id') mine.setAttribute(a.name, a.value); }); |
| 2057 | 2060 | mine.dispatchEvent(new CustomEvent('forge:swapped', { bubbles: true })); |
| 2058 | 2061 | }); |
src/web/pages/board.ts +80 −33
| @@ -22,7 +22,14 @@ import { | ||
| 22 | 22 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 23 | 23 | import { efficiencyForRepos } from "../../core/efficiency.ts"; |
| 24 | 24 | 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"; | |
| 26 | 33 | import { listApps } from "../../core/apps.ts"; |
| 27 | 34 | import { forgeHealth, rolloutLine } from "../../kernel/guard.ts"; |
| 28 | 35 | import { vitals, type Vitals } from "../../kernel/vitals.ts"; |
| @@ -315,14 +322,18 @@ function figures(parts: readonly FigureWords[]): Html { | ||
| 315 | 322 | } |
| 316 | 323 | |
| 317 | 324 | /** |
| 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. | |
| 321 | 332 | */ |
| 322 | 333 | function windowsStrip(ctx: Ctx): Html | null { |
| 323 | 334 | if (!ctx.auth.user?.isAdmin) return null; |
| 324 | 335 | const db = ctx.app.db; |
| 325 | const pool = listSubscriptions(db); | |
| 336 | const pool = listCredentials(db); | |
| 326 | 337 | if (pool.length === 0) return null; |
| 327 | 338 | const now = new Date(); |
| 328 | 339 | const all = standings( |
| @@ -330,39 +341,66 @@ function windowsStrip(ctx: Ctx): Html | null { | ||
| 330 | 341 | pool.map((s) => s.id), |
| 331 | 342 | now, |
| 332 | 343 | ); |
| 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)); | |
| 333 | 382 | return html`<div |
| 334 | 383 | class="board-windows" |
| 335 | 384 | id="windows" |
| 336 | data-live="run.*" | |
| 385 | data-live="run.*,usage.*" | |
| 337 | 386 | aria-label="Subscription windows" |
| 338 | 387 | > |
| 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> | |
| 366 | 404 | </div>`; |
| 367 | 405 | } |
| 368 | 406 | |
| @@ -766,6 +804,15 @@ ${widths} | ||
| 766 | 804 | .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); } |
| 767 | 805 | .board-sub:hover { border-color: var(--border-strong); color: var(--muted-fg); } |
| 768 | 806 | .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; } | |
| 769 | 816 | .board-window { display: inline-flex; align-items: center; gap: 8px; font-variant-numeric: tabular-nums; } |
| 770 | 817 | .board-window-name { color: var(--faint); } |
| 771 | 818 | .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 { | ||
| 12 | 12 | EVERYONE, |
| 13 | 13 | listGrantPlaces, |
| 14 | 14 | listGrants, |
| 15 | listCredentials, | |
| 15 | 16 | listSubscriptions, |
| 16 | 17 | NAME_MAX, |
| 18 | PLATFORM, | |
| 19 | scopesOf, | |
| 17 | 20 | setGrants, |
| 18 | 21 | wakeSubscription, |
| 19 | 22 | type Subscription, |
| @@ -47,6 +50,9 @@ import { | ||
| 47 | 50 | } from "../ui.ts"; |
| 48 | 51 | import { whenText } from "../../crew/limits.ts"; |
| 49 | 52 | import { accountSections } from "./origins.ts"; |
| 53 | import { readSpend } from "../../actions/spend.ts"; | |
| 54 | import { liveRegionsJs } from "../client.ts"; | |
| 55 | import { spendView } from "./spend.ts"; | |
| 50 | 56 | |
| 51 | 57 | const POOL = "/settings/subscriptions"; |
| 52 | 58 | |
| @@ -57,7 +63,11 @@ function requireAdmin(ctx: Ctx): Response | undefined { | ||
| 57 | 63 | } |
| 58 | 64 | |
| 59 | 65 | 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"; | |
| 61 | 71 | |
| 62 | 72 | const usd = (n: number): string => `$${n.toFixed(2)}`; |
| 63 | 73 | const mark = (f: Figure<unknown>): string => (f.source === "read" ? "" : "~"); |
| @@ -89,6 +99,7 @@ function shell(ctx: Ctx, body: Html): Response { | ||
| 89 | 99 | body, |
| 90 | 100 | "Account settings", |
| 91 | 101 | )}`, |
| 102 | scriptJs: liveRegionsJs, | |
| 92 | 103 | }); |
| 93 | 104 | } |
| 94 | 105 | |
| @@ -97,14 +108,30 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 97 | 108 | const denied = requireAdmin(ctx); |
| 98 | 109 | if (denied) return denied; |
| 99 | 110 | const now = new Date(); |
| 100 | const pool = listSubscriptions(ctx.app.db); | |
| 111 | const pool = listCredentials(ctx.app.db); | |
| 101 | 112 | 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"; | |
| 102 | 120 | const standingOf = standings( |
| 103 | 121 | ctx.app.db, |
| 104 | 122 | pool.map((s) => s.id), |
| 105 | 123 | now, |
| 106 | 124 | ); |
| 107 | 125 | |
| 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 | }); | |
| 108 | 135 | const list = |
| 109 | 136 | pool.length === 0 |
| 110 | 137 | ? empty({ |
| @@ -129,8 +156,9 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 129 | 156 | icon: "clock", |
| 130 | 157 | }), |
| 131 | 158 | )}`, |
| 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 | |
| 134 | 162 | ? html` by ${userLink(by.username)}` |
| 135 | 163 | : null} |
| 136 | 164 | · |
| @@ -179,11 +207,13 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 179 | 207 | ${btn("Use again now", { size: "sm", icon: "play" })} |
| 180 | 208 | </form>`, |
| 181 | 209 | )} |
| 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 | )}`, | |
| 187 | 217 | }); |
| 188 | 218 | }), |
| 189 | 219 | ); |
| @@ -260,7 +290,7 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 260 | 290 | |
| 261 | 291 | return shell( |
| 262 | 292 | ctx, |
| 263 | html`${list} | |
| 293 | html`${list} ${none === null ? null : html`<div class="mt">${none}</div>`} | |
| 264 | 294 | ${when( |
| 265 | 295 | pool.length > 0, |
| 266 | 296 | () => |
| @@ -271,6 +301,18 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 271 | 301 | and the share kept for people is never spent by the crew. |
| 272 | 302 | </p>`, |
| 273 | 303 | )} |
| 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> | |
| 274 | 316 | <div class="mt">${add}</div> |
| 275 | 317 | ${used === null ? null : html`<div class="mt">${used}</div>`}`, |
| 276 | 318 | ); |
test/apps-busy.test.ts +3 −1
| @@ -203,7 +203,9 @@ describe("apps that are busy", () => { | ||
| 203 | 203 | "an app whose child is still working survives the sweep, then sleeps once it is done", |
| 204 | 204 | async () => { |
| 205 | 205 | 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"); | |
| 207 | 209 | // Well past the idle window, and still up: it was asked, and it is busy. |
| 208 | 210 | await Bun.sleep(WINDOW + 700); |
| 209 | 211 | 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", () => { | ||
| 271 | 271 | costUsd: 0, |
| 272 | 272 | wallMs: 3, |
| 273 | 273 | outcome: "ok", |
| 274 | // Unsaid, a run is a build. | |
| 275 | kind: "build", | |
| 274 | 276 | }); |
| 275 | 277 | expect( |
| 276 | 278 | app.db |
new above
offline