Public: anyone can read this; the crew can change it.
Subscription standing: each account's 5-hour and weekly windows, used and resetting, read where the provider says and inferred from the usage ledger where it does not #380
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/372-subscription-standing-each-account-s-5-h → main 1 commit fork
15 files
changed +855
−14
docs/APPS.md +12
| @@ -685,6 +685,18 @@ it named none). The run that starts then resumes the same session in that | ||
| 685 | 685 | clone and is told to carry on. A task is paused at most six times before |
| 686 | 686 | the forge stops waiting on it. |
| 687 | 687 | |
| 688 | Each subscription keeps a _standing_ (`core/standing.ts`): where it is in | |
| 689 | its 5-hour and weekly windows — started, resets, cap, used, remaining, in | |
| 690 | US dollars of list price. When the provider turns a run away, the window | |
| 691 | it named and its reset are kept as _read_; everything else is _inferred_ | |
| 692 | from the usage ledger — a window opens at the first run after the last one | |
| 693 | closed, and the cap is what the crew had spent when the provider last | |
| 694 | called the window full (a person's own use is not in the ledger, so it is | |
| 695 | a floor). An operator keeps a share of every window for people | |
| 696 | (Settings → Subscriptions, 0–90%); `crewRemaining` is what is left after | |
| 697 | it. Both read on `GET /api/v1/repos/:owner/:name/usage` and the MCP | |
| 698 | `usage` tool, under `standing`. | |
| 699 | ||
| 688 | 700 | The agent runs Claude Code headless in its clone, with a prompt the forge |
| 689 | 701 | composes: who it is, what the task is, how to finish, and what will be |
| 690 | 702 | refused. Permissions are the forge's, passed as settings: file tools |
docs/MCP.md +1 −1
| @@ -38,7 +38,7 @@ for a tunnel and a forge that sleeps. | ||
| 38 | 38 | | `take_updates` | a fork takes its parent's updates when git can do it alone; a conflict is refused | yes | |
| 39 | 39 | | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes | |
| 40 | 40 | | `map` | an app's plan: ready now, waiting on what, the critical path, where the crew is (`app`) | | |
| 41 | | `usage` | `{ repo, days? }`: what the crew's runs spent — tokens, cost, wall time — by subscription, agent, issue; a writer's | | | |
| 41 | | `usage` | `{ repo, days? }`: what the crew's runs spent — tokens, cost, wall time — by subscription, agent, issue, and each account's 5-hour and weekly standing; a writer's | | | |
| 42 | 42 | | `link` | `{ app, issue, blockedBy }`: the issue waits for another; loops and cross-repository are refused | yes | |
| 43 | 43 | | `unlink` | `{ app, issue, blockedBy }`: the issue stops waiting for it | yes | |
| 44 | 44 | | `plan` | `{ app, issues }`: open up to 12 linked issues, all or none (see below) | yes | |
forge.json +1 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | "ready": 90 |
| 7 | 7 | }, |
| 8 | 8 | "check": { |
| 9 | "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts test/feed.test.ts test/feed-page.test.ts test/offline.test.ts test/share.test.ts test/cloud.test.ts test/run-secrets.test.ts test/forge-cloudflare.test.ts test/usage.test.ts", | |
| 9 | "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts test/feed.test.ts test/feed-page.test.ts test/offline.test.ts test/share.test.ts test/cloud.test.ts test/run-secrets.test.ts test/forge-cloudflare.test.ts test/usage.test.ts test/standing.test.ts", | |
| 10 | 10 | "timeout": 1800 |
| 11 | 11 | } |
| 12 | 12 | } |
src/actions/usage.ts +26 −1
| @@ -5,12 +5,17 @@ | ||
| 5 | 5 | * the predicate the run pages use for cost), so a reader who cannot write |
| 6 | 6 | * the repository is refused. The API route and the MCP `usage` tool both |
| 7 | 7 | * answer from here, so they never disagree. |
| 8 | * | |
| 9 | * With it, each account's standing (`core/standing.ts`): where it is in its | |
| 10 | * 5-hour and weekly windows, account-wide. The accounts shown are those | |
| 11 | * granted here or on the owner, and those that paid for a run here. | |
| 8 | 12 | */ |
| 9 | 13 | |
| 10 | 14 | import type { App } from "../app.ts"; |
| 11 | 15 | import { getIssuesByIds } from "../core/issues.ts"; |
| 12 | 16 | import { canSeeOwnerFacts, type Repo } from "../core/repos.ts"; |
| 13 | import { listSubscriptions } from "../core/subscriptions.ts"; | |
| 17 | import { standing, type Standing } from "../core/standing.ts"; | |
| 18 | import { listGrants, listSubscriptions } from "../core/subscriptions.ts"; | |
| 14 | 19 | import { |
| 15 | 20 | recentUsage, |
| 16 | 21 | usageTotals, |
| @@ -56,6 +61,10 @@ export interface UsageReport { | ||
| 56 | 61 | readonly agent: string | null; |
| 57 | 62 | readonly issue: number | null; |
| 58 | 63 | })[]; |
| 64 | /** Each account this app spends: its windows, now, every figure read or inferred. */ | |
| 65 | readonly standing: readonly (Omit<Standing, "subscriptionId"> & { | |
| 66 | readonly subscription: string; | |
| 67 | })[]; | |
| 59 | 68 | } |
| 60 | 69 | |
| 61 | 70 | const ZERO: Omit<UsageTotals, "key"> = { |
| @@ -121,6 +130,21 @@ export function readUsage( | ||
| 121 | 130 | ZERO, |
| 122 | 131 | ); |
| 123 | 132 | |
| 133 | // Granted at the repository or its owner, any agent, or spent here. | |
| 134 | const accounts = new Set<string>(); | |
| 135 | for (const g of [...listGrants(db, repo.id), ...listGrants(db, repo.ownerId)]) | |
| 136 | accounts.add(g.subscriptionId); | |
| 137 | for (const row of bySubscription) | |
| 138 | if (row.key !== null && subscriptions.has(row.key)) accounts.add(row.key); | |
| 139 | const standings = [...accounts] | |
| 140 | .flatMap((id) => { | |
| 141 | const s = standing(db, id, now); | |
| 142 | if (s === undefined) return []; | |
| 143 | const { subscriptionId: _, ...rest } = s; | |
| 144 | return [{ subscription: subscriptions.get(id) ?? id, ...rest }]; | |
| 145 | }) | |
| 146 | .sort((a, b) => a.subscription.localeCompare(b.subscription)); | |
| 147 | ||
| 124 | 148 | return ok({ |
| 125 | 149 | app: `${repo.ownerName}/${repo.name}`, |
| 126 | 150 | days, |
| @@ -138,5 +162,6 @@ export function readUsage( | ||
| 138 | 162 | agent: agentName(r.agentId), |
| 139 | 163 | issue: issueNumber(r.issueId), |
| 140 | 164 | })), |
| 165 | standing: standings, | |
| 141 | 166 | }); |
| 142 | 167 | } |
src/core/schema.ts +16
| @@ -1025,4 +1025,20 @@ export const migrations: readonly string[] = [ | ||
| 1025 | 1025 | CREATE INDEX IF NOT EXISTS usage_repo ON usage(repo_id, at); |
| 1026 | 1026 | CREATE INDEX IF NOT EXISTS usage_subscription ON usage(subscription_id, at); |
| 1027 | 1027 | `, |
| 1028 | ||
| 1029 | // 38 — a subscription's standing (`core/standing.ts`): the last time the | |
| 1030 | // provider said each window (5-hour, weekly) was full and when it resets, | |
| 1031 | // and the share of every window the operator keeps for people. | |
| 1032 | ` | |
| 1033 | CREATE TABLE subscription_limit ( | |
| 1034 | subscription_id TEXT NOT NULL REFERENCES subscription(id) ON DELETE CASCADE, | |
| 1035 | span TEXT NOT NULL CHECK (span IN ('5h','week')), | |
| 1036 | resets_at TEXT NOT NULL, | |
| 1037 | observed_at TEXT NOT NULL, | |
| 1038 | said TEXT NOT NULL DEFAULT '', | |
| 1039 | PRIMARY KEY (subscription_id, span) | |
| 1040 | ); | |
| 1041 | ALTER TABLE subscription ADD COLUMN headroom_pct INTEGER NOT NULL DEFAULT 0 | |
| 1042 | CHECK (headroom_pct BETWEEN 0 AND 90); | |
| 1043 | `, | |
| 1028 | 1044 | ]; |
src/core/standing.ts added +336
| @@ -0,0 +1,336 @@ | ||
| 1 | /** | |
| 2 | * Standing — where one subscription is in each of the windows its provider | |
| 3 | * meters it over: the 5-hour session window and the week. Per window: when | |
| 4 | * it started and resets, the cap, what is used, what remains, and what of | |
| 5 | * that the crew may spend once the operator's headroom for people is kept. | |
| 6 | * | |
| 7 | * Every figure says where it came from: | |
| 8 | * - `read` — the provider said it. Claude Code's refusal names the window | |
| 9 | * and its reset (`crew/limits.ts`'s `limitOf`); the runner notes it here | |
| 10 | * with `noteLimit`. While that reset is ahead, the window is full. | |
| 11 | * - `inferred` — worked out from the usage ledger (`core/usage.ts`). | |
| 12 | * | |
| 13 | * Units, stated once: money is US dollars of the provider's list-price | |
| 14 | * equivalent (`usage.cost_usd`), rounded to 1/10 000 of a dollar; tokens | |
| 15 | * are whole tokens; instants are ISO 8601, UTC. | |
| 16 | * | |
| 17 | * How the ledger is read: | |
| 18 | * - A run counts in the window it started in (`at - wallMs`). | |
| 19 | * - Where no reset is read, a window opens at the first run to start once | |
| 20 | * the last one closed, and lasts its length (5 h, 7 d) — the way Claude | |
| 21 | * meters a session. The chain starts two window-lengths back, or at the | |
| 22 | * last read reset if that is later; nothing started means no window is | |
| 23 | * open, and nothing is used. | |
| 24 | * - A weekly reset once read repeats every 7 days, so a past one projects | |
| 25 | * to the next (`inferred`, being a projection). | |
| 26 | * - The cap is what the ledger shows spent in the window when the provider | |
| 27 | * last said it was full. A person's own use of the account is not in the | |
| 28 | * ledger, so the cap is a floor; no refusal yet means no cap (null), and | |
| 29 | * then no remaining either. | |
| 30 | * | |
| 31 | * - The ledger is read five weeks back at most; a refusal older than that | |
| 32 | * no longer sets a cap. | |
| 33 | * | |
| 34 | * Pure data: no authz, no events. | |
| 35 | */ | |
| 36 | ||
| 37 | import type { Database } from "bun:sqlite"; | |
| 38 | ||
| 39 | /** The usage windows a Claude subscription is metered over. */ | |
| 40 | export type LimitWindow = "5h" | "week"; | |
| 41 | ||
| 42 | export const WINDOWS: readonly LimitWindow[] = ["5h", "week"]; | |
| 43 | ||
| 44 | /** Each window's length, in milliseconds. */ | |
| 45 | export const WINDOW_MS: Readonly<Record<LimitWindow, number>> = { | |
| 46 | "5h": 5 * 3_600_000, | |
| 47 | week: 7 * 86_400_000, | |
| 48 | }; | |
| 49 | ||
| 50 | /** The most of a window an operator may keep for people, in percent. */ | |
| 51 | export const HEADROOM_MAX = 90; | |
| 52 | ||
| 53 | export type Source = "read" | "inferred"; | |
| 54 | ||
| 55 | /** A number or instant, and whether the provider said it or the ledger did. */ | |
| 56 | export interface Figure<T> { | |
| 57 | readonly value: T; | |
| 58 | readonly source: Source; | |
| 59 | } | |
| 60 | ||
| 61 | export interface WindowStanding { | |
| 62 | readonly window: LimitWindow; | |
| 63 | /** The open window; null when nothing has started one. */ | |
| 64 | readonly startsAt: Figure<string> | null; | |
| 65 | readonly resetsAt: Figure<string> | null; | |
| 66 | /** US dollars the window holds; null until the provider has refused once. */ | |
| 67 | readonly cap: Figure<number> | null; | |
| 68 | /** US dollars spent in the window by the crew. */ | |
| 69 | readonly used: Figure<number>; | |
| 70 | readonly remaining: Figure<number> | null; | |
| 71 | /** US dollars of the window kept for people: cap × headroom share. */ | |
| 72 | readonly kept: Figure<number> | null; | |
| 73 | /** What the crew may still spend: remaining − kept, never below 0. */ | |
| 74 | readonly crewRemaining: Figure<number> | null; | |
| 75 | /** Ledger lines counted in `used`, and their tokens. */ | |
| 76 | readonly runs: number; | |
| 77 | readonly tokensIn: number; | |
| 78 | readonly tokensOut: number; | |
| 79 | /** The provider's words when it last refused in this window kind. */ | |
| 80 | readonly said: string | null; | |
| 81 | } | |
| 82 | ||
| 83 | export interface Standing { | |
| 84 | readonly subscriptionId: string; | |
| 85 | /** The share of each window the crew never spends, in whole percent. */ | |
| 86 | readonly headroomPct: number; | |
| 87 | readonly windows: readonly WindowStanding[]; | |
| 88 | } | |
| 89 | ||
| 90 | export interface Limit { | |
| 91 | readonly window: LimitWindow; | |
| 92 | readonly resetsAt: string; | |
| 93 | readonly observedAt: string; | |
| 94 | readonly said: string; | |
| 95 | } | |
| 96 | ||
| 97 | /** | |
| 98 | * The provider said `window` is full until `resetsAt`. The newest word for | |
| 99 | * a window replaces the last; an unknown subscription is ignored. | |
| 100 | */ | |
| 101 | export function noteLimit( | |
| 102 | db: Database, | |
| 103 | subscriptionId: string, | |
| 104 | limit: Limit, | |
| 105 | ): void { | |
| 106 | db.query( | |
| 107 | `INSERT INTO subscription_limit (subscription_id, span, resets_at, observed_at, said) | |
| 108 | SELECT ?1, ?2, ?3, ?4, ?5 WHERE EXISTS (SELECT 1 FROM subscription WHERE id = ?1) | |
| 109 | ON CONFLICT (subscription_id, span) DO UPDATE SET | |
| 110 | resets_at = excluded.resets_at, observed_at = excluded.observed_at, | |
| 111 | said = excluded.said`, | |
| 112 | ).run( | |
| 113 | subscriptionId, | |
| 114 | limit.window, | |
| 115 | limit.resetsAt, | |
| 116 | limit.observedAt, | |
| 117 | limit.said.slice(0, 160), | |
| 118 | ); | |
| 119 | } | |
| 120 | ||
| 121 | function limitsOf( | |
| 122 | db: Database, | |
| 123 | subscriptionId: string, | |
| 124 | ): Map<LimitWindow, Limit> { | |
| 125 | const rows = db | |
| 126 | .query< | |
| 127 | { span: LimitWindow; resets_at: string; observed_at: string; said: string }, | |
| 128 | [string] | |
| 129 | >( | |
| 130 | `SELECT span, resets_at, observed_at, said FROM subscription_limit | |
| 131 | WHERE subscription_id = ?1`, | |
| 132 | ) | |
| 133 | .all(subscriptionId); | |
| 134 | return new Map( | |
| 135 | rows.map((r) => [ | |
| 136 | r.span, | |
| 137 | { | |
| 138 | window: r.span, | |
| 139 | resetsAt: r.resets_at, | |
| 140 | observedAt: r.observed_at, | |
| 141 | said: r.said, | |
| 142 | }, | |
| 143 | ]), | |
| 144 | ); | |
| 145 | } | |
| 146 | ||
| 147 | /** A whole percent 0–90 from text; undefined for anything else. */ | |
| 148 | export function headroomPct(text: string): number | undefined { | |
| 149 | if (!/^\d{1,2}$/.test(text.trim())) return undefined; | |
| 150 | const n = Number(text.trim()); | |
| 151 | return n <= HEADROOM_MAX ? n : undefined; | |
| 152 | } | |
| 153 | ||
| 154 | /** The operator's share of each window kept for people; false if no such account. */ | |
| 155 | export function setHeadroom( | |
| 156 | db: Database, | |
| 157 | subscriptionId: string, | |
| 158 | pct: number, | |
| 159 | ): boolean { | |
| 160 | if (!Number.isInteger(pct) || pct < 0 || pct > HEADROOM_MAX) return false; | |
| 161 | return ( | |
| 162 | db | |
| 163 | .query("UPDATE subscription SET headroom_pct = ?2 WHERE id = ?1") | |
| 164 | .run(subscriptionId, pct).changes > 0 | |
| 165 | ); | |
| 166 | } | |
| 167 | ||
| 168 | export function getHeadroom( | |
| 169 | db: Database, | |
| 170 | subscriptionId: string, | |
| 171 | ): number | undefined { | |
| 172 | return db | |
| 173 | .query<{ headroom_pct: number }, [string]>( | |
| 174 | "SELECT headroom_pct FROM subscription WHERE id = ?1", | |
| 175 | ) | |
| 176 | .get(subscriptionId)?.headroom_pct; | |
| 177 | } | |
| 178 | ||
| 179 | /** One ledger line as the window arithmetic needs it. */ | |
| 180 | interface Spend { | |
| 181 | readonly start: number; | |
| 182 | readonly costUsd: number; | |
| 183 | readonly tokensIn: number; | |
| 184 | readonly tokensOut: number; | |
| 185 | } | |
| 186 | ||
| 187 | const money = (n: number): number => Math.round(n * 10_000) / 10_000; | |
| 188 | const iso = (ms: number): string => new Date(ms).toISOString(); | |
| 189 | ||
| 190 | /** | |
| 191 | * The account's standing in each window at `now`, or undefined when there | |
| 192 | * is no such subscription. One ledger read covers both windows. | |
| 193 | */ | |
| 194 | export function standing( | |
| 195 | db: Database, | |
| 196 | subscriptionId: string, | |
| 197 | now: Date, | |
| 198 | ): Standing | undefined { | |
| 199 | const pct = getHeadroom(db, subscriptionId); | |
| 200 | if (pct === undefined) return undefined; | |
| 201 | const limits = limitsOf(db, subscriptionId); | |
| 202 | const t = now.getTime(); | |
| 203 | // Two weeks back holds every window's chain; a refusal's window is read | |
| 204 | // too, so long as it opened within the last five weeks. | |
| 205 | const floor = t - 5 * WINDOW_MS.week; | |
| 206 | const since = Math.max( | |
| 207 | floor, | |
| 208 | Math.min( | |
| 209 | t - 2 * WINDOW_MS.week, | |
| 210 | ...[...limits.values()].map( | |
| 211 | (l) => Date.parse(l.resetsAt) - WINDOW_MS[l.window], | |
| 212 | ), | |
| 213 | ), | |
| 214 | ); | |
| 215 | const spends: Spend[] = db | |
| 216 | .query< | |
| 217 | { at: string; wall_ms: number; cost_usd: number; tokens_in: number; tokens_out: number }, | |
| 218 | [string, string] | |
| 219 | >( | |
| 220 | `SELECT at, wall_ms, cost_usd, tokens_in, tokens_out FROM usage | |
| 221 | WHERE subscription_id = ?1 AND at >= ?2`, | |
| 222 | ) | |
| 223 | .all(subscriptionId, iso(since)) | |
| 224 | .map((r) => ({ | |
| 225 | start: Date.parse(r.at) - r.wall_ms, | |
| 226 | costUsd: r.cost_usd, | |
| 227 | tokensIn: r.tokens_in, | |
| 228 | tokensOut: r.tokens_out, | |
| 229 | })) | |
| 230 | .sort((a, b) => a.start - b.start); | |
| 231 | return { | |
| 232 | subscriptionId, | |
| 233 | headroomPct: pct, | |
| 234 | windows: WINDOWS.map((w) => | |
| 235 | windowStanding(w, limits.get(w), spends, pct, t, since), | |
| 236 | ), | |
| 237 | }; | |
| 238 | } | |
| 239 | ||
| 240 | function windowStanding( | |
| 241 | window: LimitWindow, | |
| 242 | limit: Limit | undefined, | |
| 243 | spends: readonly Spend[], | |
| 244 | pct: number, | |
| 245 | t: number, | |
| 246 | since: number, | |
| 247 | ): WindowStanding { | |
| 248 | const length = WINDOW_MS[window]; | |
| 249 | const reset = limit === undefined ? undefined : Date.parse(limit.resetsAt); | |
| 250 | ||
| 251 | // Where the open window lies, and on whose word. | |
| 252 | let open: { start: number; end: number; source: Source } | undefined; | |
| 253 | if (reset !== undefined && reset > t) { | |
| 254 | open = { start: reset - length, end: reset, source: "read" }; | |
| 255 | } else if (reset !== undefined && window === "week") { | |
| 256 | const end = reset + Math.ceil((t - reset + 1) / length) * length; | |
| 257 | open = { start: end - length, end, source: "inferred" }; | |
| 258 | } else { | |
| 259 | let start = -Infinity; | |
| 260 | let end = -Infinity; | |
| 261 | const from = Math.max(t - 2 * length, reset ?? -Infinity); | |
| 262 | for (const s of spends) { | |
| 263 | if (s.start < from || s.start > t) continue; | |
| 264 | if (s.start >= end) { | |
| 265 | start = s.start; | |
| 266 | end = s.start + length; | |
| 267 | } | |
| 268 | } | |
| 269 | if (end > t) open = { start, end, source: "inferred" }; | |
| 270 | } | |
| 271 | ||
| 272 | const sum = (from: number, to: number) => { | |
| 273 | let costUsd = 0; | |
| 274 | let tokensIn = 0; | |
| 275 | let tokensOut = 0; | |
| 276 | let runs = 0; | |
| 277 | for (const s of spends) | |
| 278 | if (s.start >= from && s.start <= to) { | |
| 279 | costUsd += s.costUsd; | |
| 280 | tokensIn += s.tokensIn; | |
| 281 | tokensOut += s.tokensOut; | |
| 282 | runs++; | |
| 283 | } | |
| 284 | return { costUsd: money(costUsd), tokensIn, tokensOut, runs }; | |
| 285 | }; | |
| 286 | ||
| 287 | const spent = | |
| 288 | open === undefined | |
| 289 | ? { costUsd: 0, tokensIn: 0, tokensOut: 0, runs: 0 } | |
| 290 | : sum(open.start, Math.min(t, open.end - 1)); | |
| 291 | ||
| 292 | // The cap: what the crew had spent in the window the provider last | |
| 293 | // called full, up to the moment it said so. A refusal older than the | |
| 294 | // ledger read is stale, and gives none. | |
| 295 | const capUsd = | |
| 296 | limit === undefined || reset === undefined || reset - length < since | |
| 297 | ? 0 | |
| 298 | : sum(reset - length, Date.parse(limit.observedAt)).costUsd; | |
| 299 | const cap: Figure<number> | null = | |
| 300 | capUsd > 0 ? { value: capUsd, source: "inferred" } : null; | |
| 301 | ||
| 302 | const full = open?.source === "read"; | |
| 303 | const remaining: Figure<number> | null = full | |
| 304 | ? { value: 0, source: "read" } | |
| 305 | : cap === null | |
| 306 | ? null | |
| 307 | : { value: money(Math.max(0, cap.value - spent.costUsd)), source: "inferred" }; | |
| 308 | const kept: Figure<number> | null = | |
| 309 | cap === null ? null : { value: money((cap.value * pct) / 100), source: "inferred" }; | |
| 310 | const crewRemaining: Figure<number> | null = | |
| 311 | remaining === null | |
| 312 | ? null | |
| 313 | : remaining.source === "read" | |
| 314 | ? remaining | |
| 315 | : { | |
| 316 | value: money(Math.max(0, remaining.value - (kept?.value ?? 0))), | |
| 317 | source: "inferred", | |
| 318 | }; | |
| 319 | ||
| 320 | return { | |
| 321 | window, | |
| 322 | startsAt: | |
| 323 | open === undefined ? null : { value: iso(open.start), source: open.source }, | |
| 324 | resetsAt: | |
| 325 | open === undefined ? null : { value: iso(open.end), source: open.source }, | |
| 326 | cap, | |
| 327 | used: { value: spent.costUsd, source: "inferred" }, | |
| 328 | remaining, | |
| 329 | kept, | |
| 330 | crewRemaining, | |
| 331 | runs: spent.runs, | |
| 332 | tokensIn: spent.tokensIn, | |
| 333 | tokensOut: spent.tokensOut, | |
| 334 | said: limit?.said ?? null, | |
| 335 | }; | |
| 336 | } |
src/crew/limits.ts +31 −8
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * it will listen; the person who asked never has to press anything. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import type { LimitWindow } from "../core/standing.ts"; | |
| 8 | 9 | import type { ClaudeOutcome } from "./claude.ts"; |
| 9 | 10 | |
| 10 | 11 | /** How many times one task may be paused before the forge gives up on it. */ |
| @@ -53,11 +54,35 @@ const MONTHS = [ | ||
| 53 | 54 | * Anything else waits the default. |
| 54 | 55 | */ |
| 55 | 56 | export function resumeAt(message: string, now: Date): Date { |
| 56 | const match = | |
| 57 | /resets?\s+(?:at\s+)?(?:([a-z]{3,9})\.?\s+(\d{1,2}),?\s+(?:at\s+)?)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(([^)]+)\)/i.exec( | |
| 58 | message, | |
| 59 | ); | |
| 60 | if (match === null) return new Date(now.getTime() + DEFAULT_WAIT_MS); | |
| 57 | const at = resetOf(message, now); | |
| 58 | if (at === undefined) return new Date(now.getTime() + DEFAULT_WAIT_MS); | |
| 59 | return new Date(at.getTime() + SLACK_MS); | |
| 60 | } | |
| 61 | ||
| 62 | /** | |
| 63 | * Which window the provider said is full, and the instant it resets — read | |
| 64 | * from its own words, or undefined when it named no time. The week is named | |
| 65 | * (`weekly limit`) or dated (`resets Sep 26 at 9pm`); a bare clock time is | |
| 66 | * the 5-hour session window. No slack is added: this is the provider's time. | |
| 67 | */ | |
| 68 | export function limitOf( | |
| 69 | message: string, | |
| 70 | now: Date, | |
| 71 | ): { window: LimitWindow; resetsAt: Date } | undefined { | |
| 72 | const match = RESET.exec(message); | |
| 73 | const resetsAt = resetOf(message, now); | |
| 74 | if (match === null || resetsAt === undefined) return undefined; | |
| 75 | const weekly = match[1] !== undefined || /\bweek(?:ly)?\b/i.test(message); | |
| 76 | return { window: weekly ? "week" : "5h", resetsAt }; | |
| 77 | } | |
| 78 | ||
| 79 | const RESET = | |
| 80 | /resets?\s+(?:at\s+)?(?:([a-z]{3,9})\.?\s+(\d{1,2}),?\s+(?:at\s+)?)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(([^)]+)\)/i; | |
| 81 | ||
| 82 | /** The reset instant a message names, exactly; undefined when it names none. */ | |
| 83 | function resetOf(message: string, now: Date): Date | undefined { | |
| 84 | const match = RESET.exec(message); | |
| 85 | if (match === null) return undefined; | |
| 61 | 86 | const [, monthName, day, h, m, meridiem, zone] = match; |
| 62 | 87 | const hour = (Number(h) % 12) + (meridiem!.toLowerCase() === "pm" ? 12 : 0); |
| 63 | 88 | const minute = Number(m ?? "0"); |
| @@ -69,9 +94,7 @@ export function resumeAt(message: string, now: Date): Date { | ||
| 69 | 94 | monthName !== undefined && month >= 0 |
| 70 | 95 | ? { month: month + 1, day: Number(day) } |
| 71 | 96 | : undefined; |
| 72 | const at = nextWallTime(now, hour, minute, zone!, date); | |
| 73 | if (at === undefined) return new Date(now.getTime() + DEFAULT_WAIT_MS); | |
| 74 | return new Date(at.getTime() + SLACK_MS); | |
| 97 | return nextWallTime(now, hour, minute, zone!, date); | |
| 75 | 98 | } |
| 76 | 99 | |
| 77 | 100 | /** `12:04 UTC` — how a resume time reads on a card. */ |
src/crew/runner.ts +20 −2
| @@ -67,7 +67,14 @@ import { | ||
| 67 | 67 | } from "./brief.ts"; |
| 68 | 68 | import { policyBrief } from "../contract/audit.ts"; |
| 69 | 69 | import { BUDGET_REACHED, runClaude, type ClaudeOutcome } from "./claude.ts"; |
| 70 | import { isRateLimited, MAX_PAUSES, resumeAt, whenText } from "./limits.ts"; | |
| 70 | import { | |
| 71 | isRateLimited, | |
| 72 | limitOf, | |
| 73 | MAX_PAUSES, | |
| 74 | resumeAt, | |
| 75 | whenText, | |
| 76 | } from "./limits.ts"; | |
| 77 | import { noteLimit } from "../core/standing.ts"; | |
| 71 | 78 | import { |
| 72 | 79 | pickSubscription, |
| 73 | 80 | restSubscription, |
| @@ -755,7 +762,18 @@ async function work( | ||
| 755 | 762 | // The account rests until the provider's time; another on the same |
| 756 | 763 | // grant, if one is awake, takes the run straight away. |
| 757 | 764 | if (picked.kind === "ready") { |
| 758 | const until = resumeAt(result.result, new Date()); | |
| 765 | const now = new Date(); | |
| 766 | const until = resumeAt(result.result, now); | |
| 767 | // Which window is full and when it resets, in the provider's own | |
| 768 | // words, for the account's standing (`core/standing.ts`). | |
| 769 | const limit = limitOf(result.result, now); | |
| 770 | if (limit !== undefined) | |
| 771 | noteLimit(app.db, picked.subscription.id, { | |
| 772 | window: limit.window, | |
| 773 | resetsAt: limit.resetsAt.toISOString(), | |
| 774 | observedAt: now.toISOString(), | |
| 775 | said, | |
| 776 | }); | |
| 759 | 777 | restSubscription( |
| 760 | 778 | app.db, |
| 761 | 779 | picked.subscription.id, |
src/http/mcp.ts +1 −1
| @@ -513,7 +513,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 513 | 513 | { |
| 514 | 514 | name: "usage", |
| 515 | 515 | description: |
| 516 | "What the crew's runs on an app spent over the last days (1–90, default 7): tokens in and out, cost in US dollars, wall time and outcome, by subscription, agent and issue, with the newest runs. The same data as /api/v1/repos/:owner/:name/usage; refused unless you can write the app.", | |
| 516 | "What the crew's runs on an app spent over the last days (1–90, default 7): tokens in and out, cost in US dollars, wall time and outcome, by subscription, agent and issue, with the newest runs, and each account's standing in its 5-hour and weekly windows (cap, used, remaining, resets at — each figure read from the provider or inferred from the ledger). The same data as /api/v1/repos/:owner/:name/usage; refused unless you can write the app.", | |
| 517 | 517 | inputSchema: { |
| 518 | 518 | type: "object", |
| 519 | 519 | properties: { |
src/http/server.ts +6
| @@ -65,6 +65,7 @@ import { | ||
| 65 | 65 | subscriptionAdd, |
| 66 | 66 | subscriptionDelete, |
| 67 | 67 | subscriptionsPage, |
| 68 | subscriptionHeadroom, | |
| 68 | 69 | subscriptionWake, |
| 69 | 70 | } from "../web/pages/subscriptions.ts"; |
| 70 | 71 | import { |
| @@ -494,6 +495,11 @@ export function buildRouter(): Router<Handler> { | ||
| 494 | 495 | router.add("POST", "/settings/subscriptions", subscriptionAdd); |
| 495 | 496 | router.add("POST", "/settings/subscriptions/:id/delete", subscriptionDelete); |
| 496 | 497 | router.add("POST", "/settings/subscriptions/:id/wake", subscriptionWake); |
| 498 | router.add( | |
| 499 | "POST", | |
| 500 | "/settings/subscriptions/:id/headroom", | |
| 501 | subscriptionHeadroom, | |
| 502 | ); | |
| 497 | 503 | router.add("GET", "/settings/secrets", secretsPage); |
| 498 | 504 | router.add("POST", "/settings/secrets", secretSet); |
| 499 | 505 | router.add("POST", "/settings/secrets/delete", secretDelete); |
src/web/pages/subscriptions.ts +88
| @@ -18,6 +18,14 @@ import { | ||
| 18 | 18 | wakeSubscription, |
| 19 | 19 | type Subscription, |
| 20 | 20 | } from "../../core/subscriptions.ts"; |
| 21 | import { | |
| 22 | HEADROOM_MAX, | |
| 23 | headroomPct, | |
| 24 | setHeadroom, | |
| 25 | standing, | |
| 26 | type Figure, | |
| 27 | type WindowStanding, | |
| 28 | } from "../../core/standing.ts"; | |
| 21 | 29 | import { getUserById } from "../../core/users.ts"; |
| 22 | 30 | import type { Ctx } from "../../http/context.ts"; |
| 23 | 31 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; |
| @@ -51,6 +59,23 @@ function requireAdmin(ctx: Ctx): Response | undefined { | ||
| 51 | 59 | const kindText = (s: Subscription): string => |
| 52 | 60 | s.kind === "oauth" ? "Claude subscription" : "API key"; |
| 53 | 61 | |
| 62 | const usd = (n: number): string => `$${n.toFixed(2)}`; | |
| 63 | const mark = (f: Figure<unknown>): string => (f.source === "read" ? "" : "~"); | |
| 64 | ||
| 65 | /** `5-hour: ~$3.20 used of ~$10.00 · resets 12:04 UTC` — one window, plainly. */ | |
| 66 | function windowText(w: WindowStanding): string { | |
| 67 | const name = w.window === "5h" ? "5-hour" : "weekly"; | |
| 68 | const used = `${mark(w.used)}${usd(w.used.value)} used`; | |
| 69 | const of = w.cap === null ? "" : ` of ${mark(w.cap)}${usd(w.cap.value)}`; | |
| 70 | const full = | |
| 71 | w.remaining?.source === "read" && w.remaining.value === 0 ? " · full" : ""; | |
| 72 | const resets = | |
| 73 | w.resetsAt === null | |
| 74 | ? " · opens on next use" | |
| 75 | : ` · resets ${w.resetsAt.source === "read" ? "" : "~"}${whenText(new Date(w.resetsAt.value))}`; | |
| 76 | return `${name}: ${used}${of}${full}${resets}`; | |
| 77 | } | |
| 78 | ||
| 54 | 79 | const resting = (s: Subscription, now: Date): boolean => |
| 55 | 80 | s.restingUntil !== null && s.restingUntil > now.toISOString(); |
| 56 | 81 | |
| @@ -85,6 +110,7 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 85 | 110 | : listCard( |
| 86 | 111 | pool.map((s) => { |
| 87 | 112 | const rests = resting(s, now); |
| 113 | const st = standing(ctx.app.db, s.id, now); | |
| 88 | 114 | const by = |
| 89 | 115 | s.createdBy === null |
| 90 | 116 | ? undefined |
| @@ -108,8 +134,36 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 108 | 134 | : html`last used ${timeAgo(s.lastUsedAt)}`}${when( |
| 109 | 135 | rests && s.restingSaid !== "", |
| 110 | 136 | () => html` · ${s.restingSaid}`, |
| 137 | )}${when( | |
| 138 | st !== undefined, | |
| 139 | () => | |
| 140 | html`<br />${st!.windows.map(windowText).join(" · ")}`, | |
| 111 | 141 | )}`, |
| 112 | 142 | trail: html`${when( |
| 143 | st !== undefined, | |
| 144 | () => | |
| 145 | html`<form | |
| 146 | class="headroom row-actions" | |
| 147 | method="post" | |
| 148 | action="${POOL}/${s.id}/headroom" | |
| 149 | > | |
| 150 | <label for="h-${s.id}" class="small muted" | |
| 151 | >kept for people, %</label | |
| 152 | > | |
| 153 | <input | |
| 154 | class="input" | |
| 155 | id="h-${s.id}" | |
| 156 | name="headroom" | |
| 157 | type="number" | |
| 158 | min="0" | |
| 159 | max="${HEADROOM_MAX}" | |
| 160 | step="1" | |
| 161 | value="${st!.headroomPct}" | |
| 162 | /> | |
| 163 | ${btn("Keep", { size: "sm" })} | |
| 164 | </form>`, | |
| 165 | )} | |
| 166 | ${when( | |
| 113 | 167 | rests, |
| 114 | 168 | () => |
| 115 | 169 | html`<form |
| @@ -202,6 +256,16 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 202 | 256 | return shell( |
| 203 | 257 | ctx, |
| 204 | 258 | html`${list} |
| 259 | ${when( | |
| 260 | pool.length > 0, | |
| 261 | () => | |
| 262 | html`<p class="muted small mt"> | |
| 263 | Windows in US dollars of list price. A figure marked ~ is inferred | |
| 264 | from what the crew's runs spent; the rest the provider said. The | |
| 265 | cap is learned the first time the provider calls a window full, | |
| 266 | and the share kept for people is never spent by the crew. | |
| 267 | </p>`, | |
| 268 | )} | |
| 205 | 269 | <div class="mt">${add}</div> |
| 206 | 270 | ${used === null ? null : html`<div class="mt">${used}</div>`}`, |
| 207 | 271 | ); |
| @@ -265,6 +329,30 @@ export async function subscriptionWake(ctx: Ctx): Promise<Response> { | ||
| 265 | 329 | return flashTo(ctx, POOL, { message: "In use again." }); |
| 266 | 330 | } |
| 267 | 331 | |
| 332 | // POST /settings/subscriptions/:id/headroom | |
| 333 | export async function subscriptionHeadroom(ctx: Ctx): Promise<Response> { | |
| 334 | const denied = requireAdmin(ctx); | |
| 335 | if (denied) return denied; | |
| 336 | const form = await readForm(ctx, "admin"); | |
| 337 | if (form instanceof Response) return form; | |
| 338 | const pct = headroomPct(field(form, "headroom")); | |
| 339 | if (pct === undefined) | |
| 340 | return flashTo(ctx, POOL, { | |
| 341 | tone: "danger", | |
| 342 | message: `Headroom is a whole percent from 0 to ${HEADROOM_MAX}.`, | |
| 343 | }); | |
| 344 | const saved = setHeadroom(ctx.app.db, ctx.params.id ?? "", pct); | |
| 345 | return flashTo( | |
| 346 | ctx, | |
| 347 | POOL, | |
| 348 | saved | |
| 349 | ? { | |
| 350 | message: `The crew now leaves ${pct}% of each window for people.`, | |
| 351 | } | |
| 352 | : { tone: "danger", message: "No such subscription." }, | |
| 353 | ); | |
| 354 | } | |
| 355 | ||
| 268 | 356 | // ---- grants, on a repository's Crew settings and an organization's page ---- |
| 269 | 357 | |
| 270 | 358 | /** |
src/web/style/content.ts +3
| @@ -294,6 +294,9 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); } | ||
| 294 | 294 | .grants th[scope="row"] { text-align: left; font-weight: 500; white-space: nowrap; } |
| 295 | 295 | .grants input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--accent); } |
| 296 | 296 | .grants-foot { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-top: 1px solid var(--border); } |
| 297 | /* A subscription's headroom for people: a small number beside its windows. */ | |
| 298 | .headroom { display: flex; align-items: center; gap: 6px; } | |
| 299 | .headroom .input { width: 5em; } | |
| 297 | 300 | .backups-now { margin-top: 12px; } |
| 298 | 301 | .backups-error { display: flex; align-items: center; gap: 8px; color: var(--danger); } |
| 299 | 302 | .backups-restore { margin: 8px 0 0; padding: 8px 10px; font-size: 12px; white-space: pre-wrap; word-break: break-all; background: var(--muted); border-radius: var(--radius); } |
test/standing.test.ts added +251
| @@ -0,0 +1,251 @@ | ||
| 1 | /** | |
| 2 | * A subscription's standing in its 5-hour and weekly windows, against | |
| 3 | * windows worked by hand: which runs fall in, what they spent, when the | |
| 4 | * window opened and resets, and which of those the provider said. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import type { Repo } from "../src/core/repos.ts"; | |
| 14 | import { createRun } from "../src/core/runs.ts"; | |
| 15 | import { | |
| 16 | getHeadroom, | |
| 17 | headroomPct, | |
| 18 | noteLimit, | |
| 19 | setHeadroom, | |
| 20 | standing, | |
| 21 | type WindowStanding, | |
| 22 | } from "../src/core/standing.ts"; | |
| 23 | import { addSubscription, deleteSubscription } from "../src/core/subscriptions.ts"; | |
| 24 | import { recordUsage } from "../src/core/usage.ts"; | |
| 25 | import { createUser } from "../src/core/users.ts"; | |
| 26 | import { limitOf, resumeAt } from "../src/crew/limits.ts"; | |
| 27 | ||
| 28 | describe("which window the provider says is full", () => { | |
| 29 | test("a bare clock time is the 5-hour window, at that time exactly", () => { | |
| 30 | const now = new Date("2026-10-02T05:00:00Z"); | |
| 31 | const said = "You've hit your session limit · resets 7:20am (UTC)"; | |
| 32 | expect(limitOf(said, now)).toEqual({ | |
| 33 | window: "5h", | |
| 34 | resetsAt: new Date("2026-10-02T07:20:00Z"), | |
| 35 | }); | |
| 36 | // The pause still waits a minute past it. | |
| 37 | expect(resumeAt(said, now).toISOString()).toBe("2026-10-02T07:21:00.000Z"); | |
| 38 | }); | |
| 39 | test("a dated reset, or one named weekly, is the week's", () => { | |
| 40 | // 9pm in Chicago in September is daylight time, UTC−5: 02:00Z next day. | |
| 41 | expect( | |
| 42 | limitOf( | |
| 43 | "You've hit your weekly limit · resets Sep 26 at 9pm (America/Chicago)", | |
| 44 | new Date("2026-09-24T00:00:00Z"), | |
| 45 | ), | |
| 46 | ).toEqual({ window: "week", resetsAt: new Date("2026-09-27T02:00:00Z") }); | |
| 47 | expect( | |
| 48 | limitOf("Weekly limit reached · resets 3am (UTC)", new Date("2026-10-02T05:00:00Z")), | |
| 49 | ).toEqual({ window: "week", resetsAt: new Date("2026-10-03T03:00:00Z") }); | |
| 50 | }); | |
| 51 | test("no time named is nothing read", () => { | |
| 52 | expect(limitOf("rate limit exceeded", new Date())).toBeUndefined(); | |
| 53 | expect(limitOf("resets 7:20am (Not/AZone)", new Date())).toBeUndefined(); | |
| 54 | }); | |
| 55 | }); | |
| 56 | ||
| 57 | describe("the headroom an operator keeps", () => { | |
| 58 | test("is a whole percent 0–90", () => { | |
| 59 | expect(headroomPct("0")).toBe(0); | |
| 60 | expect(headroomPct("20")).toBe(20); | |
| 61 | expect(headroomPct(" 90 ")).toBe(90); | |
| 62 | for (const bad of ["91", "-1", "12.5", "", "1e1", "100", "abc"]) | |
| 63 | expect(headroomPct(bad)).toBeUndefined(); | |
| 64 | }); | |
| 65 | }); | |
| 66 | ||
| 67 | describe("a subscription's standing", () => { | |
| 68 | let dataDir: string; | |
| 69 | let app: App; | |
| 70 | let repo: Repo; | |
| 71 | ||
| 72 | beforeAll(async () => { | |
| 73 | dataDir = await mkdtemp(join(tmpdir(), "forge-standing-")); | |
| 74 | app = createApp({ dataDir, port: 0 }); | |
| 75 | const a = await createUser(app.db, { | |
| 76 | username: "alice", | |
| 77 | email: "[email protected]", | |
| 78 | password: "a-good-passphrase", | |
| 79 | adminIfFirst: true, | |
| 80 | }); | |
| 81 | if (!a.ok) throw new Error(a.error); | |
| 82 | const made = await createRepo(app, a.value, { name: "one", visibility: "private" }); | |
| 83 | if (!made.ok) throw new Error("repo"); | |
| 84 | repo = made.value; | |
| 85 | }); | |
| 86 | afterAll(async () => { | |
| 87 | app.close(); | |
| 88 | await rm(dataDir, { recursive: true, force: true }); | |
| 89 | }); | |
| 90 | ||
| 91 | let n = 0; | |
| 92 | const account = (): string => { | |
| 93 | const added = addSubscription(app.db, dataDir, { | |
| 94 | name: `account ${++n}`, | |
| 95 | token: `sk-ant-oat01-standing-test-${n}`, | |
| 96 | }); | |
| 97 | if (!added.ok) throw new Error(added.error); | |
| 98 | return added.value.id; | |
| 99 | }; | |
| 100 | /** A run on `sub` that started at `start`, ran `minutes`, spent `usd`. */ | |
| 101 | const spend = (sub: string, start: string, minutes: number, usd: number): void => { | |
| 102 | const wallMs = minutes * 60_000; | |
| 103 | recordUsage(app.db, { | |
| 104 | runId: createRun(app.db, { repoId: repo.id, kind: "agent" }).id, | |
| 105 | repoId: repo.id, | |
| 106 | issueId: null, | |
| 107 | agentId: null, | |
| 108 | subscriptionId: sub, | |
| 109 | tokensIn: 1000, | |
| 110 | tokensOut: 100, | |
| 111 | costUsd: usd, | |
| 112 | wallMs, | |
| 113 | outcome: "ok", | |
| 114 | at: new Date(Date.parse(start) + wallMs).toISOString(), | |
| 115 | }); | |
| 116 | }; | |
| 117 | const win = (sub: string, now: string, w: "5h" | "week"): WindowStanding => | |
| 118 | standing(app.db, sub, new Date(now))!.windows.find((x) => x.window === w)!; | |
| 119 | ||
| 120 | test("with nothing said, each window opens at the first run after the last closed", () => { | |
| 121 | const sub = account(); | |
| 122 | spend(sub, "2026-10-02T03:00:00Z", 10, 4); // opens 03:00–08:00 | |
| 123 | spend(sub, "2026-10-02T08:00:00Z", 30, 1.25); // at 08:00 that one has closed: opens 08:00–13:00 | |
| 124 | spend(sub, "2026-10-02T10:00:00Z", 10, 0.5); // inside 08:00–13:00 | |
| 125 | const now = "2026-10-02T12:00:00Z"; | |
| 126 | ||
| 127 | // 5 h: 08:00–13:00 holds 1.25 + 0.5 = 1.75. No refusal yet, so no cap. | |
| 128 | const session = win(sub, now, "5h"); | |
| 129 | expect(session.startsAt).toEqual({ value: "2026-10-02T08:00:00.000Z", source: "inferred" }); | |
| 130 | expect(session.resetsAt).toEqual({ value: "2026-10-02T13:00:00.000Z", source: "inferred" }); | |
| 131 | expect(session.used).toEqual({ value: 1.75, source: "inferred" }); | |
| 132 | expect(session.runs).toBe(2); | |
| 133 | expect(session.tokensIn).toBe(2000); | |
| 134 | expect(session.cap).toBeNull(); | |
| 135 | expect(session.remaining).toBeNull(); | |
| 136 | expect(session.crewRemaining).toBeNull(); | |
| 137 | ||
| 138 | // The week opened with the 03:00 run and holds all three: 5.75. | |
| 139 | const week = win(sub, now, "week"); | |
| 140 | expect(week.startsAt).toEqual({ value: "2026-10-02T03:00:00.000Z", source: "inferred" }); | |
| 141 | expect(week.resetsAt).toEqual({ value: "2026-10-09T03:00:00.000Z", source: "inferred" }); | |
| 142 | expect(week.used.value).toBe(5.75); | |
| 143 | ||
| 144 | // At 13:00 the 5-hour window has closed and nothing has opened another. | |
| 145 | const later = win(sub, "2026-10-02T13:00:00Z", "5h"); | |
| 146 | expect(later.startsAt).toBeNull(); | |
| 147 | expect(later.resetsAt).toBeNull(); | |
| 148 | expect(later.used).toEqual({ value: 0, source: "inferred" }); | |
| 149 | }); | |
| 150 | ||
| 151 | test("the provider's refusal is read: full until its reset, and the ledger then gives the cap", () => { | |
| 152 | const sub = account(); | |
| 153 | expect(setHeadroom(app.db, sub, 20)).toBe(true); | |
| 154 | spend(sub, "2026-10-02T08:00:00Z", 30, 2); // before the window the provider names | |
| 155 | spend(sub, "2026-10-02T09:30:00Z", 30, 3); | |
| 156 | spend(sub, "2026-10-02T10:30:00Z", 20, 2); // the run that was refused at 11:00 | |
| 157 | noteLimit(app.db, sub, { | |
| 158 | window: "5h", | |
| 159 | resetsAt: "2026-10-02T14:00:00.000Z", | |
| 160 | observedAt: "2026-10-02T11:00:00.000Z", | |
| 161 | said: "You've hit your session limit · resets 2pm (UTC)", | |
| 162 | }); | |
| 163 | ||
| 164 | // At 12:00: the window is 09:00–14:00 on the provider's word. In it | |
| 165 | // the crew had spent 3 + 2 = 5 when it was refused: the cap. Remaining | |
| 166 | // is 0, read; 20% of 5 = 1 is kept for people. | |
| 167 | const full = win(sub, "2026-10-02T12:00:00Z", "5h"); | |
| 168 | expect(full.startsAt).toEqual({ value: "2026-10-02T09:00:00.000Z", source: "read" }); | |
| 169 | expect(full.resetsAt).toEqual({ value: "2026-10-02T14:00:00.000Z", source: "read" }); | |
| 170 | expect(full.used).toEqual({ value: 5, source: "inferred" }); | |
| 171 | expect(full.cap).toEqual({ value: 5, source: "inferred" }); | |
| 172 | expect(full.remaining).toEqual({ value: 0, source: "read" }); | |
| 173 | expect(full.kept).toEqual({ value: 1, source: "inferred" }); | |
| 174 | expect(full.crewRemaining).toEqual({ value: 0, source: "read" }); | |
| 175 | expect(full.said).toContain("session limit"); | |
| 176 | ||
| 177 | // At 15:00 the reset has passed and nothing has run since: no window | |
| 178 | // open, the cap still known, everything but the 20% the crew's. | |
| 179 | const after = win(sub, "2026-10-02T15:00:00Z", "5h"); | |
| 180 | expect(after.startsAt).toBeNull(); | |
| 181 | expect(after.cap).toEqual({ value: 5, source: "inferred" }); | |
| 182 | expect(after.remaining).toEqual({ value: 5, source: "inferred" }); | |
| 183 | expect(after.crewRemaining).toEqual({ value: 4, source: "inferred" }); | |
| 184 | ||
| 185 | // A run at 14:30 for $1.20 opens the next window, 14:30–19:30: | |
| 186 | // remaining 5 − 1.2 = 3.8, the crew's 3.8 − 1 = 2.8. | |
| 187 | spend(sub, "2026-10-02T14:30:00Z", 10, 1.2); | |
| 188 | const next = win(sub, "2026-10-02T15:00:00Z", "5h"); | |
| 189 | expect(next.startsAt).toEqual({ value: "2026-10-02T14:30:00.000Z", source: "inferred" }); | |
| 190 | expect(next.resetsAt).toEqual({ value: "2026-10-02T19:30:00.000Z", source: "inferred" }); | |
| 191 | expect(next.used.value).toBe(1.2); | |
| 192 | expect(next.remaining).toEqual({ value: 3.8, source: "inferred" }); | |
| 193 | expect(next.crewRemaining).toEqual({ value: 2.8, source: "inferred" }); | |
| 194 | }); | |
| 195 | ||
| 196 | test("a weekly reset once read repeats every seven days", () => { | |
| 197 | const sub = account(); | |
| 198 | spend(sub, "2026-09-25T10:00:00Z", 60, 30); // in the week the provider closed | |
| 199 | spend(sub, "2026-09-29T10:00:00Z", 60, 6); // in the week after | |
| 200 | spend(sub, "2026-10-02T09:00:00Z", 60, 4.5); | |
| 201 | noteLimit(app.db, sub, { | |
| 202 | window: "week", | |
| 203 | resetsAt: "2026-09-28T02:00:00.000Z", | |
| 204 | observedAt: "2026-09-27T20:00:00.000Z", | |
| 205 | said: "You've hit your weekly limit · resets Sep 27 at 9pm (America/Chicago)", | |
| 206 | }); | |
| 207 | // Sep 28 02:00 + 7 d = Oct 5 02:00, the first reset after Oct 2 12:00. | |
| 208 | // Used since Sep 28 02:00: 6 + 4.5. The cap is the week to Sep 27 20:00: 30. | |
| 209 | const week = win(sub, "2026-10-02T12:00:00Z", "week"); | |
| 210 | expect(week.startsAt).toEqual({ value: "2026-09-28T02:00:00.000Z", source: "inferred" }); | |
| 211 | expect(week.resetsAt).toEqual({ value: "2026-10-05T02:00:00.000Z", source: "inferred" }); | |
| 212 | expect(week.used.value).toBe(10.5); | |
| 213 | expect(week.cap).toEqual({ value: 30, source: "inferred" }); | |
| 214 | expect(week.remaining).toEqual({ value: 19.5, source: "inferred" }); | |
| 215 | // Exactly at a projected reset, the next week begins. | |
| 216 | const edge = win(sub, "2026-10-05T02:00:00Z", "week"); | |
| 217 | expect(edge.startsAt!.value).toBe("2026-10-05T02:00:00.000Z"); | |
| 218 | expect(edge.used.value).toBe(0); | |
| 219 | }); | |
| 220 | ||
| 221 | test("the newest word for a window replaces the last; a removed account has none", () => { | |
| 222 | const sub = account(); | |
| 223 | const said = (resetsAt: string) => | |
| 224 | noteLimit(app.db, sub, { | |
| 225 | window: "5h", | |
| 226 | resetsAt, | |
| 227 | observedAt: "2026-10-02T11:00:00.000Z", | |
| 228 | said: "x".repeat(500), | |
| 229 | }); | |
| 230 | said("2026-10-02T13:00:00.000Z"); | |
| 231 | said("2026-10-02T14:00:00.000Z"); | |
| 232 | const w = win(sub, "2026-10-02T12:00:00Z", "5h"); | |
| 233 | expect(w.resetsAt!.value).toBe("2026-10-02T14:00:00.000Z"); | |
| 234 | expect(w.said!.length).toBe(160); | |
| 235 | expect(setHeadroom(app.db, sub, 91)).toBe(false); | |
| 236 | expect(setHeadroom(app.db, sub, -1)).toBe(false); | |
| 237 | expect(getHeadroom(app.db, sub)).toBe(0); | |
| 238 | expect(deleteSubscription(app.db, sub)).toBe(true); | |
| 239 | expect(standing(app.db, sub, new Date())).toBeUndefined(); | |
| 240 | expect(setHeadroom(app.db, sub, 10)).toBe(false); | |
| 241 | // A note for an account that is not there is dropped, not kept. | |
| 242 | said("2026-10-02T15:00:00.000Z"); | |
| 243 | expect( | |
| 244 | app.db | |
| 245 | .query<{ n: number }, [string]>( | |
| 246 | "SELECT COUNT(*) AS n FROM subscription_limit WHERE subscription_id = ?1", | |
| 247 | ) | |
| 248 | .get(sub)!.n, | |
| 249 | ).toBe(0); | |
| 250 | }); | |
| 251 | }); |
test/subscriptions.test.ts +40
| @@ -1,3 +1,4 @@ | ||
| 1 | import { getHeadroom } from "../src/core/standing.ts"; | |
| 1 | 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 2 | 3 | import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs"; |
| 3 | 4 | import { mkdtemp, rm } from "node:fs/promises"; |
| @@ -505,6 +506,36 @@ describe("the pages", () => { | ||
| 505 | 506 | expect(html).toContain("@acme"); |
| 506 | 507 | }); |
| 507 | 508 | |
| 509 | test("an admin keeps a share of each window for people, and sees each window", async () => { | |
| 510 | const work = listSubscriptions(app.db)[0]!; | |
| 511 | let html = await get("/settings/subscriptions"); | |
| 512 | expect(html).toContain(`action="/settings/subscriptions/${work.id}/headroom"`); | |
| 513 | expect(html).toContain("5-hour: ~$0.00 used · opens on next use"); | |
| 514 | const kept = await post(`/settings/subscriptions/${work.id}/headroom`, { | |
| 515 | headroom: "25", | |
| 516 | }); | |
| 517 | expect(kept.status).toBe(303); | |
| 518 | expect(getHeadroom(app.db, work.id)).toBe(25); | |
| 519 | for (const bad of ["91", "-5", "ten", ""]) { | |
| 520 | const refused = await post(`/settings/subscriptions/${work.id}/headroom`, { | |
| 521 | headroom: bad, | |
| 522 | }); | |
| 523 | expect(refused.status).toBe(303); | |
| 524 | } | |
| 525 | expect(getHeadroom(app.db, work.id)).toBe(25); | |
| 526 | html = await get("/settings/subscriptions"); | |
| 527 | expect(html).toContain('value="25"'); | |
| 528 | // Without the page's own origin, nothing changes. | |
| 529 | const forged = await fetch(`${base}/settings/subscriptions/${work.id}/headroom`, { | |
| 530 | method: "POST", | |
| 531 | headers: { cookie, origin: "https://elsewhere.example" }, | |
| 532 | body: new URLSearchParams({ headroom: "0" }), | |
| 533 | redirect: "manual", | |
| 534 | }); | |
| 535 | expect(forged.status).toBe(403); | |
| 536 | expect(getHeadroom(app.db, work.id)).toBe(25); | |
| 537 | }); | |
| 538 | ||
| 508 | 539 | test("nobody but an admin reaches the pool", async () => { |
| 509 | 540 | const signed = await fetch(`${base}/signup`, { |
| 510 | 541 | method: "POST", |
| @@ -528,5 +559,14 @@ describe("the pages", () => { | ||
| 528 | 559 | }); |
| 529 | 560 | expect(posted.status).toBe(404); |
| 530 | 561 | expect(listSubscriptions(app.db)).toHaveLength(1); |
| 562 | const work = listSubscriptions(app.db)[0]!; | |
| 563 | const headroom = await fetch(`${base}/settings/subscriptions/${work.id}/headroom`, { | |
| 564 | method: "POST", | |
| 565 | headers: { cookie: guest, origin: base }, | |
| 566 | body: new URLSearchParams({ headroom: "0" }), | |
| 567 | redirect: "manual", | |
| 568 | }); | |
| 569 | expect(headroom.status).toBe(404); | |
| 570 | expect(getHeadroom(app.db, work.id)).toBe(25); | |
| 531 | 571 | }); |
| 532 | 572 | }); |
test/usage.test.ts +23
| @@ -428,6 +428,25 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW | ||
| 428 | 428 | expect(body.recent[0].subscription).toBe("work"); |
| 429 | 429 | expect(JSON.stringify(body)).not.toContain(KEY); |
| 430 | 430 | |
| 431 | // Each account's standing rides along. The personal plan's week is full | |
| 432 | // on the provider's word, until the reset it named; the work key's | |
| 433 | // windows are inferred from the one run it paid for. | |
| 434 | expect(body.standing.map((s: any) => s.subscription)).toEqual([ | |
| 435 | "personal", | |
| 436 | "work", | |
| 437 | ]); | |
| 438 | const [mine, theirs] = body.standing; | |
| 439 | const week = mine.windows.find((w: any) => w.window === "week"); | |
| 440 | expect(week.resetsAt.source).toBe("read"); | |
| 441 | expect(week.resetsAt.value.slice(5, 16)).toBe("09-26T21:00"); | |
| 442 | expect(week.remaining).toEqual({ value: 0, source: "read" }); | |
| 443 | expect(week.said).toContain("weekly limit"); | |
| 444 | const session = theirs.windows.find((w: any) => w.window === "5h"); | |
| 445 | expect(session.used).toEqual({ value: 0.0375, source: "inferred" }); | |
| 446 | expect(session.startsAt.source).toBe("inferred"); | |
| 447 | expect(session.cap).toBeNull(); | |
| 448 | expect(session.tokensIn).toBe(32_120); | |
| 449 | ||
| 431 | 450 | // A window that is not 1–90 days is refused, not guessed at. |
| 432 | 451 | const bad = await fetch(`${url}?days=365`, { |
| 433 | 452 | headers: { authorization: `Bearer ${owner}` }, |
| @@ -465,6 +484,10 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW | ||
| 465 | 484 | const told = await mcp(owner, { repo: "alice/garden", days: 7 }); |
| 466 | 485 | const value = JSON.parse(told.result.content[0].text); |
| 467 | 486 | expect(value.total).toEqual(body.total); |
| 487 | expect(value.standing.map((s: any) => s.subscription)).toEqual([ | |
| 488 | "personal", | |
| 489 | "work", | |
| 490 | ]); | |
| 468 | 491 | const no = await mcp(reader, { repo: "alice/garden" }); |
| 469 | 492 | expect(no.result.isError).toBe(true); |
| 470 | 493 | expect(no.result.content[0].text).toContain("forbidden"); |
new above
offline