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 #11570
scout/forge:scout/11563-a-forge-s-included-credit-counts-what-wa → main 3 commits fork
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 @@ | ||
| 8 | 8 | import { saveCrewKey } from "../../actions/crew_key.ts"; |
| 9 | 9 | import { crewHasCredential } from "../../core/subscriptions.ts"; |
| 10 | 10 | import { usdWords } from "../../spawn/credit.ts"; |
| 11 | import { includedCredit } from "../../web/studio/crew_key.ts"; | |
| 11 | import { includedCredit } from "../../spawn/credit_figure.ts"; | |
| 12 | 12 | import type { Ctx, Screen } from "../app/app.ts"; |
| 13 | 13 | import { binding } from "../app/keys.ts"; |
| 14 | 14 | import { style } from "../term/style.ts"; |
src/web/studio/app.ts +2 −1
| @@ -62,7 +62,7 @@ import { notesCard } from "./notes.ts"; | ||
| 62 | 62 | import { adapterCard } from "./adapter.ts"; |
| 63 | 63 | import { appPanels } from "./previews.ts"; |
| 64 | 64 | import { noCrewLine, pausedWords, servingHistory, type WordCtx } from "./words.ts"; |
| 65 | import { crewKeyCard } from "./crew_key.ts"; | |
| 65 | import { creditNote, crewKeyCard } from "./crew_key.ts"; | |
| 66 | 66 | import { |
| 67 | 67 | notePhase, |
| 68 | 68 | takeSessionTime, |
| @@ -499,6 +499,7 @@ export async function studioApp( | ||
| 499 | 499 | : followingCard(ctx, repo, following)} |
| 500 | 500 | <div id="app-head">${head}</div> |
| 501 | 501 | ${ownerFacts ? crewKeyCard(ctx, true) : null} |
| 502 | ${ownerFacts ? creditNote(ctx.app) : null} | |
| 502 | 503 | <div class="stack"> |
| 503 | 504 | ${adapter} |
| 504 | 505 | ${tellRegion} |
src/web/studio/change.ts +3 −2
| @@ -29,7 +29,7 @@ import { | ||
| 29 | 29 | type Pull, |
| 30 | 30 | pullDir, |
| 31 | 31 | } 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"; | |
| 33 | 33 | import { listReviews, type Review } from "../../core/reviews.ts"; |
| 34 | 34 | import { wallOf, wallPlain, wallSentence, type Wall } from "../../contract/policy.ts"; |
| 35 | 35 | import { checkSummary } from "../../core/runs.ts"; |
| @@ -43,6 +43,7 @@ import { | ||
| 43 | 43 | repoPalette, |
| 44 | 44 | } from "../chrome.ts"; |
| 45 | 45 | import { html, raw, when, type Html } from "../html.ts"; |
| 46 | import { creditNote } from "./crew_key.ts"; | |
| 46 | 47 | import type { IconName as Glyph } from "../icons.ts"; |
| 47 | 48 | import { threadLine } from "../describe.ts"; |
| 48 | 49 | import { fragment, page, redirect } from "../layout.ts"; |
| @@ -259,7 +260,7 @@ export function changeHead( | ||
| 259 | 260 | >${icon("external")}view on origin</a |
| 260 | 261 | >`, |
| 261 | 262 | )} |
| 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( | |
| 263 | 264 | pullProposalOf(ctx.app.db, pull.id), |
| 264 | 265 | (p) => |
| 265 | 266 | html`<span class="proposed-by" |
src/web/studio/crew_key.ts +7 −11
| @@ -13,8 +13,8 @@ import type { App } from "../../app.ts"; | ||
| 13 | 13 | import { listAgents } from "../../core/agents.ts"; |
| 14 | 14 | import { saveCrewKey } from "../../actions/crew_key.ts"; |
| 15 | 15 | 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"; | |
| 18 | 18 | import type { Ctx } from "../../http/context.ts"; |
| 19 | 19 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| 20 | 20 | import { field, readForm } from "../form.ts"; |
| @@ -27,16 +27,12 @@ export const KEY_ACTION = "/settings/crew/key"; | ||
| 27 | 27 | /** The form is the key and little else: far under the general body limit. */ |
| 28 | 28 | const KEY_FORM_MAX = 2048; |
| 29 | 29 | |
| 30 | export interface Included { | |
| 31 | readonly capUsd: number; | |
| 32 | readonly leftUsd: number; | |
| 33 | } | |
| 30 | export { includedCredit, type Included } from "../../spawn/credit_figure.ts"; | |
| 34 | 31 | |
| 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>`; | |
| 40 | 36 | } |
| 41 | 37 | |
| 42 | 38 | /** 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 | }); |