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

A forge's included credit counts what was spent, and home, app and pull pages show the same figure #11572

Parked opened this
Closed without merging

scout/forge:scout/11563-a-forge-s-included-credit-counts-what-wa → main 4 commits fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
6 files changed +122 −15
src/spawn/credit_figure.ts added +40
@@ -0,0 +1,40 @@
1 /**
2 * The included credit's one figure (#11563): the cap the host lent and what
3 * the crew's runs actually cost, never a run's budget reservation (`claude:
4 * budget $5.00` is a ceiling, not money gone). Spent is the larger of what
5 * the host last counted at its model door and the sum of the usage ledger's
6 * real costs, so it never reads lower than the runs it paid for. The home
7 * notice, the terminal, the app page and the pull page all read this.
8 */
9
10 import type { App } from "../app.ts";
11 import { leftUsd, usdWords } from "./credit.ts";
12 import { fundingOf } from "./funding.ts";
13
14 export interface Included {
15 readonly capUsd: number;
16 readonly leftUsd: number;
17 }
18
19 /** What every run has cost, from the ledger (a reservation is never in it). */
20 export function ranCostUsd(app: App): number {
21 const row = app.db.query<{ usd: number }, []>("SELECT COALESCE(SUM(cost_usd), 0) AS usd FROM usage").get();
22 return row?.usd ?? 0;
23 }
24
25 /** The credit this forge came with and what is left of it, or undefined when it has none. */
26 export function includedCredit(app: App): Included | undefined {
27 const f = fundingOf(app.config.dataDir);
28 if (f === undefined || f.capUsd === null) return undefined;
29 const spentUsd = Math.max(f.spentUsd ?? 0, ranCostUsd(app));
30 return { capUsd: f.capUsd, leftUsd: leftUsd({ capUsd: f.capUsd, spentUsd }) };
31 }
32
33 /** The figure as one sentence for a page; null when the forge has no included credit. Spent only at nothing left. */
34 export function creditSentence(app: App): string | null {
35 const c = includedCredit(app);
36 if (c === undefined) return null;
37 return c.leftUsd > 0
38 ? `Included credit: ${usdWords(c.leftUsd)} of ${usdWords(c.capUsd)} left.`
39 : `Included credit: spent, ${usdWords(c.capUsd)} of ${usdWords(c.capUsd)}.`;
40 }
src/tui/screens/crewkey.ts +1 −1
@@ -8,7 +8,7 @@
88 import { saveCrewKey } from "../../actions/crew_key.ts";
99 import { crewHasCredential } from "../../core/subscriptions.ts";
1010 import { usdWords } from "../../spawn/credit.ts";
11 import { includedCredit } from "../../web/studio/crew_key.ts";
11 import { includedCredit } from "../../spawn/credit_figure.ts";
1212 import type { Ctx, Screen } from "../app/app.ts";
1313 import { binding } from "../app/keys.ts";
1414 import { style } from "../term/style.ts";
src/web/studio/app.ts +2 −1
@@ -62,7 +62,7 @@ import { notesCard } from "./notes.ts";
6262 import { adapterCard } from "./adapter.ts";
6363 import { appPanels } from "./previews.ts";
6464 import { noCrewLine, pausedWords, servingHistory, type WordCtx } from "./words.ts";
65 import { crewKeyCard } from "./crew_key.ts";
65 import { creditNote, crewKeyCard } from "./crew_key.ts";
6666 import {
6767 notePhase,
6868 takeSessionTime,
@@ -499,6 +499,7 @@ export async function studioApp(
499499 : followingCard(ctx, repo, following)}
500500 <div id="app-head">${head}</div>
501501 ${ownerFacts ? crewKeyCard(ctx, true) : null}
502 ${ownerFacts ? creditNote(ctx.app) : null}
502503 <div class="stack">
503504 ${adapter}
504505 ${tellRegion}
src/web/studio/change.ts +3 −2
@@ -29,7 +29,7 @@ import {
2929 type Pull,
3030 pullDir,
3131 } from "../../core/pulls.ts";
32 import { canAdmin, canWrite, type Repo } from "../../core/repos.ts";
32 import { canAdmin, canSeeOwnerFacts, canWrite, type Repo } from "../../core/repos.ts";
3333 import { listReviews, type Review } from "../../core/reviews.ts";
3434 import { wallOf, wallPlain, wallSentence, type Wall } from "../../contract/policy.ts";
3535 import { checkSummary } from "../../core/runs.ts";
@@ -43,6 +43,7 @@ import {
4343 repoPalette,
4444 } from "../chrome.ts";
4545 import { html, raw, when, type Html } from "../html.ts";
46 import { creditNote } from "./crew_key.ts";
4647 import type { IconName as Glyph } from "../icons.ts";
4748 import { threadLine } from "../describe.ts";
4849 import { fragment, page, redirect } from "../layout.ts";
@@ -259,7 +260,7 @@ export function changeHead(
259260 >${icon("external")}view on origin</a
260261 >`,
261262 )}
262 ${workSpent(ctx, repo, pull.id)}${when(
263 ${workSpent(ctx, repo, pull.id)}${canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user) ? creditNote(ctx.app) : null}${when(
263264 pullProposalOf(ctx.app.db, pull.id),
264265 (p) =>
265266 html`<span class="proposed-by"
src/web/studio/crew_key.ts +7 −11
@@ -13,8 +13,8 @@ import type { App } from "../../app.ts";
1313 import { listAgents } from "../../core/agents.ts";
1414 import { saveCrewKey } from "../../actions/crew_key.ts";
1515 import { crewHasCredential } from "../../core/subscriptions.ts";
16 import { leftUsd, usdWords } from "../../spawn/credit.ts";
17 import { fundingOf } from "../../spawn/funding.ts";
16 import { usdWords } from "../../spawn/credit.ts";
17 import { creditSentence, includedCredit } from "../../spawn/credit_figure.ts";
1818 import type { Ctx } from "../../http/context.ts";
1919 import { notFoundPage, signInFirst } from "../chrome.ts";
2020 import { field, readForm } from "../form.ts";
@@ -27,16 +27,12 @@ export const KEY_ACTION = "/settings/crew/key";
2727 /** The form is the key and little else: far under the general body limit. */
2828 const KEY_FORM_MAX = 2048;
2929
30 export interface Included {
31 readonly capUsd: number;
32 readonly leftUsd: number;
33 }
30 export { includedCredit, type Included } from "../../spawn/credit_figure.ts";
3431
35 /** The credit this forge came with and what is left of it (last heard from its host), or undefined when it has none. */
36 export function includedCredit(app: App): Included | undefined {
37 const f = fundingOf(app.config.dataDir);
38 if (f === undefined || f.capUsd === null) return undefined;
39 return { capUsd: f.capUsd, leftUsd: leftUsd({ capUsd: f.capUsd, spentUsd: f.spentUsd ?? 0 }) };
32 /** The credit figure as a quiet line (app and pull pages), or nothing when the forge has no included credit. */
33 export function creditNote(app: App): Html | null {
34 const said = creditSentence(app);
35 return said === null ? null : html`<p class="small muted" id="included-credit-figure">${said}</p>`;
4036 }
4137
4238 /** Whether a forge is a hosted child: its host's file is in its data directory. */
test/credit-figure.test.ts added +69
@@ -0,0 +1,69 @@
1 /** One credit figure on every page (#11563): spent is what runs cost, never a reservation. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { readFileSync, writeFileSync } from "node:fs";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { recordUsage } from "../src/core/usage.ts";
9 import { keepFunding } from "../src/spawn/funding.ts";
10 import { creditSentence, includedCredit } from "../src/spawn/credit_figure.ts";
11 import { creditNote, crewNote } from "../src/web/studio/crew_key.ts";
12 import { crewKeyLine } from "../src/tui/screens/crewkey.ts";
13 import type { Session } from "../src/tui/session.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15
16 quietLogs();
17
18 describe("the included credit's one figure", () => {
19 let dir = "";
20 let app: App;
21 let n = 0;
22 const run = (usd: number): void =>
23 recordUsage(app.db, { runId: `r${++n}`, repoId: "x", issueId: "i", agentId: null, subscriptionId: null, tokensIn: 1, tokensOut: 1, costUsd: usd, wallMs: 1, outcome: "ok", at: new Date().toISOString() });
24
25 beforeAll(async () => {
26 dir = await mkdtemp(join(tmpdir(), "forge-credit-figure-"));
27 writeFileSync(join(dir, "parent.json"), "{}");
28 app = createApp({ dataDir: dir, port: 0 });
29 app.db.run("PRAGMA foreign_keys = OFF");
30 keepFunding(dir, { token: "bearer-token-123", capUsd: 2, spentUsd: 0 });
31 });
32 afterAll(async () => {
33 app?.close();
34 await rm(dir, { recursive: true, force: true });
35 });
36
37 test("$2 credit and one $0.48 run: not spent, $1.52 left, the same on every surface", () => {
38 run(0.48);
39 expect(includedCredit(app)).toEqual({ capUsd: 2, leftUsd: 1.52 });
40 const session = { app } as unknown as Session;
41 const surfaces = [creditSentence(app)!, String(creditNote(app)), crewNote(app), crewKeyLine(session)];
42 for (const text of surfaces) {
43 expect(text).toContain("$1.52");
44 expect(text).toContain("$2");
45 expect(text).not.toContain("spent");
46 }
47 });
48
49 test("spending reaches $2: every surface says spent", () => {
50 run(1.52);
51 expect(includedCredit(app)?.leftUsd).toBe(0);
52 const session = { app } as unknown as Session;
53 for (const text of [creditSentence(app)!, String(creditNote(app)), crewNote(app), crewKeyLine(session)])
54 expect(text).toContain("spent");
55 });
56
57 test("the host's own count counts too, and a forge with no credit says nothing", () => {
58 keepFunding(dir, { token: "bearer-token-123", capUsd: 5, spentUsd: 4.9 });
59 expect(includedCredit(app)?.leftUsd).toBe(0.1);
60 writeFileSync(join(dir, "parent.json"), "{}");
61 expect(creditSentence(app)).toBeNull();
62 expect(readFileSync(join(dir, "parent.json"), "utf8")).toBe("{}");
63 });
64
65 test("the app page and the pull page both read the one function", () => {
66 for (const f of ["src/web/studio/app.ts", "src/web/studio/change.ts"])
67 expect(readFileSync(join(import.meta.dir, "..", f), "utf8")).toContain("creditNote(ctx.app)");
68 });
69 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs