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

A crew member's scorecard shows the last 30 days beside all time #3184

Merged opened this

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

Merged

torque/forge:torque/3168-a-crew-member-s-scorecard-shows-the-last → main 3 commits fork

  • Merged. torque/3168-a-crew-member-s-scorecard-shows-the-last was merged into main as 75a08c8.

Review

Sign in to review this change.
5 files changed +174 −51
README.md +3
@@ -27,6 +27,9 @@ claim state and the same apps-running note, never a claim link.
2727 Each agent's scorecard — pulls landed of opened, review rounds, red checks,
2828 reverts, time to land — is on Settings → Crew (`/settings/crew`) and in the
2929 terminal's palette as "Crew scorecard per agent"; both are a forge admin's only.
30 Landed, review rounds and red checks stand as the last 30 days (pulls opened
31 since then) beside all time, so tuning an agent shows as movement; reverts and
32 time to land are all time.
3033
3134 Git hosting, code browsing, and a JSON API in one fast Bun process, with
3235 **zero runtime dependencies** — the `package.json` dependency list is empty and
src/core/scorecard.ts +25 −4
@@ -4,7 +4,8 @@
44 * says what an agent spent and how many of its pulls landed; this says how
55 * hard they were to land and whether they stayed landed.
66 *
7 * Every figure is over the pulls an agent authored, all time:
7 * Every figure is over the pulls an agent authored — all time, or only those
8 * opened (issue.created_at) at or after `since`, so tuning shows movement:
89 *
910 * reviewRounds = count(changes_requested reviews on its landed pulls) ÷ landed
1011 * redCheckRate = failed ÷ finished (ok + failed) check runs on its pulls
@@ -35,8 +36,27 @@ export interface Scorecard {
3536 readonly meanLandMs: number | null;
3637 }
3738
39 /** Which pulls a scorecard counts: all of them, or those opened at or after `since`. */
40 export interface ScorecardWindow {
41 readonly since?: Date;
42 }
43
44 /** The recent window both doors show beside all time, in days. */
45 export const RECENT_DAYS = 30;
46
47 /** Where the recent window starts: `RECENT_DAYS` × 86 400 000 ms before `now`. */
48 export function recentWindow(now: number = Date.now()): ScorecardWindow {
49 return { since: new Date(now - RECENT_DAYS * 86_400_000) };
50 }
51
3852 /** Each agent's scorecard, keyed by its user id: one grouped query. */
39 export function crewScorecards(db: Database): Map<string, Scorecard> {
53 export function crewScorecards(
54 db: Database,
55 window: ScorecardWindow = {},
56 ): Map<string, Scorecard> {
57 // created_at is stored as ISO-8601 UTC, so the string compare is the time compare.
58 // An invalid Date throws here rather than widen the window to all time.
59 const since = window.since === undefined ? null : window.since.toISOString();
4060 const rows = db
4161 .query<
4262 {
@@ -49,7 +69,7 @@ export function crewScorecards(db: Database): Map<string, Scorecard> {
4969 reverts: number;
5070 mean_land_days: number | null;
5171 },
52 []
72 [string | null]
5373 >(
5474 `SELECT issue.author_id AS author_id,
5575 COUNT(*) AS opened,
@@ -74,9 +94,10 @@ export function crewScorecards(db: Database): Map<string, Scorecard> {
7494 FROM issue
7595 JOIN pull ON pull.issue_id = issue.id
7696 JOIN user ON user.id = issue.author_id AND user.is_agent = 1
97 WHERE ?1 IS NULL OR issue.created_at >= ?1
7798 GROUP BY issue.author_id`,
7899 )
79 .all();
100 .all(since);
80101 return new Map(
81102 rows.map((r) => {
82103 const checks = r.checks_ok + r.checks_failed;
src/tui/screens/scorecard.ts +31 −8
@@ -1,13 +1,16 @@
11 /**
22 * The crew's scorecard in the terminal, a forge admin's (#2677): each
3 * agent's pulls landed of opened, review rounds, red checks, reverts and
4 * time to land, as Settings → Crew shows them (`crewScorecards`). The gate
3 * agent's pulls landed of opened, review rounds and red checks over the
4 * last `RECENT_DAYS` days beside all time, then reverts and time to land
5 * over all time, as Settings → Crew shows them (`crewScorecards`). The gate
56 * is the page's: a forge admin; anyone else sees nothing.
67 */
78
89 import { listAllAgents } from "../../actions/crew.ts";
910 import {
1011 crewScorecards,
12 RECENT_DAYS,
13 recentWindow,
1114 spanText,
1215 type Scorecard,
1316 } from "../../core/scorecard.ts";
@@ -20,21 +23,34 @@ import type { Session } from "../session.ts";
2023 import { fill, len, split } from "../ui/layout.ts";
2124 import { ListState, drawList, type ListRow } from "./common.ts";
2225
23 /** One row per agent; one with no pull yet says so rather than a row of zeros. */
26 const landed = (s: Scorecard | undefined) =>
27 s === undefined ? "—" : `${s.landed} of ${s.opened}`;
28 const rounds = (s: Scorecard | undefined) =>
29 s?.reviewRounds == null ? "—" : s.reviewRounds.toFixed(1);
30 const red = (s: Scorecard | undefined) =>
31 s?.redCheckRate == null ? "—" : `${Math.round(100 * s.redCheckRate)}%`;
32
33 /**
34 * One row per agent; one with no pull yet says so rather than a row of
35 * zeros. Each windowed figure reads "<recent>d … · all …", the page's order.
36 */
2437 export function scorecardRows(
2538 agents: readonly Agent[],
2639 cards: ReadonlyMap<string, Scorecard>,
40 recent: ReadonlyMap<string, Scorecard>,
2741 ): ListRow[] {
42 const d = `${RECENT_DAYS}d`;
2843 return agents.map((agent) => {
2944 const s = cards.get(agent.user.id);
3045 if (s === undefined)
3146 return { title: agent.user.username, secondary: "no pull yet" };
47 const r = recent.get(agent.user.id);
3248 return {
3349 title: agent.user.username,
34 secondary: `${s.landed} landed of ${s.opened}`,
50 secondary: `landed ${d} ${landed(r)} · all ${landed(s)}`,
3551 meta: [
36 `review rounds ${s.reviewRounds === null ? "—" : s.reviewRounds.toFixed(1)}`,
37 `red checks ${s.redCheckRate === null ? "—" : `${Math.round(100 * s.redCheckRate)}%`}`,
52 `review rounds ${d} ${rounds(r)} · all ${rounds(s)}`,
53 `red checks ${d} ${red(r)} · all ${red(s)}`,
3854 `reverts ${s.reverts}`,
3955 `time to land ${s.meanLandMs === null ? "—" : spanText(s.meanLandMs)}`,
4056 ].join(" "),
@@ -48,7 +64,13 @@ export function scorecardScreen(f: Session, ctx: Ctx): Screen {
4864 // Read once when the screen opens, as the page reads it once per request.
4965 const listed = allowed ? listAllAgents(f.app, f.user) : undefined;
5066 const rows =
51 listed?.ok === true ? scorecardRows(listed.value, crewScorecards(f.db)) : [];
67 listed?.ok === true
68 ? scorecardRows(
69 listed.value,
70 crewScorecards(f.db),
71 crewScorecards(f.db, recentWindow()),
72 )
73 : [];
5274 return {
5375 title: "Crew scorecard per agent",
5476 keys: [binding(["j", "k"], "move cursor")],
@@ -59,7 +81,8 @@ export function scorecardScreen(f: Session, ctx: Ctx): Screen {
5981 ? "The crew's scorecard is read by a forge admin."
6082 : rows.length === 0
6183 ? "No agents yet."
62 : "Over every pull each agent opened, as Settings → Crew shows it.";
84 : `Over the last ${RECENT_DAYS} days (${RECENT_DAYS}d) and all time, ` +
85 "as Settings → Crew shows it.";
6386 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
6487 drawList(s, list!, c, state, rows);
6588 },
src/web/pages/crew.ts +46 −26
@@ -26,6 +26,8 @@ import {
2626 } from "../../core/runs.ts";
2727 import {
2828 crewScorecards,
29 RECENT_DAYS,
30 recentWindow,
2931 spanText,
3032 type Scorecard,
3133 } from "../../core/scorecard.ts";
@@ -229,25 +231,49 @@ function totalsCells(t: AgentTotals | undefined): Html {
229231
230232 /**
231233 * The scorecard: how each agent's pulls fare once opened — so the owner
232 * can tune who does what. What they cost is the table above's ("Per
233 * landed"), not repeated here. An agent with no pull yet is a row of
234 * dashes, never a row of misleading zeros. The agent's name stays in view
235 * while a phone scrolls the figures sideways.
234 * can tune who does what. Landed, review rounds and red checks stand as the
235 * last 30 days beside all time, so a change to an agent shows as movement.
236 * What they cost is the table above's ("Per landed"), not repeated here. An
237 * agent with no pull yet is a row of dashes, never a row of misleading
238 * zeros. The agent's name stays in view while a phone scrolls the figures
239 * sideways.
236240 */
237241 function scorecardCard(
238242 agents: readonly Agent[],
239243 cards: ReadonlyMap<string, Scorecard>,
244 recent: ReadonlyMap<string, Scorecard>,
240245 ): Html {
241246 const dash = html`<td class="num muted">—</td>`;
247 const lately = `${RECENT_DAYS} days`;
248 const windowHead = (label: string, span: string) =>
249 html`<th>${label}<br /><span class="muted small">${span}</span></th>`;
250 const landed = (s: Scorecard | undefined) =>
251 s === undefined
252 ? dash
253 : html`<td class="num">
254 ${s.landed}
255 <span class="muted small">of ${s.opened}</span>
256 </td>`;
257 const rounds = (s: Scorecard | undefined) =>
258 s?.reviewRounds == null
259 ? dash
260 : html`<td class="num">${s.reviewRounds.toFixed(1)}</td>`;
261 const red = (s: Scorecard | undefined) =>
262 s?.redCheckRate == null
263 ? dash
264 : html`<td class="num">${Math.round(100 * s.redCheckRate)}%</td>`;
242265 return card(
243266 html`<div class="table-scroll">
244267 <table class="table" id="crew-scorecard" data-sticky-lead>
245268 <thead>
246269 <tr>
247270 <th>Agent</th>
248 <th>Pulls landed</th>
249 <th>Review rounds</th>
250 <th>Red checks</th>
271 ${windowHead("Pulls landed", lately)}
272 ${windowHead("Pulls landed", "all time")}
273 ${windowHead("Review rounds", lately)}
274 ${windowHead("Review rounds", "all time")}
275 ${windowHead("Red checks", lately)}
276 ${windowHead("Red checks", "all time")}
251277 <th>Reverts</th>
252278 <th>Time to land</th>
253279 </tr>
@@ -255,6 +281,7 @@ function scorecardCard(
255281 <tbody>
256282 ${agents.map((agent) => {
257283 const s = cards.get(agent.user.id);
284 const r = recent.get(agent.user.id);
258285 return html`<tr>
259286 <td>
260287 ${userLink(agent.user.username, {
@@ -262,21 +289,10 @@ function scorecardCard(
262289 mark: markOf(agent.user),
263290 })}
264291 </td>
292 ${landed(r)}${landed(s)}${rounds(r)}${rounds(s)}${red(r)}${red(s)}
265293 ${s === undefined
266 ? html`${dash}${dash}${dash}${dash}${dash}`
267 : html`<td class="num">
268 ${s.landed}
269 <span class="muted small">of ${s.opened}</span>
270 </td>
271 ${s.reviewRounds === null
272 ? dash
273 : html`<td class="num">${s.reviewRounds.toFixed(1)}</td>`}
274 ${s.redCheckRate === null
275 ? dash
276 : html`<td class="num">
277 ${Math.round(100 * s.redCheckRate)}%
278 </td>`}
279 <td class="num">${s.reverts}</td>
294 ? html`${dash}${dash}`
295 : html`<td class="num">${s.reverts}</td>
280296 ${s.meanLandMs === null
281297 ? dash
282298 : html`<td class="num">${spanText(s.meanLandMs)}</td>`}`}
@@ -291,10 +307,11 @@ function scorecardCard(
291307 head: html`${icon("activity")}
292308 <h2>Scorecard</h2>
293309 <span class="muted small"
294 >Over every pull each agent opened: review rounds are change
295 requests per landed pull, red checks the share of finished checks
296 that failed, reverts the landed pulls a merged pull took back, time
297 to land the mean from opening to merge.</span
310 >Over the pulls each agent opened in the last ${RECENT_DAYS} days
311 and over all time: review rounds are change requests per landed pull, red checks
312 the share of finished checks that failed; reverts (the landed pulls
313 a merged pull took back) and time to land (the mean from opening to
314 merge) are all time.</span
298315 >`,
299316 },
300317 );
@@ -363,6 +380,7 @@ function crewView(ctx: Ctx, values: AgentValues, error?: string): Html {
363380 const agents = listed.ok ? listed.value : [];
364381 const totals = agentTotals(ctx.app.db);
365382 const scorecards = crewScorecards(ctx.app.db);
383 const recentScorecards = crewScorecards(ctx.app.db, recentWindow());
366384 // Where the money goes today: every app running on its own, its spend
367385 // against its ceiling. The ceilings are the one control on the drain, and
368386 // this is the page where the crew is wielded, so they are read here.
@@ -460,7 +478,9 @@ function crewView(ctx: Ctx, values: AgentValues, error?: string): Html {
460478 </div>`,
461479 { pad: false },
462480 )}
463 ${when(agents.length > 0, () => scorecardCard(agents, scorecards))}
481 ${when(agents.length > 0, () =>
482 scorecardCard(agents, scorecards, recentScorecards),
483 )}
464484 ${splitsCard(
465485 "Per model",
466486 "Model",
test/crew-scorecard.test.ts +69 −13
@@ -10,7 +10,7 @@ import {
1010 createRun,
1111 finishRun,
1212 } from "../src/core/runs.ts";
13 import { crewScorecards } from "../src/core/scorecard.ts";
13 import { crewScorecards, recentWindow } from "../src/core/scorecard.ts";
1414 import { createUser, getUserByName } from "../src/core/users.ts";
1515 import { createForgeApp } from "../src/tui/forge.ts";
1616 import { headlessTerminal } from "../src/tui/term/terminal.ts";
@@ -97,14 +97,10 @@ describe("a scorecard per crew member", () => {
9797 headers: { cookie },
9898 }).then((r) => r.text());
9999 expect(page).toContain(`id="crew-scorecard"`);
100 for (const head of [
101 "Pulls landed",
102 "Review rounds",
103 "Red checks",
104 "Reverts",
105 "Time to land",
106 ])
107 expect(page).toContain(`<th>${head}</th>`);
100 for (const head of ["Pulls landed", "Review rounds", "Red checks"])
101 for (const span of ["30 days", "all time"])
102 expect(page).toContain(`<th>${head}<br /><span class="muted small">${span}</span></th>`);
103 for (const head of ["Reverts", "Time to land"]) expect(page).toContain(`<th>${head}</th>`);
108104 // What a landed pull costs is the agent table's "Per landed"; one figure, one column.
109105 expect(page).not.toContain("<th>Landed per $</th>");
110106 // The agent's name stays in view while a phone scrolls the figures.
@@ -166,6 +162,53 @@ describe("a scorecard per crew member", () => {
166162 expect(table).toContain("3h");
167163 });
168164
165 test("the last 30 days and all time are different figures over old and new pulls", async () => {
166 const old = (n: number) => `2020-01-0${n}T00:00:00.000Z`;
167 await fetch(`${base}/api/v1/agents`, {
168 method: "POST",
169 headers: { cookie, "content-type": "application/json" },
170 body: JSON.stringify({ username: "jones", model: "opus" }),
171 });
172 const jones = getUserByName(app.db, "jones")!.id;
173 // Long ago: two landed, three change requests between them, one red check of one.
174 const a = pull(jones, "Old one", { openedAt: old(1), mergedAt: old(2) });
175 const b = pull(jones, "Old two", { openedAt: old(3), mergedAt: old(4) });
176 for (const issueId of [a, a, b])
177 submitReview(app.db, { issueId, authorId: adminId, state: "changes_requested", body: "", commitOid: "b" });
178 check(a, "failed");
179 // Lately: one landed with no change request and a green check, one still open.
180 const hour = 3_600_000;
181 const recent = pull(jones, "New one", {
182 openedAt: new Date(Date.now() - 2 * hour).toISOString(),
183 mergedAt: new Date(Date.now() - hour).toISOString(),
184 });
185 check(recent, "ok");
186 pull(jones, "New, open");
187
188 // The window both doors share: 30 days of 86 400 000 ms before now.
189 expect(recentWindow(Date.UTC(2026, 9, 31)).since).toEqual(new Date(Date.UTC(2026, 9, 1)));
190 const all = crewScorecards(app.db).get(jones)!;
191 const month = crewScorecards(app.db, recentWindow()).get(jones)!;
192 expect([all.opened, all.landed]).toEqual([4, 3]);
193 expect(all.reviewRounds).toBe(1); // 3 change requests ÷ 3 landed
194 expect(all.redCheckRate).toBe(0.5); // 1 failed ÷ 2 finished
195 expect([month.opened, month.landed]).toEqual([2, 1]);
196 expect(month.reviewRounds).toBe(0); // 0 ÷ 1 landed
197 expect(month.redCheckRate).toBe(0); // 0 failed ÷ 1 finished
198 // An agent with nothing in the window is absent from it, not zeros.
199 const later = new Date(Date.now() + 86_400_000);
200 expect(crewScorecards(app.db, { since: later }).has(jones)).toBe(false);
201
202 const page = await fetch(`${base}/settings/crew`, { headers: { cookie } }).then((r) => r.text());
203 const table = page.slice(page.indexOf(`id="crew-scorecard"`));
204 const row = table.slice(table.indexOf("jones"), table.indexOf("</tr>", table.indexOf("jones")));
205 const cells = [...row.matchAll(/<td class="num">([\s\S]*?)<\/td>/g)].map((m) =>
206 m[1]!.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim(),
207 );
208 // 30 days beside all time: landed, review rounds, red checks; then reverts.
209 expect(cells.slice(0, 7)).toEqual(["1 of 2", "3 of 4", "0.0", "1.0", "0%", "50%", "0"]);
210 });
211
169212 /** The forge's terminal, signed in as one account, on a headless screen. */
170213 function terminal(username: string) {
171214 const user = getUserByName(app.db, username)!;
@@ -198,14 +241,27 @@ describe("a scorecard per crew member", () => {
198241 await t.enter();
199242 const text = t.text();
200243 expect(text).toContain("smith");
201 expect(text).toContain("2 landed of 3");
202 expect(text).toContain("review rounds 1.0");
203 expect(text).toContain("red checks 25%");
244 expect(text).toContain("· all 2 of 3");
245 expect(text).toContain("· all 1.0");
246 expect(text).toContain("· all 25%");
204247 expect(text).toContain("reverts 1");
205248 expect(text).toContain("time to land 3h");
206249 t.stop();
207250 });
208251
252 test("the terminal shows the last 30 days beside all time, as the page does", async () => {
253 // jones: two landed pulls from 2020 and two opened within the hour (see above).
254 const t = terminal("ada");
255 await t.palette("Crew scorecard per agent");
256 await t.enter();
257 const text = t.text();
258 expect(text).toContain("Over the last 30 days (30d) and all time");
259 expect(text).toContain("landed 30d 1 of 2 · all 3 of 4");
260 expect(text).toContain("review rounds 30d 0.0 · all 1.0");
261 expect(text).toContain("red checks 30d 0% · all 50%");
262 t.stop();
263 });
264
209265 test("someone who is not an admin has no scorecard in the terminal", async () => {
210266 const made = await createUser(app.db, {
211267 origin: "seed",
@@ -219,7 +275,7 @@ describe("a scorecard per crew member", () => {
219275 await t.palette("Crew scorecard per agent");
220276 expect(t.text()).not.toContain("review rounds, red checks");
221277 await t.enter();
222 expect(t.text()).not.toContain("2 landed of 3");
278 expect(t.text()).not.toContain("all 2 of 3");
223279 t.stop();
224280 });
225281 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs