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

Merged opened this

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

Merged

mason/forge:mason/105-this-week-s-runs-and-spend-come-from-one → main 1 commit fork

  • Merged. mason/105-this-week-s-runs-and-spend-come-from-one was merged into main as 9a4f373.

Review

Sign in to review this change.
3 files changed +166 −8
src/core/runs.ts +26
@@ -567,6 +567,32 @@ export function agentTotals(db: Database): Map<string, AgentTotals> {
567567 );
568568 }
569569
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
570596 /** The newest agent run of each repository, in one query. */
571597 export function latestAgentRunsForRepos(
572598 db: Database,
src/web/studio/app.ts +8 −8
@@ -14,6 +14,7 @@ import { getIssueById, listIssues, type Issue } from "../../core/issues.ts";
1414 import { listPulls, openPullsClosing } from "../../core/pulls.ts";
1515 import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
1616 import {
17 agentRunTotals,
1718 latestRunsForIssues,
1819 listRuns,
1920 previewLatency,
@@ -125,13 +126,12 @@ export async function studioApp(
125126 state: "closed",
126127 limit: 100,
127128 }).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;
135135 const ledgerWeek = ownerFacts ? decisionStats(db, repo.id, week) : [];
136136 const weekLatency = ownerFacts
137137 ? previewLatency(db, repo.id, week)
@@ -330,7 +330,7 @@ export async function studioApp(
330330 <dt>Shipped</dt>
331331 <dd>${shipped} change${shipped === 1 ? "" : "s"}</dd>
332332 <dt>Runs</dt>
333 <dd>${weekRuns.length}</dd>
333 <dd>${weekTotals.runs}</dd>
334334 <dt>Preview</dt>
335335 <dd>${previewLatencyWords(weekLatency)}</dd>
336336 <dt>Spent</dt>
test/studio.test.ts +132
@@ -32,6 +32,7 @@ import { recordLook } from "../src/core/looks.ts";
3232 import { getPull, type Pull } from "../src/core/pulls.ts";
3333 import { setDirectionIssue, type Repo } from "../src/core/repos.ts";
3434 import {
35 agentRunTotals,
3536 claimRun,
3637 createRun,
3738 finishRun,
@@ -3145,3 +3146,134 @@ describe("the studio's own words for how fast a change reaches its preview", ()
31453146 expect(one).not.toContain("reaches its preview in");
31463147 });
31473148 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs