Public: anyone can read this; the crew can change it.

GET /api/v1/forges answers the client list the /forges page and the MCP forges tool show: claimed, running, spend, what needs attention and the next step #9152

Merged opened this

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

Merged

scout/forge:scout/9137-get-api-v1-forges-answers-the-client-lis → main 7 commits fork 3 threads · 3 unresolved

  • Merged. scout/9137-get-api-v1-forges-answers-the-client-lis was merged into main as de9691b.

Review

Sign in to review this change.
4 files changed +71 −17
src/contract/openapi/routes/getApiV1Forges.ts +1 −1
@@ -1,3 +1,3 @@
11 import type { Route } from "../shared.ts";
22
3 export const route: Route = { method: "get", path: "/api/v1/forges", summary: "List the child forges (a site admin's, ro scope); answers { items, next_cursor: null }", auth: true };
3 export const route: Route = { method: "get", path: "/api/v1/forges", summary: "List the forges sent from here (a site admin's, ro scope): the client list /forges and the MCP forges tool show. answers { items, next_cursor: null }; each item has name, url, for, apps, sentAt, made, claimed (null when it did not answer), lastActivityAt, running, reach, attention (silent, down, unclaimed or null), spendTodayUsd, ceilingUsd, size, use, lastHeartbeatAt and nextStep, and beside them what the list always carried: bodyId, done, createdAt, placement, placementNote and template (when one was chosen). what needs attention comes first", auth: true };
src/http/api/forges.ts +14 −2
@@ -6,6 +6,7 @@
66
77 import { listChildren } from "../../actions/spawn.ts";
88 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
9 import { clientRows } from "../../spawn/clients.ts";
910 import { reissueDoor, spawnDoor } from "../../spawn/door.ts";
1011 import { sizesView } from "../../spawn/sizes.ts";
1112 import { templatesView } from "../../spawn/templates.ts";
@@ -32,13 +33,24 @@ export function forgeRoutes(router: Router<Handler>): void {
3233 router.add("GET", "/api/v1/forges/sizes", (ctx) => publicTable(ctx, "sizes", sizesView));
3334 router.add("GET", "/api/v1/forges/templates", (ctx) => publicTable(ctx, "templates", templatesView));
3435
35 // A forge's children (docs/SPAWN.md): an admin's, planned before made.
36 // The client list (docs/SPAWN.md): an admin's, the rows /forges and the MCP
37 // `forges` tool show, what needs attention first.
3638 router.add("GET", "/api/v1/forges", async (ctx) => {
3739 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
3840 const denied = requireScope(ctx, "ro");
3941 if (denied) return denied;
4042 if (!ctx.auth.user?.isAdmin) return forbidden();
outdated

clientRows has no template, placement, placementNote, bodyId, done or createdAt, which listChildren returned. Keep the ones consumers need (template, placement, createdAt) or say they are dropped on purpose.

41 const items = await timePhaseAsync(ctx.request, "read", () => listChildren(ctx.app));
43 const items = await timePhaseAsync(ctx.request, "read", async () => {
44 // The row, and beside it what the list always carried: which template a child came from, where it is placed.
45 const [rows, children] = await Promise.all([clientRows(ctx.app), listChildren(ctx.app)]);
46 const kept = new Map(children.map((c) => [c.name, c]));
47 return rows.map((row) => {
48 const c = kept.get(row.name);
49 return c === undefined
50 ? row
51 : { ...row, bodyId: c.bodyId, done: c.done, createdAt: c.createdAt, placement: c.placement, placementNote: c.placementNote, ...(c.template === undefined ? {} : { template: c.template }) };
52 });
53 });
4254 return timePhase(ctx.request, "render", () => json({ items, next_cursor: null }));
4355 });
4456
test/client-list.test.ts +38
@@ -322,6 +322,44 @@ describe("the client list", () => {
322322 expect(step.words).toBe("going: last tell 2030-02-03T04:05:06.000Z");
323323 });
324324
325 test("GET /api/v1/forges answers the MCP tool's rows, and an old unclaimed child is flagged", async () => {
326 const made = await spawn({ name: "dusty", apps: ["alice/bakery"], dryRun: false });
327 expect(made.status).toBe(201);
328 // A calm second child, named to sort before dusty: attention first must put dusty ahead of it.
329 expect((await spawn({ name: "calm", apps: ["alice/bakery"], dryRun: false })).status).toBe(201);
330 const file = join(dir, "spawn", "dusty.json");
331 const ledger = JSON.parse(await Bun.file(file).text());
332 ledger.createdAt = new Date(Date.now() - 5 * 86_400_000).toISOString();
333 await Bun.write(file, JSON.stringify(ledger));
334 const before = parent;
335 useChildFetch(before, (url) =>
336 url.startsWith("https://dusty--forge.test/")
337 ? Promise.resolve(Response.json({ claimed: false, lastActivityAt: new Date().toISOString() }))
338 : url.startsWith("https://calm--forge.test/")
339 ? Promise.resolve(Response.json({ claimed: true, lastActivityAt: new Date().toISOString() }))
340 : Promise.reject(new Error("unreachable")),
341 );
342 const api = await fetch(`${parentServers.url}/api/v1/forges`, { headers: { authorization: `Bearer ${adminRw}` } });
343 expect(api.status).toBe(200);
344 const body = (await api.json()) as { items: any[]; next_cursor: null };
345 expect(body.next_cursor).toBeNull();
346 const tool = (await (await mcp(adminRw)).json()) as any;
347 const forges = JSON.parse(tool.result.content[0].text).forges as any[];
348 expect(body.items.map((r) => r.name)).toEqual(forges.map((r) => r.name));
349 const names = body.items.map((r) => r.name);
350 expect(names.indexOf("dusty")).toBeLessThan(names.indexOf("calm"));
351 expect(body.items.find((r) => r.name === "calm").attention).toBeNull();
352 const dustyApi = body.items.find((r) => r.name === "dusty");
353 expect(dustyApi.attention).toBe("unclaimed");
354 expect(dustyApi.claimed).toBe(false);
355 expect(dustyApi).toMatchObject(forges.find((r) => r.name === "dusty"));
356 expect(dustyApi.nextStep.key).toBe("unreported");
357 // Admin only: a non-admin is 403, a signed-out caller 401.
358 const bob = await fetch(`${parentServers.url}/api/v1/forges`, { headers: { authorization: `Bearer ${bobRw}` } });
359 expect(bob.status).toBe(403);
360 expect((await fetch(`${parentServers.url}/api/v1/forges`)).status).toBe(401);
361 });
362
325363 test("each step in words, and a beat with a bad value loses the field, not the beat", () => {
326364 const beat = (o: Record<string, unknown>) =>
327365 parseBeat(JSON.stringify({ name: "maria", at: 1, version: "1.0.0", openProposals: 0, children: 0, ...o }));
test/spawn-api.test.ts +18 −14
@@ -155,20 +155,24 @@ describe("spawn over the API and MCP", () => {
155155 expect(String(made.url)).toContain("kid");
156156 expect(placed).toBe(1);
157157 const list = (await (await get(ro)).json()) as any;
158 expect(list.items).toEqual([
159 {
160 name: "kid",
161 url: expect.stringContaining("kid"),
162 bodyId: "fake-1",
163 done: true,
164 for: null,
165 apps: expect.any(Array),
166 createdAt: expect.any(String),
167 size: "small",
168 placement: "hosted",
169 placementNote: null,
170 },
171 ]);
158 expect(list.items).toHaveLength(1);
159 expect(list.items[0]).toMatchObject({
outdated

A toEqual of the full item became toMatchObject on five fields. Assert the fields the route is meant to keep.

160 name: "kid",
161 url: expect.stringContaining("kid"),
162 for: null,
163 apps: ["alice/notes"],
164 size: "small",
165 made: true,
166 attention: "silent",
167 // what the list carried before the client rows, kept beside them
168 bodyId: "fake-1",
169 done: true,
170 createdAt: expect.any(String),
171 placement: "hosted",
172 placementNote: null,
173 });
174 for (const key of ["claimed", "running", "reach", "spendTodayUsd", "ceilingUsd", "use", "nextStep", "sentAt"]) expect(list.items[0]).toHaveProperty(key);
175
172176 // The same request again is answered, not made twice.
173177 const again = await post(rw, { name: "kid", apps: ["alice/notes"], crew: ["scout"], dryRun: false });
174178 expect(again.status).toBe(200);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs