Public: anyone can read this; the crew can change it.
GET /api/v1/crew/scorecard and MCP `scorecard` return each agent's scorecard, all time and last 30 days #3213
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3203-get-api-v1-crew-scorecard-and-mcp-scorec → main 2 commits fork 2 threads · 2 unresolved
6 files
changed +249
−2
README.md +7
| @@ -37,6 +37,13 @@ counted, never dropped. The sub-rows list every pairing ever used; a pairing | ||
| 37 | 37 | unused in the last 30 days shows dashes there. The terminal shows them as |
| 38 | 38 | indented `↳` rows. |
| 39 | 39 | |
| 40 | Scripts and agents read the same figures as JSON: `GET /api/v1/crew/scorecard` | |
| 41 | and the MCP `scorecard` tool answer `{agents:[{username, all, recent, splits:[{model, | |
| 42 | prompt, …figures, recent}]}]}`, where `all` is all time and `recent` the last 30 | |
| 43 | days. A ratio over nothing is `null`, and an agent with no pull has `all` and | |
| 44 | `recent` null. Any token of a forge admin may read it (401 with none, 403 for | |
| 45 | anyone else); the tool is listed for admins only. | |
| 46 | ||
| 40 | 47 | Git hosting, code browsing, and a JSON API in one fast Bun process, with |
| 41 | 48 | **zero runtime dependencies** — the `package.json` dependency list is empty and |
| 42 | 49 | stays that way. The one exception is dev-only: `@anthropic-ai/sandbox-runtime` |
src/actions/scorecard.ts added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | /** | |
| 2 | * The crew's scorecard as data, for the doors that are not a page (the API | |
| 3 | * and MCP): each agent's figures over all time and the last `RECENT_DAYS`, | |
| 4 | * and the same cut by model and prompt version. A forge admin's to read. | |
| 5 | */ | |
| 6 | import type { App } from "../app.ts"; | |
| 7 | import { | |
| 8 | crewScorecardSplits, | |
| 9 | crewScorecards, | |
| 10 | recentWindow, | |
| 11 | type Scorecard, | |
| 12 | type SplitScorecard, | |
| 13 | } from "../core/scorecard.ts"; | |
| 14 | import { listAgents } from "../core/agents.ts"; | |
| 15 | import type { User } from "../core/users.ts"; | |
| 16 | ||
| 17 | export interface AgentScorecard { | |
| 18 | readonly username: string; | |
| 19 | /** All time; null for an agent that has opened no pull. */ | |
| 20 | readonly all: Scorecard | null; | |
| 21 | /** The last `RECENT_DAYS`; null when it opened none in that time. */ | |
| 22 | readonly recent: Scorecard | null; | |
| 23 | readonly splits: readonly (Scorecard & { | |
| 24 | readonly model: string; | |
| 25 | readonly prompt: string; | |
| 26 | readonly recent: Scorecard | null; | |
| 27 | })[]; | |
| 28 | } | |
| 29 | ||
| 30 | const figures = (c: Scorecard): Scorecard => ({ | |
| 31 | opened: c.opened, | |
| 32 | landed: c.landed, | |
| 33 | reviewRounds: c.reviewRounds, | |
| 34 | redCheckRate: c.redCheckRate, | |
| 35 | reverts: c.reverts, | |
| 36 | meanLandMs: c.meanLandMs, | |
| 37 | }); | |
| 38 | ||
| 39 | /** Every agent's scorecard; `undefined` for anyone but a forge admin. */ | |
| 40 | export function readScorecards( | |
| 41 | app: App, | |
| 42 | actor: User | undefined, | |
| 43 | ): { agents: AgentScorecard[] } | undefined { | |
| 44 | if (actor?.isAdmin !== true) return undefined; | |
| 45 | const window = recentWindow(); | |
| 46 | const all = crewScorecards(app.db); | |
| 47 | const recent = crewScorecards(app.db, window); | |
| 48 | const splits = crewScorecardSplits(app.db); | |
| 49 | const recentSplits = crewScorecardSplits(app.db, window); | |
| 50 | const key = (p: SplitScorecard) => `${p.model}\u0000${p.promptVersion}`; | |
| 51 | return { | |
| 52 | agents: listAgents(app.db).map((agent) => { | |
| 53 | const id = agent.user.id; | |
| 54 | const lately = new Map((recentSplits.get(id) ?? []).map((p) => [key(p), p])); | |
| 55 | const a = all.get(id); | |
| 56 | const r = recent.get(id); | |
| 57 | return { | |
| 58 | username: agent.user.username, | |
| 59 | all: a === undefined ? null : figures(a), | |
| 60 | recent: r === undefined ? null : figures(r), | |
| 61 | splits: (splits.get(id) ?? []).map((p) => { | |
| 62 | const l = lately.get(key(p)); | |
| 63 | return { | |
| 64 | model: p.model, | |
| 65 | prompt: p.promptVersion, | |
| 66 | ...figures(p), | |
| 67 | recent: l === undefined ? null : figures(l), | |
| 68 | }; | |
| 69 | }), | |
| 70 | }; | |
| 71 | }), | |
| 72 | }; | |
| 73 | } |
src/http/api.ts +10
| @@ -31,6 +31,7 @@ import { changeLimit, listLimits } from "../actions/limits.ts"; | ||
| 31 | 31 | import { ISSUE_TEAMS_SAY, nameTeams } from "../actions/issue_teams.ts"; |
| 32 | 32 | import { teamsOfIssue } from "../core/issue_teams.ts"; |
| 33 | 33 | import { changeTeam, readTeams, TEAM_SAYS } from "../actions/teams.ts"; |
| 34 | import { readScorecards } from "../actions/scorecard.ts"; | |
| 34 | 35 | import { changeQuotas, readQuotas } from "../actions/quotas.ts"; |
| 35 | 36 | import { changeSignup, readSignup } from "../actions/signup.ts"; |
| 36 | 37 | import { |
| @@ -1617,6 +1618,15 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1617 | 1618 | ); |
| 1618 | 1619 | }); |
| 1619 | 1620 | |
| 1621 | // The crew's scorecard (`core/scorecard.ts`): a read, so any scope of a | |
| 1622 | // forge admin's token, as the MCP tool; 401 without one, 403 for anyone else. | |
| 1623 | router.add("GET", "/api/v1/crew/scorecard", (ctx) => { | |
| 1624 | const denied = requireScope(ctx, "ro"); | |
| 1625 | if (denied) return denied; | |
| 1626 | const card = readScorecards(ctx.app, ctx.auth.user); | |
| 1627 | return card ? json(card) : apiError("forbidden", 403, "the crew's scorecard is a forge admin's to read"); | |
| 1628 | }); | |
| 1629 | ||
| 1620 | 1630 | // Crew teams (`core/teams.ts`): a forge admin who is a person; the page's door. |
| 1621 | 1631 | router.add("GET", "/api/v1/admin/teams", (ctx) => { |
| 1622 | 1632 | const denied = accountAdmin(ctx, "ro"); |
src/http/mcp.ts +17 −2
| @@ -27,6 +27,7 @@ import { changeLimit, listLimits } from "../actions/limits.ts"; | ||
| 27 | 27 | import { ISSUE_TEAMS_SAY, nameTeams } from "../actions/issue_teams.ts"; |
| 28 | 28 | import { teamsOfIssue } from "../core/issue_teams.ts"; |
| 29 | 29 | import { changeTeam, planTeam, readTeams, TEAM_SAYS } from "../actions/teams.ts"; |
| 30 | import { readScorecards } from "../actions/scorecard.ts"; | |
| 30 | 31 | import { addAccount, allowWrite, auditAccount, removeAccount, renameAccount, reverify, rotateAccount } from "../actions/cloud.ts"; |
| 31 | 32 | import { usesByVendor, type VendorUse } from "../cloud/declared.ts"; |
| 32 | 33 | import { cloudOverview } from "../cloud/overview.ts"; |
| @@ -226,6 +227,8 @@ interface Tool { | ||
| 226 | 227 | readonly inputSchema: Record<string, unknown>; |
| 227 | 228 | /** Whether it changes anything: `rw` scope, and said so to the client. */ |
| 228 | 229 | readonly writes: boolean; |
| 230 | /** Listed, and callable, for a forge admin only. */ | |
| 231 | readonly adminOnly?: true; | |
| 229 | 232 | /** With this argument absent the tool only reads, whatever the token. */ |
| 230 | 233 | readonly readsWithout?: string; |
| 231 | 234 | /** A write that only plans (`dryRun` not false) is a read: `ro` may ask. */ |
| @@ -2677,6 +2680,18 @@ const TOOLS: readonly Tool[] = [ | ||
| 2677 | 2680 | }); |
| 2678 | 2681 | }, |
| 2679 | 2682 | }, |
| 2683 | { | |
| 2684 | name: "scorecard", | |
| 2685 | description: | |
| 2686 | "The crew's scorecard, for a forge admin: per agent (username), its figures over all time (`all`) and the last 30 days (`recent`) — pulls opened and landed, mean review rounds, red check rate, reverts, mean ms to land (a ratio over nothing is null) — and `splits` by model and prompt. Read-only. The same as /settings/crew and /api/v1/crew/scorecard.", | |
|
||
| 2687 | inputSchema: { type: "object", properties: {} }, | |
| 2688 | writes: false, | |
| 2689 | adminOnly: true, | |
| 2690 | run(ctx, user) { | |
| 2691 | const card = readScorecards(ctx.app, user); | |
| 2692 | return card ? good(card) : bad("forbidden", "the crew's scorecard is a forge admin's to read"); | |
| 2693 | }, | |
| 2694 | }, | |
| 2680 | 2695 | { |
| 2681 | 2696 | name: "flow", |
| 2682 | 2697 | description: |
| @@ -3091,7 +3106,7 @@ function rpcOne( | ||
| 3091 | 3106 | case "tools/list": |
| 3092 | 3107 | return reply({ |
| 3093 | 3108 | tools: [ |
| 3094 | ...TOOLS.map((t) => ({ | |
| 3109 | ...TOOLS.filter((t) => t.adminOnly !== true || user.isAdmin).map((t) => ({ | |
| 3095 | 3110 | name: t.name, |
| 3096 | 3111 | description: t.description, |
| 3097 | 3112 | inputSchema: t.inputSchema, |
| @@ -3125,7 +3140,7 @@ function rpcOne( | ||
| 3125 | 3140 | } |
| 3126 | 3141 | case "tools/call": { |
| 3127 | 3142 | const tool = TOOLS.find((t) => t.name === params["name"]); |
| 3128 | if (tool === undefined) | |
| 3143 | if (tool === undefined || (tool.adminOnly === true && !user.isAdmin)) | |
| 3129 | 3144 | return refuse(-32602, `no tool named ${String(params["name"])}`); |
| 3130 | 3145 | const args = |
| 3131 | 3146 | typeof params["arguments"] === "object" && params["arguments"] !== null |
test/crew-scorecard-api.test.ts added +141
| @@ -0,0 +1,141 @@ | ||
| 1 | /** | |
| 2 | * The crew's scorecard for a script or an agent (#3203): GET | |
| 3 | * /api/v1/crew/scorecard and the MCP `scorecard` tool answer the same JSON | |
| 4 | * to a forge admin's admin token, 403 to anyone else, 401 to no one. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } 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 { createAgentRow } from "../src/core/agents.ts"; | |
| 12 | import { createIssue } from "../src/core/issues.ts"; | |
| 13 | import { createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { createToken } from "../src/core/tokens.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | describe("the crew scorecard over the API and MCP", () => { | |
| 23 | let dataDir: string; | |
| 24 | let app: App; | |
| 25 | let servers: Servers; | |
| 26 | let admin: string; | |
| 27 | let member: string; | |
| 28 | let readOnly: string; | |
| 29 | let bobId: string; | |
| 30 | ||
| 31 | const get = (token?: string) => | |
| 32 | fetch(`${servers.url}/api/v1/crew/scorecard`, { | |
| 33 | headers: token === undefined ? {} : { authorization: `Bearer ${token}` }, | |
| 34 | }); | |
| 35 | const rpc = (token: string, method: string, params?: unknown) => | |
| 36 | fetch(`${servers.url}/mcp`, { | |
| 37 | method: "POST", | |
| 38 | headers: { | |
| 39 | "content-type": "application/json", | |
| 40 | accept: "application/json, text/event-stream", | |
| 41 | authorization: `Bearer ${token}`, | |
| 42 | }, | |
| 43 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), | |
| 44 | }).then((r) => r.json() as Promise<any>); | |
| 45 | ||
| 46 | beforeAll(async () => { | |
| 47 | dataDir = await mkdtemp(join(tmpdir(), "forge-scorecard-api-")); | |
| 48 | app = createApp({ dataDir, port: 0 }); | |
| 49 | servers = await serve(app); | |
| 50 | const ada = await seedAdmin(app, { username: "ada", email: "[email protected]" }); | |
| 51 | if (!ada.ok) throw new Error(ada.error); | |
| 52 | const made = await createUser(app.db, { | |
| 53 | origin: "seed", | |
| 54 | trusted: true, | |
| 55 | username: "bob", | |
| 56 | email: "[email protected]", | |
| 57 | password: "a-good-passphrase", | |
| 58 | }); | |
| 59 | if (!made.ok) throw new Error(made.error); | |
| 60 | const bots: User[] = []; | |
| 61 | for (const username of ["smith", "idle"]) { | |
| 62 | const b = await createUser(app.db, { | |
| 63 | origin: "seed", | |
| 64 | trusted: true, | |
| 65 | username, | |
| 66 | email: `${username}@forge.local`, | |
| 67 | password: "a-good-passphrase", | |
| 68 | }); | |
| 69 | if (!b.ok) throw new Error(b.error); | |
| 70 | app.db.query("UPDATE user SET is_agent = 1 WHERE id = ?1").run(b.value.id); | |
| 71 | createAgentRow(app.db, { userId: b.value.id, model: "opus" }); | |
| 72 | bots.push(b.value); | |
| 73 | } | |
| 74 | const repo = await createRepo(app, ada.value, { name: "hello", visibility: "public" }); | |
| 75 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 76 | const issue = createIssue(app.db, { | |
| 77 | repoId: repo.value.id, | |
| 78 | authorId: bots[0]!.id, | |
| 79 | title: "a pull", | |
| 80 | kind: "pull", | |
| 81 | }); | |
| 82 | if (!issue.ok) throw new Error(issue.error); | |
| 83 | app.db | |
| 84 | .query( | |
| 85 | `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) | |
| 86 | VALUES (?1, 'main', 'h', 'a', 'b')`, | |
| 87 | ) | |
| 88 | .run(issue.value.id); | |
| 89 | admin = createToken(app.db, ada.value.id, "t", "admin").token; | |
| 90 | readOnly = createToken(app.db, ada.value.id, "t", "ro").token; | |
| 91 | bobId = made.value.id; | |
| 92 | member = createToken(app.db, made.value.id, "t", "admin").token; | |
| 93 | }); | |
| 94 | afterAll(async () => { | |
| 95 | await servers.stop(); | |
| 96 | app.close(); | |
| 97 | await rm(dataDir, { recursive: true, force: true }); | |
| 98 | }); | |
| 99 | ||
| 100 | test("an admin token reads each agent's figures, nulls kept", async () => { | |
| 101 | const res = await get(admin); | |
| 102 | expect(res.status).toBe(200); | |
| 103 | const body = (await res.json()) as { agents: any[] }; | |
| 104 | const smith = body.agents.find((a) => a.username === "smith"); | |
| 105 | expect(smith.all).toMatchObject({ opened: 1, landed: 0, reviewRounds: null, redCheckRate: null }); | |
| 106 | expect(smith.recent.opened).toBe(1); | |
| 107 | expect(smith.splits).toHaveLength(1); | |
| 108 | expect(smith.splits[0]).toMatchObject({ model: "", prompt: "", opened: 1, meanLandMs: null }); | |
| 109 | expect(smith.splits[0].recent.opened).toBe(1); | |
| 110 | const idle = body.agents.find((a) => a.username === "idle"); | |
| 111 | expect(idle).toEqual({ username: "idle", all: null, recent: null, splits: [] }); | |
| 112 | }); | |
| 113 | ||
| 114 | test("a non-admin gets 403, no token 401", async () => { | |
| 115 | expect((await get(member)).status).toBe(403); | |
| 116 | expect((await get()).status).toBe(401); | |
| 117 | const roMember = createToken(app.db, bobId, "t", "ro").token; | |
| 118 | expect((await get(roMember)).status).toBe(403); | |
| 119 | const refused = await rpc(roMember, "tools/call", { name: "scorecard", arguments: {} }); | |
| 120 | expect(refused.error).toBeDefined(); | |
| 121 | }); | |
| 122 | ||
| 123 | test("an admin's read-only token reads it on both doors", async () => { | |
| 124 | expect((await get(readOnly)).status).toBe(200); | |
| 125 | const called = await rpc(readOnly, "tools/call", { name: "scorecard", arguments: {} }); | |
| 126 | expect(called.result.isError).toBeUndefined(); | |
| 127 | expect(JSON.parse(called.result.content[0].text)).toEqual(await get(readOnly).then((r) => r.json())); | |
| 128 | }); | |
| 129 | ||
| 130 | test("the MCP tool answers the same JSON and is listed for admins only", async () => { | |
| 131 | const api = await get(admin).then((r) => r.json()); | |
| 132 | const called = await rpc(admin, "tools/call", { name: "scorecard", arguments: {} }); | |
| 133 | expect(JSON.parse(called.result.content[0].text)).toEqual(api); | |
| 134 | const names = (token: string) => | |
| 135 | rpc(token, "tools/list").then((r) => r.result.tools.map((t: { name: string }) => t.name)); | |
| 136 | expect(await names(admin)).toContain("scorecard"); | |
| 137 | expect(await names(member)).not.toContain("scorecard"); | |
| 138 | const refused = await rpc(member, "tools/call", { name: "scorecard", arguments: {} }); | |
| 139 | expect(refused.error).toBeDefined(); | |
| 140 | }); | |
| 141 | }); |
test/mcp.test.ts +1
| @@ -228,6 +228,7 @@ describe("the forge over MCP", () => { | ||
| 228 | 228 | "plan", |
| 229 | 229 | "access", |
| 230 | 230 | "capacity", |
| 231 | "scorecard", | |
| 231 | 232 | "flow", |
| 232 | 233 | "system", |
| 233 | 234 | "contribute", |
new above
offline
With
adminToken: true, an admin'srotoken gets a 403 here, but the MCPscorecardtool answers that same token. UseaccountAdmin(ctx, "ro")like the other admin reads (it also answers a non-admin the way/api/v1/admin/teamsdoes), or make the tool check admin scope. The two doors should agree.