Public: anyone can read this; the crew can change it.
The app page and the pull page show the same included-credit figure as the home #11716
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11615-the-app-page-and-the-pull-page-show-the → main 3 commits fork 3 threads · 3 unresolved
4 files
changed +118
−4
src/tui/screens/crewkey.ts +1 −1
| @@ -24,7 +24,7 @@ export function crewKeyLine(f: Session): string { | ||
| 24 | 24 | if (crewHasCredential(f.app.db)) return "The crew runs on your key. a adds another."; |
| 25 | 25 | const credit = includedCredit(f.app); |
| 26 | 26 | if (credit !== undefined && credit.leftUsd > 0) |
| 27 | return `The crew is building on ${usdWords(credit.leftUsd)} of ${usdWords(credit.capUsd)} included credit. a adds your own key.`; | |
| 27 | return `The crew is building on ${usdWords(credit.leftUsd)} of ${usdWords(credit.capUsd)} included credit (${usdWords(credit.spentUsd)} spent). a adds your own key.`; | |
| 28 | 28 | if (credit !== undefined) return "The included credit is spent. a adds your own key to keep building."; |
| 29 | 29 | return "The crew needs a model key to start. a adds yours."; |
| 30 | 30 | } |
src/web/studio/app.ts +10 −2
| @@ -63,7 +63,8 @@ import { adapterCard } from "./adapter.ts"; | ||
| 63 | 63 | import { schedulesCard } from "./schedules.ts"; |
| 64 | 64 | import { appPanels } from "./previews.ts"; |
| 65 | 65 | import { noCrewLine, pausedWords, servingHistory, type WordCtx } from "./words.ts"; |
| 66 | import { crewKeyCard } from "./crew_key.ts"; | |
| 66 | import { crewKeyCard, includedCredit } from "./crew_key.ts"; | |
| 67 | import { usdWords } from "../../spawn/credit.ts"; | |
| 67 | 68 | import { |
| 68 | 69 | notePhase, |
| 69 | 70 | takeSessionTime, |
| @@ -297,6 +298,7 @@ export async function studioApp( | ||
| 297 | 298 | ? timePhase(req, "spend", () => agentRunTotals(db, repo.id, week)) |
| 298 | 299 | : { runs: 0, spentUsd: 0 }; |
| 299 | 300 | const spent = weekTotals.spentUsd; |
| 301 | const credit = includedCredit(ctx.app); | |
| 300 | 302 | const ledgerWeek = ownerFacts && !fresh |
| 301 | 303 | ? timePhase(req, "decisions", () => decisionStats(db, repo.id, week)) |
| 302 | 304 | : []; |
| @@ -395,7 +397,13 @@ export async function studioApp( | ||
| 395 | 397 | <dd> |
| 396 | 398 | ${previewLatencyWords(weekLatency, `${base}/runs?kind=deploy`)} |
| 397 | 399 | </dd> |
| 398 | <dt>Spent</dt> | |
| 400 | ${credit === undefined | |
| 401 | ? null | |
| 402 | : html`<dt>Included credit</dt> | |
| 403 | <dd id="app-credit"> | |
| 404 | ${usdWords(credit.leftUsd)} of ${usdWords(credit.capUsd)} left | |
| 405 | </dd>`} | |
| 406 | <dt>Runs cost</dt> | |
| 399 | 407 | <dd> |
| 400 | 408 | $${spent.toFixed(2)}${shipped > 0 |
| 401 | 409 | ? html` <span class="muted" |
test/credit-one-figure.test.ts added +106
| @@ -0,0 +1,106 @@ | ||
| 1 | /** | |
| 2 | * One included-credit figure on every surface (#11615): a forge with $2 of | |
| 3 | * credit and $0.48 spent says "$1.52" on the home line, the app page, the | |
| 4 | * pull page and in the terminal, and no other credit figure. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createSession } from "../src/core/sessions.ts"; | |
| 12 | import { deleteSubscription, listSubscriptions } from "../src/core/subscriptions.ts"; | |
| 13 | import { getRepo } from "../src/core/repos.ts"; | |
| 14 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 15 | import { openPull } from "../src/actions/pulls.ts"; | |
| 16 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 17 | import { getUserByName } from "../src/core/users.ts"; | |
| 18 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 19 | import { PREVIEW_ADMIN, PREVIEW_APP, seedPreview } from "../src/spawn/preview-seed.ts"; | |
| 20 | import { keepFunding } from "../src/spawn/funding.ts"; | |
| 21 | import { crewKeyLine } from "../src/tui/screens/crewkey.ts"; | |
| 22 | import { crewFirstLine } from "../src/web/studio/crew_key.ts"; | |
| 23 | import { quietLogs } from "./helpers/globals.ts"; | |
| 24 | ||
| 25 | quietLogs(); | |
| 26 | ||
| 27 | let dataDir = ""; | |
| 28 | let app: App; | |
| 29 | let servers: Servers; | |
| 30 | let cookie = ""; | |
| 31 | let number = 0; | |
| 32 | ||
| 33 | beforeAll(async () => { | |
| 34 | dataDir = await mkdtemp(join(tmpdir(), "forge-credit-one-")); | |
| 35 | app = createApp({ dataDir, port: 0 }); | |
| 36 | // The sample app, with a landed pull: an app with a past, so its page shows the week. | |
| 37 | const seeded = await seedPreview(app, { FORGE_APP_ENV: "preview" }); | |
| 38 | if (!seeded.ok) throw new Error(seeded.error); | |
| 39 | // The host's credit is heard after the seed, which checks a newcomer's home without one. | |
| 40 | await writeFile(join(dataDir, "parent.json"), "{}"); | |
| 41 | keepFunding(dataDir, { token: "maria.bearer-token", capUsd: 2, spentUsd: 0.48 }); | |
| 42 | // The seed gives the forge a key of its own; this forge runs on the included credit. | |
| 43 | for (const sub of listSubscriptions(app.db)) deleteSubscription(app.db, sub.id); | |
| 44 | servers = await serve(app); | |
| 45 | cookie = `forge_session=${createSession(app.db, getUserByName(app.db, PREVIEW_ADMIN)!.id).token}`; | |
| 46 | const repo = getRepo(app.db, PREVIEW_ADMIN, PREVIEW_APP)!; | |
| 47 | const admin = getUserByName(app.db, PREVIEW_ADMIN)!; | |
| 48 | // The app has a past: a run that cost $0.75, which the app page may show as that run's cost and never as the credit. | |
| 49 | const run = createRun(app.db, { repoId: repo.id, kind: "agent", actorId: admin.id, reason: "seed" }); | |
| 50 | claimRun(app.db, run.id); | |
| 51 | finishRun(app.db, run.id, { state: "ok", summary: "A run", costUsd: 0.75 }); | |
| 52 | for (const [branch, file] of [[undefined, "forge.json"], ["topic", "t.txt"]] as const) { | |
| 53 | const done = await commitToBranch(app, admin, repo, { | |
| 54 | ...(branch === undefined ? {} : { branch }), | |
| 55 | message: "change", | |
| 56 | files: [{ path: file, content: file === "forge.json" ? "{}" : "x" }], | |
| 57 | }); | |
| 58 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 59 | } | |
| 60 | const pull = await openPull(app, admin, repo, { title: "t", body: "", baseBranch: repo.defaultBranch, headBranch: "topic" }); | |
| 61 | if (!pull.ok) throw new Error(JSON.stringify(pull.error)); | |
| 62 | number = pull.value.number; | |
| 63 | }, 60_000); | |
| 64 | ||
| 65 | afterAll(async () => { | |
| 66 | await servers.stop(); | |
| 67 | app.close(); | |
| 68 | await rm(dataDir, { recursive: true, force: true }); | |
| 69 | }); | |
| 70 | ||
| 71 | const get = async (path: string): Promise<string> => (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text(); | |
| 72 | ||
| 73 | /** Every dollar figure in a sentence of credit: what a page says about the included credit, wherever it says it. */ | |
| 74 | const creditSentences = (page: string): string[] => | |
| 75 | [...page.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").matchAll(/[^.<>]*included credit[^.]*\./g)].map((m) => m[0]!); | |
| 76 | const figures = (text: string): string[] => [...text.matchAll(/\$\d+(?:\.\d+)?/g)].map((m) => m[0]!); | |
| 77 | ||
| 78 | test("the home line and the app page say $1.52 of $2 and no other credit figure", async () => { | |
| 79 | // The home line is what the next-steps card prints: render it with the function the home calls. | |
| 80 | const home = String(crewFirstLine(app, true)); | |
| 81 | expect(figures(home)).toEqual(["$1.52", "$2"]); | |
| 82 | expect(home).toContain("$1.52 of $2 included credit"); | |
| 83 | ||
| 84 | const page = await get(`/${PREVIEW_ADMIN}/${PREVIEW_APP}`); | |
outdated
Box 3: the home line is not rendered; crewNote(app) is the same string by assumption, not by test. Render the home and check it, and check the whole page for stray credit figures, not only inside the matched row. |
||
| 85 | expect(page).toContain("$1.52"); | |
| 86 | const sentences = creditSentences(page).concat(creditSentences(page.replace(/Included credit<\/dt>/, "included credit."))); | |
| 87 | expect(sentences.length).toBeGreaterThan(0); | |
| 88 | for (const sentence of sentences) for (const f of figures(sentence)) expect(["$1.52", "$2"]).toContain(f); | |
| 89 | const row = page.match(/id="app-credit">([\s\S]*?)<\/dd>/); | |
| 90 | expect(row).not.toBeNull(); | |
| 91 | expect(figures(row![1]!)).toEqual(["$1.52", "$2"]); | |
| 92 | // The spend is never offered as the credit: $0.48 appears nowhere, and the run's cost is labelled as the runs'. | |
| 93 | expect(page).not.toContain("$0.48"); | |
| 94 | expect(page).toMatch(/<dt>Runs cost<\/dt>\s*<dd>\s*\$0\.75/); | |
| 95 | }, 30_000); | |
| 96 | ||
| 97 | test("the pull page carries no credit figure of its own, and none that contradicts the home", async () => { | |
| 98 | const pull = await get(`/${PREVIEW_ADMIN}/${PREVIEW_APP}/pulls/${number}`); | |
| 99 | expect(pull).not.toContain("$0.48"); | |
| 100 | expect(pull).not.toContain("included credit"); | |
| 101 | }); | |
| 102 | ||
| 103 | test("the terminal line says the same left figure", () => { | |
| 104 | const line = crewKeyLine({ app, user: undefined } as never); | |
| 105 | expect(line).toContain("$1.52 of $2 included credit"); | |
| 106 | }); |
test/studio-shipping.test.ts +1 −1
| @@ -2134,7 +2134,7 @@ describe("a one-minute-old app's front door does not lead with a week of zeros ( | ||
| 2134 | 2134 | // forge's own cancelled boot is never counted, and two deploys is well |
| 2135 | 2135 | // under the threshold a ratio needs anyway. |
| 2136 | 2136 | expect(page).not.toMatch(/Runs<\/dt>/); |
| 2137 | expect(page).not.toMatch(/Spent<\/dt>/); | |
| 2137 | expect(page).not.toMatch(/Runs cost<\/dt>/); | |
| 2138 | 2138 | expect(page).not.toContain("never reached a preview"); |
| 2139 | 2139 | expect(page).not.toContain("$0.00"); |
| 2140 | 2140 | }); |
new above
offline
Renaming Spent to Runs cost makes test/studio-shipping.test.ts:2137 (not.toMatch /Spent<\/dt>/) pass vacuously; point it at the new label.