Public: anyone can read this; the crew can change it.
MCP restore_drill, scorecard, reports and usage draw ui:// cards, as held and board do #11503
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11475-mcp-restore-drill-scorecard-reports-and → main 5 commits fork 1 thread · 1 unresolved
8 files
changed +243
−20
docs/MCP.md +7 −4
| @@ -179,8 +179,8 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 179 | 179 | | `repo` | — | One repository you can read, as GET /api/v1/repos/:owner/:name | 223 | |
| 180 | 180 | | `repo_labels` | — | Repo labels: list (default), create, update, remove (admin token) | 351 | |
| 181 | 181 | | `repo_settings` | — | Read, change or move (newOwner) a repo: PATCH /api/v1/repos/:owner/:name (admin); mirrorPacks mirrors the packs' upstreams | 444 | |
| 182 | | `reports` | `accept`, `dismiss` | Outsiders' reports; `id`+`action`: accept or dismiss | 258 | | |
| 183 | | `restore_drill` | — | Drill-restore the newest snapshot | 123 | | |
| 182 | | `reports` | `accept`, `dismiss` | Outsiders' reports; `id`+`action`: accept or dismiss | 310 | | |
| 183 | | `restore_drill` | — | Drill-restore the newest snapshot | 173 | | |
| 184 | 184 | | `returns` | — | What crew tokens bought in `days` (1–90, default 7): dollars, runs, landed, cost/change | 284 | |
| 185 | 185 | | `review` | — | Approve a pull, or request changes (`reason` required); your own pull is refused | 350 | |
| 186 | 186 | | `rollback` | — | Plan, then apply, a rollback to a kept release (admin) | 209 | |
| @@ -222,7 +222,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 222 | 222 | | `tokens` | — | Your API tokens, no secrets | 146 | |
| 223 | 223 | | `trail` | — | A migrated repository's source and forks; `shown` shows or hides them (admin) | 258 | |
| 224 | 224 | | `upstream` | — | Read, set (`enabled`) or `check` the upstream a repository follows | 302 | |
| 225 | | `usage` | — | Crew spend: with `repo`, `days` (1–90) by agent, issue; without, today, last24h, headroom % | 295 | | |
| 225 | | `usage` | — | Crew spend: with `repo`, `days` (1–90) by agent, issue; without, today, last24h, headroom % | 345 | | |
| 226 | 226 | | `valves` | — | The week's pipes and valves; with `repo` and `level`, turns a valve (plans first) | 269 | |
| 227 | 227 | | `variant` | — | Like or dislike a pull among its variants; a later vote replaces; dislike closes it | 349 | |
| 228 | 228 | | `vision` | — | VISION.md: purpose, order, rules | 235 | |
| @@ -527,7 +527,7 @@ answers | ||
| 527 | 527 | | `ui://forge/explore` | `explore` | Apps, people and forges ranked, at most 20: name, kind, the numbers (and likes or ship time when sorted by them), linked; only what you may see. With `kind: tree`: upstream, this and fork rows, each with how it follows and linked to its repository. | |
| 528 | 528 | | `ui://forge/issue` | `issue` | One issue: title, state, assignee, labels, what it waits for. | |
| 529 | 529 | | `ui://forge/flow` | `flow` | The pipeline: per stage counts, oldest waiting, the bottleneck. | |
| 530 | | `ui://forge/scorecard` | `scorecard` | The crew's scorecard per agent (forge admin). | | |
| 530 | | `ui://forge/scorecard` | `scorecard` | The crew's scorecard, one row per agent: landed and red rate all time, then the week (forge admin). | | |
| 531 | 531 | | `ui://forge/crew` | `now` | What the crew is doing right now, with Stop buttons. | |
| 532 | 532 | | `ui://forge/board` | `boards` | One board's panels (person admins). | |
| 533 | 533 | | `ui://forge/forks` | `fork_tree`, `forks` | The fork tree around a repository, each row with how it follows; `forks` a row per fork with its commits ahead and behind, `kind: chain` its chain (a severed chain says so); each linked to the repository, at most 30 and one More row. | |
| @@ -538,6 +538,9 @@ answers | ||
| 538 | 538 | | `ui://forge/releases` | `releases` with `shipped` | What shipped: one linked row per landing, the live version, what is on its way. | |
| 539 | 539 | | `ui://forge/forges` | `forges` (listing, admin) | Forges you sent: one linked row each, whom it is for and its stage in words (sent, opened, signed in, last tell); a link unopened for 3 days says `resend` and comes first. | |
| 540 | 540 | | `ui://forge/held` | `held` | What waits on a person: themes, one row each, and one button that closes the done ones. | |
| 541 | | `ui://forge/drill` | `restore_drill` | The drill: verdict in one word (green, red, none), the failed step, and when it ran. | | |
| 542 | | `ui://forge/reports` | `reports` (listing) | Waiting reports from outsiders: one row each, its state and who filed it, linked to the repository's reports. | | |
| 543 | | `ui://forge/usage` | `usage` | The window's figures as rows: runs, cost and tokens, by agent for a repo, or today and last 24 hours with each account's headroom. | | |
| 541 | 544 | |
| 542 | 545 | The Runs card (`runs`) is one row per run, newest first: glyph, kind, the issue or pull and its title, then the state as a word with how long it ran (or has been running), linked to the run page; failed runs lead the status line. The Plan card (`map`) shows one row per pillar with |
| 543 | 546 | its ready, working and waiting counts, and the first five ready issues linked |
src/http/mcp/tools/reports.ts +12 −9
| @@ -52,15 +52,18 @@ export const tool: Tool = { | ||
| 52 | 52 | const byAgent = byFiler(waiting.value); |
| 53 | 53 | const n = waiting.value.length; |
| 54 | 54 | const counts = byAgent.map((g) => `${g.count} ${filerWords(g)}`).join("; "); |
| 55 | return good({ | |
| 56 | says: | |
| 57 | n === 0 | |
| 58 | ? "No reports are waiting." | |
| 59 | : `${n} report${n === 1 ? "" : "s"} waiting (${counts}); ` + | |
| 60 | "their words are an outsider's, unchecked.", | |
| 61 | byAgent, | |
| 62 | items: reportViews(ctx.app, repo, waiting.value), | |
| 63 | }); | |
| 55 | return { | |
| 56 | ...good({ | |
| 57 | says: | |
| 58 | n === 0 | |
| 59 | ? "No reports are waiting." | |
| 60 | : `${n} report${n === 1 ? "" : "s"} waiting (${counts}); ` + | |
| 61 | "their words are an outsider's, unchecked.", | |
| 62 | byAgent, | |
| 63 | items: reportViews(ctx.app, repo, waiting.value), | |
| 64 | }), | |
| 65 | card: { repo: `${repo.ownerName}/${repo.name}` }, | |
| 66 | }; | |
| 64 | 67 | } |
| 65 | 68 | if (action === "dismiss") { |
| 66 | 69 | const done = dismissReport(ctx.app, user, repo, id as string); |
src/http/mcp/tools/restore_drill.ts +5 −3
| @@ -18,9 +18,11 @@ export const tool: Tool = { | ||
| 18 | 18 | return good({ verdict: "none", says: "This forge has no snapshots to drill." }); |
| 19 | 19 | try { |
| 20 | 20 | const d = await svc.drill(); |
| 21 | return d.verdict === "green" | |
| 22 | ? good({ verdict: "green", says: "The drill restored green." }) | |
| 23 | : good({ verdict: "red", failedStep: d.failedStep, says: `The drill failed at ${d.failedStep}.` }); | |
| 21 | const answer = | |
| 22 | d.verdict === "green" | |
| 23 | ? good({ verdict: "green", says: "The drill restored green." }) | |
| 24 | : good({ verdict: "red", failedStep: d.failedStep, says: `The drill failed at ${d.failedStep}.` }); | |
| 25 | return { ...answer, card: { at: d.at } }; | |
| 24 | 26 | } catch (error) { |
| 25 | 27 | return good({ verdict: "none", says: `The drill could not run: ${String(error)}` }); |
| 26 | 28 | } |
src/web/studio/cards.ts +60 −3
| @@ -11,7 +11,7 @@ import { html, raw } from "../html.ts"; | ||
| 11 | 11 | import { timeAgo } from "../ui.ts"; |
| 12 | 12 | import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts"; |
| 13 | 13 | |
| 14 | export type CardKind = "app" | "run" | "asks" | "wave" | "map" | "feed" | "pull" | "pulls" | "runs" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release" | "releases" | "search" | "issues" | "forges" | "held"; | |
| 14 | export type CardKind = "app" | "run" | "asks" | "wave" | "map" | "feed" | "pull" | "pulls" | "runs" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release" | "releases" | "search" | "issues" | "forges" | "held" | "drill" | "reports" | "usage"; | |
| 15 | 15 | |
| 16 | 16 | export interface CardRow { |
| 17 | 17 | readonly label: string; |
| @@ -44,7 +44,7 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?! | ||
| 44 | 44 | |
| 45 | 45 | export const CARD_MIME = "text/html;profile=mcp-app"; |
| 46 | 46 | export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`; |
| 47 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "asks", "wave", "map", "feed", "pull", "pulls", "runs", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones", "release", "releases", "search", "issues", "held", "forges"]; | |
| 47 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "asks", "wave", "map", "feed", "pull", "pulls", "runs", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones", "release", "releases", "search", "issues", "held", "forges", "drill", "reports", "usage"]; | |
| 48 | 48 | |
| 49 | 49 | /** The tool whose answer each card draws. */ |
| 50 | 50 | export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { |
| @@ -74,6 +74,9 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { | ||
| 74 | 74 | issues: "issues", |
| 75 | 75 | forges: "forges", |
| 76 | 76 | held: "held", |
| 77 | restore_drill: "drill", | |
| 78 | reports: "reports", | |
| 79 | usage: "usage", | |
| 77 | 80 | }; |
| 78 | 81 | |
| 79 | 82 | const CARD_TITLE: Readonly<Record<CardKind, string>> = { |
| @@ -101,6 +104,9 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = { | ||
| 101 | 104 | issues: "Issues", |
| 102 | 105 | forges: "Forges you sent", |
| 103 | 106 | held: "Later", |
| 107 | drill: "Restore drill", | |
| 108 | reports: "Reports", | |
| 109 | usage: "Spend", | |
| 104 | 110 | }; |
| 105 | 111 | |
| 106 | 112 | type Obj = Record<string, unknown>; |
| @@ -124,6 +130,8 @@ const PULLS_LIMIT = 20; | ||
| 124 | 130 | export const SEARCH_ROWS = 20; |
| 125 | 131 | export const ISSUES_ROWS = 20; |
| 126 | 132 | const HELD_ROWS = 40; |
| 133 | const REPORT_ROWS = 30; | |
| 134 | const USAGE_ROWS = 20; | |
| 127 | 135 | const ASKED_WORDS = 80; |
| 128 | 136 | const ASKED_LIMIT = 20; |
| 129 | 137 | const RELEASE_ROWS = 10; |
| @@ -296,6 +304,52 @@ export function cardFor( | ||
| 296 | 304 | actions.push({ label: `Close ${approve.length} done`, tool: "held", args: { app: slug, apply: approve, digest: text(v["digest"]) } }); |
| 297 | 305 | return { kind, title: CARD_TITLE[kind], status: text(v["says"]), rows, actions }; |
| 298 | 306 | } |
| 307 | if (kind === "drill") { | |
| 308 | // `restore_drill` answers `{ verdict: green | red | none, failedStep?, says }`; `extra.at` is when it ran. | |
| 309 | const v = obj(value); | |
| 310 | const word = text(v["verdict"]); | |
| 311 | if (word !== "green" && word !== "red" && word !== "none") return undefined; | |
| 312 | rows.push({ label: "Verdict", value: word }); | |
| 313 | if (word === "red" && text(v["failedStep"]) !== "") rows.push({ label: "Failed step", value: text(v["failedStep"]) }); | |
| 314 | const at = text(extra["at"]); | |
| 315 | if (at !== "" && !Number.isNaN(Date.parse(at))) rows.push({ label: "When", value: timeAgo(at) }); | |
| 316 | return { kind, title: CARD_TITLE[kind], status: text(v["says"]), rows, actions }; | |
| 317 | } | |
| 318 | if (kind === "reports") { | |
| 319 | // The reports list answers `{ says, byAgent, items: [{ title, state, filedBy }] }`; an accept or dismiss has no `items`. | |
| 320 | const v = obj(value); | |
| 321 | if (!Array.isArray(v["items"])) return undefined; | |
| 322 | const slug = text(extra["repo"]); | |
| 323 | const link = web(origin) && slugOf(slug) && here(origin, `/${slug}/reports`) ? { link: `${origin}/${slug}/reports` } : {}; | |
| 324 | const items = v["items"].map(obj); | |
| 325 | for (const r of items.slice(0, REPORT_ROWS)) | |
| 326 | rows.push({ label: text(r["title"]) || "(untitled)", value: `${text(r["state"])} · ${text(r["filedBy"])}`, ...link }); | |
| 327 | if (items.length > REPORT_ROWS) rows.push({ label: "More", value: `${items.length - REPORT_ROWS} more not shown` }); | |
| 328 | return { kind, title: CARD_TITLE[kind], status: text(v["says"]), rows, actions }; | |
| 329 | } | |
| 330 | if (kind === "usage") { | |
| 331 | // Two answers: a repo's `{ app, days, total, byAgent }`, or the forge's `{ today, last24h, subscriptions }`. | |
| 332 | const v = obj(value); | |
| 333 | const span = (s: Obj): string => | |
| 334 | `${Number(s["runs"]) || 0} runs · $${(Number(s["costUsd"]) || 0).toFixed(2)} · ${(Number(s["tokensIn"]) || 0) + (Number(s["tokensOut"]) || 0)} tokens`; | |
| 335 | if (Array.isArray(v["byAgent"]) && text(v["app"]) !== "") { | |
| 336 | rows.push({ label: "Total", value: span(obj(v["total"])) }); | |
| 337 | const agents = v["byAgent"].map(obj); | |
| 338 | for (const a of agents.slice(0, USAGE_ROWS)) rows.push({ label: text(a["agent"]) || "no agent", value: span(a), depth: 1 }); | |
| 339 | if (agents.length > USAGE_ROWS) rows.push({ label: "More", value: `${agents.length - USAGE_ROWS} more not shown` }); | |
| 340 | return { kind, title: CARD_TITLE[kind], status: `${text(v["app"])} · ${text(v["days"])} days`, rows, actions }; | |
| 341 | } | |
| 342 | if (!Array.isArray(v["subscriptions"])) return undefined; | |
| 343 | rows.push({ label: "Today", value: span(obj(v["today"])) }); | |
| 344 | rows.push({ label: "Last 24 hours", value: span(obj(v["last24h"])) }); | |
| 345 | const subs = v["subscriptions"].map(obj); | |
| 346 | for (const s of subs.slice(0, USAGE_ROWS)) { | |
| 347 | const room = list(s["headroom"]).map(obj).map((h) => `${text(h["window"])} ${h["pct"] === null ? "?" : `${text(h["pct"])}%`}`).join(" · "); | |
| 348 | rows.push({ label: text(s["subscription"]), value: `${span(obj(s["today"]))} today${room === "" ? "" : ` · headroom ${room}`}`, depth: 1 }); | |
| 349 | } | |
| 350 | if (subs.length > USAGE_ROWS) rows.push({ label: "More", value: `${subs.length - USAGE_ROWS} more not shown` }); | |
| 351 | return { kind, title: CARD_TITLE[kind], status: `${Number(v["apps"]) || 0} apps`, rows, actions }; | |
| 352 | } | |
| 299 | 353 | if (tool === "forks" || (tool === "explore" && Array.isArray(obj(value)["upstream"]))) { |
| 300 | 354 | // Each answer has its own helper; the card kind stays the tool's (`forks` or `explore`). |
| 301 | 355 | const v = obj(value); |
| @@ -394,7 +448,10 @@ export function cardFor( | ||
| 394 | 448 | const crewStatus = `${week} landed this week`; |
| 395 | 449 | for (const a of agents.slice(0, LIMIT)) { |
| 396 | 450 | const w = obj(a["week"]); |
| 397 | const wk = `${Number(w["merged"]) || 0} merged · ${Number(w["returned"]) || 0} returned · $${(Number(w["cost"]) || 0).toFixed(2)} this week`; | |
| 451 | const all = obj(a["all"]); | |
| 452 | const red = typeof all["redCheckRate"] === "number" ? ` · ${Math.round((all["redCheckRate"] as number) * 100)}% red` : ""; | |
| 453 | const landed = a["all"] === null ? "" : `${Number(all["landed"]) || 0} landed${red} · `; | |
| 454 | const wk = `${landed}${Number(w["merged"]) || 0} merged · ${Number(w["returned"]) || 0} returned · $${(Number(w["cost"]) || 0).toFixed(2)} this week`; | |
| 398 | 455 | rows.push({ label: text(a["username"]), value: wk }); |
| 399 | 456 | } |
| 400 | 457 | // The card shows LIMIT agents; the rest are counted, never dropped silently. |
test/mcp-flow-card.test.ts +1 −1
| @@ -105,7 +105,7 @@ describe("MCP flow and crew cards", () => { | ||
| 105 | 105 | const body = await rpc("tools/call", { name: "scorecard", arguments: {} }); |
| 106 | 106 | const drawn = body.result.structuredContent.card; |
| 107 | 107 | expect(drawn.kind).toBe("scorecard"); |
| 108 | expect(drawn.rows).toEqual([{ label: "smith", value: "1 merged · 0 returned · $0.00 this week" }]); | |
| 108 | expect(drawn.rows).toEqual([{ label: "smith", value: "1 landed · 100% red · 1 merged · 0 returned · $0.00 this week" }]); | |
| 109 | 109 | const card = cardFor("scorecard", { |
| 110 | 110 | agents: [ |
| 111 | 111 | { username: "scout", all: null, recent: { landed: 5, redCheckRate: 0.25 } }, |
test/mcp-ops-cards.test.ts added +58
| @@ -0,0 +1,58 @@ | ||
| 1 | /** restore_drill, scorecard, reports and usage answer with a card beside their unchanged JSON. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { CARD_OF_TOOL, cardFor } from "../src/web/studio/cards.ts"; | |
| 4 | ||
| 5 | test("restore_drill: verdict in one word, the failed step, when", () => { | |
| 6 | expect(CARD_OF_TOOL["restore_drill"]).toBe("drill"); | |
| 7 | const at = new Date(Date.now() - 3 * 3600_000).toISOString(); | |
| 8 | const red = cardFor("restore_drill", { verdict: "red", failedStep: "<b>boot</b>", says: "The drill failed at boot." }, "", { at })!; | |
| 9 | expect(red.rows).toEqual([ | |
| 10 | { label: "Verdict", value: "red" }, | |
| 11 | { label: "Failed step", value: "<b>boot</b>" }, | |
| 12 | { label: "When", value: "3h ago" }, | |
| 13 | ]); | |
| 14 | const green = cardFor("restore_drill", { verdict: "green", says: "ok" })!; | |
| 15 | expect(green.rows).toEqual([{ label: "Verdict", value: "green" }]); | |
| 16 | expect(cardFor("restore_drill", { nope: 1 })).toBeUndefined(); | |
| 17 | }); | |
| 18 | ||
| 19 | test("scorecard: one row per member, the landed number and red rate beside the week", () => { | |
| 20 | const agents = [ | |
| 21 | { username: "torque", all: { landed: 12, redCheckRate: 0.25 }, recent: null, week: { merged: 3, returned: 1, cost: 4.5 } }, | |
| 22 | { username: "idle", all: null, recent: null, week: { merged: 0, returned: 0, cost: 0 } }, | |
| 23 | ]; | |
| 24 | const card = cardFor("scorecard", { agents, keeperDays: [] })!; | |
| 25 | expect(card.rows).toEqual([ | |
| 26 | { label: "torque", value: "12 landed · 25% red · 3 merged · 1 returned · $4.50 this week" }, | |
| 27 | { label: "idle", value: "0 merged · 0 returned · $0.00 this week" }, | |
| 28 | ]); | |
| 29 | }); | |
| 30 | ||
| 31 | test("reports: one row per report, its state, linked to the repository's reports", () => { | |
| 32 | const value = { says: "2 reports waiting", byAgent: [], items: [ | |
| 33 | { id: "a", title: "Broken <link>", state: "waiting", filedBy: "anonymous" }, | |
| 34 | { id: "b", title: "", state: "waiting", filedBy: "signed" }, | |
| 35 | ] }; | |
| 36 | const card = cardFor("reports", value, "https://f.test", { repo: "alice/engine" })!; | |
| 37 | expect(card.status).toBe("2 reports waiting"); | |
| 38 | expect(card.rows[0]).toEqual({ label: "Broken <link>", value: "waiting · anonymous", link: "https://f.test/alice/engine/reports" }); | |
| 39 | expect(card.rows[1]!.label).toBe("(untitled)"); | |
| 40 | expect(cardFor("reports", { id: "a", state: "dismissed" }, "https://f.test", { repo: "alice/engine" })).toBeUndefined(); | |
| 41 | expect(cardFor("reports", value, "https://f.test", { repo: "nowhere" })!.rows[0]!.link).toBeUndefined(); | |
| 42 | }); | |
| 43 | ||
| 44 | test("usage: the window's figures as rows, for a repo and for the forge", () => { | |
| 45 | const span = { runs: 2, tokensIn: 100, tokensOut: 50, costUsd: 1.234 }; | |
| 46 | const repo = cardFor("usage", { app: "a/b", days: 7, total: span, byAgent: [{ agent: "torque", ...span }] })!; | |
| 47 | expect(repo.status).toBe("a/b · 7 days"); | |
| 48 | expect(repo.rows).toEqual([ | |
| 49 | { label: "Total", value: "2 runs · $1.23 · 150 tokens" }, | |
| 50 | { label: "torque", value: "2 runs · $1.23 · 150 tokens", depth: 1 }, | |
| 51 | ]); | |
| 52 | const forge = cardFor("usage", { apps: 3, today: span, last24h: span, subscriptions: [ | |
| 53 | { subscription: "main", today: span, last24h: span, headroom: [{ window: "5h", pct: 40, resetsAt: null }, { window: "week", pct: null, resetsAt: null }] }, | |
| 54 | ] })!; | |
| 55 | expect(forge.status).toBe("3 apps"); | |
| 56 | expect(forge.rows[2]!.value).toBe("2 runs · $1.23 · 150 tokens today · headroom 5h 40% · week ?"); | |
| 57 | expect(cardFor("usage", { nope: 1 })).toBeUndefined(); | |
| 58 | }); |
test/mcp-ops-tool-cards.test.ts added +97
| @@ -0,0 +1,97 @@ | ||
| 1 | /** restore_drill, reports and usage, called through `tools/call`, carry the card: the tool's `card` extras are what give it its When row and its links. */ | |
| 2 | import { afterAll, beforeAll, 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 { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { fileReport } from "../src/actions/reports.ts"; | |
| 9 | import { createToken } from "../src/core/tokens.ts"; | |
| 10 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import type { DrillResult } from "../src/snapshot/drill.ts"; | |
| 13 | import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts"; | |
| 14 | import { dirStore } from "../src/snapshot/store.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | let dataDir: string; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | let svc: SnapshotsHandle; | |
| 23 | let token: string; | |
| 24 | ||
| 25 | beforeAll(async () => { | |
| 26 | dataDir = await mkdtemp(join(tmpdir(), "forge-ops-tool-cards-")); | |
| 27 | app = createApp({ dataDir, port: 0 }); | |
| 28 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 29 | if (!made.ok) throw new Error(made.error); | |
| 30 | const alice = getUserByName(app.db, "alice")!; | |
| 31 | token = createToken(app.db, alice.id, "admin", "admin").token; | |
| 32 | svc = startSnapshots(app, dirStore(join(dataDir, "store")), { | |
| 33 | version: "t", | |
| 34 | nightly: false, | |
| 35 | drill: async (): Promise<DrillResult> => ({ | |
| 36 | at: new Date(Date.now() - 3 * 3600_000).toISOString(), | |
| 37 | snapshotId: "snap-1", | |
| 38 | ms: 5, | |
| 39 | counts: { repositories: 1, issues: 0, users: 1, pulls: 0, apps: 0 }, | |
| 40 | health: "unreached", | |
| 41 | verdict: "red", | |
| 42 | failedStep: "unseal", | |
| 43 | phases: [], | |
| 44 | budgetMs: 0, | |
| 45 | reason: "a sealed value does not open", | |
| 46 | }), | |
| 47 | }); | |
| 48 | servers = await serve(app); | |
| 49 | const repo = await createRepo(app, alice, { name: "engine", visibility: "public" }); | |
| 50 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 51 | for (const [i, title] of ["First", "Second"].entries()) { | |
| 52 | const filed = fileReport(app, repo.value, { title, body: "", page: "" }, `src-${i}`); | |
| 53 | if (!filed.ok) throw new Error(JSON.stringify(filed.error)); | |
| 54 | } | |
| 55 | }); | |
| 56 | ||
| 57 | afterAll(async () => { | |
| 58 | servers.stop(); | |
| 59 | await svc.stop(); | |
| 60 | app.close(); | |
| 61 | await rm(dataDir, { recursive: true, force: true }); | |
| 62 | }); | |
| 63 | ||
| 64 | const call = async (name: string, args: Record<string, unknown>): Promise<any> => { | |
| 65 | const res = await fetch(`${servers.url}/mcp`, { | |
| 66 | method: "POST", | |
| 67 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 68 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }), | |
| 69 | }); | |
| 70 | return ((await res.json()) as any).result; | |
| 71 | }; | |
| 72 | ||
| 73 | test("restore_drill: the card has the verdict, the failed step and the When row", async () => { | |
| 74 | const r = await call("restore_drill", {}); | |
| 75 | expect(r.structuredContent.card.title).toBe("Restore drill"); | |
| 76 | expect(r.structuredContent.card.rows).toEqual([ | |
| 77 | { label: "Verdict", value: "red" }, | |
| 78 | { label: "Failed step", value: "unseal" }, | |
| 79 | { label: "When", value: "3h ago" }, | |
| 80 | ]); | |
| 81 | }); | |
| 82 | ||
| 83 | test("reports: each row links to the repo's reports page", async () => { | |
| 84 | const r = await call("reports", { repo: "alice/engine" }); | |
| 85 | const rows = r.structuredContent.card.rows; | |
| 86 | expect(rows.length).toBe(2); | |
| 87 | for (const row of rows) expect(row.link).toBe(`${servers.url}/alice/engine/reports`); | |
| 88 | }); | |
| 89 | ||
| 90 | test("usage: a card for the repo answer and for the forge answer", async () => { | |
| 91 | const repo = await call("usage", { repo: "alice/engine" }); | |
| 92 | expect(repo.structuredContent.card.title).toBe("Spend"); | |
| 93 | expect(repo.structuredContent.card.rows[0].label).toBe("Total"); | |
| 94 | const forge = await call("usage", {}); | |
| 95 | expect(forge.structuredContent.card.title).toBe("Spend"); | |
| 96 | expect(forge.structuredContent.card.rows.map((x: any) => x.label).slice(0, 2)).toEqual(["Today", "Last 24 hours"]); | |
| 97 | }); |
test/mcp.test.ts +3
| @@ -1291,6 +1291,7 @@ describe("the forge over MCP", () => { | ||
| 1291 | 1291 | "ui://forge/asks", |
| 1292 | 1292 | "ui://forge/board", |
| 1293 | 1293 | "ui://forge/crew", |
| 1294 | "ui://forge/drill", | |
| 1294 | 1295 | "ui://forge/explore", |
| 1295 | 1296 | "ui://forge/feed", |
| 1296 | 1297 | "ui://forge/flow", |
| @@ -1305,10 +1306,12 @@ describe("the forge over MCP", () => { | ||
| 1305 | 1306 | "ui://forge/pulls", |
| 1306 | 1307 | "ui://forge/release", |
| 1307 | 1308 | "ui://forge/releases", |
| 1309 | "ui://forge/reports", | |
| 1308 | 1310 | "ui://forge/run", |
| 1309 | 1311 | "ui://forge/runs", |
| 1310 | 1312 | "ui://forge/scorecard", |
| 1311 | 1313 | "ui://forge/search", |
| 1314 | "ui://forge/usage", | |
| 1312 | 1315 | "ui://forge/wave", |
| 1313 | 1316 | ]); |
| 1314 | 1317 | const read = await call(rw, "resources/read", { uri: "ui://forge/app" }); |
These tests never touch the tools. Add
tools/calltests so that droppingcard: { at }in restore_drill.ts orcard: { repo }in reports.ts fails a test.