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

Merged opened this

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

Merged

scout/forge:scout/8013-get-api-v1-admin-drills-answers-each-fai → main 2 commits fork

  • Merged. scout/8013-get-api-v1-admin-drills-answers-each-fai was merged into main as 05df01e.

Review

Sign in to review this change.
7 files changed +136 −19
src/actions/resilience.ts +20
@@ -8,6 +8,7 @@
88 import type { App } from "../app.ts";
99 import { lastDrill, type LastDrill } from "../core/domain_drills.ts";
1010 import type { User } from "../core/users.ts";
11 import { drillCommand } from "../kernel/domain_drill.ts";
1112 import { DOMAINS } from "../kernel/domains.ts";
1213 import { err, ok, type Result } from "../kernel/result.ts";
1314 import { snapshotsOf } from "../snapshot/service.ts";
@@ -45,6 +46,25 @@ export function listResilience(
4546 );
4647 }
4748
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
4868 /** Where snapshots go and the newest one, as Settings → Backups says them. */
4969 export interface SnapshotView {
5070 /** 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
3030 export { route as deleteApiV1UsersUsernameSessionsId } from "./deleteApiV1UsersUsernameSessionsId.ts";
3131 export { route as getApiV1AdminAccounts } from "./getApiV1AdminAccounts.ts";
3232 export { route as getApiV1AdminCapacity } from "./getApiV1AdminCapacity.ts";
33 export { route as getApiV1AdminDrills } from "./getApiV1AdminDrills.ts";
3334 export { route as getApiV1AdminInvites } from "./getApiV1AdminInvites.ts";
3435 export { route as getApiV1AdminLimits } from "./getApiV1AdminLimits.ts";
3536 export { route as getApiV1AdminPeople } from "./getApiV1AdminPeople.ts";
src/http/api.ts +2
@@ -330,6 +330,7 @@ import { conversationConfirmRoutes } from "./api/conversation_confirms.ts";
330330 import { orgRoutes } from "./api/orgs.ts";
331331 import { promptRoutes } from "./api/prompts.ts";
332332 import { secretRoutes } from "./api/secrets.ts";
333 import { drillRoutes } from "./api/drills.ts";
333334 import { upstreamRoutes } from "./api/upstream.ts";
334335 import type { Router } from "./router.ts";
335336 import { clientAddress } from "./peer.ts";
@@ -3345,6 +3346,7 @@ export function apiRoutes(router: Router<Handler>): void {
33453346 promptRoutes(router);
33463347
33473348 secretRoutes(router);
3349 drillRoutes(router);
33483350
33493351 // What the crew's runs here spent over a window (`core/usage.ts`):
33503352 // 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";
32 import { bad, good, type Tool } from "../shared.ts";
43
54 export const tool: Tool = {
@@ -12,22 +11,6 @@ export const tool: Tool = {
1211 const listed = listResilience(ctx.app, user);
1312 if (!listed.ok) return bad("forbidden", "failure domains are for a forge admin who is a person");
1413 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));
3215 },
3316 };
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs