Public: anyone can read this; the crew can change it.
The board shows each subscription's window and each app's units per shipped change #381
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/374-the-board-shows-each-subscription-s-wind → main 1 commit fork
9 files
changed +704
−67
docs/STUDIO.md +10 −1
| @@ -374,7 +374,16 @@ changes across your apps, each with the app's production look on a phone. | ||
| 374 | 374 | **Decided** is the last dozen entries in the decision ledger: which issue |
| 375 | 375 | next, ship or show, try again — the answer, how sure the decider was, and |
| 376 | 376 | what came of it. **Today** is one row per app that runs on its own: spend |
| 377 | against the ceiling, runs, shipped, and whether it is paused. Every region | |
| 377 | against the ceiling, runs, shipped, and whether it is paused — plus any | |
| 378 | other app the crew spent on this week — and beneath it the efficiency | |
| 379 | signal: spend per shipped change over the last 7 days (the usage ledger's | |
| 380 | `cost_usd` ÷ pulls merged, `core/efficiency.ts`, every app in one grouped | |
| 381 | query), or "$4.10 spent, nothing shipped" when there is no ratio. A site | |
| 382 | admin also gets a strip of the subscriptions above the regions: each | |
| 383 | account's 5-hour and weekly window — used, left, resets — in the words | |
| 384 | `windowWords` (`studio/words.ts`) gives them, with _inferred_ beside every | |
| 385 | figure the provider did not say (`core/standing.ts`; all the accounts in | |
| 386 | three statements, `standings`). Every region | |
| 378 | 387 | is live — it swaps itself when a matching event lands, and the dot in the |
| 379 | 388 | corner says whether the stream is connected. `?apps=a,b` narrows it to some |
| 380 | 389 | apps. Keys: `f` fullscreen, `1`–`4` one region full-bleed, `0` all. |
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 test/standing.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 test/board-windows.test.ts", | |
| 10 | 10 | "timeout": 1800 |
| 11 | 11 | } |
| 12 | 12 | } |
src/actions/usage.ts +5 −4
| @@ -14,7 +14,7 @@ | ||
| 14 | 14 | 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 | import { standing, type Standing } from "../core/standing.ts"; | |
| 17 | import { standings, type Standing } from "../core/standing.ts"; | |
| 18 | 18 | import { listGrants, listSubscriptions } from "../core/subscriptions.ts"; |
| 19 | 19 | import { |
| 20 | 20 | recentUsage, |
| @@ -136,9 +136,10 @@ export function readUsage( | ||
| 136 | 136 | accounts.add(g.subscriptionId); |
| 137 | 137 | for (const row of bySubscription) |
| 138 | 138 | if (row.key !== null && subscriptions.has(row.key)) accounts.add(row.key); |
| 139 | const standings = [...accounts] | |
| 139 | const read = standings(db, [...accounts], now); | |
| 140 | const standingsOut = [...accounts] | |
| 140 | 141 | .flatMap((id) => { |
| 141 | const s = standing(db, id, now); | |
| 142 | const s = read.get(id); | |
| 142 | 143 | if (s === undefined) return []; |
| 143 | 144 | const { subscriptionId: _, ...rest } = s; |
| 144 | 145 | return [{ subscription: subscriptions.get(id) ?? id, ...rest }]; |
| @@ -162,6 +163,6 @@ export function readUsage( | ||
| 162 | 163 | agent: agentName(r.agentId), |
| 163 | 164 | issue: issueNumber(r.issueId), |
| 164 | 165 | })), |
| 165 | standing: standings, | |
| 166 | standing: standingsOut, | |
| 166 | 167 | }); |
| 167 | 168 | } |
src/core/efficiency.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | /** | |
| 2 | * Efficiency — what an app's crew spent per change it shipped, over a | |
| 3 | * window: the forge's efficiency signal. Spend is the usage ledger's | |
| 4 | * (`core/usage.ts`), so it counts every agent run that ended in the window, | |
| 5 | * shipped or not; a shipped change is a pull merged in the window. | |
| 6 | * | |
| 7 | * Units: US dollars of the provider's list-price equivalent | |
| 8 | * (`usage.cost_usd`), rounded to 1/10 000 of a dollar. | |
| 9 | * | |
| 10 | * perShippedUsd = Σ usage.cost_usd (at ≥ since) ÷ count(merged_at ≥ since) | |
| 11 | * | |
| 12 | * and null when nothing shipped — a ratio over zero changes is not a | |
| 13 | * number, and "all of it" is said in words by the page, not as infinity. | |
| 14 | * | |
| 15 | * Pure data: no authz, no events. | |
| 16 | */ | |
| 17 | ||
| 18 | import type { Database } from "bun:sqlite"; | |
| 19 | ||
| 20 | export interface Efficiency { | |
| 21 | readonly repoId: string; | |
| 22 | /** Ledger lines (agent runs) that ended in the window. */ | |
| 23 | readonly runs: number; | |
| 24 | readonly spentUsd: number; | |
| 25 | /** Pulls merged in the window. */ | |
| 26 | readonly shipped: number; | |
| 27 | /** spentUsd ÷ shipped; null when nothing shipped. */ | |
| 28 | readonly perShippedUsd: number | null; | |
| 29 | } | |
| 30 | ||
| 31 | const money = (n: number): number => Math.round(n * 10_000) / 10_000; | |
| 32 | ||
| 33 | /** | |
| 34 | * Each repository's spend and shipped changes since an instant, in one | |
| 35 | * grouped query. A repository with neither is absent: a quiet app is no | |
| 36 | * row, not a row of zeros. | |
| 37 | */ | |
| 38 | export function efficiencyForRepos( | |
| 39 | db: Database, | |
| 40 | repoIds: readonly string[], | |
| 41 | since: string, | |
| 42 | ): Map<string, Efficiency> { | |
| 43 | const ids = [...new Set(repoIds)].slice(0, 400); | |
| 44 | const out = new Map<string, Efficiency>(); | |
| 45 | if (ids.length === 0) return out; | |
| 46 | const list = ids.map((_, n) => `?${n + 2}`).join(","); | |
| 47 | const rows = db | |
| 48 | .query< | |
| 49 | { repo_id: string; runs: number; spent: number; shipped: number }, | |
| 50 | string[] | |
| 51 | >( | |
| 52 | `SELECT repo_id, SUM(runs) AS runs, SUM(spent) AS spent, SUM(shipped) AS shipped | |
| 53 | FROM ( | |
| 54 | SELECT repo_id, COUNT(*) AS runs, SUM(cost_usd) AS spent, 0 AS shipped | |
| 55 | FROM usage | |
| 56 | WHERE at >= ?1 AND repo_id IN (${list}) | |
| 57 | GROUP BY repo_id | |
| 58 | UNION ALL | |
| 59 | SELECT issue.repo_id, 0, 0, COUNT(*) | |
| 60 | FROM issue JOIN pull ON pull.issue_id = issue.id | |
| 61 | WHERE pull.merged_at IS NOT NULL AND pull.merged_at >= ?1 | |
| 62 | AND issue.repo_id IN (${list}) | |
| 63 | GROUP BY issue.repo_id | |
| 64 | ) | |
| 65 | GROUP BY repo_id`, | |
| 66 | ) | |
| 67 | .all(since, ...ids); | |
| 68 | for (const r of rows) { | |
| 69 | const spentUsd = money(r.spent); | |
| 70 | out.set(r.repo_id, { | |
| 71 | repoId: r.repo_id, | |
| 72 | runs: r.runs, | |
| 73 | spentUsd, | |
| 74 | shipped: r.shipped, | |
| 75 | perShippedUsd: r.shipped > 0 ? money(r.spent / r.shipped) : null, | |
| 76 | }); | |
| 77 | } | |
| 78 | return out; | |
| 79 | } |
src/core/standing.ts +101 −53
| @@ -118,32 +118,6 @@ export function noteLimit( | ||
| 118 | 118 | ); |
| 119 | 119 | } |
| 120 | 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 | 121 | /** A whole percent 0–90 from text; undefined for anything else. */ |
| 148 | 122 | export function headroomPct(text: string): number | undefined { |
| 149 | 123 | if (!/^\d{1,2}$/.test(text.trim())) return undefined; |
| @@ -196,45 +170,119 @@ export function standing( | ||
| 196 | 170 | subscriptionId: string, |
| 197 | 171 | now: Date, |
| 198 | 172 | ): Standing | undefined { |
| 199 | const pct = getHeadroom(db, subscriptionId); | |
| 200 | if (pct === undefined) return undefined; | |
| 201 | const limits = limitsOf(db, subscriptionId); | |
| 173 | return standings(db, [subscriptionId], now).get(subscriptionId); | |
| 174 | } | |
| 175 | ||
| 176 | const placeholders = (n: number, from = 1): string => | |
| 177 | Array.from({ length: n }, (_, i) => `?${i + from}`).join(","); | |
| 178 | ||
| 179 | /** | |
| 180 | * Several accounts' standing at `now`, keyed by id, in three statements | |
| 181 | * whatever their number: the headroom, the provider's last words, and one | |
| 182 | * ledger read from the earliest any of them needs. An unknown id is absent. | |
| 183 | */ | |
| 184 | export function standings( | |
| 185 | db: Database, | |
| 186 | subscriptionIds: readonly string[], | |
| 187 | now: Date, | |
| 188 | ): Map<string, Standing> { | |
| 189 | const ids = [...new Set(subscriptionIds)].slice(0, 400); | |
| 190 | const out = new Map<string, Standing>(); | |
| 191 | if (ids.length === 0) return out; | |
| 192 | const pcts = new Map( | |
| 193 | db | |
| 194 | .query<{ id: string; headroom_pct: number }, string[]>( | |
| 195 | `SELECT id, headroom_pct FROM subscription WHERE id IN (${placeholders(ids.length)})`, | |
| 196 | ) | |
| 197 | .all(...ids) | |
| 198 | .map((r) => [r.id, r.headroom_pct]), | |
| 199 | ); | |
| 200 | if (pcts.size === 0) return out; | |
| 201 | const known = [...pcts.keys()]; | |
| 202 | ||
| 203 | const limits = new Map<string, Map<LimitWindow, Limit>>(); | |
| 204 | for (const r of db | |
| 205 | .query< | |
| 206 | { | |
| 207 | subscription_id: string; | |
| 208 | span: LimitWindow; | |
| 209 | resets_at: string; | |
| 210 | observed_at: string; | |
| 211 | said: string; | |
| 212 | }, | |
| 213 | string[] | |
| 214 | >( | |
| 215 | `SELECT subscription_id, span, resets_at, observed_at, said FROM subscription_limit | |
| 216 | WHERE subscription_id IN (${placeholders(known.length)})`, | |
| 217 | ) | |
| 218 | .all(...known)) { | |
| 219 | const of = limits.get(r.subscription_id) ?? new Map<LimitWindow, Limit>(); | |
| 220 | of.set(r.span, { | |
| 221 | window: r.span, | |
| 222 | resetsAt: r.resets_at, | |
| 223 | observedAt: r.observed_at, | |
| 224 | said: r.said, | |
| 225 | }); | |
| 226 | limits.set(r.subscription_id, of); | |
| 227 | } | |
| 228 | ||
| 202 | 229 | const t = now.getTime(); |
| 203 | 230 | // Two weeks back holds every window's chain; a refusal's window is read |
| 204 | 231 | // too, so long as it opened within the last five weeks. |
| 205 | 232 | 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], | |
| 233 | const sinceOf = (id: string): number => | |
| 234 | Math.max( | |
| 235 | floor, | |
| 236 | Math.min( | |
| 237 | t - 2 * WINDOW_MS.week, | |
| 238 | ...[...(limits.get(id)?.values() ?? [])].map( | |
| 239 | (l) => Date.parse(l.resetsAt) - WINDOW_MS[l.window], | |
| 240 | ), | |
| 212 | 241 | ), |
| 213 | ), | |
| 214 | ); | |
| 215 | const spends: Spend[] = db | |
| 242 | ); | |
| 243 | const since = new Map(known.map((id) => [id, sinceOf(id)])); | |
| 244 | const earliest = Math.min(...since.values()); | |
| 245 | ||
| 246 | const spends = new Map<string, Spend[]>(known.map((id) => [id, []])); | |
| 247 | for (const r of db | |
| 216 | 248 | .query< |
| 217 | { at: string; wall_ms: number; cost_usd: number; tokens_in: number; tokens_out: number }, | |
| 218 | [string, string] | |
| 249 | { | |
| 250 | subscription_id: string; | |
| 251 | at: string; | |
| 252 | wall_ms: number; | |
| 253 | cost_usd: number; | |
| 254 | tokens_in: number; | |
| 255 | tokens_out: number; | |
| 256 | }, | |
| 257 | string[] | |
| 219 | 258 | >( |
| 220 | `SELECT at, wall_ms, cost_usd, tokens_in, tokens_out FROM usage | |
| 221 | WHERE subscription_id = ?1 AND at >= ?2`, | |
| 259 | `SELECT subscription_id, at, wall_ms, cost_usd, tokens_in, tokens_out FROM usage | |
| 260 | WHERE subscription_id IN (${placeholders(known.length, 2)}) AND at >= ?1`, | |
| 222 | 261 | ) |
| 223 | .all(subscriptionId, iso(since)) | |
| 224 | .map((r) => ({ | |
| 262 | .all(iso(earliest), ...known)) { | |
| 263 | // Each account reads from its own floor, as if read alone. | |
| 264 | if (r.at < iso(since.get(r.subscription_id)!)) continue; | |
| 265 | spends.get(r.subscription_id)!.push({ | |
| 225 | 266 | start: Date.parse(r.at) - r.wall_ms, |
| 226 | 267 | costUsd: r.cost_usd, |
| 227 | 268 | tokensIn: r.tokens_in, |
| 228 | 269 | 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 | }; | |
| 270 | }); | |
| 271 | } | |
| 272 | ||
| 273 | for (const id of known) { | |
| 274 | const pct = pcts.get(id)!; | |
| 275 | const mine = spends.get(id)!.sort((a, b) => a.start - b.start); | |
| 276 | const of = limits.get(id); | |
| 277 | out.set(id, { | |
| 278 | subscriptionId: id, | |
| 279 | headroomPct: pct, | |
| 280 | windows: WINDOWS.map((w) => | |
| 281 | windowStanding(w, of?.get(w), mine, pct, t, since.get(id)!), | |
| 282 | ), | |
| 283 | }); | |
| 284 | } | |
| 285 | return out; | |
| 238 | 286 | } |
| 239 | 287 | |
| 240 | 288 | function windowStanding( |
src/web/pages/board.ts +106 −6
| @@ -1,8 +1,10 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The board: a live wall of the crews at work, for a second monitor. Four |
| 3 | 3 | * regions — what is being worked on now, what landed, what the decider |
| 4 | * decided, and today's spend against each app's ceiling — each a live | |
| 5 | * region that swaps itself when a matching event lands. Nothing on it | |
| 4 | * decided, and today's spend against each app's ceiling with what a shipped | |
| 5 | * change cost over the week — each a live region that swaps itself when a | |
| 6 | * matching event lands. A site admin also gets two strips above them: the | |
| 7 | * forge's health, and each subscription's windows. Nothing on it | |
| 6 | 8 | * needs a click, and everything on it links somewhere. It reads the same |
| 7 | 9 | * tables as the studio and adds no state of its own. |
| 8 | 10 | */ |
| @@ -14,6 +16,9 @@ import { | ||
| 14 | 16 | type DecisionKind, |
| 15 | 17 | } from "../../core/decisions.ts"; |
| 16 | 18 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 19 | import { efficiencyForRepos } from "../../core/efficiency.ts"; | |
| 20 | import { standings, WINDOW_MS } from "../../core/standing.ts"; | |
| 21 | import { listSubscriptions } from "../../core/subscriptions.ts"; | |
| 17 | 22 | import { listApps } from "../../core/apps.ts"; |
| 18 | 23 | import { vitals, type Vitals } from "../../kernel/vitals.ts"; |
| 19 | 24 | import { getIssue, getIssuesByIds, type Issue } from "../../core/issues.ts"; |
| @@ -34,6 +39,11 @@ import { html, when, type Html } from "../html.ts"; | ||
| 34 | 39 | import { page } from "../layout.ts"; |
| 35 | 40 | import { avatar, icon, time } from "../ui.ts"; |
| 36 | 41 | import { voiceFor } from "../studio/voice.ts"; |
| 42 | import { | |
| 43 | perShippedWords, | |
| 44 | windowWords, | |
| 45 | type FigureWords, | |
| 46 | } from "../studio/words.ts"; | |
| 37 | 47 | import { lookUrl, thumbLook } from "./looks.ts"; |
| 38 | 48 | |
| 39 | 49 | // ---- words ---- |
| @@ -175,7 +185,7 @@ export function boardPage(ctx: Ctx): Response { | ||
| 175 | 185 | ></span> |
| 176 | 186 | <time class="board-clock" id="board-clock"></time> |
| 177 | 187 | </header> |
| 178 | ${forgeStrip(ctx)} | |
| 188 | ${forgeStrip(ctx)} ${windowsStrip(ctx)} | |
| 179 | 189 | <div class="board-grid"> |
| 180 | 190 | ${working(ctx, scope)} ${landed(ctx, scope)} ${decided(ctx, scope)} |
| 181 | 191 | ${today(ctx, scope)} |
| @@ -264,6 +274,70 @@ function forgeStrip(ctx: Ctx): Html | null { | ||
| 264 | 274 | </div>`; |
| 265 | 275 | } |
| 266 | 276 | |
| 277 | // ---- the subscriptions' windows ---- | |
| 278 | ||
| 279 | /** Figures in a row, `inferred` said once when every one is, else on each. */ | |
| 280 | function figures(parts: readonly FigureWords[]): Html { | |
| 281 | const all = parts.every((p) => p.inferred); | |
| 282 | const tag = html`<i class="board-inferred">inferred</i>`; | |
| 283 | return html`${parts.map( | |
| 284 | (p, n) => | |
| 285 | html`${n > 0 ? " · " : null}<span | |
| 286 | >${p.text}${when(!all && p.inferred, () => html` ${tag}`)}</span | |
| 287 | >`, | |
| 288 | )}${when(all, () => html` ${tag}`)}`; | |
| 289 | } | |
| 290 | ||
| 291 | /** | |
| 292 | * Each subscription's 5-hour and weekly window — used, left, resets — for a | |
| 293 | * site admin only: the pool is theirs (`/settings/subscriptions`). Three | |
| 294 | * statements however many accounts (`standings`). A live region of its own. | |
| 295 | */ | |
| 296 | function windowsStrip(ctx: Ctx): Html | null { | |
| 297 | if (!ctx.auth.user?.isAdmin) return null; | |
| 298 | const db = ctx.app.db; | |
| 299 | const pool = listSubscriptions(db); | |
| 300 | if (pool.length === 0) return null; | |
| 301 | const now = new Date(); | |
| 302 | const all = standings( | |
| 303 | db, | |
| 304 | pool.map((s) => s.id), | |
| 305 | now, | |
| 306 | ); | |
| 307 | return html`<div | |
| 308 | class="board-windows" | |
| 309 | id="windows" | |
| 310 | data-live="run.*" | |
| 311 | aria-label="Subscription windows" | |
| 312 | > | |
| 313 | ${pool.map((s) => { | |
| 314 | const st = all.get(s.id); | |
| 315 | if (st === undefined) return null; | |
| 316 | return html`<a | |
| 317 | class="board-sub" | |
| 318 | href="/settings/subscriptions" | |
| 319 | data-key="sub:${s.id}" | |
| 320 | > | |
| 321 | <span class="board-sub-name">${s.name}</span> | |
| 322 | ${st.windows.map((w) => { | |
| 323 | const words = windowWords(w); | |
| 324 | return html`<span class="board-window" data-window="${w.window}" | |
| 325 | ><span class="board-window-name">${words.name}</span> | |
| 326 | ${words.usedPct === null | |
| 327 | ? null | |
| 328 | : html`<span class="board-bar" | |
| 329 | ><span class="board-fill" data-w="${words.usedPct}"></span | |
| 330 | ></span>`} | |
| 331 | <span class="board-window-nums" | |
| 332 | >${figures([words.used, words.remaining, words.resets])}</span | |
| 333 | ></span | |
| 334 | >`; | |
| 335 | })} | |
| 336 | </a>`; | |
| 337 | })} | |
| 338 | </div>`; | |
| 339 | } | |
| 340 | ||
| 267 | 341 | // ---- working now ---- |
| 268 | 342 | |
| 269 | 343 | function working(ctx: Ctx, scope: BoardScope): Html { |
| @@ -479,10 +553,21 @@ function decided(ctx: Ctx, scope: BoardScope): Html { | ||
| 479 | 553 | |
| 480 | 554 | function today(ctx: Ctx, scope: BoardScope): Html { |
| 481 | 555 | const db = ctx.app.db; |
| 482 | const since = startOfTodayUtc(new Date()).toISOString(); | |
| 483 | // Today's spend against the ceiling is the owner's own dashboard. | |
| 556 | const now = new Date(); | |
| 557 | const since = startOfTodayUtc(now).toISOString(); | |
| 558 | // Spend per shipped change over the weekly window, every app in one read. | |
| 559 | const week = efficiencyForRepos( | |
| 560 | db, | |
| 561 | [...scope.ownerFacts], | |
| 562 | new Date(now.getTime() - WINDOW_MS.week).toISOString(), | |
| 563 | ); | |
| 564 | // Today's spend against the ceiling is the owner's own dashboard: each | |
| 565 | // app that runs on its own, and any other the crew spent on this week. | |
| 484 | 566 | const rows = scope.repos |
| 485 | .filter((r) => r.autonomy !== "off" && scope.ownerFacts.has(r.id)) | |
| 567 | .filter( | |
| 568 | (r) => | |
| 569 | scope.ownerFacts.has(r.id) && (r.autonomy !== "off" || week.has(r.id)), | |
| 570 | ) | |
| 486 | 571 | .map((repo) => { |
| 487 | 572 | const s = autonomyStatus(ctx.app, repo); |
| 488 | 573 | const runs = listRuns(db, repo.id, { kind: "agent", limit: 200 }).filter( |
| @@ -519,6 +604,9 @@ function today(ctx: Ctx, scope: BoardScope): Html { | ||
| 519 | 604 | <span><b>${runs}</b> run${runs === 1 ? "" : "s"}</span> |
| 520 | 605 | <span><b>${shipped}</b> shipped</span> |
| 521 | 606 | </span> |
| 607 | <span class="board-app-week" | |
| 608 | >${perShippedWords(week.get(repo.id))} this week</span | |
| 609 | > | |
| 522 | 610 | <span class="board-spend" |
| 523 | 611 | ><span class="board-fill" data-w="${pct(share)}"></span |
| 524 | 612 | ></span> |
| @@ -643,6 +731,18 @@ ${widths} | ||
| 643 | 731 | .board-spend { grid-column: 1 / -1; display: block; height: 6px; border-radius: 999px; background: var(--muted); overflow: hidden; } |
| 644 | 732 | .board-app[data-paused="ceiling"] .board-fill { background: var(--attn); } |
| 645 | 733 | |
| 734 | /* Windows: each subscription's 5-hour and weekly window, admin only. */ | |
| 735 | .board-windows { display: flex; flex-wrap: wrap; gap: 8px 12px; padding: 8px var(--pad) 0; font-size: 13px; } | |
| 736 | .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); } | |
| 737 | .board-sub:hover { border-color: var(--border-strong); color: var(--muted-fg); } | |
| 738 | .board-sub-name { font-weight: 600; color: var(--fg); } | |
| 739 | .board-window { display: inline-flex; align-items: center; gap: 8px; font-variant-numeric: tabular-nums; } | |
| 740 | .board-window-name { color: var(--faint); } | |
| 741 | .board-window .board-bar { width: 56px; } | |
| 742 | .board-window[data-window] .board-fill[data-w="100"] { background: var(--attn); } | |
| 743 | .board-inferred { font-style: normal; font-size: .85em; color: var(--faint); border: 1px dashed var(--border); border-radius: 4px; padding: 0 4px; } | |
| 744 | .board-app-week { grid-column: 1 / -1; font-size: .875em; color: var(--muted-fg); font-variant-numeric: tabular-nums; } | |
| 745 | ||
| 646 | 746 | /* Wide: four regions fill the window, each scrolling on its own. */ |
| 647 | 747 | @media (min-width: 1100px) { |
| 648 | 748 | .board { flex: none; height: 100dvh; min-height: 0; } |
src/web/pages/subscriptions.ts +7 −2
| @@ -22,7 +22,7 @@ import { | ||
| 22 | 22 | HEADROOM_MAX, |
| 23 | 23 | headroomPct, |
| 24 | 24 | setHeadroom, |
| 25 | standing, | |
| 25 | standings, | |
| 26 | 26 | type Figure, |
| 27 | 27 | type WindowStanding, |
| 28 | 28 | } from "../../core/standing.ts"; |
| @@ -99,6 +99,11 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 99 | 99 | const now = new Date(); |
| 100 | 100 | const pool = listSubscriptions(ctx.app.db); |
| 101 | 101 | const places = listGrantPlaces(ctx.app.db); |
| 102 | const standingOf = standings( | |
| 103 | ctx.app.db, | |
| 104 | pool.map((s) => s.id), | |
| 105 | now, | |
| 106 | ); | |
| 102 | 107 | |
| 103 | 108 | const list = |
| 104 | 109 | pool.length === 0 |
| @@ -110,7 +115,7 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 110 | 115 | : listCard( |
| 111 | 116 | pool.map((s) => { |
| 112 | 117 | const rests = resting(s, now); |
| 113 | const st = standing(ctx.app.db, s.id, now); | |
| 118 | const st = standingOf.get(s.id); | |
| 114 | 119 | const by = |
| 115 | 120 | s.createdBy === null |
| 116 | 121 | ? undefined |
src/web/studio/words.ts +68
| @@ -12,6 +12,9 @@ import type { Database } from "bun:sqlite"; | ||
| 12 | 12 | import { ancestorsOf } from "../../git/merge.ts"; |
| 13 | 13 | import type { AppsRuntime, EnvStatus } from "../../apps/runtime.ts"; |
| 14 | 14 | import type { AppRecord } from "../../core/apps.ts"; |
| 15 | import type { Efficiency } from "../../core/efficiency.ts"; | |
| 16 | import type { WindowStanding } from "../../core/standing.ts"; | |
| 17 | import { whenText } from "../../crew/limits.ts"; | |
| 15 | 18 | import { getIssueById } from "../../core/issues.ts"; |
| 16 | 19 | import { getPull, pullForBranch, type Pull } from "../../core/pulls.ts"; |
| 17 | 20 | import { |
| @@ -346,3 +349,68 @@ export function wordBadge(word: Word, id?: string): Html { | ||
| 346 | 349 | >${word === "working" ? icon("loader") : null}${word}</span |
| 347 | 350 | >`; |
| 348 | 351 | } |
| 352 | ||
| 353 | // ---- what a subscription has left, and what a change costs ---- | |
| 354 | ||
| 355 | /** One figure as a person reads it, and whether the forge worked it out | |
| 356 | * (from the usage ledger) rather than the provider saying it. */ | |
| 357 | export interface FigureWords { | |
| 358 | readonly text: string; | |
| 359 | readonly inferred: boolean; | |
| 360 | } | |
| 361 | ||
| 362 | /** A subscription's window (`core/standing.ts`) in words: what is used, | |
| 363 | * what is left, when it resets. Money in US dollars of list price. */ | |
| 364 | export interface WindowWords { | |
| 365 | readonly name: string; | |
| 366 | readonly used: FigureWords; | |
| 367 | readonly remaining: FigureWords; | |
| 368 | readonly resets: FigureWords; | |
| 369 | /** Whole percent of the cap used, 0–100; null while no cap is known. */ | |
| 370 | readonly usedPct: number | null; | |
| 371 | } | |
| 372 | ||
| 373 | const dollars = (n: number): string => `$${n.toFixed(2)}`; | |
| 374 | ||
| 375 | export function windowWords(w: WindowStanding): WindowWords { | |
| 376 | const full = w.remaining?.source === "read" && w.remaining.value === 0; | |
| 377 | const usedPct = full | |
| 378 | ? 100 | |
| 379 | : w.cap === null || w.cap.value <= 0 | |
| 380 | ? null | |
| 381 | : Math.max(0, Math.min(100, Math.round((w.used.value / w.cap.value) * 100))); | |
| 382 | return { | |
| 383 | name: w.window === "5h" ? "5-hour window" : "weekly window", | |
| 384 | used: { | |
| 385 | text: `${dollars(w.used.value)} used`, | |
| 386 | inferred: w.used.source === "inferred", | |
| 387 | }, | |
| 388 | remaining: | |
| 389 | w.remaining === null | |
| 390 | ? { text: "cap not known yet", inferred: false } | |
| 391 | : full | |
| 392 | ? { text: "full", inferred: false } | |
| 393 | : { | |
| 394 | text: `${dollars(w.remaining.value)} left`, | |
| 395 | inferred: w.remaining.source === "inferred", | |
| 396 | }, | |
| 397 | resets: | |
| 398 | w.resetsAt === null | |
| 399 | ? { text: "opens on next use", inferred: false } | |
| 400 | : { | |
| 401 | text: `resets ${whenText(new Date(w.resetsAt.value))}`, | |
| 402 | inferred: w.resetsAt.source === "inferred", | |
| 403 | }, | |
| 404 | usedPct, | |
| 405 | }; | |
| 406 | } | |
| 407 | ||
| 408 | /** An app's spend per shipped change (`core/efficiency.ts`) in a few words: | |
| 409 | * `$1.37 per shipped change`, or what is true when there is no ratio. */ | |
| 410 | export function perShippedWords(e: Efficiency | undefined): string { | |
| 411 | if (e === undefined || (e.spentUsd === 0 && e.shipped === 0)) | |
| 412 | return "nothing spent or shipped"; | |
| 413 | if (e.perShippedUsd === null) | |
| 414 | return `${dollars(e.spentUsd)} spent, nothing shipped`; | |
| 415 | return `${dollars(e.perShippedUsd)} per shipped change`; | |
| 416 | } |
test/board-windows.test.ts added +327
| @@ -0,0 +1,327 @@ | ||
| 1 | /** | |
| 2 | * The board's two new readings: each subscription's windows (used, left, | |
| 3 | * resets — and *inferred* wherever the provider did not say), and each | |
| 4 | * app's spend per shipped change over the week. Numbers are checked against | |
| 5 | * a hand calculation; statements are counted, so neither grows a query per | |
| 6 | * row (`test/counts.test.ts`'s rule). | |
| 7 | */ | |
| 8 | ||
| 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 10 | import type { Database } from "bun:sqlite"; | |
| 11 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 12 | import { tmpdir } from "node:os"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { efficiencyForRepos } from "../src/core/efficiency.ts"; | |
| 16 | import { createRun } from "../src/core/runs.ts"; | |
| 17 | import { | |
| 18 | noteLimit, | |
| 19 | standing, | |
| 20 | standings, | |
| 21 | type WindowStanding, | |
| 22 | } from "../src/core/standing.ts"; | |
| 23 | import { addSubscription } from "../src/core/subscriptions.ts"; | |
| 24 | import { recordUsage } from "../src/core/usage.ts"; | |
| 25 | import { createUser, type User } from "../src/core/users.ts"; | |
| 26 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 27 | import { id } from "../src/kernel/id.ts"; | |
| 28 | import { perShippedWords, windowWords } from "../src/web/studio/words.ts"; | |
| 29 | ||
| 30 | /** Count the statements a block of work runs, through `db.query`. */ | |
| 31 | async function statements(db: Database, work: () => Promise<void> | void) { | |
| 32 | const original = db.query.bind(db); | |
| 33 | let count = 0; | |
| 34 | (db as unknown as { query: unknown }).query = (sql: string) => { | |
| 35 | count++; | |
| 36 | return original(sql); | |
| 37 | }; | |
| 38 | try { | |
| 39 | await work(); | |
| 40 | } finally { | |
| 41 | (db as unknown as { query: unknown }).query = original; | |
| 42 | } | |
| 43 | return count; | |
| 44 | } | |
| 45 | ||
| 46 | describe("the words for a window", () => { | |
| 47 | const base: WindowStanding = { | |
| 48 | window: "5h", | |
| 49 | startsAt: { value: "2026-10-02T10:00:00.000Z", source: "inferred" }, | |
| 50 | resetsAt: { value: "2026-10-02T15:00:00.000Z", source: "inferred" }, | |
| 51 | cap: { value: 10, source: "inferred" }, | |
| 52 | used: { value: 3.2, source: "inferred" }, | |
| 53 | remaining: { value: 6.8, source: "inferred" }, | |
| 54 | kept: { value: 0, source: "inferred" }, | |
| 55 | crewRemaining: { value: 6.8, source: "inferred" }, | |
| 56 | runs: 2, | |
| 57 | tokensIn: 0, | |
| 58 | tokensOut: 0, | |
| 59 | said: null, | |
| 60 | }; | |
| 61 | ||
| 62 | test("worked out from the ledger, every figure says inferred", () => { | |
| 63 | const w = windowWords(base); | |
| 64 | expect(w.name).toBe("5-hour window"); | |
| 65 | expect(w.used).toEqual({ text: "$3.20 used", inferred: true }); | |
| 66 | expect(w.remaining).toEqual({ text: "$6.80 left", inferred: true }); | |
| 67 | expect(w.resets).toEqual({ text: "resets 15:00 UTC", inferred: true }); | |
| 68 | // 3.20 ÷ 10.00 = 32 %. | |
| 69 | expect(w.usedPct).toBe(32); | |
| 70 | }); | |
| 71 | ||
| 72 | test("a window the provider called full reads full, its reset as said", () => { | |
| 73 | const w = windowWords({ | |
| 74 | ...base, | |
| 75 | window: "week", | |
| 76 | resetsAt: { value: "2026-10-05T03:00:00.000Z", source: "read" }, | |
| 77 | remaining: { value: 0, source: "read" }, | |
| 78 | }); | |
| 79 | expect(w.name).toBe("weekly window"); | |
| 80 | expect(w.remaining).toEqual({ text: "full", inferred: false }); | |
| 81 | expect(w.resets).toEqual({ text: "resets 03:00 UTC", inferred: false }); | |
| 82 | expect(w.usedPct).toBe(100); | |
| 83 | }); | |
| 84 | ||
| 85 | test("before the first refusal there is no cap, and no bar", () => { | |
| 86 | const w = windowWords({ ...base, cap: null, remaining: null }); | |
| 87 | expect(w.remaining).toEqual({ text: "cap not known yet", inferred: false }); | |
| 88 | expect(w.usedPct).toBeNull(); | |
| 89 | const closed = windowWords({ ...base, startsAt: null, resetsAt: null }); | |
| 90 | expect(closed.resets.text).toBe("opens on next use"); | |
| 91 | }); | |
| 92 | ||
| 93 | test("spend per shipped change, and what is true without a ratio", () => { | |
| 94 | const e = { repoId: "r", runs: 2, spentUsd: 3.5, shipped: 2 }; | |
| 95 | expect(perShippedWords({ ...e, perShippedUsd: 1.75 })).toBe( | |
| 96 | "$1.75 per shipped change", | |
| 97 | ); | |
| 98 | expect(perShippedWords({ ...e, shipped: 0, perShippedUsd: null })).toBe( | |
| 99 | "$3.50 spent, nothing shipped", | |
| 100 | ); | |
| 101 | expect(perShippedWords(undefined)).toBe("nothing spent or shipped"); | |
| 102 | }); | |
| 103 | }); | |
| 104 | ||
| 105 | describe("efficiency and standing, grouped", () => { | |
| 106 | let dataDir: string; | |
| 107 | let app: App; | |
| 108 | let alice: User; | |
| 109 | let servers: Servers; | |
| 110 | let cookie = ""; | |
| 111 | let bobCookie = ""; | |
| 112 | const repos: Record<string, string> = {}; | |
| 113 | ||
| 114 | const signIn = async (username: string) => | |
| 115 | ( | |
| 116 | ( | |
| 117 | await fetch(`${servers.url}/signin`, { | |
| 118 | method: "POST", | |
| 119 | body: new URLSearchParams({ username, password: "a-good-passphrase" }), | |
| 120 | redirect: "manual", | |
| 121 | }) | |
| 122 | ).headers.get("set-cookie") ?? "" | |
| 123 | ).split(";")[0]!; | |
| 124 | ||
| 125 | const seedRepo = (name: string, autonomy = "off") => { | |
| 126 | const repoId = id(); | |
| 127 | app.db | |
| 128 | .query( | |
| 129 | `INSERT INTO repo (id, owner_id, name, visibility, description, default_branch) | |
| 130 | VALUES (?1, ?2, ?3, 'private', '', 'main')`, | |
| 131 | ) | |
| 132 | .run(repoId, alice.id, name); | |
| 133 | app.db | |
| 134 | .query( | |
| 135 | `INSERT INTO app (id, repo_id, slug, access) VALUES (?1, ?2, ?3, 'private')`, | |
| 136 | ) | |
| 137 | .run(id(), repoId, name); | |
| 138 | app.db.query("UPDATE repo SET autonomy = ?2 WHERE id = ?1").run(repoId, autonomy); | |
| 139 | repos[name] = repoId; | |
| 140 | return repoId; | |
| 141 | }; | |
| 142 | let number = 0; | |
| 143 | const merged = (repoId: string, at: string | null) => { | |
| 144 | const issueId = id(); | |
| 145 | app.db | |
| 146 | .query( | |
| 147 | `INSERT INTO issue (id, repo_id, number, kind, title, author_id, state) | |
| 148 | VALUES (?1, ?2, ?3, 'pull', 'a change', ?4, ?5)`, | |
| 149 | ) | |
| 150 | .run(issueId, repoId, ++number, alice.id, at === null ? "open" : "closed"); | |
| 151 | app.db | |
| 152 | .query( | |
| 153 | `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) | |
| 154 | VALUES (?1, 'main', 'b', 'x', 'y', ?2)`, | |
| 155 | ) | |
| 156 | .run(issueId, at); | |
| 157 | }; | |
| 158 | const spend = (repoId: string, at: string, usd: number, sub: string | null = null) => | |
| 159 | recordUsage(app.db, { | |
| 160 | runId: createRun(app.db, { repoId, kind: "agent" }).id, | |
| 161 | repoId, | |
| 162 | issueId: null, | |
| 163 | agentId: null, | |
| 164 | subscriptionId: sub, | |
| 165 | tokensIn: 100, | |
| 166 | tokensOut: 10, | |
| 167 | costUsd: usd, | |
| 168 | wallMs: 60_000, | |
| 169 | outcome: "ok", | |
| 170 | at, | |
| 171 | }); | |
| 172 | let subs = 0; | |
| 173 | const account = () => { | |
| 174 | const added = addSubscription(app.db, dataDir, { | |
| 175 | name: `account ${++subs}`, | |
| 176 | token: `sk-ant-oat01-board-windows-${subs}`, | |
| 177 | }); | |
| 178 | if (!added.ok) throw new Error(added.error); | |
| 179 | return added.value.id; | |
| 180 | }; | |
| 181 | ||
| 182 | beforeAll(async () => { | |
| 183 | dataDir = await mkdtemp(join(tmpdir(), "forge-board-windows-")); | |
| 184 | app = createApp({ dataDir, port: 0 }); | |
| 185 | servers = await serve(app); | |
| 186 | for (const [username, adminIfFirst] of [ | |
| 187 | ["alice", true], | |
| 188 | ["bob", false], | |
| 189 | ] as const) { | |
| 190 | const u = await createUser(app.db, { | |
| 191 | username, | |
| 192 | email: `${username}@forge.local`, | |
| 193 | password: "a-good-passphrase", | |
| 194 | adminIfFirst, | |
| 195 | }); | |
| 196 | if (!u.ok) throw new Error(u.error); | |
| 197 | if (username === "alice") alice = u.value; | |
| 198 | } | |
| 199 | cookie = await signIn("alice"); | |
| 200 | bobCookie = await signIn("bob"); | |
| 201 | }); | |
| 202 | afterAll(async () => { | |
| 203 | servers.stop(); | |
| 204 | app.close(); | |
| 205 | await rm(dataDir, { recursive: true, force: true }); | |
| 206 | }); | |
| 207 | ||
| 208 | test("spend per shipped change matches the hand sum, in one statement", async () => { | |
| 209 | const one = seedRepo("one"); | |
| 210 | const two = seedRepo("two"); | |
| 211 | const three = seedRepo("three"); | |
| 212 | const quiet = seedRepo("quiet"); | |
| 213 | const since = "2026-09-25T00:00:00.000Z"; | |
| 214 | // one: $1.00 + $2.50 in the window ($10 before it is not); two merges | |
| 215 | // in it, one before, one still open. 3.50 ÷ 2 = 1.75. | |
| 216 | spend(one, "2026-09-26T12:00:00.000Z", 1); | |
| 217 | spend(one, "2026-09-30T12:00:00.000Z", 2.5); | |
| 218 | spend(one, "2026-09-20T12:00:00.000Z", 10); | |
| 219 | merged(one, "2026-09-27T00:00:00.000Z"); | |
| 220 | merged(one, "2026-10-01T00:00:00.000Z"); | |
| 221 | merged(one, "2026-09-01T00:00:00.000Z"); | |
| 222 | merged(one, null); | |
| 223 | // two: spent, shipped nothing. three: shipped on no ledger spend. | |
| 224 | spend(two, "2026-09-28T00:00:00.000Z", 0.4); | |
| 225 | merged(three, "2026-09-29T00:00:00.000Z"); | |
| 226 | ||
| 227 | let got = new Map(); | |
| 228 | const n = await statements(app.db, () => { | |
| 229 | got = efficiencyForRepos(app.db, [one, two, three, quiet], since); | |
| 230 | }); | |
| 231 | expect(n).toBe(1); | |
| 232 | expect(got.get(one)).toEqual({ | |
| 233 | repoId: one, | |
| 234 | runs: 2, | |
| 235 | spentUsd: 3.5, | |
| 236 | shipped: 2, | |
| 237 | perShippedUsd: 1.75, | |
| 238 | }); | |
| 239 | expect(got.get(two)).toEqual({ | |
| 240 | repoId: two, | |
| 241 | runs: 1, | |
| 242 | spentUsd: 0.4, | |
| 243 | shipped: 0, | |
| 244 | perShippedUsd: null, | |
| 245 | }); | |
| 246 | expect(got.get(three)?.perShippedUsd).toBe(0); | |
| 247 | expect(got.has(quiet)).toBe(false); | |
| 248 | expect(efficiencyForRepos(app.db, [], since).size).toBe(0); | |
| 249 | }); | |
| 250 | ||
| 251 | test("the batch agrees with each account read alone, in three statements however many", async () => { | |
| 252 | const repoId = repos.one!; | |
| 253 | const now = new Date("2026-10-02T12:00:00.000Z"); | |
| 254 | const a = account(); | |
| 255 | const b = account(); | |
| 256 | const c = account(); | |
| 257 | spend(repoId, "2026-10-02T10:00:00.000Z", 1.25, a); | |
| 258 | spend(repoId, "2026-09-30T10:00:00.000Z", 4, a); | |
| 259 | spend(repoId, "2026-10-02T11:00:00.000Z", 0.75, b); | |
| 260 | noteLimit(app.db, b, { | |
| 261 | window: "5h", | |
| 262 | resetsAt: "2026-10-02T14:00:00.000Z", | |
| 263 | observedAt: "2026-10-02T11:30:00.000Z", | |
| 264 | said: "You've hit your session limit", | |
| 265 | }); | |
| 266 | for (const ids of [[a], [a, b, c, "nope"]]) { | |
| 267 | const n = await statements(app.db, () => { | |
| 268 | standings(app.db, ids, now); | |
| 269 | }); | |
| 270 | expect(n).toBe(3); | |
| 271 | } | |
| 272 | const all = standings(app.db, [a, b, c, "nope"], now); | |
| 273 | expect(all.has("nope")).toBe(false); | |
| 274 | for (const s of [a, b, c]) expect(all.get(s)).toEqual(standing(app.db, s, now)!); | |
| 275 | // a: the 5-hour window opened with the 10:00 run — $1.25; the week, the | |
| 276 | // chain from 30 Sep — $1.25 + $4.00. | |
| 277 | expect(all.get(a)!.windows.map((w) => w.used.value)).toEqual([1.25, 5.25]); | |
| 278 | expect(all.get(b)!.windows[0]!.remaining).toEqual({ value: 0, source: "read" }); | |
| 279 | }); | |
| 280 | ||
| 281 | test("the board shows each window to an admin, and each app's efficiency", async () => { | |
| 282 | // Fresh, relative to now: the board reads the week back from the clock. | |
| 283 | const now = Date.now(); | |
| 284 | const ago = (h: number) => new Date(now - h * 3_600_000).toISOString(); | |
| 285 | const live = seedRepo("live", "autonomous"); | |
| 286 | spend(live, ago(30), 3); | |
| 287 | spend(live, ago(2), 1.5); | |
| 288 | merged(live, ago(20)); | |
| 289 | merged(live, ago(1)); | |
| 290 | merged(live, ago(9 * 24)); | |
| 291 | const full = account(); | |
| 292 | spend(live, ago(1), 0.5, full); | |
| 293 | noteLimit(app.db, full, { | |
| 294 | window: "5h", | |
| 295 | resetsAt: new Date(now + 2 * 3_600_000).toISOString(), | |
| 296 | observedAt: ago(0.5), | |
| 297 | said: "You've hit your session limit", | |
| 298 | }); | |
| 299 | ||
| 300 | const board = async (c: string) => | |
| 301 | (await fetch(`${servers.url}/board`, { headers: { cookie: c } })).text(); | |
| 302 | const page = await board(cookie); | |
| 303 | expect(page).toContain('id="windows"'); | |
| 304 | expect(page).toContain("account 1"); | |
| 305 | expect(page).toContain("5-hour window"); | |
| 306 | expect(page).toContain("weekly window"); | |
| 307 | expect(page).toContain('class="board-inferred">inferred</i>'); | |
| 308 | expect(page).toContain(">full<"); | |
| 309 | // ($3.00 + $1.50 + $0.50) ÷ 2 merged this week = $2.50. | |
| 310 | expect(page).toContain("$2.50 per shipped change this week"); | |
| 311 | // An app the crew spent on with autonomy off still reads its ratio. | |
| 312 | expect(page).toContain("$0.40 spent, nothing shipped this week"); | |
| 313 | ||
| 314 | // The pool is an admin's; nobody else gets the strip, or the reads. | |
| 315 | expect(await board(bobCookie)).not.toContain('id="windows"'); | |
| 316 | ||
| 317 | // More accounts cost the board no more statements. | |
| 318 | const before = await statements(app.db, async () => { | |
| 319 | await board(cookie); | |
| 320 | }); | |
| 321 | for (let i = 0; i < 4; i++) spend(live, ago(3), 0.1, account()); | |
| 322 | const after = await statements(app.db, async () => { | |
| 323 | await board(cookie); | |
| 324 | }); | |
| 325 | expect(after).toBe(before); | |
| 326 | }); | |
| 327 | }); |
new above
offline