Public: anyone can read this; the crew can change it.
GET /api/v1/admin/drills answers each failure domain's last drill, verdict and command, as MCP `drills` does #8023
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8013-get-api-v1-admin-drills-answers-each-fai → main 2 commits fork
7 files
changed +136
−19
src/actions/resilience.ts +20
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | import type { App } from "../app.ts"; |
| 9 | 9 | import { lastDrill, type LastDrill } from "../core/domain_drills.ts"; |
| 10 | 10 | import type { User } from "../core/users.ts"; |
| 11 | import { drillCommand } from "../kernel/domain_drill.ts"; | |
| 11 | 12 | import { DOMAINS } from "../kernel/domains.ts"; |
| 12 | 13 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 13 | 14 | import { snapshotsOf } from "../snapshot/service.ts"; |
| @@ -45,6 +46,25 @@ export function listResilience( | ||
| 45 | 46 | ); |
| 46 | 47 | } |
| 47 | 48 | |
| 49 | /** A domain as MCP `drills` and `GET /api/v1/admin/drills` say it: drilled with its last result and command, or why not. */ | |
| 50 | export function drillRows(rows: readonly ResilienceRow[], origin: string) { | |
| 51 | return rows.map((d) => | |
| 52 | d.drill.name === null | |
| 53 | ? { key: d.key, breaks: d.breaks, drilled: false, why: d.drill.why } | |
| 54 | : { | |
| 55 | key: d.key, | |
| 56 | breaks: d.breaks, | |
| 57 | drilled: true, | |
| 58 | drill: d.drill.name, | |
| 59 | last: | |
| 60 | d.drill.last === null | |
| 61 | ? null | |
| 62 | : { at: d.drill.last.at, green: d.drill.last.green, why: d.drill.last.why }, | |
| 63 | command: drillCommand(d.key, origin), | |
| 64 | }, | |
| 65 | ); | |
| 66 | } | |
| 67 | ||
| 48 | 68 | /** Where snapshots go and the newest one, as Settings → Backups says them. */ |
| 49 | 69 | export interface SnapshotView { |
| 50 | 70 | /** The store's name (never a secret), or null when the forge has none. */ |
src/contract/openapi/routes/getApiV1AdminDrills.ts added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: "/api/v1/admin/drills", | |
| 6 | summary: | |
| 7 | "Each failure domain's last drill, as MCP `drills` says it (a person admin's token, any scope); answers { items: [{ key, breaks, drilled, why } or { key, breaks, drilled, drill, last: { at, green, why } | null, command }], next_cursor }", | |
| 8 | auth: true, | |
| 9 | refusals: { forbidden: "the token is not a forge admin who is a person" }, | |
| 10 | }; |
src/contract/openapi/routes/index.ts +1
| @@ -30,6 +30,7 @@ export { route as deleteApiV1UserPinsOwnerName } from "./deleteApiV1UserPinsOwne | ||
| 30 | 30 | export { route as deleteApiV1UsersUsernameSessionsId } from "./deleteApiV1UsersUsernameSessionsId.ts"; |
| 31 | 31 | export { route as getApiV1AdminAccounts } from "./getApiV1AdminAccounts.ts"; |
| 32 | 32 | export { route as getApiV1AdminCapacity } from "./getApiV1AdminCapacity.ts"; |
| 33 | export { route as getApiV1AdminDrills } from "./getApiV1AdminDrills.ts"; | |
| 33 | 34 | export { route as getApiV1AdminInvites } from "./getApiV1AdminInvites.ts"; |
| 34 | 35 | export { route as getApiV1AdminLimits } from "./getApiV1AdminLimits.ts"; |
| 35 | 36 | export { route as getApiV1AdminPeople } from "./getApiV1AdminPeople.ts"; |
src/http/api.ts +2
| @@ -330,6 +330,7 @@ import { conversationConfirmRoutes } from "./api/conversation_confirms.ts"; | ||
| 330 | 330 | import { orgRoutes } from "./api/orgs.ts"; |
| 331 | 331 | import { promptRoutes } from "./api/prompts.ts"; |
| 332 | 332 | import { secretRoutes } from "./api/secrets.ts"; |
| 333 | import { drillRoutes } from "./api/drills.ts"; | |
| 333 | 334 | import { upstreamRoutes } from "./api/upstream.ts"; |
| 334 | 335 | import type { Router } from "./router.ts"; |
| 335 | 336 | import { clientAddress } from "./peer.ts"; |
| @@ -3345,6 +3346,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3345 | 3346 | promptRoutes(router); |
| 3346 | 3347 | |
| 3347 | 3348 | secretRoutes(router); |
| 3349 | drillRoutes(router); | |
| 3348 | 3350 | |
| 3349 | 3351 | // What the crew's runs here spent over a window (`core/usage.ts`): |
| 3350 | 3352 | // tokens, cost, wall time and outcome, by subscription, agent and issue. |
src/http/api/drills.ts added +17
| @@ -0,0 +1,17 @@ | ||
| 1 | /** `GET /api/v1/admin/drills`: what MCP `drills` answers, for the same reader. */ | |
| 2 | ||
| 3 | import { drillRows, listResilience } from "../../actions/resilience.ts"; | |
| 4 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 5 | import type { Handler } from "../context.ts"; | |
| 6 | import { apiError, json, unauthorized } from "../respond.ts"; | |
| 7 | import type { Router } from "../router.ts"; | |
| 8 | ||
| 9 | export function drillRoutes(router: Router<Handler>): void { | |
| 10 | router.add("GET", "/api/v1/admin/drills", (ctx) => { | |
| 11 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 12 | if (!ctx.auth.user) return unauthorized(); | |
| 13 | const listed = listResilience(ctx.app, ctx.auth.user); | |
| 14 | if (!listed.ok) return apiError("forbidden", 403, "failure domains are for a forge admin who is a person"); | |
| 15 | return json({ items: drillRows(listed.value, ctx.app.config.url ?? ctx.url.origin), next_cursor: null }); | |
| 16 | }); | |
| 17 | } |
src/http/mcp/tools/drills.ts +2 −19
| @@ -1,5 +1,4 @@ | ||
| 1 | import { listResilience } from "../../../actions/resilience.ts"; | |
| 2 | import { drillCommand } from "../../../kernel/domain_drill.ts"; | |
| 1 | import { drillRows, listResilience } from "../../../actions/resilience.ts"; | |
| 3 | 2 | import { bad, good, type Tool } from "../shared.ts"; |
| 4 | 3 | |
| 5 | 4 | export const tool: Tool = { |
| @@ -12,22 +11,6 @@ export const tool: Tool = { | ||
| 12 | 11 | const listed = listResilience(ctx.app, user); |
| 13 | 12 | if (!listed.ok) return bad("forbidden", "failure domains are for a forge admin who is a person"); |
| 14 | 13 | const origin = ctx.app.config.url ?? ctx.url.origin; |
| 15 | return good( | |
| 16 | listed.value.map((d) => | |
| 17 | d.drill.name === null | |
| 18 | ? { key: d.key, breaks: d.breaks, drilled: false, why: d.drill.why } | |
| 19 | : { | |
| 20 | key: d.key, | |
| 21 | breaks: d.breaks, | |
| 22 | drilled: true, | |
| 23 | drill: d.drill.name, | |
| 24 | last: | |
| 25 | d.drill.last === null | |
| 26 | ? null | |
| 27 | : { at: d.drill.last.at, green: d.drill.last.green, why: d.drill.last.why }, | |
| 28 | command: drillCommand(d.key, origin), | |
| 29 | }, | |
| 30 | ), | |
| 31 | ); | |
| 14 | return good(drillRows(listed.value, origin)); | |
| 32 | 15 | }, |
| 33 | 16 | }; |
test/api-admin-drills.test.ts added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | /** `GET /api/v1/admin/drills` answers what MCP `drills` answers, to a person admin only. */ | |
| 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 { createToken } from "../src/core/tokens.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | ||
| 11 | let dataDir = ""; | |
| 12 | let app: App; | |
| 13 | let servers: Servers; | |
| 14 | let admin = ""; | |
| 15 | let member = ""; | |
| 16 | let agentAdmin = ""; | |
| 17 | ||
| 18 | const get = (token?: string) => | |
| 19 | fetch(`${servers.url}/api/v1/admin/drills`, token ? { headers: { authorization: `Bearer ${token}` } } : {}); | |
| 20 | ||
| 21 | beforeAll(async () => { | |
| 22 | dataDir = await mkdtemp(join(tmpdir(), "forge-api-drills-")); | |
| 23 | app = createApp({ dataDir, port: 0, url: "https://forge.example" }); | |
| 24 | servers = await serve(app); | |
| 25 | const mk = async (username: string, extra: Record<string, unknown> = {}) => { | |
| 26 | const u = await createUser(app.db, { | |
| 27 | origin: "seed", trusted: true, username, | |
| 28 | email: `${username}@forge.local`, password: "a-good-passphrase", ...extra, | |
| 29 | }); | |
| 30 | if (!u.ok) throw new Error(u.error); | |
| 31 | return u.value; | |
| 32 | }; | |
| 33 | const alice = await mk("alice", { adminIfFirst: true }); | |
| 34 | const bob = await mk("bob"); | |
| 35 | const scribe = await mk("scribe", { agent: true, isAdmin: true }); | |
| 36 | admin = createToken(app.db, alice.id, "a", "admin").token; | |
| 37 | member = createToken(app.db, bob.id, "b", "admin").token; | |
| 38 | agentAdmin = createToken(app.db, scribe.id, "c", "admin").token; | |
| 39 | }, 30_000); | |
| 40 | afterAll(async () => { | |
| 41 | await servers.stop(); | |
| 42 | app.close(); | |
| 43 | await rm(dataDir, { recursive: true, force: true }); | |
| 44 | }); | |
| 45 | ||
| 46 | async function mcp(token: string): Promise<unknown> { | |
| 47 | const res = await fetch(`${servers.url}/mcp`, { | |
| 48 | method: "POST", | |
| 49 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 50 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "drills", arguments: {} } }), | |
| 51 | }); | |
| 52 | return JSON.parse(((await res.json()) as any).result.content[0].text); | |
| 53 | } | |
| 54 | ||
| 55 | test("an admin person gets the rows MCP drills returns", async () => { | |
| 56 | const res = await get(admin); | |
| 57 | expect(res.status).toBe(200); | |
| 58 | const body = (await res.json()) as { items: any[]; next_cursor: null }; | |
| 59 | const rows = body.items; | |
| 60 | expect(body.next_cursor).toBeNull(); | |
| 61 | expect(rows).toEqual((await mcp(admin)) as any[]); | |
| 62 | expect(rows.length).toBeGreaterThan(0); | |
| 63 | const drilled = rows.find((r) => r.drilled); | |
| 64 | expect(drilled.command).toContain("https://forge.example"); | |
| 65 | }); | |
| 66 | ||
| 67 | test("an agent admin is refused as forbidden, a member at the admin gate", async () => { | |
| 68 | const agent = await get(agentAdmin); | |
| 69 | expect(agent.status).toBe(403); | |
| 70 | expect(await agent.json()).toMatchObject({ | |
| 71 | error: "forbidden", | |
| 72 | message: "failure domains are for a forge admin who is a person", | |
| 73 | }); | |
| 74 | expect((await get(member)).status).toBe(403); | |
| 75 | }); | |
| 76 | ||
| 77 | test("no token is 401", async () => { | |
| 78 | expect((await get()).status).toBe(401); | |
| 79 | }); | |
| 80 | ||
| 81 | test("openapi lists the route", async () => { | |
| 82 | const doc = (await (await fetch(`${servers.url}/api/v1/openapi.json`)).json()) as any; | |
| 83 | expect(doc.paths["/api/v1/admin/drills"].get).toBeDefined(); | |
| 84 | }); |
new above
offline