Public: anyone can read this; the crew can change it.
/crew shows a scorecard row per team, pooled from its members #3227
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3204-crew-shows-a-scorecard-row-per-team-pool → main 2 commits fork 2 threads · 2 unresolved
5 files
changed +320
−27
README.md +6 −1
| @@ -27,6 +27,9 @@ claim state and the same apps-running note, never a claim link. | ||
| 27 | 27 | Each agent's scorecard — pulls landed of opened, review rounds, red checks, |
| 28 | 28 | reverts, time to land — is on Settings → Crew (`/settings/crew`) and in the |
| 29 | 29 | terminal's palette as "Crew scorecard per agent"; both are a forge admin's only. |
| 30 | Under the agents, each team that has a pull has one pooled row (its members' | |
| 31 | counts added, the ratios taken from the sums; an agent on two teams counts in | |
| 32 | both): `data-team` rows on the page, "team <name>" rows in that terminal screen. | |
| 30 | 33 | Landed, review rounds and red checks stand as the last 30 days (pulls opened |
| 31 | 34 | since then) beside all time, so tuning an agent shows as movement; reverts and |
| 32 | 35 | time to land are all time. Under each agent, a sub-row per model and prompt |
| @@ -938,7 +941,9 @@ share a version and editing the brief starts a new one). Settings → Crew shows | ||
| 938 | 941 | the scorecard cut both ways ("Per model", "Per prompt version": runs, landed, |
| 939 | 942 | failed, spent, per run), and the terminal's `Crew scorecard` command shows the |
| 940 | 943 | same to an admin. The scorecard of pulls landed, review rounds and red checks |
| 941 | is cut the same way as sub-rows under each agent (see above). Runs from before this was recorded show as *default* / | |
| 944 | is cut the same way as sub-rows under each agent (see above); a team's pooled | |
| 945 | row is not cut by model, and is under the agents on the page and in the | |
| 946 | terminal's "Crew scorecard per agent" (see above). Runs from before this was recorded show as *default* / | |
| 942 | 947 | *not recorded*; they are not backfilled. |
| 943 | 948 | |
| 944 | 949 | **Tell the crew from any page.** In a repository you may direct, the web palette |
src/core/scorecard.ts +90 −21
| @@ -20,6 +20,7 @@ | ||
| 20 | 20 | */ |
| 21 | 21 | |
| 22 | 22 | import type { Database } from "bun:sqlite"; |
| 23 | import { listTeams } from "./teams.ts"; | |
| 23 | 24 | |
| 24 | 25 | export interface Scorecard { |
| 25 | 26 | /** Pulls the agent opened. */ |
| @@ -71,6 +72,63 @@ export function crewScorecards( | ||
| 71 | 72 | return new Map(scorecardRows(db, window, false).map((r) => [r.authorId, r.card])); |
| 72 | 73 | } |
| 73 | 74 | |
| 75 | /** | |
| 76 | * Each team's scorecard, keyed by team slug: its members' counts pooled, then | |
| 77 | * the ratios taken from the pooled counts (never an average of the members' | |
| 78 | * ratios, which would weigh a one-pull agent like a fifty-pull one). A team | |
| 79 | * whose members opened no pull in the window is absent. An agent on two | |
| 80 | * teams counts toward both. | |
| 81 | */ | |
| 82 | export function teamScorecards( | |
| 83 | db: Database, | |
| 84 | window: ScorecardWindow = {}, | |
| 85 | ): Map<string, Scorecard> { | |
| 86 | const byAgent = new Map(scorecardRows(db, window, false).map((r) => [r.authorId, r])); | |
| 87 | const out = new Map<string, Scorecard>(); | |
| 88 | for (const team of listTeams(db)) { | |
| 89 | const sum = { opened: 0, landed: 0, rounds: 0, ok: 0, failed: 0, reverts: 0, landDays: 0 }; | |
| 90 | let any = false; | |
| 91 | for (const id of team.members) { | |
| 92 | const r = byAgent.get(id); | |
| 93 | if (r === undefined) continue; | |
| 94 | any = true; | |
| 95 | sum.opened += r.card.opened; | |
| 96 | sum.landed += r.card.landed; | |
| 97 | sum.rounds += r.rounds; | |
| 98 | sum.ok += r.checksOk; | |
| 99 | sum.failed += r.checksFailed; | |
| 100 | sum.reverts += r.card.reverts; | |
| 101 | sum.landDays += r.landDays; | |
| 102 | } | |
| 103 | if (any) out.set(team.slug, toCard(sum)); | |
| 104 | } | |
| 105 | return out; | |
| 106 | } | |
| 107 | ||
| 108 | interface Counts { | |
| 109 | opened: number; | |
| 110 | landed: number; | |
| 111 | rounds: number; | |
| 112 | ok: number; | |
| 113 | failed: number; | |
| 114 | reverts: number; | |
| 115 | /** Days from opening to merge, summed over landed pulls. */ | |
| 116 | landDays: number; | |
| 117 | } | |
| 118 | ||
| 119 | function toCard(c: Counts): Scorecard { | |
| 120 | const checks = c.ok + c.failed; | |
| 121 | return { | |
| 122 | opened: c.opened, | |
| 123 | landed: c.landed, | |
| 124 | reviewRounds: c.landed === 0 ? null : c.rounds / c.landed, | |
| 125 | redCheckRate: checks === 0 ? null : c.failed / checks, | |
| 126 | reverts: c.reverts, | |
| 127 | meanLandMs: | |
| 128 | c.landed === 0 ? null : Math.max(0, Math.round((c.landDays / c.landed) * 86_400_000)), | |
| 129 | }; | |
| 130 | } | |
| 131 | ||
| 74 | 132 | /** |
| 75 | 133 | * Each agent's scorecard cut by (model, prompt version), keyed by user id. |
| 76 | 134 | * A pull belongs to the pairing of the agent's latest run on it; a pull with |
| @@ -101,7 +159,16 @@ function scorecardRows( | ||
| 101 | 159 | db: Database, |
| 102 | 160 | window: ScorecardWindow, |
| 103 | 161 | split: boolean, |
| 104 | ): { authorId: string; model: string; promptVersion: string; card: Scorecard }[] { | |
| 162 | ): { | |
| 163 | authorId: string; | |
| 164 | model: string; | |
| 165 | promptVersion: string; | |
| 166 | card: Scorecard; | |
| 167 | rounds: number; | |
| 168 | checksOk: number; | |
| 169 | checksFailed: number; | |
| 170 | landDays: number; | |
| 171 | }[] { | |
| 105 | 172 | // created_at is stored as ISO-8601 UTC, so the string compare is the time compare. |
| 106 | 173 | // An invalid Date throws here rather than widen the window to all time. |
| 107 | 174 | const since = window.since === undefined ? null : window.since.toISOString(); |
| @@ -117,7 +184,7 @@ function scorecardRows( | ||
| 117 | 184 | checks_ok: number; |
| 118 | 185 | checks_failed: number; |
| 119 | 186 | reverts: number; |
| 120 | mean_land_days: number | null; | |
| 187 | land_days: number | null; | |
| 121 | 188 | }, |
| 122 | 189 | [string | null] |
| 123 | 190 | >( |
| @@ -140,9 +207,9 @@ function scorecardRows( | ||
| 140 | 207 | SELECT 1 FROM pull AS back |
| 141 | 208 | WHERE back.reverts_issue_id = issue.id |
| 142 | 209 | AND back.merged_at IS NOT NULL)) AS reverts, |
| 143 | AVG(CASE WHEN pull.merged_at IS NOT NULL | |
| 210 | SUM(CASE WHEN pull.merged_at IS NOT NULL | |
| 144 | 211 | THEN julianday(pull.merged_at) - julianday(issue.created_at) END) |
| 145 | AS mean_land_days | |
| 212 | AS land_days | |
| 146 | 213 | FROM issue |
| 147 | 214 | JOIN pull ON pull.issue_id = issue.id |
| 148 | 215 | JOIN user ON user.id = issue.author_id AND user.is_agent = 1 |
| @@ -151,23 +218,25 @@ function scorecardRows( | ||
| 151 | 218 | ) |
| 152 | 219 | .all(since); |
| 153 | 220 | return rows.map((r) => { |
| 154 | const checks = r.checks_ok + r.checks_failed; | |
| 155 | return { | |
| 156 | authorId: r.author_id, | |
| 157 | model: r.model, | |
| 158 | promptVersion: r.prompt_version, | |
| 159 | card: { | |
| 160 | opened: r.opened, | |
| 161 | landed: r.landed, | |
| 162 | reviewRounds: r.landed === 0 ? null : r.rounds / r.landed, | |
| 163 | redCheckRate: checks === 0 ? null : r.checks_failed / checks, | |
| 164 | reverts: r.reverts, | |
| 165 | meanLandMs: | |
| 166 | r.mean_land_days === null | |
| 167 | ? null | |
| 168 | : Math.max(0, Math.round(r.mean_land_days * 86_400_000)), | |
| 169 | }, | |
| 170 | }; | |
| 221 | const counts = { | |
| 222 | opened: r.opened, | |
| 223 | landed: r.landed, | |
| 224 | rounds: r.rounds, | |
| 225 | ok: r.checks_ok, | |
| 226 | failed: r.checks_failed, | |
| 227 | reverts: r.reverts, | |
| 228 | landDays: r.land_days ?? 0, | |
| 229 | }; | |
| 230 | return { | |
| 231 | authorId: r.author_id, | |
| 232 | model: r.model, | |
| 233 | promptVersion: r.prompt_version, | |
| 234 | card: toCard(counts), | |
| 235 | rounds: r.rounds, | |
| 236 | checksOk: r.checks_ok, | |
| 237 | checksFailed: r.checks_failed, | |
| 238 | landDays: counts.landDays, | |
| 239 | }; | |
| 171 | 240 | }); |
| 172 | 241 | } |
| 173 | 242 |
src/tui/screens/scorecard.ts +19 −3
| @@ -11,6 +11,7 @@ import { listAllAgents } from "../../actions/crew.ts"; | ||
| 11 | 11 | import { |
| 12 | 12 | crewScorecards, |
| 13 | 13 | crewScorecardSplits, |
| 14 | teamScorecards, | |
| 14 | 15 | RECENT_DAYS, |
| 15 | 16 | recentWindow, |
| 16 | 17 | spanText, |
| @@ -19,6 +20,7 @@ import { | ||
| 19 | 20 | type SplitScorecard, |
| 20 | 21 | } from "../../core/scorecard.ts"; |
| 21 | 22 | import type { Agent } from "../../core/agents.ts"; |
| 23 | import { listTeams, type Team } from "../../core/teams.ts"; | |
| 22 | 24 | import type { Ctx, Screen } from "../app/app.ts"; |
| 23 | 25 | import { binding } from "../app/keys.ts"; |
| 24 | 26 | import { style } from "../term/style.ts"; |
| @@ -58,7 +60,8 @@ function figures( | ||
| 58 | 60 | * zeros. Each windowed figure reads "<recent>d … · all …", the page's order. |
| 59 | 61 | * Under an agent, one indented row per model · prompt version its pulls ran |
| 60 | 62 | * on, with the same figures ("unrecorded" for pulls from before runs |
| 61 | * recorded them). | |
| 63 | * recorded them). Then one row per team that has a card, its members' | |
| 64 | * counts pooled (an agent on two teams counts in both), under the agents. | |
| 62 | 65 | */ |
| 63 | 66 | export function scorecardRows( |
| 64 | 67 | agents: readonly Agent[], |
| @@ -66,8 +69,11 @@ export function scorecardRows( | ||
| 66 | 69 | recent: ReadonlyMap<string, Scorecard>, |
| 67 | 70 | splits: ReadonlyMap<string, readonly SplitScorecard[]> = new Map(), |
| 68 | 71 | recentSplits: ReadonlyMap<string, readonly SplitScorecard[]> = new Map(), |
| 72 | teams: readonly Team[] = [], | |
| 73 | teamCards: ReadonlyMap<string, Scorecard> = new Map(), | |
| 74 | recentTeamCards: ReadonlyMap<string, Scorecard> = new Map(), | |
| 69 | 75 | ): ListRow[] { |
| 70 | return agents.flatMap((agent) => { | |
| 76 | const agentRows = agents.flatMap((agent) => { | |
| 71 | 77 | const s = cards.get(agent.user.id); |
| 72 | 78 | if (s === undefined) |
| 73 | 79 | return [{ title: agent.user.username, secondary: "no pull yet" }]; |
| @@ -81,6 +87,13 @@ export function scorecardRows( | ||
| 81 | 87 | ), |
| 82 | 88 | ]; |
| 83 | 89 | }); |
| 90 | const teamRows = teams.flatMap((team) => { | |
| 91 | const s = teamCards.get(team.slug); | |
| 92 | return s === undefined | |
| 93 | ? [] | |
| 94 | : [figures(`team ${team.name}`, recentTeamCards.get(team.slug), s)]; | |
| 95 | }); | |
| 96 | return [...agentRows, ...teamRows]; | |
| 84 | 97 | } |
| 85 | 98 | |
| 86 | 99 | export function scorecardScreen(f: Session, ctx: Ctx): Screen { |
| @@ -96,6 +109,9 @@ export function scorecardScreen(f: Session, ctx: Ctx): Screen { | ||
| 96 | 109 | crewScorecards(f.db, recentWindow()), |
| 97 | 110 | crewScorecardSplits(f.db), |
| 98 | 111 | crewScorecardSplits(f.db, recentWindow()), |
| 112 | listTeams(f.db), | |
| 113 | teamScorecards(f.db), | |
| 114 | teamScorecards(f.db, recentWindow()), | |
| 99 | 115 | ) |
| 100 | 116 | : []; |
| 101 | 117 | return { |
| @@ -109,7 +125,7 @@ export function scorecardScreen(f: Session, ctx: Ctx): Screen { | ||
| 109 | 125 | : rows.length === 0 |
| 110 | 126 | ? "No agents yet." |
| 111 | 127 | : `Over the last ${RECENT_DAYS} days (${RECENT_DAYS}d) and all time, ` + |
| 112 | "as Settings → Crew shows it."; | |
| 128 | "as Settings → Crew shows it, with a pooled row per team."; | |
| 113 | 129 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 114 | 130 | drawList(s, list!, c, state, rows); |
| 115 | 131 | }, |
src/web/pages/crew.ts +20 −2
| @@ -27,6 +27,7 @@ import { | ||
| 27 | 27 | import { |
| 28 | 28 | crewScorecards, |
| 29 | 29 | crewScorecardSplits, |
| 30 | teamScorecards, | |
| 30 | 31 | splitLabel, |
| 31 | 32 | RECENT_DAYS, |
| 32 | 33 | recentWindow, |
| @@ -34,6 +35,7 @@ import { | ||
| 34 | 35 | type Scorecard, |
| 35 | 36 | type SplitScorecard, |
| 36 | 37 | } from "../../core/scorecard.ts"; |
| 38 | import { listTeams, type Team } from "../../core/teams.ts"; | |
| 37 | 39 | import { crewFor } from "../../crew/dispatch.ts"; |
| 38 | 40 | import { MODELS } from "../../crew/models.ts"; |
| 39 | 41 | import type { Ctx } from "../../http/context.ts"; |
| @@ -247,6 +249,9 @@ const pairKey = (p: SplitScorecard) => `${p.model}\0${p.promptVersion}`; | ||
| 247 | 249 | */ |
| 248 | 250 | function scorecardCard( |
| 249 | 251 | agents: readonly Agent[], |
| 252 | teams: readonly Team[], | |
| 253 | teamCards: ReadonlyMap<string, Scorecard>, | |
| 254 | recentTeamCards: ReadonlyMap<string, Scorecard>, | |
| 250 | 255 | cards: ReadonlyMap<string, Scorecard>, |
| 251 | 256 | recent: ReadonlyMap<string, Scorecard>, |
| 252 | 257 | splits: ReadonlyMap<string, readonly SplitScorecard[]>, |
| @@ -284,7 +289,7 @@ function scorecardCard( | ||
| 284 | 289 | <table class="table" id="crew-scorecard" data-sticky-lead> |
| 285 | 290 | <thead> |
| 286 | 291 | <tr> |
| 287 | <th>Agent</th> | |
| 292 | <th>Agent / team</th> | |
| 288 | 293 | ${windowHead("Pulls landed", lately)} |
| 289 | 294 | ${windowHead("Pulls landed", "all time")} |
| 290 | 295 | ${windowHead("Review rounds", lately)} |
| @@ -320,6 +325,12 @@ function scorecardCard( | ||
| 320 | 325 | </tr>`; |
| 321 | 326 | })}`; |
| 322 | 327 | })} |
| 328 | ${teams | |
| 329 | .filter((team) => teamCards.has(team.slug)) | |
| 330 | .map((team) => html`<tr data-team="${team.slug}"> | |
The only cue that this is a team is a small muted word. Add a tokenised separator or tone for [data-team] rows (a top border from existing tokens) so the group reads at a glance on 360px. |
||
| 331 | <td>${team.name} <span class="muted small">team</span></td> | |
| 332 | ${figures(recentTeamCards.get(team.slug), teamCards.get(team.slug))} | |
| 333 | </tr>`)} | |
| 323 | 334 | </tbody> |
| 324 | 335 | </table> |
| 325 | 336 | </div>`, |
| @@ -329,7 +340,8 @@ function scorecardCard( | ||
| 329 | 340 | head: html`${icon("activity")} |
| 330 | 341 | <h2>Scorecard</h2> |
| 331 | 342 | <span class="muted small" |
| 332 | >Over the pulls each agent opened in the last ${RECENT_DAYS} days | |
| 343 | >Over the pulls each agent opened, and each team's members pooled (an | |
| 344 | agent on two teams counts in both), in the last ${RECENT_DAYS} days | |
| 333 | 345 | and over all time: review rounds are change requests per landed pull, red checks |
| 334 | 346 | the share of finished checks that failed; reverts (the landed pulls |
| 335 | 347 | a merged pull took back) and time to land (the mean from opening to |
| @@ -401,6 +413,9 @@ function crewView(ctx: Ctx, values: AgentValues, error?: string): Html { | ||
| 401 | 413 | const listed = listAllAgents(ctx.app, ctx.auth.user); |
| 402 | 414 | const agents = listed.ok ? listed.value : []; |
| 403 | 415 | const totals = agentTotals(ctx.app.db); |
| 416 | const teams = listTeams(ctx.app.db); | |
| 417 | const teamCards = teamScorecards(ctx.app.db); | |
| 418 | const recentTeamCards = teamScorecards(ctx.app.db, recentWindow()); | |
| 404 | 419 | const scorecards = crewScorecards(ctx.app.db); |
| 405 | 420 | const recentScorecards = crewScorecards(ctx.app.db, recentWindow()); |
| 406 | 421 | const splitScorecards = crewScorecardSplits(ctx.app.db); |
| @@ -505,6 +520,9 @@ function crewView(ctx: Ctx, values: AgentValues, error?: string): Html { | ||
| 505 | 520 | ${when(agents.length > 0, () => |
| 506 | 521 | scorecardCard( |
| 507 | 522 | agents, |
| 523 | teams, | |
| 524 | teamCards, | |
| 525 | recentTeamCards, | |
| 508 | 526 | scorecards, |
| 509 | 527 | recentScorecards, |
| 510 | 528 | splitScorecards, |
test/crew-team-scorecard.test.ts added +185
| @@ -0,0 +1,185 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { createApp, type App } from "../src/app.ts"; | |
| 6 | import { createAgentRow } from "../src/core/agents.ts"; | |
| 7 | import { createIssue } from "../src/core/issues.ts"; | |
| 8 | import { submitReview } from "../src/core/reviews.ts"; | |
| 9 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 10 | import { crewScorecards, teamScorecards, recentWindow } from "../src/core/scorecard.ts"; | |
| 11 | import { listTeams, saveTeam } from "../src/core/teams.ts"; | |
| 12 | import { createSession } from "../src/core/sessions.ts"; | |
| 13 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 16 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 17 | import { settleApp } from "./helpers/tui.ts"; | |
| 18 | import { createRepo } from "../src/core/repos.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | describe("a scorecard row per team", () => { | |
| 24 | let dataDir: string; | |
| 25 | let app: App; | |
| 26 | let servers: Servers; | |
| 27 | let repoId = ""; | |
| 28 | let reviewer = ""; | |
| 29 | const ids: Record<string, string> = {}; | |
| 30 | ||
| 31 | function pull(author: string, title: string, merged: boolean, hours = 2, openedAt?: string) { | |
| 32 | const issue = createIssue(app.db, { repoId, authorId: ids[author]!, title, kind: "pull" }); | |
| 33 | if (!issue.ok) throw new Error(issue.error); | |
| 34 | const opened = openedAt ?? "2026-10-01T00:00:00.000Z"; | |
| 35 | app.db.query(`UPDATE issue SET created_at = ?2 WHERE id = ?1`).run(issue.value.id, opened); | |
| 36 | const mergedAt = merged ? new Date(Date.parse(opened) + hours * 3_600_000).toISOString() : null; | |
| 37 | app.db | |
| 38 | .query( | |
| 39 | `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) | |
| 40 | VALUES (?1, 'main', ?2, 'a', 'b', ?3)`, | |
| 41 | ) | |
| 42 | .run(issue.value.id, `h${issue.value.number}`, mergedAt); | |
| 43 | return issue.value.id; | |
| 44 | } | |
| 45 | const check = (issueId: string, state: "ok" | "failed") => { | |
| 46 | const run = createRun(app.db, { repoId, kind: "check", issueId }); | |
| 47 | claimRun(app.db, run.id); | |
| 48 | finishRun(app.db, run.id, { state, summary: state }); | |
| 49 | }; | |
| 50 | const change = (issueId: string) => | |
| 51 | submitReview(app.db, { | |
| 52 | issueId, | |
| 53 | authorId: reviewer, | |
| 54 | state: "changes_requested", | |
| 55 | body: "", | |
| 56 | commitOid: "b", | |
| 57 | }); | |
| 58 | ||
| 59 | beforeAll(async () => { | |
| 60 | dataDir = await mkdtemp(join(tmpdir(), "forge-team-scorecard-")); | |
| 61 | app = createApp({ dataDir, port: 0 }); | |
| 62 | for (const name of ["boss", "a1", "a2", "b1", "idle"]) { | |
| 63 | const made = await createUser(app.db, { | |
| 64 | origin: "seed", | |
| 65 | trusted: true, | |
| 66 | username: name, | |
| 67 | email: `${name}@forge.local`, | |
| 68 | password: "a-good-passphrase", | |
| 69 | adminIfFirst: name === "boss", | |
| 70 | }); | |
| 71 | if (!made.ok) throw new Error(made.error); | |
| 72 | ids[name] = made.value.id; | |
| 73 | if (name !== "boss") app.db.query("UPDATE user SET is_agent = 1 WHERE id = ?1").run(made.value.id); | |
| 74 | if (name !== "boss") createAgentRow(app.db, { userId: made.value.id, model: "opus" }); | |
| 75 | } | |
| 76 | reviewer = ids.boss!; | |
| 77 | const repo = await createRepo(app.db, app.reposRoot, { ownerId: reviewer, name: "hello" }); | |
| 78 | if (!repo.ok) throw new Error(repo.error); | |
| 79 | repoId = repo.value.id; | |
| 80 | const [one, two, three] = listTeams(app.db).map((t) => t.slug); | |
| 81 | saveTeam(app.db, one!, { charter: "", dial: 0, members: [ids.a1!, ids.a2!] }); | |
| 82 | saveTeam(app.db, two!, { charter: "", dial: 0, members: [ids.b1!] }); | |
| 83 | saveTeam(app.db, three!, { charter: "", dial: 0, members: [ids.idle!] }); | |
| 84 | }); | |
| 85 | ||
| 86 | afterAll(async () => { | |
| 87 | servers?.stop(); | |
| 88 | app.close(); | |
| 89 | await rm(dataDir, { recursive: true, force: true }); | |
| 90 | }); | |
| 91 | ||
| 92 | test("ratios come from pooled counts, not an average of ratios", () => { | |
| 93 | // a1: one landed pull, 1 change request, 1 failed of 1 check (rate 1.0). | |
| 94 | const p1 = pull("a1", "a1 one", true, 2); | |
| 95 | change(p1); | |
| 96 | check(p1, "failed"); | |
| 97 | // a2: three landed pulls, no change requests, 0 failed of 3 checks (rate 0). | |
| 98 | for (const n of [1, 2, 3]) check(pull("a2", `a2 ${n}`, true, 6), "ok"); | |
| 99 | // b1: one landed, one open. | |
| 100 | pull("b1", "b1 one", true, 4); | |
| 101 | pull("b1", "b1 open", false); | |
| 102 | ||
| 103 | const [one, two, three] = listTeams(app.db).map((t) => t.slug); | |
| 104 | const cards = teamScorecards(app.db); | |
| 105 | const a = cards.get(one!)!; | |
| 106 | expect([a.opened, a.landed]).toEqual([4, 4]); | |
| 107 | expect(a.reviewRounds).toBe(0.25); // 1 ÷ 4, not mean(1, 0) | |
| 108 | expect(a.redCheckRate).toBe(0.25); // 1 of 4 finished, not mean(1, 0) | |
| 109 | expect(a.meanLandMs).toBe(5 * 3_600_000); // (2 + 6 + 6 + 6) ÷ 4 | |
| 110 | const b = cards.get(two!)!; | |
| 111 | expect([b.opened, b.landed, b.reviewRounds, b.redCheckRate]).toEqual([2, 1, 0, null]); | |
| 112 | expect(b.meanLandMs).toBe(4 * 3_600_000); | |
| 113 | // A team with no pulls has no card; the page says dashes. | |
| 114 | expect(cards.has(three!)).toBe(false); | |
| 115 | // The members' own cards are unchanged by pooling. | |
| 116 | expect(crewScorecards(app.db).get(ids.a1!)!.reviewRounds).toBe(1); | |
| 117 | // Oct 1 is inside the last 30 days; the idle team is still absent. | |
| 118 | expect(teamScorecards(app.db, recentWindow()).has(three!)).toBe(false); | |
| 119 | }); | |
| 120 | ||
| 121 | const cookieOf = (name: string) => | |
| 122 | `forge_session=${createSession(app.db, ids[name]!).token}`; | |
| 123 | ||
| 124 | test("the page shows a team row with its pooled figures, to an admin only", async () => { | |
| 125 | app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(ids.boss!); | |
| 126 | servers = await serve(app); | |
| 127 | const [one, , three] = listTeams(app.db).map((t) => t.slug); | |
| 128 | const page = await fetch(`${servers.url}/settings/crew`, { | |
| 129 | headers: { cookie: cookieOf("boss") }, | |
| 130 | }).then((r) => r.text()); | |
| 131 | expect(page).toContain("<th>Agent / team</th>"); | |
| 132 | const table = page.slice(page.indexOf(`id="crew-scorecard"`)); | |
| 133 | const row = table.slice( | |
| 134 | table.indexOf(`data-team="${one}"`), | |
| 135 | table.indexOf("</tr>", table.indexOf(`data-team="${one}"`)), | |
| 136 | ); | |
| 137 | const cells = [...row.matchAll(/<td class="num">([\s\S]*?)<\/td>/g)].map((m) => | |
| 138 | m[1]!.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim(), | |
| 139 | ); | |
| 140 | // 30 days beside all time: landed, review rounds, red checks; then reverts. | |
| 141 | expect(cells.slice(0, 7)).toEqual(["4 of 4", "4 of 4", "0.3", "0.3", "25%", "25%", "0"]); | |
| 142 | // A team without a card has no row, and the team rows sit under the agents. | |
| 143 | expect(table).not.toContain(`data-team="${three}"`); | |
| 144 | expect(table.indexOf("a1")).toBeLessThan(table.indexOf(`data-team="${one}"`)); | |
| 145 | // Someone who is not an admin gets no scorecard. | |
| 146 | const other = await fetch(`${servers.url}/settings/crew`, { | |
| 147 | headers: { cookie: cookieOf("a1") }, | |
| 148 | }); | |
| 149 | expect(await other.text()).not.toContain("crew-scorecard"); | |
| 150 | }); | |
| 151 | ||
| 152 | test("the terminal shows the team rows to an admin, and nothing to anyone else", async () => { | |
| 153 | const terminal = (username: string) => { | |
| 154 | const user = getUserByName(app.db, username)!; | |
| 155 | const { app: tui } = createForgeApp( | |
| 156 | app, | |
| 157 | { sshUsername: "t", remoteAddress: "127.0.0.1", user }, | |
| 158 | { terminal: headlessTerminal(160, 40), exitHooks: false }, | |
| 159 | ); | |
| 160 | tui.start(); | |
| 161 | return tui; | |
| 162 | }; | |
| 163 | const open = async (username: string) => { | |
| 164 | const tui = terminal(username); | |
| 165 | const key = (k: string, ctrl = false) => | |
| 166 | tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 167 | key("k", true); | |
| 168 | for (const ch of "Crew scorecard per agent") key(ch); | |
| 169 | await settleApp(tui); | |
| 170 | key("enter"); | |
| 171 | await settleApp(tui); | |
| 172 | const text = tui.screen.toText(); | |
| 173 | tui.stop(); | |
| 174 | return text; | |
| 175 | }; | |
| 176 | const [one, , three] = listTeams(app.db); | |
| 177 | const admin = await open("boss"); | |
| 178 | expect(admin).toContain(`team ${one!.name}`); | |
| 179 | expect(admin).toContain("all 4 of 4"); | |
| 180 | expect(admin).toContain("all 0.3"); | |
| 181 | expect(admin).not.toContain(`team ${three!.name}`); | |
| 182 | const other = await open("a1"); | |
| 183 | expect(other).not.toContain(`team ${one!.name}`); | |
| 184 | }); | |
| 185 | }); |
new above
offline
Three team parameters were slotted ahead of cards/recent/splits. Eight positional args of near-identical Map types is easy to swap silently; group them in one object or append them last, as the TUI does.