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

Merged opened this

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

Merged

scout/forge:scout/3203-get-api-v1-crew-scorecard-and-mcp-scorec → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/3203-get-api-v1-crew-scorecard-and-mcp-scorec was merged into main as 588fb73.

Review

Sign in to review this change.
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
3737 unused in the last 30 days shows dashes there. The terminal shows them as
3838 indented `↳` rows.
3939
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
4047 Git hosting, code browsing, and a JSON API in one fast Bun process, with
4148 **zero runtime dependencies** — the `package.json` dependency list is empty and
4249 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";
3131 import { ISSUE_TEAMS_SAY, nameTeams } from "../actions/issue_teams.ts";
3232 import { teamsOfIssue } from "../core/issue_teams.ts";
3333 import { changeTeam, readTeams, TEAM_SAYS } from "../actions/teams.ts";
34 import { readScorecards } from "../actions/scorecard.ts";
3435 import { changeQuotas, readQuotas } from "../actions/quotas.ts";
3536 import { changeSignup, readSignup } from "../actions/signup.ts";
3637 import {
@@ -1617,6 +1618,15 @@ export function apiRoutes(router: Router<Handler>): void {
16171618 );
16181619 });
16191620
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");
outdated

With adminToken: true, an admin's ro token gets a 403 here, but the MCP scorecard tool answers that same token. Use accountAdmin(ctx, "ro") like the other admin reads (it also answers a non-admin the way /api/v1/admin/teams does), or make the tool check admin scope. The two doors should agree.

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
16201630 // Crew teams (`core/teams.ts`): a forge admin who is a person; the page's door.
16211631 router.add("GET", "/api/v1/admin/teams", (ctx) => {
16221632 const denied = accountAdmin(ctx, "ro");
src/http/mcp.ts +17 −2
@@ -27,6 +27,7 @@ import { changeLimit, listLimits } from "../actions/limits.ts";
2727 import { ISSUE_TEAMS_SAY, nameTeams } from "../actions/issue_teams.ts";
2828 import { teamsOfIssue } from "../core/issue_teams.ts";
2929 import { changeTeam, planTeam, readTeams, TEAM_SAYS } from "../actions/teams.ts";
30 import { readScorecards } from "../actions/scorecard.ts";
3031 import { addAccount, allowWrite, auditAccount, removeAccount, renameAccount, reverify, rotateAccount } from "../actions/cloud.ts";
3132 import { usesByVendor, type VendorUse } from "../cloud/declared.ts";
3233 import { cloudOverview } from "../cloud/overview.ts";
@@ -226,6 +227,8 @@ interface Tool {
226227 readonly inputSchema: Record<string, unknown>;
227228 /** Whether it changes anything: `rw` scope, and said so to the client. */
228229 readonly writes: boolean;
230 /** Listed, and callable, for a forge admin only. */
231 readonly adminOnly?: true;
229232 /** With this argument absent the tool only reads, whatever the token. */
230233 readonly readsWithout?: string;
231234 /** A write that only plans (`dryRun` not false) is a read: `ro` may ask. */
@@ -2677,6 +2680,18 @@ const TOOLS: readonly Tool[] = [
26772680 });
26782681 },
26792682 },
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.",

writes: false and no scope check, so an admin's ro token reads the scorecard here even though the REST route refuses it. Make this agree with the route, and add a test that calls both doors with the ro token.

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 },
26802695 {
26812696 name: "flow",
26822697 description:
@@ -3091,7 +3106,7 @@ function rpcOne(
30913106 case "tools/list":
30923107 return reply({
30933108 tools: [
3094 ...TOOLS.map((t) => ({
3109 ...TOOLS.filter((t) => t.adminOnly !== true || user.isAdmin).map((t) => ({
30953110 name: t.name,
30963111 description: t.description,
30973112 inputSchema: t.inputSchema,
@@ -3125,7 +3140,7 @@ function rpcOne(
31253140 }
31263141 case "tools/call": {
31273142 const tool = TOOLS.find((t) => t.name === params["name"]);
3128 if (tool === undefined)
3143 if (tool === undefined || (tool.adminOnly === true && !user.isAdmin))
31293144 return refuse(-32602, `no tool named ${String(params["name"])}`);
31303145 const args =
31313146 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", () => {
228228 "plan",
229229 "access",
230230 "capacity",
231 "scorecard",
231232 "flow",
232233 "system",
233234 "contribute",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs