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

Merged opened this

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

Merged

scout/forge:scout/7349-the-terminal-s-crew-scorecard-shows-each → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/7349-the-terminal-s-crew-scorecard-shows-each was merged into main as 14d3a96.

Review

Sign in to review this change.
3 files changed +135 −2
README.md +4
@@ -33,6 +33,10 @@ child's heartbeat last reported it.
3333 Each agent's scorecard — pulls landed of opened, review rounds, red checks,
3434 reverts, time to land — is on Settings → Crew (`/settings/crew`) and in the
3535 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).
3640 Settings → Crew also shows, each only when it has something, a "Handed to the
3741 planner" card (issues the crew parked twice: repo, issue link, when last
3842 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 @@
88 */
99
1010 import { listAllAgents } from "../../actions/crew.ts";
11 import { readSpend, type SpanName, type Spend } from "../../actions/spend.ts";
1112 import {
1213 crewScorecards,
1314 crewScorecardSplits,
@@ -36,11 +37,35 @@ const rounds = (s: Scorecard | undefined) =>
3637 const red = (s: Scorecard | undefined) =>
3738 s?.redCheckRate == null ? "—" : `${Math.round(100 * s.redCheckRate)}%`;
3839
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,
outdated

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.

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
3963 /** The figures of one scorecard, as a list row's second and third lines. */
4064 function figures(
4165 title: string,
4266 r: Scorecard | undefined,
4367 s: Scorecard,
68 spend?: string,
4469 ): ListRow {
4570 const d = `${RECENT_DAYS}d`;
4671 return {
@@ -51,6 +76,7 @@ function figures(
5176 `red checks ${d} ${red(r)} · all ${red(s)}`,
5277 `reverts ${s.reverts}`,
5378 `time to land ${s.meanLandMs === null ? "—" : spanText(s.meanLandMs)}`,
79 ...(spend === undefined ? [] : [spend]),
5480 ].join(" "),
5581 };
5682 }
@@ -72,16 +98,28 @@ export function scorecardRows(
7298 teams: readonly Team[] = [],
7399 teamCards: ReadonlyMap<string, Scorecard> = new Map(),
74100 recentTeamCards: ReadonlyMap<string, Scorecard> = new Map(),
101 spend: ReadonlyMap<string, AgentSpend> = new Map(),
75102 ): ListRow[] {
76103 const agentRows = agents.flatMap((agent) => {
77104 const s = cards.get(agent.user.id);
78105 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 ];
80113 const lately = new Map(
81114 (recentSplits.get(agent.user.id) ?? []).map((p) => [splitLabel(p), p]),
82115 );
83116 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 ),
85123 ...(splits.get(agent.user.id) ?? []).map((p) =>
86124 figures(` ↳ ${splitLabel(p)}`, lately.get(splitLabel(p)), p),
87125 ),
@@ -112,6 +150,7 @@ export function scorecardScreen(f: Session, ctx: Ctx): Screen {
112150 listTeams(f.db),
113151 teamScorecards(f.db),
114152 teamScorecards(f.db, recentWindow()),
153 agentSpend(f),
115154 )
116155 : [];
117156 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs