Public: anyone can read this; the crew can change it.
`flow` and `scorecard` answer an MCP host with a card: the pipeline and its bottleneck, the crew one line each #10841
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10836-flow-and-scorecard-answer-an-mcp-host-wi → main 4 commits fork 3 threads · 3 unresolved
5 files
changed +188
−10
docs/MCP.md +2 −2
| @@ -106,7 +106,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 106 | 106 | | `flag_set` | — | Set or clear a flag (admin) | 209 | |
| 107 | 107 | | `flag_winner` | — | An A/B flag winner becomes default | 206 | |
| 108 | 108 | | `flags` | — | Declared flags: why, default, owner issue, live value | 171 | |
| 109 | | `flow` | `bugs` | Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck | 431 | | |
| 109 | | `flow` | `bugs` | Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck | 480 | | |
| 110 | 110 | | `follow_mode` | — | How a spawned forge follows its parent: `tell-me` or `take-updates` | 265 | |
| 111 | 111 | | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 | |
| 112 | 112 | | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 | |
| @@ -186,7 +186,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 186 | 186 | | `run_log` | — | A run's log, or its last `tail` lines, as GET /api/v1/repos/:owner/:name/runs/:id/log | 332 | |
| 187 | 187 | | `runs` | `agent`, `deploy`, `check` | A repository's runs, newest first: id, kind, state, number, start, end | 482 | |
| 188 | 188 | | `say` | `list`, `new`, `rename`, `archive`, `restore` | Say something on an issue or pull; with no repo, to the forge's own agent, and manage its conversations (action) | 448 | |
| 189 | | `scorecard` | — | Crew scorecard (forge admin): per agent, all time and 30 days | 183 | | |
| 189 | | `scorecard` | — | Crew scorecard (forge admin): per agent, all time and 30 days | 232 | | |
| 190 | 190 | | `seal` | `private` | Seal or unseal an issue or pull: a sealed one is seen only by writers and crew | 297 | |
| 191 | 191 | | `search` | `all`, `issues`, `repos`, `code` | Find issues, pulls, repositories and code you can read; `kind` and `repo` narrow | 378 | |
| 192 | 192 | | `secrets` | — | Admin: list\|set\|remove sealed secrets | 248 | |
src/web/studio/cards.ts +41 −2
| @@ -9,8 +9,9 @@ | ||
| 9 | 9 | |
| 10 | 10 | import { html, raw } from "../html.ts"; |
| 11 | 11 | import { timeAgo } from "../ui.ts"; |
| 12 | import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts"; | |
| 12 | 13 | |
| 13 | export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull" | "pulls" | "asked" | "explore" | "issue"; | |
| 14 | export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull" | "pulls" | "asked" | "explore" | "issue" | "flow" | "crew"; | |
| 14 | 15 | |
| 15 | 16 | export interface CardRow { |
| 16 | 17 | readonly label: string; |
| @@ -39,7 +40,7 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?! | ||
| 39 | 40 | |
| 40 | 41 | export const CARD_MIME = "text/html;profile=mcp-app"; |
| 41 | 42 | export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`; |
| 42 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed", "pull", "pulls", "asked", "explore", "issue"]; | |
| 43 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed", "pull", "pulls", "asked", "explore", "issue", "flow", "crew"]; | |
| 43 | 44 | |
| 44 | 45 | /** The tool whose answer each card draws. */ |
| 45 | 46 | export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { |
| @@ -57,6 +58,8 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { | ||
| 57 | 58 | pulls: "pulls", |
| 58 | 59 | asked: "asked", |
| 59 | 60 | issue: "issue", |
| 61 | flow: "flow", | |
| 62 | scorecard: "crew", | |
| 60 | 63 | }; |
| 61 | 64 | |
| 62 | 65 | const CARD_TITLE: Readonly<Record<CardKind, string>> = { |
| @@ -71,6 +74,8 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = { | ||
| 71 | 74 | pulls: "Pulls", |
| 72 | 75 | asked: "Asked", |
| 73 | 76 | issue: "Issue", |
| 77 | flow: "Flow", | |
| 78 | crew: "Crew", | |
| 74 | 79 | }; |
| 75 | 80 | |
| 76 | 81 | type Obj = Record<string, unknown>; |
| @@ -125,6 +130,40 @@ export function cardFor( | ||
| 125 | 130 | rows.push({ label: "Pulls", value: pulls.slice(0, LIMIT).map((n) => `#${n}`).join(" ") }); |
| 126 | 131 | return { kind, title: `Wave ${text(w["version"])}`, status: `${repo} · ${text(w["date"])}`.replace(/^ · /, ""), rows, actions }; |
| 127 | 132 | } |
| 133 | if (kind === "flow") { | |
| 134 | // Only the no-argument answer has `stages`; a fleet query or a `why` answer stays plain JSON. | |
| 135 | const stages = list(obj(value)["stages"]).map(obj).filter((s) => text(s["key"]) in STAGE_LABEL); | |
| 136 | if (stages.length === 0) return undefined; | |
| 137 | const now = Date.now(); | |
| 138 | const typed = stages.map((s): PipelineStage => ({ | |
| 139 | key: text(s["key"]) as PipelineKey, | |
| 140 | waiting: Number(s["waiting"]) || 0, | |
| 141 | active: Number(s["active"]) || 0, | |
| 142 | oldestWaiting: typeof s["oldestWaiting"] === "string" ? s["oldestWaiting"] : null, | |
| 143 | capacity: typeof s["capacity"] === "string" ? s["capacity"] : null, | |
| 144 | })); | |
| 145 | for (const s of typed) { | |
| 146 | const wait = s.oldestWaiting === null || Number.isNaN(Date.parse(s.oldestWaiting)) ? "" : ` · oldest ${span(now - Date.parse(s.oldestWaiting))}`; | |
| 147 | rows.push({ label: STAGE_LABEL[s.key], value: `${s.waiting} waiting · ${s.active} active${wait}` }); | |
| 148 | } | |
| 149 | return { kind, title: CARD_TITLE[kind], status: verdict(typed, now), rows, actions }; | |
| 150 | } | |
| 151 | if (kind === "crew") { | |
| 152 | // `scorecard` answers `{ agents: [{ username, all, recent }], keeperDays: [{ landed }] }`. | |
| 153 | const v = obj(value); | |
| 154 | if (!Array.isArray(v["agents"])) return undefined; | |
| 155 | const agents = v["agents"].map(obj); | |
| 156 | const week = list(v["keeperDays"]).reduce<number>((n, d) => n + (Number(obj(d)["landed"]) || 0), 0); | |
| 157 | const crewStatus = `${week} landed this week`; | |
| 158 | for (const a of agents.slice(0, LIMIT)) { | |
| 159 | const r = obj(a["recent"]); | |
| 160 | const red = typeof r["redCheckRate"] === "number" ? `${Math.round(r["redCheckRate"] * 100)}% red` : "no checks"; | |
| 161 | rows.push({ label: text(a["username"]), value: `${Number(r["landed"]) || 0} landed this month · ${red}` }); | |
| 162 | } | |
| 163 | // The card shows LIMIT agents; the rest are counted, never dropped silently. | |
| 164 | if (agents.length > LIMIT) rows.push({ label: "More", value: `+${agents.length - LIMIT} more` }); | |
| 165 | return { kind, title: CARD_TITLE[kind], status: crewStatus, rows, actions }; | |
| 166 | } | |
| 128 | 167 | if (tool === "new_app") { |
| 129 | 168 | const made = obj(value); |
| 130 | 169 | const repo = text(made["repo"]); |
test/mcp-flow-card.test.ts added +128
| @@ -0,0 +1,128 @@ | ||
| 1 | /** MCP `flow` (no arguments) and `scorecard` draw the `ui://forge/flow` and `ui://forge/crew` cards. */ | |
| 2 | import { afterAll, beforeAll, describe, 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, type App } from "../src/app.ts"; | |
| 7 | import { createAgentRow } from "../src/core/agents.ts"; | |
| 8 | import { createIssue } from "../src/core/issues.ts"; | |
| 9 | import { createRepo } from "../src/actions/repos.ts"; | |
| 10 | import { STAGE_LABEL } from "../src/flow/pipeline.ts"; | |
| 11 | import { createToken } from "../src/core/tokens.ts"; | |
| 12 | import { createUser } from "../src/core/users.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | import { cardFor } from "../src/web/studio/cards.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | import { listVia } from "./mcp-list.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | describe("MCP flow and crew cards", () => { | |
| 21 | let dataDir: string; | |
| 22 | let app: App; | |
| 23 | let servers: Servers; | |
| 24 | let token = ""; | |
| 25 | let id = 1; | |
| 26 | ||
| 27 | const rpc = async (method: string, params: unknown = {}) => { | |
| 28 | const res = await fetch(`${servers.url}/mcp`, { | |
| 29 | method: "POST", | |
| 30 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 31 | body: JSON.stringify({ jsonrpc: "2.0", id: id++, method, params }), | |
| 32 | }); | |
| 33 | return (await res.json()) as any; | |
| 34 | }; | |
| 35 | ||
| 36 | beforeAll(async () => { | |
| 37 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-flow-card-")); | |
| 38 | app = createApp({ dataDir, port: 0 }); | |
| 39 | servers = await serve(app); | |
| 40 | const a = await createUser(app.db, { origin: "seed", trusted: true, | |
| 41 | username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 42 | if (!a.ok) throw new Error("users"); | |
| 43 | token = createToken(app.db, a.value.id, "admin", "admin").token; | |
| 44 | // One agent with a landed pull and one red check, for the crew card. | |
| 45 | const bot = await createUser(app.db, { origin: "seed", trusted: true, | |
| 46 | username: "smith", email: "[email protected]", password: "a-good-passphrase" }); | |
| 47 | if (!bot.ok) throw new Error("bot"); | |
| 48 | app.db.query("UPDATE user SET is_agent = 1 WHERE id = ?1").run(bot.value.id); | |
| 49 | createAgentRow(app.db, { userId: bot.value.id, model: "opus" }); | |
| 50 | const repo = await createRepo(app, a.value, { name: "hello", visibility: "public" }); | |
| 51 | if (!repo.ok) throw new Error("repo"); | |
| 52 | const issue = createIssue(app.db, { repoId: repo.value.id, authorId: bot.value.id, title: "a pull", kind: "pull" }); | |
| 53 | if (!issue.ok) throw new Error("issue"); | |
| 54 | app.db.query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) | |
| 55 | VALUES (?1, 'main', 'h', 'a', 'b', ?2)`).run(issue.value.id, new Date().toISOString()); | |
| 56 | app.db.query(`INSERT INTO run (id, repo_id, issue_id, kind, actor_id, state) VALUES (?1, ?2, ?3, 'check', ?4, 'failed')`) | |
| 57 | .run(crypto.randomUUID(), repo.value.id, issue.value.id, bot.value.id); | |
| 58 | }, 30_000); | |
| 59 | ||
| 60 | afterAll(async () => { | |
| 61 | servers.stop(); | |
| 62 | app.close(); | |
| 63 | await rm(dataDir, { recursive: true, force: true }); | |
outdated
The label map is rebuilt by hand; import STAGE_LABEL instead so the test follows the code. |
||
| 64 | }); | |
| 65 | ||
| 66 | test("tools/list advertises both resources and both resources read", async () => { | |
| 67 | const tools: any[] = (await listVia((p) => rpc("tools/list", p))).result.tools; | |
| 68 | for (const [name, kind] of [["flow", "flow"], ["scorecard", "crew"]] as const) { | |
| 69 | expect(tools.find((t) => t.name === name)._meta.ui.resourceUri).toBe(`ui://forge/${kind}`); | |
| 70 | const read = await rpc("resources/read", { uri: `ui://forge/${kind}` }); | |
| 71 | expect(read.result.contents[0].text.length).toBeGreaterThan(0); | |
| 72 | } | |
| 73 | }); | |
| 74 | ||
| 75 | test("flow with no arguments carries a card, one row per stage, beside the text", async () => { | |
| 76 | const body = await rpc("tools/call", { name: "flow", arguments: {} }); | |
| 77 | const text = JSON.parse(body.result.content[0].text); | |
| 78 | const card = body.result.structuredContent.card; | |
| 79 | expect(card.kind).toBe("flow"); | |
| 80 | expect(card.title).toBe("Flow"); | |
| 81 | expect(card.rows.map((r: any) => r.label)).toEqual(text.stages.map((s: any) => (STAGE_LABEL as Record<string, string>)[s.key])); | |
| 82 | expect(card.status).toBe(text.says); | |
| 83 | }); | |
| 84 | ||
| 85 | test("a fleet query or a why answer stays plain", async () => { | |
| 86 | const body = await rpc("tools/call", { name: "flow", arguments: { q: "repo" } }); | |
| 87 | expect(body.result.structuredContent?.card).toBeUndefined(); | |
| 88 | expect(cardFor("flow", { subject: "a/b", moving: true })).toBeUndefined(); | |
| 89 | }); | |
| 90 | ||
outdated
Box 4 asks both models to be drawn from a seeded forge. The tool call here has no agents, so the per-agent rows are only checked with supplied data. Seed one agent and assert its row in the tool answer. |
||
| 91 | test("the flow card names the bottleneck and the oldest wait", () => { | |
| 92 | const old = new Date(Date.now() - 3 * 3_600_000).toISOString(); | |
| 93 | const card = cardFor("flow", { stages: [ | |
| 94 | { key: "ready", waiting: 2, active: 0, oldestWaiting: new Date(Date.now() - 60_000).toISOString(), capacity: null }, | |
| 95 | { key: "review", waiting: 4, active: 1, oldestWaiting: old, capacity: null }, | |
| 96 | { key: "live", waiting: 0, active: 3, oldestWaiting: null, capacity: null }, | |
| 97 | ] })!; | |
| 98 | expect(card.status).toContain("Review is the bottleneck"); | |
| 99 | expect(card.rows).toHaveLength(3); | |
| 100 | expect(card.rows[1]!.value).toBe("4 waiting · 1 active · oldest 3h"); | |
| 101 | expect(card.rows[2]!.value).toBe("0 waiting · 3 active"); | |
| 102 | }); | |
| 103 | ||
| 104 | test("scorecard carries the crew card, one row per agent, status the week's landed", async () => { | |
| 105 | const body = await rpc("tools/call", { name: "scorecard", arguments: {} }); | |
| 106 | const drawn = body.result.structuredContent.card; | |
| 107 | expect(drawn.kind).toBe("crew"); | |
| 108 | expect(drawn.rows).toEqual([{ label: "smith", value: "1 landed this month · 100% red" }]); | |
| 109 | const card = cardFor("scorecard", { | |
| 110 | agents: [ | |
| 111 | { username: "scout", all: null, recent: { landed: 5, redCheckRate: 0.25 } }, | |
| 112 | { username: "<b>x</b>", all: null, recent: null }, | |
| 113 | ], | |
| 114 | keeperDays: [{ landed: 2 }, { landed: 3 }], | |
| 115 | })!; | |
| 116 | expect(card.status).toBe("5 landed this week"); | |
| 117 | expect(card.rows).toEqual([ | |
| 118 | { label: "scout", value: "5 landed this month · 25% red" }, | |
| 119 | { label: "<b>x</b>", value: "0 landed this month · no checks" }, | |
| 120 | ]); | |
| 121 | }); | |
| 122 | ||
| 123 | test("agents past the card's limit are counted, not dropped", () => { | |
| 124 | const many = cardFor("scorecard", { agents: Array.from({ length: 15 }, (_, i) => ({ username: `a${i}`, all: null, recent: null })), keeperDays: [] })!; | |
| 125 | expect(many.rows).toHaveLength(13); | |
| 126 | expect(many.rows[12]).toEqual({ label: "More", value: "+3 more" }); | |
| 127 | }); | |
| 128 | }); |
test/mcp-forge-view.test.ts +13 −6
| @@ -73,12 +73,19 @@ test("a private app another person owns is absent from both", async () => { | ||
| 73 | 73 | }); |
| 74 | 74 | |
| 75 | 75 | test("a ro token lists the tool and is answered; polling builds once", async () => { |
| 76 | const res = await fetch(`${servers.url}/mcp`, { | |
| 77 | method: "POST", | |
| 78 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${adaRo}` }, | |
| 79 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), | |
| 80 | }); | |
| 81 | const listed = ((await res.json()) as any).result.tools.find((t: any) => t.name === "forge_view"); | |
| 76 | // The list is paged by size: follow the cursor until the tool turns up. | |
| 77 | let listed: any; | |
| 78 | let cursor: string | undefined; | |
| 79 | do { | |
| 80 | const res = await fetch(`${servers.url}/mcp`, { | |
| 81 | method: "POST", | |
| 82 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${adaRo}` }, | |
| 83 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: cursor === undefined ? {} : { cursor } }), | |
| 84 | }); | |
| 85 | const page = ((await res.json()) as any).result; | |
| 86 | listed = page.tools.find((t: any) => t.name === "forge_view"); | |
| 87 | cursor = listed === undefined ? page.nextCursor : undefined; | |
| 88 | } while (listed === undefined && cursor !== undefined); | |
| 82 | 89 | expect(listed).toBeDefined(); |
| 83 | 90 | expect(listed.inputSchema.properties).toEqual({}); |
| 84 | 91 | // A build reads the runs once; three calls in one window may build once. |
test/mcp.test.ts +4
| @@ -1284,9 +1284,11 @@ describe("the forge over MCP", () => { | ||
| 1284 | 1284 | expect(uris).toEqual([ |
| 1285 | 1285 | "ui://forge/app", |
| 1286 | 1286 | "ui://forge/asked", |
| 1287 | "ui://forge/crew", | |
| 1287 | 1288 | "ui://forge/decision", |
| 1288 | 1289 | "ui://forge/explore", |
| 1289 | 1290 | "ui://forge/feed", |
| 1291 | "ui://forge/flow", | |
| 1290 | 1292 | "ui://forge/issue", |
| 1291 | 1293 | "ui://forge/map", |
| 1292 | 1294 | "ui://forge/pull", |
| @@ -1315,6 +1317,8 @@ describe("the forge over MCP", () => { | ||
| 1315 | 1317 | expect(meta("pull")).toBe("ui://forge/pull"); |
| 1316 | 1318 | expect(meta("pulls")).toBe("ui://forge/pulls"); |
| 1317 | 1319 | expect(meta("asked")).toBe("ui://forge/asked"); |
| 1320 | expect(meta("flow")).toBe("ui://forge/flow"); | |
| 1321 | expect(meta("scorecard")).toBe("ui://forge/crew"); | |
| 1318 | 1322 | }); |
| 1319 | 1323 | |
| 1320 | 1324 | test("tools/call pull carries the card beside an unchanged text answer", async () => { |
Crew branch:
agentsis mapped before the Array.isArray guard, andstatusreuses the outer variable. Guard first, then build. Rows are capped at LIMIT with no sign of the rest, but the issue asks for one row per agent.