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

Merged opened this

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

Merged

scout/forge:scout/12114-cost-ledger-a-pull-shows-what-it-cost-to → main 11 commits fork 5 threads · 5 unresolved

  • Merged. scout/12114-cost-ledger-a-pull-shows-what-it-cost-to was merged into main as 9e15d22.

Review

Sign in to review this change.
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.
2020
2121 The tests are `test/cost-ledger.test.ts`, not `test/ledger.test.ts`: that name is the decision ledger's.
2222
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
2336 ## Health: a broken chain is a need
2437
2538 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
outdated

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.

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";
4141 import type { IssuePlan } from "../../core/issue_plans.ts";
4242 import { shownStates } from "../../runs/state.ts";
4343 import { headline, planLines, planTitle } from "./runplan.ts";
44 import { costLineFor } from "../../core/ledger_rollup.ts";
4445 import { privateRepoForSealed } from "../../actions/repos.ts";
4546 import { confirmIssue } from "../../actions/dependencies.ts";
4647 import { isCrew } from "../../core/agents.ts";
@@ -170,6 +171,8 @@ export function issueScreen(
170171 let plan: IssuePlan | undefined;
171172 /** What the issue and its closing pulls cost, when this viewer may see it. */
172173 let cost: IssueCost | null = null;
174 /** The cost-ledger line (#12114); "" when none or hidden from this viewer. */
175 let ledgerLine = "";
173176 let shown: ReturnType<typeof headline>;
174177 /** Which issues this one waits for and holds up; a line when any. */
175178 let waits: Waits = { blockedBy: [], blocks: [] };
@@ -223,6 +226,8 @@ export function issueScreen(
223226 repo === undefined || issueRow === undefined || !mayReadRuns(f.app, f.user, repo)
224227 ? null
225228 : issueCostFor(f.db, repo, issueRow, f.user);
229 ledgerLine =
230 repo === undefined || issueRow === undefined ? "" : costLineFor(f.db, repo, f.user, issueRow, "issue");
226231 crew.set(runs !== undefined && runs.ok ? runs.value : []);
227232 plan =
228233 repo === undefined || issueRow === undefined ? undefined : readPlan(f.app, repo, f.user, issueRow);
@@ -597,7 +602,7 @@ export function issueScreen(
597602 const planH = planRows.length === 0 ? 0 : Math.min(planRows.length, CREW_ROWS + 2) + 2;
598603 const waitsLine = waitsParts(waits);
599604 const headH = waitsLine.length === 0 ? 3 : 4;
600 const costH = cost === null ? 0 : 1;
605 const costH = cost === null && ledgerLine === "" ? 0 : 1;
601606 const [strip, head, waitRow, crewRow, costRow, planRow, body, box] = split(area, "v", [
602607 len(whereRows(src)),
603608 len(headH),
@@ -698,8 +703,8 @@ export function issueScreen(
698703 });
699704 }
700705
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);
703708
704709 if (planH > 0 && plan !== undefined) {
705710 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";
7878 import { fromKeptFork } from "../../core/landing_kind.ts";
7979 import { getPull, pullDir, variantSiblings, type MergePullError, type Pull } from "../../core/pulls.ts";
8080 import { voteVariant } from "../../actions/variants.ts";
81 import { costLineFor } from "../../core/ledger_rollup.ts";
8182 import { getRepoById, type Repo } from "../../core/repos.ts";
8283 import { verdictOf, type Verdict } from "../../web/pages/pull-verdict.ts";
8384 import { wallOf, wallPlain, type Wall } from "../../contract/policy.ts";
@@ -321,6 +322,8 @@ export function pullScreen(
321322 const mayWrite = (): boolean => src.capabilities.write && repo !== undefined;
322323 /** The hold that stands on this pull; undefined when none does. */
323324 let hold: Hold | undefined;
325 /** What this pull cost (#12114), or "" when none or not for this viewer. */
326 let ledgerLine = "";
324327 /** What the access wall holds this open pull for, read from git (#7847). */
325328 let wall: Wall | undefined;
326329 /** 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(
359362 threads = row === undefined ? [] : readThreads(row);
360363 issueRow = repo === undefined ? undefined : getIssue(f.db, repo.id, number);
361364 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");
362368 wall =
363369 repo !== undefined && row !== undefined && row.state === "open" && row.mergedAt === null && facts !== null
364370 ? await (async () => {
@@ -1251,7 +1257,7 @@ export function pullScreen(
12511257 x1,
12521258 head!.y + 1,
12531259 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}`}`,
12551261 Math.max(0, edge - x1),
12561262 ),
12571263 style(c.mutedFg, c.bg),
src/web/pages/runs.ts +19 −4
@@ -31,6 +31,7 @@ import {
3131 } from "../../core/repos.ts";
3232 import { getRun, type Run, type RunKind } from "../../core/runs.ts";
3333 import { getUsage, issueUsage } from "../../core/usage.ts";
34 import { costLineFor, type CostThing } from "../../core/ledger_rollup.ts";
3435 import { getUsersByIds, type User } from "../../core/users.ts";
3536 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
3637 import type { Ctx } from "../../http/context.ts";
@@ -122,15 +123,29 @@ export const costText = (usd: number): string => `$${usd.toFixed(2)}`;
122123 * The owner's facts, like a run's cost — nothing for a reader who cannot
123124 * write here, and nothing before a run has landed in the ledger.
124125 */
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 {
126132 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;
129144 return html`<span class="num" id="work-spent"
130145 >${used.runs} run${used.runs === 1 ? "" : "s"} ·
131146 ${costText(used.costUsd)} · ${tokenCount(used.tokensIn, "counted")} in ·
132147 ${tokenCount(used.tokensOut, "counted")} out</span
133 >`;
148 >${cost}`;
134149 }
135150
136151 /**
src/web/studio/change.ts +1 −1
@@ -261,7 +261,7 @@ export function changeHead(
261261 >${icon("external")}view on origin</a
262262 >`,
263263 )}
264 ${workSpent(ctx, repo, pull.id)}${when(
264 ${workSpent(ctx, repo, pull, "pull")}${when(
265265 pullProposalOf(ctx.app.db, pull.id),
266266 (p) =>
267267 html`<span class="proposed-by"
src/web/studio/tell.ts +1 −1
@@ -524,7 +524,7 @@ async function studioTell(
524524 () =>
525525 badge("Sealed security report", { tone: "danger", icon: "lock" }),
526526 )}
527 ${workSpent(ctx, repo, issue.id)}
527 ${workSpent(ctx, repo, issue, "issue")}
528528 ${promiseLine(db, issue)}
529529 ${!direction && issue.state === "open" && canWrite(db, repo, user)
530530 ? 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", () => {
131131 r.stop();
132132 }
133133 });
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 });
134155 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs