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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9137-get-api-v1-forges-answers-the-client-lis → main 7 commits fork 3 threads · 3 unresolved
4 files
changed +71
−17
src/contract/openapi/routes/getApiV1Forges.ts +1 −1
| @@ -1,3 +1,3 @@ | ||
| 1 | 1 | import type { Route } from "../shared.ts"; |
| 2 | 2 | |
| 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 @@ | ||
| 6 | 6 | |
| 7 | 7 | import { listChildren } from "../../actions/spawn.ts"; |
| 8 | 8 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 9 | import { clientRows } from "../../spawn/clients.ts"; | |
| 9 | 10 | import { reissueDoor, spawnDoor } from "../../spawn/door.ts"; |
| 10 | 11 | import { sizesView } from "../../spawn/sizes.ts"; |
| 11 | 12 | import { templatesView } from "../../spawn/templates.ts"; |
| @@ -32,13 +33,24 @@ export function forgeRoutes(router: Router<Handler>): void { | ||
| 32 | 33 | router.add("GET", "/api/v1/forges/sizes", (ctx) => publicTable(ctx, "sizes", sizesView)); |
| 33 | 34 | router.add("GET", "/api/v1/forges/templates", (ctx) => publicTable(ctx, "templates", templatesView)); |
| 34 | 35 | |
| 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. | |
| 36 | 38 | router.add("GET", "/api/v1/forges", async (ctx) => { |
| 37 | 39 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 38 | 40 | const denied = requireScope(ctx, "ro"); |
| 39 | 41 | if (denied) return denied; |
| 40 | 42 | if (!ctx.auth.user?.isAdmin) return forbidden(); |
| 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 | }); | |
| 42 | 54 | return timePhase(ctx.request, "render", () => json({ items, next_cursor: null })); |
| 43 | 55 | }); |
| 44 | 56 |
test/client-list.test.ts +38
| @@ -322,6 +322,44 @@ describe("the client list", () => { | ||
| 322 | 322 | expect(step.words).toBe("going: last tell 2030-02-03T04:05:06.000Z"); |
| 323 | 323 | }); |
| 324 | 324 | |
| 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 | ||
| 325 | 363 | test("each step in words, and a beat with a bad value loses the field, not the beat", () => { |
| 326 | 364 | const beat = (o: Record<string, unknown>) => |
| 327 | 365 | 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", () => { | ||
| 155 | 155 | expect(String(made.url)).toContain("kid"); |
| 156 | 156 | expect(placed).toBe(1); |
| 157 | 157 | 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 | ||
| 172 | 176 | // The same request again is answered, not made twice. |
| 173 | 177 | const again = await post(rw, { name: "kid", apps: ["alice/notes"], crew: ["scout"], dryRun: false }); |
| 174 | 178 | expect(again.status).toBe(200); |
new above
offline
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.