Public: anyone can read this; the crew can change it.

What the tokens bought, by pillar: tokenReturns groups by pillar and /board/returns shows it #1757

Merged opened this

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

Merged

scout/forge:scout/1594-what-the-tokens-bought-by-pillar-tokenre → main 1 commit fork

  • Merged. scout/1594-what-the-tokens-bought-by-pillar-tokenre was merged into main as db4b895.

Review

Sign in to review this change.
3 files changed +151 −1
src/core/returns.ts +89
@@ -15,7 +15,9 @@
1515 */
1616
1717 import type { Database } from "bun:sqlite";
18 import { NO_PILLAR, pillarsOf, servedBy } from "./pillars.ts";
1819 import { closesFromBody } from "./pulls.ts";
20 import { getReposByIds } from "./repos.ts";
1921
2022 export interface ReturnsWindow {
2123 readonly since: string;
@@ -42,6 +44,19 @@ export interface CostlyIssue extends Money {
4244 readonly outcome: Outcome;
4345 }
4446
47 /** What one pillar's work cost and came to; "no pillar" is the work that serves none. */
48 export interface PillarReturn extends Money {
49 readonly pillar: string;
50 /** Distinct changes that merged for this pillar's runs. */
51 readonly changes: number;
52 /** Dollars on work that landed. */
53 readonly landedUsd: number;
54 /** Dollars on work that landed, over those changes; 0 when none landed. */
55 readonly perChange: number;
56 readonly closed: Money;
57 readonly open: Money;
58 }
59
4560 export interface Returns {
4661 readonly window: ReturnsWindow;
4762 /** Every agent run in the window. */
@@ -72,6 +87,8 @@ export interface Returns {
7287 readonly usd: { readonly median: number; readonly p90: number };
7388 readonly minutes: { readonly median: number; readonly p90: number };
7489 };
90 /** The same dollars by the pillar each run's issue serves: pillars in order, no pillar last. */
91 readonly byPillar: readonly PillarReturn[];
7592 /** The issues that cost the most, with what came of each. */
7693 readonly costliest: readonly CostlyIssue[];
7794 }
@@ -206,6 +223,43 @@ export function tokenReturns(
206223 issues.set(row.id, row);
207224 }
208225
226 // Which pillar each issue serves, per repository: one read of the plan each.
227 const repoById = getReposByIds(db, repoIds);
228 const order: string[] = [];
229 const served = new Map<string, string>();
230 for (const repo of repoById.values()) {
231 const set = pillarsOf(db, repo);
232 for (const [id, name] of servedBy(db, set)) served.set(id, name);
233 for (const pillar of set.pillars)
234 if (!order.includes(pillar.name)) order.push(pillar.name);
235 if ((set.gate !== undefined || set.later !== undefined) && !order.includes("Later"))
236 order.push("Later");
237 }
238 const byNumber = db.query<{ id: string }, [string, number]>(
239 "SELECT id FROM issue WHERE repo_id = ?1 AND number = ?2",
240 );
241 /** The pillar a run's issue serves; a pull takes the first pillar of what it closes. */
242 const pillarOfIssue = (issue: IssueRow | undefined): string => {
243 if (issue === undefined) return NO_PILLAR;
244 const own = served.get(issue.id);
245 if (own !== undefined || issue.kind !== "pull") return own ?? NO_PILLAR;
246 for (const k of closesFromBody(pullById.get(issue.id)?.body ?? "")) {
247 const target = byNumber.get(issue.repo_id, k);
248 const name = target === null ? undefined : served.get(target.id);
249 if (name !== undefined) return name;
250 }
251 return NO_PILLAR;
252 };
253 interface Tally {
254 runs: number;
255 usd: number;
256 landedUsd: number;
257 changes: Set<string>;
258 closed: { runs: number; usd: number };
259 open: { runs: number; usd: number };
260 }
261 const tallies = new Map<string, Tally>();
262
209263 /** The change a run worked toward: the merged pull if any of its candidates merged, else what is left. */
210264 const changeOf = (r: RunRow): { merged?: PullRow; candidates: PullRow[] } => {
211265 const issue = r.issue_id === null ? undefined : issues.get(r.issue_id);
@@ -273,6 +327,24 @@ export function tokenReturns(
273327 add(failures, reasonOf(r.summary), usd);
274328 } else if (r.state === "cancelled") add(nothing, "cancelled", usd);
275329 }
330 const tally = tallies.get(pillarOfIssue(issue)) ?? {
331 runs: 0,
332 usd: 0,
333 landedUsd: 0,
334 changes: new Set<string>(),
335 closed: { runs: 0, usd: 0 },
336 open: { runs: 0, usd: 0 },
337 };
338 tally.runs++;
339 tally.usd += usd;
340 if (outcome === "landed") {
341 tally.landedUsd += usd;
342 tally.changes.add(change.merged!.id);
343 } else {
344 tally[outcome].runs++;
345 tally[outcome].usd += usd;
346 }
347 tallies.set(pillarOfIssue(issue), tally);
276348 if (issue !== undefined) {
277349 const m = perIssue.get(issue.id) ?? { runs: 0, usd: 0, outcome };
278350 m.runs++;
@@ -354,6 +426,23 @@ export function tokenReturns(
354426 ),
355427 },
356428 },
429 byPillar: [...order, NO_PILLAR].flatMap((pillar) => {
430 const t = tallies.get(pillar);
431 return t === undefined
432 ? []
433 : [
434 {
435 pillar,
436 ...money(t),
437 changes: t.changes.size,
438 landedUsd: cents(t.landedUsd),
439 perChange:
440 t.changes.size === 0 ? 0 : cents(t.landedUsd / t.changes.size),
441 closed: money(t.closed),
442 open: money(t.open),
443 },
444 ];
445 }),
357446 costliest: [...perIssue]
358447 .sort((a, b) => b[1].usd - a[1].usd)
359448 .slice(0, TOP_ISSUES)
src/web/pages/returns.ts +16
@@ -94,6 +94,22 @@ export function returnsPage(ctx: Ctx): Response {
9494 ],
9595 { head: html`<h2>What did not land, and why</h2>` },
9696 )}
97 ${r.byPillar.length === 0
98 ? null
99 : listCard(
100 r.byPillar.map((b) =>
101 row({
102 title: b.pillar,
103 meta: html`${runs(b)} · ${b.changes}
104 ${b.changes === 1 ? "change" : "changes"} landed${b.changes === 0
105 ? ""
106 : ` · ${usd(b.perChange)} a change`} ·
107 ${usd(b.closed.usd)} closed with nothing · ${usd(b.open.usd)} still open`,
108 trail: html`<b>${usd(b.usd)}</b>`,
109 }),
110 ),
111 { head: html`<h2 id="returns-by-pillar">By pillar</h2>` },
112 )}
97113 ${listCard(
98114 r.costliest.map((c) => {
99115 const repo = repos.get(c.repoId);
test/returns.test.ts +46 −1
@@ -14,9 +14,10 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts";
1414 import { openIssue, setIssueState } from "../src/actions/issues.ts";
1515 import { openPull } from "../src/actions/pulls.ts";
1616 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
17 import { addDependency } from "../src/core/dependencies.ts";
1718 import { getIssueById, type Issue } from "../src/core/issues.ts";
1819 import type { Repo } from "../src/core/repos.ts";
19 import { reasonOf, tokenReturns } from "../src/core/returns.ts";
20 import { reasonOf, tokenReturns, type PillarReturn } from "../src/core/returns.ts";
2021 import { createRun } from "../src/core/runs.ts";
2122 import { createToken } from "../src/core/tokens.ts";
2223 import { createUser, getUserByName, type User } from "../src/core/users.ts";
@@ -391,4 +392,48 @@ describe("what the tokens bought", () => {
391392 expect(r.landedNothing.open).toEqual({ runs: 2, usd: 0.8 });
392393 expect(r.landedNothing.closed).toEqual({ runs: 2, usd: 2.4 });
393394 });
395
396 test("by pillar: dollars, landed changes, closed and open add up; a reader gets none", async () => {
397 const byTitle = (t: string): Issue =>
398 getIssueById(
399 app.db,
400 app.db.query<{ id: string }, [string]>("SELECT id FROM issue WHERE title = ?1").get(t)!.id,
401 )!;
402 for (const [umbrella, children] of [
403 ["P1 Ship: it", ["Landed work", "Declined work"]],
404 ["P2 Tidy: up", ["Dropped work", "Open work"]],
405 ] as const) {
406 const made = openIssue(app, alice, repo, { title: umbrella });
407 if (!made.ok) throw new Error(String(made.error));
408 for (const c of children)
409 addDependency(app.db, made.value, byTitle(c), alice.id);
410 }
411 const now = new Date(Date.now() + 1000);
412 const r = tokenReturns(app.db, [repo.id], {
413 since: new Date(now.getTime() - 7 * 86_400_000).toISOString(),
414 until: now.toISOString(),
415 });
416 expect(r.byPillar.map((b) => b.pillar)).toEqual(["P1 Ship", "P2 Tidy", "no pillar"]);
417 const [p1, p2, none] = r.byPillar as [PillarReturn, PillarReturn, PillarReturn];
418 expect([p1.runs, p1.usd, p1.changes, p1.perChange]).toEqual([3, 3.5, 1, 1.5]);
419 expect(p1.closed).toEqual({ runs: 1, usd: 2 });
420 expect(p1.open).toEqual({ runs: 0, usd: 0 });
421 expect([p2.runs, p2.usd, p2.changes, p2.perChange]).toEqual([2, 0.5, 0, 0]);
422 expect(p2.closed).toEqual({ runs: 1, usd: 0.4 });
423 expect(p2.open).toEqual({ runs: 1, usd: 0.1 });
424 expect(none.usd).toBe(0.7);
425 expect(r.byPillar.reduce((sum, b) => sum + b.usd, 0)).toBeCloseTo(r.spent.usd, 2);
426
427 const page = await (await get("/board/returns", { cookie: aliceCookie })).text();
428 expect(page).toContain("By pillar");
429 expect(page).toContain("P1 Ship");
430 const bobs = (await (await get("/api/v1/returns", { token: bobRw })).json()) as {
431 byPillar: unknown[];
432 };
433 expect(bobs.byPillar).toEqual([]);
434 const theirs = await (await get("/board/returns", { cookie: bobCookie })).text();
435 expect(theirs).not.toContain("By pillar");
436 expect(theirs).not.toContain("P1 Ship");
437 expect((await get("/board/returns")).status).not.toBe(200);
438 });
394439 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs