Public: anyone can read this; the crew can change it.
Cost ledger: a pull shows what it cost — tokens, lane minutes, waiting, ask to live #12330
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12114-cost-ledger-a-pull-shows-what-it-cost-to → main 11 commits fork 5 threads · 5 unresolved
10 files
changed +434
−10
docs/features/cost-ledger.md +13
| @@ -20,6 +20,19 @@ Prints `ok N entries`, or `broken at <file>:<line>: <why>` and exits 1. | ||
| 20 | 20 | |
| 21 | 21 | The tests are `test/cost-ledger.test.ts`, not `test/ledger.test.ts`: that name is the decision ledger's. |
| 22 | 22 | |
| 23 | ## Reading it on a pull and an issue (#12114) | |
| 24 | ||
| 25 | A pull's header, and an issue's, carry one line for people who may see owner facts (writers; nobody else sees it, on the page or in the terminal): | |
| 26 | ||
| 27 | cost: $0.42 tokens · 30 lane-min · 3.0 h waiting · ask→live 5.3 h | |
| 28 | ||
| 29 | - **tokens**: dollars the finished runs recorded. **lane-min**: minutes the runs held a lane, start to finish. **waiting**: hours the runs sat queued before starting. **ask→live**: hours from the ask (the issue the pull closes; for an issue, its own creation) to its change being live in production. | |
| 30 | - A pull's line sums its own finished runs; an issue's sums its own and those of every pull that closes it. A part no run knows is left out, never shown as zero; with nothing to say there is no line. | |
| 31 | - It is read from the finished `run` rows with indexed queries (`src/core/ledger_rollup.ts`), never from the ledger's files, and only when the thing has agent runs, so a page with none pays no extra read. | |
| 32 | - The terminal's pull screen puts it in the head's second row; the issue screen puts it on the cost row. Both call `costLineFor`, the same gate as the page. | |
| 33 | ||
| 34 | Tests: `test/ledger-rollup.test.ts`, `test/tui-issue-cost.test.ts`, `test/tui-pull-cost.test.ts`. | |
| 35 | ||
| 23 | 36 | ## Health: a broken chain is a need |
| 24 | 37 | |
| 25 | 38 | The chain is walked once at boot and then once a day, on the serving thread (`checkLedger` in `src/core/ledger.ts`, run under `during("ledger", …)` from `src/main.ts`; not in read-only mode). The walk logs `ledger: verified in N ms: ok, N entries` or `broken at <file>:<line>: <why>`, and keeps the verdict in memory. |
src/core/ledger_rollup.ts added +137
| @@ -0,0 +1,137 @@ | ||
| 1 | /** | |
| 2 | * The cost ledger rolled up for a person (#12114): what a pull, or an issue | |
| 3 | * and its pulls, cost, as the one line a page carries. | |
| 4 | * | |
| 5 | * The ledger's file (`ledger.ts`) holds one entry per finished run, written | |
| 6 | * from the run row at the moment it ends, so the roll-up reads the same | |
| 7 | * finished rows with one indexed query (`run_issue`, `deploy_pull_issue`) and | |
| 8 | * never opens the files on a request. Parts a run does not know are left out, | |
| 9 | * never zero, as the entries leave them out. | |
| 10 | */ | |
| 11 | ||
| 12 | import type { Database } from "bun:sqlite"; | |
| 13 | import { closesFromBody } from "./pulls.ts"; | |
| 14 | import { canSeeOwnerFacts, type Repo } from "./repos.ts"; | |
| 15 | import type { User } from "./users.ts"; | |
| 16 | ||
| 17 | export interface CostSum { | |
| 18 | /** Dollars of model tokens, when any run carried a cost. */ | |
| 19 | readonly usd?: number; | |
| 20 | /** Minutes the runs held a lane (start to finish). */ | |
| 21 | readonly laneMin?: number; | |
| 22 | /** Hours the runs waited between being queued and starting. */ | |
| 23 | readonly waitingH?: number; | |
| 24 | /** Hours from the ask to its change being live in production. */ | |
| 25 | readonly askToLiveH?: number; | |
| 26 | } | |
| 27 | ||
| 28 | interface Row { | |
| 29 | n: number; | |
| 30 | usd: number | null; | |
| 31 | lane_s: number | null; | |
| 32 | wait_s: number | null; | |
| 33 | live: string | null; | |
| 34 | } | |
| 35 | ||
| 36 | const marks = (n: number): string => Array.from({ length: n }, (_, i) => `?${i + 1}`).join(","); | |
| 37 | ||
| 38 | /** | |
| 39 | * The sum over the finished runs of these issue ids (a pull's id, or an | |
| 40 | * issue's and its pulls'), or `undefined` when none has an entry. | |
| 41 | * `askedAt` is when the ask was made, for `ask→live`. | |
| 42 | */ | |
| 43 | export function costSum(db: Database, ids: readonly string[], askedAt: string): CostSum | undefined { | |
| 44 | if (ids.length === 0) return undefined; | |
| 45 | const m = marks(ids.length); | |
| 46 | const row = db | |
| 47 | .query<Row, string[]>( | |
| 48 | `SELECT COUNT(*) AS n, SUM(cost_usd) AS usd, | |
| 49 | SUM(CASE WHEN started_at IS NOT NULL | |
| 50 | THEN (julianday(finished_at) - julianday(started_at)) * 86400 END) AS lane_s, | |
| 51 | SUM(CASE WHEN started_at IS NOT NULL | |
| 52 | THEN (julianday(started_at) - julianday(created_at)) * 86400 END) AS wait_s, | |
| 53 | (SELECT MIN(r.finished_at) FROM deploy_pull dp JOIN run r ON r.id = dp.run_id | |
| 54 | WHERE dp.issue_id IN (${m})) AS live | |
| 55 | FROM run WHERE issue_id IN (${m}) AND finished_at IS NOT NULL`, | |
| 56 | ) | |
| 57 | .get(...ids); | |
| 58 | if (!row || row.n === 0) return undefined; | |
| 59 | const live = row.live; | |
| 60 | const ask = Date.parse(askedAt); | |
| 61 | const lived = live ? Date.parse(live) : Number.NaN; | |
| 62 | const out: { -readonly [K in keyof CostSum]: CostSum[K] } = {}; | |
| 63 | if (row.usd !== null && row.usd > 0) out.usd = row.usd; | |
| 64 | if (row.lane_s !== null && row.lane_s > 0) out.laneMin = row.lane_s / 60; | |
| 65 | if (row.wait_s !== null && row.wait_s > 0) out.waitingH = row.wait_s / 3600; | |
| 66 | if (Number.isFinite(ask) && Number.isFinite(lived) && lived >= ask) out.askToLiveH = (lived - ask) / 3600000; | |
| 67 | return out; | |
| 68 | } | |
| 69 | ||
| 70 | /** `cost: $0.42 tokens · 31 lane-min · 2.1 h waiting · ask→live 4.3 h`; "" if none known. */ | |
| 71 | export function costLine(sum: CostSum | undefined): string { | |
| 72 | if (sum === undefined) return ""; | |
| 73 | const parts: string[] = []; | |
| 74 | if (sum.usd !== undefined) parts.push(`$${sum.usd.toFixed(2)} tokens`); | |
| 75 | if (sum.laneMin !== undefined) parts.push(`${Math.round(sum.laneMin)} lane-min`); | |
| 76 | if (sum.waitingH !== undefined) parts.push(`${sum.waitingH.toFixed(1)} h waiting`); | |
| 77 | if (sum.askToLiveH !== undefined) parts.push(`ask→live ${sum.askToLiveH.toFixed(1)} h`); | |
| 78 | return parts.length === 0 ? "" : `cost: ${parts.join(" · ")}`; | |
| 79 | } | |
| 80 | ||
| 81 | /** The ids of the pulls in a repository whose description closes issue `number`, newest 200. */ | |
| 82 | export function pullsClosing(db: Database, repoId: string, number: number): string[] { | |
| 83 | const rows = db | |
| 84 | .query<{ id: string; body: string }, [string, string]>( | |
| 85 | `SELECT id, body FROM issue WHERE repo_id = ?1 AND kind = 'pull' AND body LIKE ?2 | |
| 86 | ORDER BY number DESC LIMIT 200`, | |
| 87 | ) | |
| 88 | .all(repoId, `%#${number}%`); | |
| 89 | return rows.filter((r) => closesFromBody(r.body).includes(number)).map((r) => r.id); | |
| 90 | } | |
| 91 | ||
| 92 | /** The earliest creation among the issues a pull closes: when the ask was made. */ | |
| 93 | function askedAtOf(db: Database, pull: { repoId: string; body: string; createdAt: string }): string { | |
| 94 | const numbers = closesFromBody(pull.body); | |
| 95 | if (numbers.length === 0) return pull.createdAt; | |
| 96 | const row = db | |
| 97 | .query<{ at: string | null }, string[]>( | |
| 98 | `SELECT MIN(created_at) AS at FROM issue | |
| 99 | WHERE repo_id = ?1 AND kind = 'issue' AND number IN (${numbers.map((_, i) => `?${i + 2}`).join(",")})`, | |
| 100 | ) | |
| 101 | .get(pull.repoId, ...numbers.map(String)); | |
| 102 | return row?.at ?? pull.createdAt; | |
| 103 | } | |
| 104 | ||
| 105 | export interface CostThing { | |
| 106 | readonly id: string; | |
| 107 | readonly repoId: string; | |
| 108 | readonly number: number; | |
| 109 | readonly createdAt: string; | |
| 110 | readonly body: string; | |
| 111 | } | |
| 112 | ||
| 113 | /** | |
| 114 | * The line for a pull (its own runs; `ask→live` from the issue it closes) or an | |
| 115 | * issue (its runs and those of every pull that closes it), for the one reader | |
| 116 | * who may see owner facts, else "". The page and the terminal both call this. | |
| 117 | * `ownRuns` is what the caller already knows of the thing's own agent runs | |
| 118 | * (the usage read): `false` on a pull means there is nothing to sum, with no | |
| 119 | * further read. | |
| 120 | */ | |
| 121 | export function costLineFor( | |
| 122 | db: Database, | |
| 123 | repo: Repo, | |
| 124 | user: User | undefined, | |
| 125 | thing: CostThing, | |
| 126 | kind: "pull" | "issue", | |
| 127 | ownRuns?: boolean, | |
| 128 | ): string { | |
| 129 | if (!canSeeOwnerFacts(db, repo, user)) return ""; | |
| 130 | if (kind === "pull") { | |
| 131 | if (ownRuns === false) return ""; | |
| 132 | return costLine(costSum(db, [thing.id], askedAtOf(db, thing))); | |
| 133 | } | |
| 134 | const pulls = pullsClosing(db, thing.repoId, thing.number); | |
| 135 | if (ownRuns === false && pulls.length === 0) return ""; | |
| 136 | return costLine(costSum(db, [thing.id, ...pulls], thing.createdAt)); | |
| 137 | } |
src/tui/screens/issue.ts +8 −3
| @@ -41,6 +41,7 @@ import { readPlan } from "../../actions/run_plan.ts"; | ||
| 41 | 41 | import type { IssuePlan } from "../../core/issue_plans.ts"; |
| 42 | 42 | import { shownStates } from "../../runs/state.ts"; |
| 43 | 43 | import { headline, planLines, planTitle } from "./runplan.ts"; |
| 44 | import { costLineFor } from "../../core/ledger_rollup.ts"; | |
| 44 | 45 | import { privateRepoForSealed } from "../../actions/repos.ts"; |
| 45 | 46 | import { confirmIssue } from "../../actions/dependencies.ts"; |
| 46 | 47 | import { isCrew } from "../../core/agents.ts"; |
| @@ -170,6 +171,8 @@ export function issueScreen( | ||
| 170 | 171 | let plan: IssuePlan | undefined; |
| 171 | 172 | /** What the issue and its closing pulls cost, when this viewer may see it. */ |
| 172 | 173 | let cost: IssueCost | null = null; |
| 174 | /** The cost-ledger line (#12114); "" when none or hidden from this viewer. */ | |
| 175 | let ledgerLine = ""; | |
| 173 | 176 | let shown: ReturnType<typeof headline>; |
| 174 | 177 | /** Which issues this one waits for and holds up; a line when any. */ |
| 175 | 178 | let waits: Waits = { blockedBy: [], blocks: [] }; |
| @@ -223,6 +226,8 @@ export function issueScreen( | ||
| 223 | 226 | repo === undefined || issueRow === undefined || !mayReadRuns(f.app, f.user, repo) |
| 224 | 227 | ? null |
| 225 | 228 | : issueCostFor(f.db, repo, issueRow, f.user); |
| 229 | ledgerLine = | |
| 230 | repo === undefined || issueRow === undefined ? "" : costLineFor(f.db, repo, f.user, issueRow, "issue"); | |
| 226 | 231 | crew.set(runs !== undefined && runs.ok ? runs.value : []); |
| 227 | 232 | plan = |
| 228 | 233 | repo === undefined || issueRow === undefined ? undefined : readPlan(f.app, repo, f.user, issueRow); |
| @@ -597,7 +602,7 @@ export function issueScreen( | ||
| 597 | 602 | const planH = planRows.length === 0 ? 0 : Math.min(planRows.length, CREW_ROWS + 2) + 2; |
| 598 | 603 | const waitsLine = waitsParts(waits); |
| 599 | 604 | const headH = waitsLine.length === 0 ? 3 : 4; |
| 600 | const costH = cost === null ? 0 : 1; | |
| 605 | const costH = cost === null && ledgerLine === "" ? 0 : 1; | |
| 601 | 606 | const [strip, head, waitRow, crewRow, costRow, planRow, body, box] = split(area, "v", [ |
| 602 | 607 | len(whereRows(src)), |
| 603 | 608 | len(headH), |
| @@ -698,8 +703,8 @@ export function issueScreen( | ||
| 698 | 703 | }); |
| 699 | 704 | } |
| 700 | 705 | |
| 701 | if (cost !== null && costRow!.h > 0) | |
| 702 | s.text(costRow!.x, costRow!.y, truncate(costLine(cost), costRow!.w), style(c.fg, c.bg), costRow!.x + costRow!.w); | |
| 706 | if (costH > 0 && costRow!.h > 0) | |
| 707 | s.text(costRow!.x, costRow!.y, truncate([cost === null ? "" : costLine(cost), ledgerLine].filter((p) => p !== "").join(" · "), costRow!.w), style(c.fg, c.bg), costRow!.x + costRow!.w); | |
| 703 | 708 | |
| 704 | 709 | if (planH > 0 && plan !== undefined) { |
| 705 | 710 | const inner = card(s, planRow!, c, { title: planTitle(plan, shown) }); |
src/tui/screens/pull.ts +7 −1
| @@ -78,6 +78,7 @@ import { stepLine, stepOfPull, type PathStep } from "../../flow/path.ts"; | ||
| 78 | 78 | import { fromKeptFork } from "../../core/landing_kind.ts"; |
| 79 | 79 | import { getPull, pullDir, variantSiblings, type MergePullError, type Pull } from "../../core/pulls.ts"; |
| 80 | 80 | import { voteVariant } from "../../actions/variants.ts"; |
| 81 | import { costLineFor } from "../../core/ledger_rollup.ts"; | |
| 81 | 82 | import { getRepoById, type Repo } from "../../core/repos.ts"; |
| 82 | 83 | import { verdictOf, type Verdict } from "../../web/pages/pull-verdict.ts"; |
| 83 | 84 | import { wallOf, wallPlain, type Wall } from "../../contract/policy.ts"; |
| @@ -321,6 +322,8 @@ export function pullScreen( | ||
| 321 | 322 | const mayWrite = (): boolean => src.capabilities.write && repo !== undefined; |
| 322 | 323 | /** The hold that stands on this pull; undefined when none does. */ |
| 323 | 324 | let hold: Hold | undefined; |
| 325 | /** What this pull cost (#12114), or "" when none or not for this viewer. */ | |
| 326 | let ledgerLine = ""; | |
| 324 | 327 | /** What the access wall holds this open pull for, read from git (#7847). */ |
| 325 | 328 | let wall: Wall | undefined; |
| 326 | 329 | /** What happens next to a stranger's open pull, the page's sentence (`crew/stranger.ts`); empty for anyone else's. */ |
| @@ -359,6 +362,9 @@ export function pullScreen( | ||
| 359 | 362 | threads = row === undefined ? [] : readThreads(row); |
| 360 | 363 | issueRow = repo === undefined ? undefined : getIssue(f.db, repo.id, number); |
| 361 | 364 | hold = row === undefined ? undefined : getHold(f.db, row.id); |
| 365 | // The cost-ledger line, gated as the page gates it (owner facts only). | |
| 366 | ledgerLine = | |
| 367 | settings === undefined || issueRow === undefined ? "" : costLineFor(f.db, settings, f.user, issueRow, "pull"); | |
| 362 | 368 | wall = |
| 363 | 369 | repo !== undefined && row !== undefined && row.state === "open" && row.mergedAt === null && facts !== null |
| 364 | 370 | ? await (async () => { |
| @@ -1251,7 +1257,7 @@ export function pullScreen( | ||
| 1251 | 1257 | x1, |
| 1252 | 1258 | head!.y + 1, |
| 1253 | 1259 | truncate( |
| 1254 | ` ${facts?.mergedAt != null ? "merged" : issue.state} · opened by ${who(issue.authorName, issue.originAuthor)} ${ago(issue.createdAt)}${guestWaiting() ? ` · ${isContribHead(facts?.headBranch ?? "") ? OUTSIDE_NOTE : repo !== undefined && crewFilters(repo) ? GUEST_NOTE_FILTERED : GUEST_NOTE}` : ""}${assignedText(issueRow, ctx.icons)}${facts?.proposal ? ` · ${proposedByText(facts.proposal)}` : ""}${hold === undefined ? "" : ` · held by ${hold.heldByName}: ${hold.why}`}${step === null ? "" : ` · step: ${stepLine(step)}`}${landing === null ? "" : ` · ${landing}`}${next === null ? "" : ` · next: ${next.who}, ${next.why}`}`, | |
| 1260 | ` ${facts?.mergedAt != null ? "merged" : issue.state} · opened by ${who(issue.authorName, issue.originAuthor)} ${ago(issue.createdAt)}${guestWaiting() ? ` · ${isContribHead(facts?.headBranch ?? "") ? OUTSIDE_NOTE : repo !== undefined && crewFilters(repo) ? GUEST_NOTE_FILTERED : GUEST_NOTE}` : ""}${assignedText(issueRow, ctx.icons)}${facts?.proposal ? ` · ${proposedByText(facts.proposal)}` : ""}${hold === undefined ? "" : ` · held by ${hold.heldByName}: ${hold.why}`}${step === null ? "" : ` · step: ${stepLine(step)}`}${landing === null ? "" : ` · ${landing}`}${next === null ? "" : ` · next: ${next.who}, ${next.why}`}${ledgerLine === "" ? "" : ` · ${ledgerLine}`}`, | |
| 1255 | 1261 | Math.max(0, edge - x1), |
| 1256 | 1262 | ), |
| 1257 | 1263 | style(c.mutedFg, c.bg), |
src/web/pages/runs.ts +19 −4
| @@ -31,6 +31,7 @@ import { | ||
| 31 | 31 | } from "../../core/repos.ts"; |
| 32 | 32 | import { getRun, type Run, type RunKind } from "../../core/runs.ts"; |
| 33 | 33 | import { getUsage, issueUsage } from "../../core/usage.ts"; |
| 34 | import { costLineFor, type CostThing } from "../../core/ledger_rollup.ts"; | |
| 34 | 35 | import { getUsersByIds, type User } from "../../core/users.ts"; |
| 35 | 36 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 36 | 37 | import type { Ctx } from "../../http/context.ts"; |
| @@ -122,15 +123,29 @@ export const costText = (usd: number): string => `$${usd.toFixed(2)}`; | ||
| 122 | 123 | * The owner's facts, like a run's cost — nothing for a reader who cannot |
| 123 | 124 | * write here, and nothing before a run has landed in the ledger. |
| 124 | 125 | */ |
| 125 | export function workSpent(ctx: Ctx, repo: Repo, issueId: string): Html { | |
| 126 | export function workSpent( | |
| 127 | ctx: Ctx, | |
| 128 | repo: Repo, | |
| 129 | thing: CostThing, | |
| 130 | kind: "pull" | "issue", | |
| 131 | ): Html { | |
| 126 | 132 | if (!canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user)) return html``; |
| 127 | const used = issueUsage(ctx.app.db, issueId); | |
| 128 | if (used === undefined) return html``; | |
| 133 | const used = issueUsage(ctx.app.db, thing.id); | |
| 134 | // The cost-ledger line (#12114) rides on the usage read above: a pull no run | |
| 135 | // has worked on costs nothing further to say so. | |
| 136 | const line = costLineFor(ctx.app.db, repo, ctx.auth.user, thing, kind, used !== undefined); | |
| 137 | const cost = | |
| 138 | line === "" | |
| 139 | ? html`` | |
| 140 | : html` <span id="cost-ledger" class="num" role="group" aria-label="What this cost" | |
| 141 | >${line}</span | |
| 142 | >`; | |
| 143 | if (used === undefined) return cost; | |
| 129 | 144 | return html`<span class="num" id="work-spent" |
| 130 | 145 | >${used.runs} run${used.runs === 1 ? "" : "s"} · |
| 131 | 146 | ${costText(used.costUsd)} · ${tokenCount(used.tokensIn, "counted")} in · |
| 132 | 147 | ${tokenCount(used.tokensOut, "counted")} out</span |
| 133 | >`; | |
| 148 | >${cost}`; | |
| 134 | 149 | } |
| 135 | 150 | |
| 136 | 151 | /** |
src/web/studio/change.ts +1 −1
| @@ -261,7 +261,7 @@ export function changeHead( | ||
| 261 | 261 | >${icon("external")}view on origin</a |
| 262 | 262 | >`, |
| 263 | 263 | )} |
| 264 | ${workSpent(ctx, repo, pull.id)}${when( | |
| 264 | ${workSpent(ctx, repo, pull, "pull")}${when( | |
| 265 | 265 | pullProposalOf(ctx.app.db, pull.id), |
| 266 | 266 | (p) => |
| 267 | 267 | html`<span class="proposed-by" |
src/web/studio/tell.ts +1 −1
| @@ -524,7 +524,7 @@ async function studioTell( | ||
| 524 | 524 | () => |
| 525 | 525 | badge("Sealed security report", { tone: "danger", icon: "lock" }), |
| 526 | 526 | )} |
| 527 | ${workSpent(ctx, repo, issue.id)} | |
| 527 | ${workSpent(ctx, repo, issue, "issue")} | |
| 528 | 528 | ${promiseLine(db, issue)} |
| 529 | 529 | ${!direction && issue.state === "open" && canWrite(db, repo, user) |
| 530 | 530 | ? flowButton( |
test/ledger-rollup.test.ts added +152
| @@ -0,0 +1,152 @@ | ||
| 1 | /** | |
| 2 | * The cost line (#12114): the sum of what a pull's finished runs cost, said | |
| 3 | * once on the pull and, over its pulls, on the issue; nothing when no run has | |
| 4 | * an entry; escaped; and read with an indexed query, never the files. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { openIssue } from "../src/actions/issues.ts"; | |
| 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { costLine, costLineFor, costSum } from "../src/core/ledger_rollup.ts"; | |
| 15 | import { recordUsage } from "../src/core/usage.ts"; | |
| 16 | import { createPull } from "../src/core/pulls.ts"; | |
| 17 | import type { Repo } from "../src/core/repos.ts"; | |
| 18 | import { createUser, type User } from "../src/core/users.ts"; | |
| 19 | import { repoDir } from "../src/git/repos.ts"; | |
| 20 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 21 | ||
| 22 | let app: App; | |
| 23 | let servers: Servers; | |
| 24 | let alice: User; | |
| 25 | let repo: Repo; | |
| 26 | let cookie = ""; | |
| 27 | let issue: { id: string; number: number; createdAt: string }; | |
| 28 | let pull: { id: string; number: number }; | |
| 29 | ||
| 30 | function run(issueId: string, usd: number, queued: string, start: string | null, end: string): void { | |
| 31 | const id = `r${Math.random().toString(36).slice(2)}`; | |
| 32 | app.db | |
| 33 | .query( | |
| 34 | `INSERT INTO run (id, repo_id, kind, issue_id, state, cost_usd, created_at, started_at, finished_at) | |
| 35 | VALUES (?1, ?2, 'agent', ?3, 'ok', ?4, ?5, ?6, ?7)`, | |
| 36 | ) | |
| 37 | .run(id, repo.id, issueId, usd, queued, start, end); | |
| 38 | // Finishing a run writes its usage row too; the line rides on that read. | |
| 39 | recordUsage(app.db, { runId: id, repoId: repo.id, issueId, agentId: null, subscriptionId: null, tokensIn: 1, tokensOut: 1, costUsd: usd, wallMs: 1, outcome: "ok", at: end }); | |
| 40 | } | |
| 41 | ||
| 42 | beforeAll(async () => { | |
| 43 | app = createApp({ dataDir: await mkdtemp(join(tmpdir(), "forge-rollup-")), port: 0 }); | |
| 44 | servers = await serve(app); | |
| 45 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 46 | if (!u.ok) throw new Error(u.error); | |
| 47 | alice = u.value; | |
| 48 | const made = await createRepo(app, alice, { name: "one", visibility: "public" }); | |
| 49 | if (!made.ok) throw new Error("repo"); | |
| 50 | repo = made.value; | |
| 51 | const c = await commitToBranch(app, alice, repo, { files: [{ path: "a.txt", content: "a" }] }); | |
| 52 | if (!c.ok) throw new Error("commit"); | |
| 53 | const f = await commitToBranch(app, alice, repo, { branch: "feat", files: [{ path: "b.txt", content: "b" }] }); | |
| 54 | if (!f.ok) throw new Error(`commit feat: ${JSON.stringify(f.error)}`); | |
| 55 | const opened = openIssue(app, alice, repo, { title: "ask" }); | |
| 56 | if (!opened.ok) throw new Error("issue"); | |
| 57 | issue = opened.value; | |
| 58 | const made2 = await createPull(app.db, repoDir(app.reposRoot, repo.ownerName, repo.name), { | |
| 59 | repoId: repo.id, | |
| 60 | authorId: alice.id, | |
| 61 | title: "<b>answer</b>", | |
| 62 | body: `Closes #${issue.number}`, | |
| 63 | baseBranch: repo.defaultBranch, | |
| 64 | headBranch: "feat", | |
| 65 | }); | |
| 66 | if (!made2.ok) throw new Error(`pull: ${JSON.stringify(made2.error)}`); | |
| 67 | pull = made2.value; | |
| 68 | const login = await fetch(`${servers.url}/signin`, { | |
| 69 | method: "POST", | |
| 70 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 71 | redirect: "manual", | |
| 72 | }); | |
| 73 | cookie = (login.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 74 | }); | |
| 75 | afterAll(() => servers?.stop?.()); | |
| 76 | ||
| 77 | const get = async (path: string) => (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text(); | |
| 78 | ||
| 79 | test("a pull with no finished run shows no line", async () => { | |
| 80 | expect(costSum(app.db, [pull.id], issue.createdAt)).toBeUndefined(); | |
| 81 | expect(costLine(undefined)).toBe(""); | |
| 82 | expect(await get(`/alice/one/pulls/${pull.number}`)).not.toContain('id="cost-ledger"'); | |
| 83 | }); | |
| 84 | ||
| 85 | test("the line is the sum of the entries, by hand", async () => { | |
| 86 | // 10 min lane + 20 min lane = 30; waited 1 h + 2 h = 3 h; $0.30 + $0.12. | |
| 87 | run(pull.id, 0.3, "2026-11-01T00:00:00.000Z", "2026-11-01T01:00:00.000Z", "2026-11-01T01:10:00.000Z"); | |
| 88 | run(pull.id, 0.12, "2026-11-01T02:00:00.000Z", "2026-11-01T04:00:00.000Z", "2026-11-01T04:20:00.000Z"); | |
| 89 | // A run still queued has no entry and adds nothing. | |
| 90 | app.db.query(`INSERT INTO run (id, repo_id, kind, issue_id, cost_usd) VALUES ('queued1', ?1, 'agent', ?2, 9)`).run(repo.id, pull.id); | |
| 91 | const sum = costSum(app.db, [pull.id], "2026-11-01T00:00:00.000Z"); | |
| 92 | expect(costLine(sum)).toBe("cost: $0.42 tokens · 30 lane-min · 3.0 h waiting"); | |
| 93 | const page = await get(`/alice/one/pulls/${pull.number}`); | |
| 94 | expect(page).toContain('id="cost-ledger"'); | |
| 95 | expect(page).toContain("cost: $0.42 tokens · 30 lane-min · 3.0 h waiting"); | |
| 96 | }); | |
| 97 | ||
| 98 | test("parts not known are left out; ask to live is added once production carries it", () => { | |
| 99 | const only = costSum(app.db, ["none"], "2026-11-01T00:00:00.000Z"); | |
| 100 | expect(only).toBeUndefined(); | |
| 101 | run(issue.id, 0, "2026-11-01T00:00:00.000Z", null, "2026-11-01T00:05:00.000Z"); | |
| 102 | const bare = costSum(app.db, [issue.id], "2026-11-01T00:00:00.000Z"); | |
| 103 | expect(costLine(bare)).toBe(""); | |
| 104 | const prod = "prod1"; | |
| 105 | app.db | |
| 106 | .query(`INSERT INTO run (id, repo_id, kind, state, created_at, finished_at) VALUES (?1, ?2, 'deploy', 'ok', '2026-11-01T05:00:00.000Z', '2026-11-01T05:18:00.000Z')`) | |
| 107 | .run(prod, repo.id); | |
| 108 | app.db.query(`INSERT INTO deploy_pull (run_id, issue_id, pull_number, merged_oid) VALUES (?1, ?2, ?3, 'x')`).run(prod, pull.id, pull.number); | |
| 109 | expect(costLine(costSum(app.db, [pull.id], "2026-11-01T00:00:00.000Z"))).toContain(" · ask→live 5.3 h"); | |
| 110 | }); | |
| 111 | ||
| 112 | test("an issue shows the sum over its pulls", async () => { | |
outdated
This only checks the title is not echoed. It does not test that the cost line is escaped. Add a test that renders the line through the page's escaping path, and a test that a guest (no owner facts) sees no line, and a terminal test once the screen exists. |
||
| 113 | const thing = { id: issue.id, repoId: repo.id, number: issue.number, createdAt: "2026-11-01T00:00:00.000Z", body: "" }; | |
| 114 | const line = costLineFor(app.db, repo, alice, thing, "issue"); | |
| 115 | expect(line).toContain("$0.42 tokens"); | |
| 116 | expect(line).toContain("ask→live 5.3 h"); | |
| 117 | const page = await get(`/alice/one/issues/${issue.number}`); | |
| 118 | expect(page).toContain('id="cost-ledger"'); | |
| 119 | expect(page).toContain("$0.42 tokens"); | |
| 120 | }); | |
| 121 | ||
| 122 | test("the line is escaped markup with a plain-words label, and the title is not echoed", async () => { | |
| 123 | const page = await get(`/alice/one/pulls/${pull.number}`); | |
| 124 | const span = /<span id="cost-ledger"[^>]*>\s*[^<]*<\/span\s*>/.exec(page)?.[0] ?? ""; | |
| 125 | expect(span).toMatch(/role="group"\s+aria-label="What this cost"/); | |
| 126 | expect(span).not.toContain("<b>"); | |
| 127 | expect(page).not.toContain("<b>answer</b>"); | |
| 128 | }); | |
| 129 | ||
| 130 | test("a guest and a stranger see no line, on the page or from the gate", async () => { | |
| 131 | const guest = await fetch(`${servers.url}/alice/one/pulls/${pull.number}`).then((r) => r.text()); | |
| 132 | expect(guest).not.toContain("cost-ledger"); | |
| 133 | const thing = { id: pull.id, repoId: repo.id, number: pull.number, createdAt: "2026-11-01T00:00:00.000Z", body: "" }; | |
| 134 | expect(costLineFor(app.db, repo, undefined, thing, "pull")).toBe(""); | |
| 135 | }); | |
| 136 | ||
| 137 | test("a pull's ask to live runs from the issue it closes, not from its own creation", () => { | |
| 138 | // The pull was created now; the issue's ask is moved back to 2026-11-01T00:00. | |
| 139 | app.db.query(`UPDATE issue SET created_at = '2026-11-01T00:00:00.000Z' WHERE id = ?1`).run(issue.id); | |
| 140 | const full = app.db.query<{ body: string; created_at: string }, [string]>(`SELECT body, created_at FROM issue WHERE id = ?1`).get(pull.id)!; | |
| 141 | const line = costLineFor(app.db, repo, alice, { id: pull.id, repoId: repo.id, number: pull.number, createdAt: full.created_at, body: full.body }, "pull"); | |
| 142 | expect(line).toContain("ask→live 5.3 h"); | |
| 143 | }); | |
| 144 | ||
| 145 | test("the roll-up reads walk indexes, never a table scan", () => { | |
| 146 | const plan = (sql: string, ...p: string[]) => | |
| 147 | app.db.query<{ detail: string }, string[]>(`EXPLAIN QUERY PLAN ${sql}`).all(...p).map((r) => r.detail).join("\n"); | |
| 148 | const runs = plan(`SELECT COUNT(*) FROM run WHERE issue_id IN (?1,?2) AND finished_at IS NOT NULL`, "a", "b"); | |
| 149 | expect(runs).not.toMatch(/SCAN run/); | |
| 150 | const live = plan(`SELECT MIN(r.finished_at) FROM deploy_pull dp JOIN run r ON r.id = dp.run_id WHERE dp.issue_id IN (?1)`, "a"); | |
| 151 | expect(live).not.toMatch(/SCAN (dp|deploy_pull)/); | |
| 152 | }); |
test/tui-issue-cost.test.ts +21
| @@ -131,4 +131,25 @@ describe("the terminal's issue screen and its cost", () => { | ||
| 131 | 131 | r.stop(); |
| 132 | 132 | } |
| 133 | 133 | }); |
| 134 | ||
| 135 | test("the cost-ledger line (#12114) is on the screen for the owner and not for a reader", async () => { | |
| 136 | const issue = newIssue("Water"); | |
| 137 | const r = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: issue.id }); | |
| 138 | app.db | |
| 139 | .query("UPDATE run SET cost_usd = 0.3, created_at = '2026-11-01T00:00:00.000Z', started_at = '2026-11-01T01:00:00.000Z', finished_at = '2026-11-01T01:30:00.000Z' WHERE id = ?1") | |
| 140 | .run(r.id); | |
| 141 | const t = await open(alice, issue.number); | |
| 142 | try { | |
| 143 | expect(t.text()).toContain("cost: $0.30 tokens · 30 lane-min · 1.0 h waiting"); | |
| 144 | } finally { | |
| 145 | t.stop(); | |
| 146 | } | |
| 147 | const g = await open(bob, issue.number); | |
| 148 | try { | |
| 149 | expect(g.text()).toContain("Water"); | |
| 150 | expect(g.text()).not.toContain("lane-min"); | |
| 151 | } finally { | |
| 152 | g.stop(); | |
| 153 | } | |
| 154 | }); | |
| 134 | 155 | }); |
test/tui-pull-cost.test.ts added +75
| @@ -0,0 +1,75 @@ | ||
| 1 | /** The terminal's pull screen says what the pull cost (#12114), through the page's gate. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { openIssue } from "../src/actions/issues.ts"; | |
| 7 | import { openPull } from "../src/actions/pulls.ts"; | |
| 8 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { getPull } from "../src/core/pulls.ts"; | |
| 11 | import { createRun } from "../src/core/runs.ts"; | |
| 12 | import { recordUsage } from "../src/core/usage.ts"; | |
| 13 | import { createUser, type User } from "../src/core/users.ts"; | |
| 14 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 15 | import { pullScreen } from "../src/tui/screens/pull.ts"; | |
| 16 | import { local } from "../src/tui/screens/where.ts"; | |
| 17 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | import { settleApp } from "./helpers/tui.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | let dataDir: string; | |
| 24 | let app: App; | |
| 25 | ||
| 26 | beforeAll(async () => { | |
| 27 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-pull-cost-")); | |
| 28 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 29 | }); | |
| 30 | afterAll(async () => { | |
| 31 | app.close(); | |
| 32 | await rm(dataDir, { recursive: true, force: true }); | |
| 33 | }); | |
| 34 | ||
| 35 | test("the owner's pull screen carries the cost line, a reader's does not", async () => { | |
| 36 | const user = async (username: string, first: boolean): Promise<User> => { | |
| 37 | const m = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first }); | |
| 38 | if (!m.ok) throw new Error(String(m.error)); | |
| 39 | return m.value; | |
| 40 | }; | |
| 41 | const alice = await user("alice", true); | |
| 42 | const bob = await user("bob", false); | |
| 43 | const r = await createRepo(app, alice, { name: "lib", visibility: "public" }); | |
| 44 | if (!r.ok) throw new Error(String(r.error)); | |
| 45 | const repo = r.value; | |
| 46 | for (const branch of ["main", "feat"]) { | |
| 47 | const c = await commitToBranch(app, alice, repo, { branch, message: branch, files: [{ path: `${branch}.md`, content: "x\n" }] }); | |
| 48 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 49 | } | |
| 50 | const ask = openIssue(app, alice, repo, { title: "ask" }); | |
| 51 | if (!ask.ok) throw new Error("issue"); | |
| 52 | const o = await openPull(app, alice, repo, { title: "Pull feat", body: `Closes #${ask.value.number}`, baseBranch: "main", headBranch: "feat" }); | |
| 53 | if (!o.ok) throw new Error(String(o.error)); | |
| 54 | const number = o.value.number; | |
| 55 | const pullId = getPull(app.db, repo.id, number)!.id; | |
| 56 | const run = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: pullId }); | |
| 57 | app.db | |
| 58 | .query("UPDATE run SET cost_usd = 0.3, created_at = '2026-11-01T00:00:00.000Z', started_at = '2026-11-01T01:00:00.000Z', finished_at = '2026-11-01T01:30:00.000Z' WHERE id = ?1") | |
| 59 | .run(run.id); | |
| 60 | recordUsage(app.db, { runId: run.id, repoId: repo.id, issueId: pullId, agentId: null, subscriptionId: null, tokensIn: 1, tokensOut: 1, costUsd: 0.3, wallMs: 1, outcome: "ok", at: "2026-11-01T01:30:00.000Z" }); | |
| 61 | ||
| 62 | const screenText = async (who: User): Promise<string> => { | |
| 63 | const { app: tui, session } = createForgeApp(app, { sshUsername: who.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: who }, { terminal: headlessTerminal(220, 44), exitHooks: false }); | |
| 64 | tui.start(); | |
| 65 | tui.ctx.push(pullScreen(session, tui.ctx, local(session, repo), number)); | |
| 66 | await settleApp(tui); | |
| 67 | const text = tui.screen.toText(); | |
| 68 | tui.stop(); | |
| 69 | return text; | |
| 70 | }; | |
| 71 | expect(await screenText(alice)).toContain("cost: $0.30 tokens · 30 lane-min · 1.0 h waiting"); | |
| 72 | const seen = await screenText(bob); | |
| 73 | expect(seen).toContain("Pull feat"); | |
| 74 | expect(seen).not.toContain("lane-min"); | |
| 75 | }); |
new above
offline
Both doors: the terminal's pull and issue screens in src/tui/ must show this line through the same gated function, and docs/features/cost-ledger.md must say how to read it. Neither exists in this diff.