Public: anyone can read this; the crew can change it.
This week's runs and spend come from one query, not the last 200 rows #116
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/105-this-week-s-runs-and-spend-come-from-one → main 1 commit fork
3 files
changed +166
−8
src/core/runs.ts +26
| @@ -567,6 +567,32 @@ export function agentTotals(db: Database): Map<string, AgentTotals> { | ||
| 567 | 567 | ); |
| 568 | 568 | } |
| 569 | 569 | |
| 570 | /** One repository's agent runs over a window: what "this week" is made of. */ | |
| 571 | export interface AgentRunTotals { | |
| 572 | readonly runs: number; | |
| 573 | readonly spentUsd: number; | |
| 574 | } | |
| 575 | ||
| 576 | /** | |
| 577 | * How many agent runs a repository has had since an instant, and what they | |
| 578 | * cost — counted by the database, not by listing rows and adding them up in | |
| 579 | * JavaScript. `SUM` over no rows is `NULL`, so a quiet week reads `0`, and | |
| 580 | * the float dollars are summed once here and rounded once where printed. | |
| 581 | */ | |
| 582 | export function agentRunTotals( | |
| 583 | db: Database, | |
| 584 | repoId: string, | |
| 585 | since: string, | |
| 586 | ): AgentRunTotals { | |
| 587 | const row = db | |
| 588 | .query<{ runs: number; spent: number | null }, [string, string]>( | |
| 589 | `SELECT COUNT(*) AS runs, SUM(cost_usd) AS spent FROM run | |
| 590 | WHERE repo_id = ?1 AND kind = 'agent' AND created_at >= ?2`, | |
| 591 | ) | |
| 592 | .get(repoId, since); | |
| 593 | return { runs: row?.runs ?? 0, spentUsd: row?.spent ?? 0 }; | |
| 594 | } | |
| 595 | ||
| 570 | 596 | /** The newest agent run of each repository, in one query. */ |
| 571 | 597 | export function latestAgentRunsForRepos( |
| 572 | 598 | db: Database, |
src/web/studio/app.ts +8 −8
| @@ -14,6 +14,7 @@ import { getIssueById, listIssues, type Issue } from "../../core/issues.ts"; | ||
| 14 | 14 | import { listPulls, openPullsClosing } from "../../core/pulls.ts"; |
| 15 | 15 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 16 | 16 | import { |
| 17 | agentRunTotals, | |
| 17 | 18 | latestRunsForIssues, |
| 18 | 19 | listRuns, |
| 19 | 20 | previewLatency, |
| @@ -125,13 +126,12 @@ export async function studioApp( | ||
| 125 | 126 | state: "closed", |
| 126 | 127 | limit: 100, |
| 127 | 128 | }).filter((p) => p.mergedAt !== null && p.mergedAt >= week).length; |
| 128 | // The rest is spend, worked out only for a viewer who is going to see it. | |
| 129 | const weekRuns = ownerFacts | |
| 130 | ? listRuns(db, repo.id, { kind: "agent", limit: 200 }).filter( | |
| 131 | (r) => r.createdAt >= week, | |
| 132 | ) | |
| 133 | : []; | |
| 134 | const spent = weekRuns.reduce((s, r) => s + r.costUsd, 0); | |
| 129 | // The rest is spend, worked out only for a viewer who is going to see it — | |
| 130 | // counted by the database over the whole week, not off a page of rows. | |
| 131 | const weekTotals = ownerFacts | |
| 132 | ? agentRunTotals(db, repo.id, week) | |
| 133 | : { runs: 0, spentUsd: 0 }; | |
| 134 | const spent = weekTotals.spentUsd; | |
| 135 | 135 | const ledgerWeek = ownerFacts ? decisionStats(db, repo.id, week) : []; |
| 136 | 136 | const weekLatency = ownerFacts |
| 137 | 137 | ? previewLatency(db, repo.id, week) |
| @@ -330,7 +330,7 @@ export async function studioApp( | ||
| 330 | 330 | <dt>Shipped</dt> |
| 331 | 331 | <dd>${shipped} change${shipped === 1 ? "" : "s"}</dd> |
| 332 | 332 | <dt>Runs</dt> |
| 333 | <dd>${weekRuns.length}</dd> | |
| 333 | <dd>${weekTotals.runs}</dd> | |
| 334 | 334 | <dt>Preview</dt> |
| 335 | 335 | <dd>${previewLatencyWords(weekLatency)}</dd> |
| 336 | 336 | <dt>Spent</dt> |
test/studio.test.ts +132
| @@ -32,6 +32,7 @@ import { recordLook } from "../src/core/looks.ts"; | ||
| 32 | 32 | import { getPull, type Pull } from "../src/core/pulls.ts"; |
| 33 | 33 | import { setDirectionIssue, type Repo } from "../src/core/repos.ts"; |
| 34 | 34 | import { |
| 35 | agentRunTotals, | |
| 35 | 36 | claimRun, |
| 36 | 37 | createRun, |
| 37 | 38 | finishRun, |
| @@ -3145,3 +3146,134 @@ describe("the studio's own words for how fast a change reaches its preview", () | ||
| 3145 | 3146 | expect(one).not.toContain("reaches its preview in"); |
| 3146 | 3147 | }); |
| 3147 | 3148 | }); |
| 3149 | ||
| 3150 | describe("this week's runs and spend are counted by the database, not read off a page of runs", () => { | |
| 3151 | let dataDir: string; | |
| 3152 | let app: App; | |
| 3153 | let servers: Servers; | |
| 3154 | let base: string; | |
| 3155 | let alice: User; | |
| 3156 | let busy: Repo; | |
| 3157 | let ownerCookie: string; | |
| 3158 | ||
| 3159 | /** How many agent runs this week — more than `listRuns` would ever hand back. */ | |
| 3160 | const RUNS = 250; | |
| 3161 | /** What each of them cost, in the float dollars the column holds. */ | |
| 3162 | const COST = 0.13; | |
| 3163 | ||
| 3164 | beforeAll(async () => { | |
| 3165 | dataDir = await mkdtemp(join(tmpdir(), "forge-week-totals-")); | |
| 3166 | app = createApp({ dataDir, port: 0 }); | |
| 3167 | servers = await serve(app); | |
| 3168 | base = servers.url; | |
| 3169 | const a = await createUser(app.db, { | |
| 3170 | username: "alice", | |
| 3171 | email: "[email protected]", | |
| 3172 | password: "a-good-passphrase", | |
| 3173 | adminIfFirst: true, | |
| 3174 | }); | |
| 3175 | if (!a.ok) throw new Error(a.error); | |
| 3176 | alice = a.value; | |
| 3177 | ownerCookie = ( | |
| 3178 | ( | |
| 3179 | await fetch(`${base}/signin`, { | |
| 3180 | method: "POST", | |
| 3181 | body: new URLSearchParams({ | |
| 3182 | username: "alice", | |
| 3183 | password: "a-good-passphrase", | |
| 3184 | }), | |
| 3185 | redirect: "manual", | |
| 3186 | }) | |
| 3187 | ).headers.get("set-cookie") ?? "" | |
| 3188 | ).split(";")[0]!; | |
| 3189 | ||
| 3190 | for (const name of ["busy", "quiet"] as const) { | |
| 3191 | const made = await createRepo(app, alice, { | |
| 3192 | name, | |
| 3193 | visibility: "private", | |
| 3194 | description: "", | |
| 3195 | }); | |
| 3196 | if (!made.ok) throw new Error(String(made.error)); | |
| 3197 | const seeded = await commitToBranch(app, alice, made.value, { | |
| 3198 | message: "Start", | |
| 3199 | files: [{ path: "README.md", content: "start\n" }], | |
| 3200 | }); | |
| 3201 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 3202 | const enabled = await enableApp(app, alice, made.value, { slug: name }); | |
| 3203 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 3204 | if (name === "busy") busy = made.value; | |
| 3205 | } | |
| 3206 | ||
| 3207 | const keeper = await createAgent(app, alice, { username: "keeper" }); | |
| 3208 | if (!keeper.ok) throw new Error(String(keeper.error)); | |
| 3209 | expect(addToCrew(app, alice, busy, "keeper").ok).toBe(true); | |
| 3210 | const keeperId = getUserByName(app.db, "keeper")!.id; | |
| 3211 | ||
| 3212 | // 250 agent runs inside the week — past the 200 `listRuns` will return — | |
| 3213 | // and one fat run ten days back that the window must leave out. | |
| 3214 | for (let i = 0; i < RUNS; i++) { | |
| 3215 | const r = createRun(app.db, { | |
| 3216 | repoId: busy.id, | |
| 3217 | kind: "agent", | |
| 3218 | actorId: keeperId, | |
| 3219 | reason: "assigned", | |
| 3220 | }); | |
| 3221 | claimRun(app.db, r.id); | |
| 3222 | finishRun(app.db, r.id, { state: "ok", summary: "did a thing", costUsd: COST }); | |
| 3223 | } | |
| 3224 | const old = createRun(app.db, { | |
| 3225 | repoId: busy.id, | |
| 3226 | kind: "agent", | |
| 3227 | actorId: keeperId, | |
| 3228 | reason: "assigned", | |
| 3229 | }); | |
| 3230 | claimRun(app.db, old.id); | |
| 3231 | finishRun(app.db, old.id, { state: "ok", summary: "last month", costUsd: 99 }); | |
| 3232 | app.db | |
| 3233 | .query("UPDATE run SET created_at = ?2 WHERE id = ?1") | |
| 3234 | .run(old.id, new Date(Date.now() - 10 * 24 * 60 * 60_000).toISOString()); | |
| 3235 | }); | |
| 3236 | ||
| 3237 | afterAll(async () => { | |
| 3238 | servers.stop(); | |
| 3239 | app.close(); | |
| 3240 | await rm(dataDir, { recursive: true, force: true }); | |
| 3241 | }); | |
| 3242 | ||
| 3243 | const get = (path: string) => | |
| 3244 | fetch(`${base}${path}`, { | |
| 3245 | headers: { cookie: `${ownerCookie}; forge_mode=studio` }, | |
| 3246 | redirect: "manual", | |
| 3247 | }); | |
| 3248 | ||
| 3249 | test("the aggregate counts every run in the window, skips the one outside it, and is 0 for a week with none", () => { | |
| 3250 | const week = new Date(Date.now() - 7 * 24 * 60 * 60_000).toISOString(); | |
| 3251 | const totals = agentRunTotals(app.db, busy.id, week); | |
| 3252 | expect(totals.runs).toBe(RUNS); | |
| 3253 | expect(totals.spentUsd).toBeCloseTo(RUNS * COST, 6); | |
| 3254 | ||
| 3255 | // The whole ledger, window opened wide: the old run is there after all. | |
| 3256 | const ever = agentRunTotals(app.db, busy.id, "2000-01-01T00:00:00.000Z"); | |
| 3257 | expect(ever.runs).toBe(RUNS + 1); | |
| 3258 | expect(ever.spentUsd).toBeCloseTo(RUNS * COST + 99, 6); | |
| 3259 | ||
| 3260 | // SUM over no rows is NULL; a repository with no runs reads zero. | |
| 3261 | const none = agentRunTotals(app.db, "no-such-repo", week); | |
| 3262 | expect(none).toEqual({ runs: 0, spentUsd: 0 }); | |
| 3263 | }); | |
| 3264 | ||
| 3265 | test("the app page says 250 runs and the sum of all 250, not the last 200", async () => { | |
| 3266 | const page = await (await get("/alice/busy")).text(); | |
| 3267 | expect(page).toMatch(/Runs<\/dt>\s*<dd>250<\/dd>/); | |
| 3268 | expect(page).toContain(`$${(RUNS * COST).toFixed(2)}`); | |
| 3269 | // What the 200-row read would have said, and the run outside the window. | |
| 3270 | expect(page).not.toContain(`$${(200 * COST).toFixed(2)}`); | |
| 3271 | expect(page).not.toContain("$99.00"); | |
| 3272 | }); | |
| 3273 | ||
| 3274 | test("an app with no runs this week reads 0 and $0.00, never blank", async () => { | |
| 3275 | const page = await (await get("/alice/quiet")).text(); | |
| 3276 | expect(page).toMatch(/Runs<\/dt>\s*<dd>0<\/dd>/); | |
| 3277 | expect(page).toContain("$0.00"); | |
| 3278 | }); | |
| 3279 | }); |
new above
offline