Public: anyone can read this; the crew can change it.
The terminal's crew scorecard shows each agent's spend over the last 5 hours, today and 7 days, as /settings/spend reads it #7414
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7349-the-terminal-s-crew-scorecard-shows-each → main 2 commits fork 1 thread · 1 unresolved
3 files
changed +135
−2
README.md +4
| @@ -33,6 +33,10 @@ child's heartbeat last reported it. | ||
| 33 | 33 | Each agent's scorecard — pulls landed of opened, review rounds, red checks, |
| 34 | 34 | reverts, time to land — is on Settings → Crew (`/settings/crew`) and in the |
| 35 | 35 | terminal's palette as "Crew scorecard per agent"; both are a forge admin's only. |
| 36 | The terminal's scorecard also ends each agent's row with `spend $ 5h · today · 7d`, | |
| 37 | its cost over the last 5 hours, since 00:00 UTC and the last 7 days, as | |
| 38 | `/settings/spend` reads it; `—` means nothing was spent in that span (`spend —`, | |
| 39 | in all three). | |
| 36 | 40 | Settings → Crew also shows, each only when it has something, a "Handed to the |
| 37 | 41 | planner" card (issues the crew parked twice: repo, issue link, when last |
| 38 | 42 | parked, why) and a "Restore drill" card (the last drill's pass or fail and |
src/tui/screens/scorecard.ts +41 −2
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { listAllAgents } from "../../actions/crew.ts"; |
| 11 | import { readSpend, type SpanName, type Spend } from "../../actions/spend.ts"; | |
| 11 | 12 | import { |
| 12 | 13 | crewScorecards, |
| 13 | 14 | crewScorecardSplits, |
| @@ -36,11 +37,35 @@ const rounds = (s: Scorecard | undefined) => | ||
| 36 | 37 | const red = (s: Scorecard | undefined) => |
| 37 | 38 | s?.redCheckRate == null ? "—" : `${Math.round(100 * s.redCheckRate)}%`; |
| 38 | 39 | |
| 40 | /** An agent's spend over 5h, today and 7d, as /settings/spend reads it; `—` for a span of none. */ | |
| 41 | export type AgentSpend = readonly [number, number, number]; | |
| 42 | const spendText = (v: AgentSpend | undefined): string => | |
| 43 | v === undefined || v.every((n) => !(n > 0)) | |
| 44 | ? "spend —" | |
| 45 | : `spend $ ${v.map((n) => (n > 0 ? n.toFixed(2) : "—")).join(" · ")}`; | |
| 46 | ||
| 47 | /** Each agent's cost per span, by username, matched on the span's name and not its place. */ | |
| 48 | export function spendByAgent(spans: Spend["spans"]): Map<string, AgentSpend> { | |
| 49 | const cost = (span: SpanName, name: string): number => | |
| 50 | spans.find((x) => x.span === span)?.byAgent.find((l) => l.name === name)?.costUsd ?? 0; | |
| 51 | const names = new Set(spans.flatMap((x) => x.byAgent.map((l) => l.name))); | |
| 52 | return new Map( | |
| 53 | [...names].map((n): [string, AgentSpend] => [ | |
| 54 | n, | |
| 55 | [cost("5h", n), cost("today", n), cost("week", n)], | |
| 56 | ]), | |
| 57 | ); | |
| 58 | } | |
| 59 | ||
| 60 | /** One `readSpend` for the whole screen. */ | |
| 61 | const agentSpend = (f: Session) => spendByAgent(readSpend(f.app).spans); | |
| 62 | ||
| 39 | 63 | /** The figures of one scorecard, as a list row's second and third lines. */ |
| 40 | 64 | function figures( |
| 41 | 65 | title: string, |
| 42 | 66 | r: Scorecard | undefined, |
| 43 | 67 | s: Scorecard, |
| 68 | spend?: string, | |
| 44 | 69 | ): ListRow { |
| 45 | 70 | const d = `${RECENT_DAYS}d`; |
| 46 | 71 | return { |
| @@ -51,6 +76,7 @@ function figures( | ||
| 51 | 76 | `red checks ${d} ${red(r)} · all ${red(s)}`, |
| 52 | 77 | `reverts ${s.reverts}`, |
| 53 | 78 | `time to land ${s.meanLandMs === null ? "—" : spanText(s.meanLandMs)}`, |
| 79 | ...(spend === undefined ? [] : [spend]), | |
| 54 | 80 | ].join(" "), |
| 55 | 81 | }; |
| 56 | 82 | } |
| @@ -72,16 +98,28 @@ export function scorecardRows( | ||
| 72 | 98 | teams: readonly Team[] = [], |
| 73 | 99 | teamCards: ReadonlyMap<string, Scorecard> = new Map(), |
| 74 | 100 | recentTeamCards: ReadonlyMap<string, Scorecard> = new Map(), |
| 101 | spend: ReadonlyMap<string, AgentSpend> = new Map(), | |
| 75 | 102 | ): ListRow[] { |
| 76 | 103 | const agentRows = agents.flatMap((agent) => { |
| 77 | 104 | const s = cards.get(agent.user.id); |
| 78 | 105 | if (s === undefined) |
| 79 | return [{ title: agent.user.username, secondary: "no pull yet" }]; | |
| 106 | return [ | |
| 107 | { | |
| 108 | title: agent.user.username, | |
| 109 | secondary: "no pull yet", | |
| 110 | meta: spendText(spend.get(agent.user.username)), | |
| 111 | }, | |
| 112 | ]; | |
| 80 | 113 | const lately = new Map( |
| 81 | 114 | (recentSplits.get(agent.user.id) ?? []).map((p) => [splitLabel(p), p]), |
| 82 | 115 | ); |
| 83 | 116 | return [ |
| 84 | figures(agent.user.username, recent.get(agent.user.id), s), | |
| 117 | figures( | |
| 118 | agent.user.username, | |
| 119 | recent.get(agent.user.id), | |
| 120 | s, | |
| 121 | spendText(spend.get(agent.user.username)), | |
| 122 | ), | |
| 85 | 123 | ...(splits.get(agent.user.id) ?? []).map((p) => |
| 86 | 124 | figures(` ↳ ${splitLabel(p)}`, lately.get(splitLabel(p)), p), |
| 87 | 125 | ), |
| @@ -112,6 +150,7 @@ export function scorecardScreen(f: Session, ctx: Ctx): Screen { | ||
| 112 | 150 | listTeams(f.db), |
| 113 | 151 | teamScorecards(f.db), |
| 114 | 152 | teamScorecards(f.db, recentWindow()), |
| 153 | agentSpend(f), | |
| 115 | 154 | ) |
| 116 | 155 | : []; |
| 117 | 156 | return { |
test/tui-scorecard-spend.test.ts added +90
| @@ -0,0 +1,90 @@ | ||
| 1 | /** The crew scorecard's spend column (#7349): each agent's cost over 5h · today · 7d, `—` for none. */ | |
| 2 | import { afterAll, 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 { createApp } from "../src/app.ts"; | |
| 7 | import { createAgent } from "../src/actions/crew.ts"; | |
| 8 | import { createRepo } from "../src/actions/repos.ts"; | |
| 9 | import { createRun } from "../src/core/runs.ts"; | |
| 10 | import { recordUsage } from "../src/core/usage.ts"; | |
| 11 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 12 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 13 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 14 | import { quietLogs } from "./helpers/globals.ts"; | |
| 15 | import { settleApp } from "./helpers/tui.ts"; | |
| 16 | import { spendByAgent } from "../src/tui/screens/scorecard.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | const dir = await mkdtemp(join(tmpdir(), "forge-tui-scorecard-spend-")); | |
| 21 | const app = createApp({ dataDir: dir, port: 0 }); | |
| 22 | afterAll(async () => { | |
| 23 | app.close(); | |
| 24 | await rm(dir, { recursive: true, force: true }); | |
| 25 | }); | |
| 26 | ||
| 27 | test("each agent's row carries its own spend, and an agent that spent nothing shows —", async () => { | |
| 28 | for (const [username, adminIfFirst] of [["ada", true], ["bob", false]] as const) { | |
| 29 | const made = await createUser(app.db, { | |
| 30 | origin: "seed", trusted: true, username, email: `${username}@forge.local`, | |
| 31 | password: "a-good-passphrase", adminIfFirst, | |
| 32 | }); | |
| 33 | if (!made.ok) throw new Error(made.error); | |
| 34 | } | |
| 35 | const ada = getUserByName(app.db, "ada"); | |
| 36 | const repo = await createRepo(app, ada, { name: "hello", visibility: "public" }); | |
| 37 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 38 | const ids: Record<string, string> = {}; | |
| 39 | for (const name of ["smith", "jones", "idle"]) { | |
| 40 | const a = await createAgent(app, ada, { username: name }); | |
| 41 | if (!a.ok) throw new Error(String(a.error)); | |
| 42 | ids[name] = a.value.user.id; | |
| 43 | } | |
| 44 | const spend = (agent: string, costUsd: number, ageMs: number) => | |
| 45 | recordUsage(app.db, { | |
| 46 | runId: createRun(app.db, { repoId: repo.value.id, kind: "agent" }).id, | |
| 47 | repoId: repo.value.id, issueId: null, agentId: ids[agent]!, subscriptionId: null, | |
| 48 | tokensIn: 1, tokensOut: 1, costUsd, wallMs: 1, outcome: "ok", | |
| 49 | at: new Date(Date.now() - ageMs).toISOString(), | |
| 50 | }); | |
| 51 | spend("smith", 1.5, 60_000); | |
| 52 | spend("smith", 2, 6 * 86_400_000); | |
| 53 | spend("jones", 7.25, 60_000); | |
| 54 | ||
| 55 | const open = async (username: string) => { | |
| 56 | const { app: tui } = createForgeApp( | |
| 57 | app, | |
| 58 | { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(app.db, username)! }, | |
| 59 | { terminal: headlessTerminal(200, 40), exitHooks: false }, | |
| 60 | ); | |
| 61 | tui.start(); | |
| 62 | const key = (k: string) => tui.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 63 | key(":"); | |
| 64 | for (const ch of "Crew scorecard per agent") key(ch); | |
| 65 | await settleApp(tui); | |
| 66 | key("enter"); | |
| 67 | await settleApp(tui); | |
| 68 | const text = tui.screen.toText(); | |
| 69 | tui.stop(); | |
| 70 | return text; | |
| 71 | }; | |
| 72 | const text = await open("ada"); | |
| 73 | expect(text).toContain("spend $ 7.25 · 7.25 · 7.25"); | |
| 74 | expect(text).toContain("spend $ 1.50 · 1.50 · 3.50"); | |
| 75 | expect(text).toContain("spend —"); | |
| 76 | expect(text).not.toContain("$0.00"); | |
| 77 | expect(await open("bob")).not.toContain("spend"); | |
| 78 | }); | |
| 79 | ||
| 80 | test("spans are matched by name, so a reordered readSpend still lands each figure under its label", () => { | |
| 81 | const span = (name: "5h" | "today" | "week", costUsd: number) => ({ | |
| 82 | span: name, | |
| 83 | since: "", | |
| 84 | total: { units: 0, tokensIn: 0, tokensOut: 0, costUsd }, | |
| 85 | byCredential: [], byApp: [], byIssue: [], byKind: [], | |
| 86 | byAgent: [{ name: "smith", units: 1, tokensIn: 1, tokensOut: 1, costUsd }], | |
| 87 | }); | |
| 88 | const got = spendByAgent([span("week", 7), span("5h", 1), span("today", 2)]); | |
| 89 | expect(got.get("smith")).toEqual([1, 2, 7]); | |
| 90 | }); |
new above
offline
The index i is assumed to match [5h, today, 7d]. Key on span.span, not position. Drop the double cast at line 55 by building a typed tuple.