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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1594-what-the-tokens-bought-by-pillar-tokenre → main 1 commit fork
3 files
changed +151
−1
src/core/returns.ts +89
| @@ -15,7 +15,9 @@ | ||
| 15 | 15 | */ |
| 16 | 16 | |
| 17 | 17 | import type { Database } from "bun:sqlite"; |
| 18 | import { NO_PILLAR, pillarsOf, servedBy } from "./pillars.ts"; | |
| 18 | 19 | import { closesFromBody } from "./pulls.ts"; |
| 20 | import { getReposByIds } from "./repos.ts"; | |
| 19 | 21 | |
| 20 | 22 | export interface ReturnsWindow { |
| 21 | 23 | readonly since: string; |
| @@ -42,6 +44,19 @@ export interface CostlyIssue extends Money { | ||
| 42 | 44 | readonly outcome: Outcome; |
| 43 | 45 | } |
| 44 | 46 | |
| 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 | ||
| 45 | 60 | export interface Returns { |
| 46 | 61 | readonly window: ReturnsWindow; |
| 47 | 62 | /** Every agent run in the window. */ |
| @@ -72,6 +87,8 @@ export interface Returns { | ||
| 72 | 87 | readonly usd: { readonly median: number; readonly p90: number }; |
| 73 | 88 | readonly minutes: { readonly median: number; readonly p90: number }; |
| 74 | 89 | }; |
| 90 | /** The same dollars by the pillar each run's issue serves: pillars in order, no pillar last. */ | |
| 91 | readonly byPillar: readonly PillarReturn[]; | |
| 75 | 92 | /** The issues that cost the most, with what came of each. */ |
| 76 | 93 | readonly costliest: readonly CostlyIssue[]; |
| 77 | 94 | } |
| @@ -206,6 +223,43 @@ export function tokenReturns( | ||
| 206 | 223 | issues.set(row.id, row); |
| 207 | 224 | } |
| 208 | 225 | |
| 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 | ||
| 209 | 263 | /** The change a run worked toward: the merged pull if any of its candidates merged, else what is left. */ |
| 210 | 264 | const changeOf = (r: RunRow): { merged?: PullRow; candidates: PullRow[] } => { |
| 211 | 265 | const issue = r.issue_id === null ? undefined : issues.get(r.issue_id); |
| @@ -273,6 +327,24 @@ export function tokenReturns( | ||
| 273 | 327 | add(failures, reasonOf(r.summary), usd); |
| 274 | 328 | } else if (r.state === "cancelled") add(nothing, "cancelled", usd); |
| 275 | 329 | } |
| 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); | |
| 276 | 348 | if (issue !== undefined) { |
| 277 | 349 | const m = perIssue.get(issue.id) ?? { runs: 0, usd: 0, outcome }; |
| 278 | 350 | m.runs++; |
| @@ -354,6 +426,23 @@ export function tokenReturns( | ||
| 354 | 426 | ), |
| 355 | 427 | }, |
| 356 | 428 | }, |
| 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 | }), | |
| 357 | 446 | costliest: [...perIssue] |
| 358 | 447 | .sort((a, b) => b[1].usd - a[1].usd) |
| 359 | 448 | .slice(0, TOP_ISSUES) |
src/web/pages/returns.ts +16
| @@ -94,6 +94,22 @@ export function returnsPage(ctx: Ctx): Response { | ||
| 94 | 94 | ], |
| 95 | 95 | { head: html`<h2>What did not land, and why</h2>` }, |
| 96 | 96 | )} |
| 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 | )} | |
| 97 | 113 | ${listCard( |
| 98 | 114 | r.costliest.map((c) => { |
| 99 | 115 | const repo = repos.get(c.repoId); |
test/returns.test.ts +46 −1
| @@ -14,9 +14,10 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts"; | ||
| 14 | 14 | import { openIssue, setIssueState } from "../src/actions/issues.ts"; |
| 15 | 15 | import { openPull } from "../src/actions/pulls.ts"; |
| 16 | 16 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 17 | import { addDependency } from "../src/core/dependencies.ts"; | |
| 17 | 18 | import { getIssueById, type Issue } from "../src/core/issues.ts"; |
| 18 | 19 | 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"; | |
| 20 | 21 | import { createRun } from "../src/core/runs.ts"; |
| 21 | 22 | import { createToken } from "../src/core/tokens.ts"; |
| 22 | 23 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| @@ -391,4 +392,48 @@ describe("what the tokens bought", () => { | ||
| 391 | 392 | expect(r.landedNothing.open).toEqual({ runs: 2, usd: 0.8 }); |
| 392 | 393 | expect(r.landedNothing.closed).toEqual({ runs: 2, usd: 2.4 }); |
| 393 | 394 | }); |
| 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 | }); | |
| 394 | 439 | }); |
new above
offline