Public: anyone can read this; the crew can change it.
GET /api/v1/forges/sizes and MCP `sizes` say what small, medium and large mean #3218
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3205-get-api-v1-forges-sizes-and-mcp-sizes-sa → main 4 commits fork 2 threads · 2 unresolved
6 files
changed +125
−1
README.md +1 −1
| @@ -516,7 +516,7 @@ forge can be a parent. The terminal shows the count, not the release names. | ||
| 516 | 516 | |
| 517 | 517 | A spawned forge's own code is a fork of its parent's (`<org>/forge`, chosen |
| 518 | 518 | `followMode` `tell-me` or `take-updates`; in `take-updates` the hourly check puts what the parent gained in a pull. |
| 519 | A spawn may also name a `size`: `small`, `medium` or `large` (`src/spawn/sizes.ts` maps each to CPU, memory and disk). It is recorded in the plan only when given, so a plan without one hashes as it always did, and an unknown size is a 422 on `size`. Nothing enforces the limits yet, and the table's numbers are provisional. | |
| 519 | A spawn may also name a `size`: `small`, `medium` or `large` (`src/spawn/sizes.ts` maps each to CPU, memory and disk). It is recorded in the plan only when given, so a plan without one hashes as it always did, and an unknown size is a 422 on `size`. Nothing enforces the limits yet, and the table's numbers are provisional. `GET /api/v1/forges/sizes` (no sign-in, ETag, cached for 5 minutes) and the MCP tool `sizes` give the same answer — each size's CPU, memory and disk, read from that table, and one sentence on what it is for — so a page or an agent offering a size reads it there. | |
| 520 | 520 | Once an hour, and |
| 521 | 521 | on demand, a forge that follows and has not severed fetches its parent's code into |
| 522 | 522 | `refs/upstream/*` of its own `<org>/forge` (no branch of the fork moves) and keeps |
src/http/api.ts +11
| @@ -358,6 +358,8 @@ import { readRunLog, subscribeRunLog } from "../runs/log.ts"; | ||
| 358 | 358 | import { blobForRender, commitLog, listRefs, listTree } from "../git/read.ts"; |
| 359 | 359 | import { repoDir } from "../git/repos.ts"; |
| 360 | 360 | import type { Result } from "../kernel/result.ts"; |
| 361 | import { sizesView } from "../spawn/sizes.ts"; | |
| 362 | import { holds, weakEtag } from "./etag.ts"; | |
| 361 | 363 | import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../actions/invites.ts"; |
| 362 | 364 | import { originForRepo, type OriginRepoRow } from "../core/origins.ts"; |
| 363 | 365 | import { subscribe as subscribeJob, type MirrorJob } from "../origins/jobs.ts"; |
| @@ -4885,6 +4887,15 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4885 | 4887 | return json(await familyView(ctx.app, ctx.auth.user?.isAdmin === true)); |
| 4886 | 4888 | }); |
| 4887 | 4889 | |
| 4890 | // What small, medium and large mean: public, and cacheable by ETag. | |
| 4891 | router.add("GET", "/api/v1/forges/sizes", (ctx) => { | |
| 4892 | const text = JSON.stringify(sizesView()); | |
| 4893 | const etag = weakEtag(text); | |
| 4894 | const headers = { etag, "cache-control": "public, max-age=300" }; | |
| 4895 | if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers }); | |
| 4896 | return new Response(text, { headers: { "content-type": "application/json", ...headers } }); | |
| 4897 | }); | |
| 4898 | ||
| 4888 | 4899 | // A forge's children (docs/SPAWN.md): an admin's, planned before made. |
| 4889 | 4900 | router.add("GET", "/api/v1/forges", async (ctx) => { |
| 4890 | 4901 | const denied = requireScope(ctx, "ro"); |
src/http/mcp.ts +9
| @@ -182,6 +182,7 @@ import { parentForgeFor } from "../core/parent_releases.ts"; | ||
| 182 | 182 | import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts"; |
| 183 | 183 | import { resourceMetadataUrl } from "./oauth.ts"; |
| 184 | 184 | import { body, boundedBody, json } from "./respond.ts"; |
| 185 | import { sizesView } from "../spawn/sizes.ts"; | |
| 185 | 186 | import type { Router } from "./router.ts"; |
| 186 | 187 | import { mayReadRuns, runForReader } from "../actions/runs.ts"; |
| 187 | 188 | import { manifestFor } from "../actions/here.ts"; |
| @@ -385,6 +386,14 @@ function boardFailed(e: BoardError): Answer { | ||
| 385 | 386 | } |
| 386 | 387 | |
| 387 | 388 | const TOOLS: readonly Tool[] = [ |
| 389 | { | |
| 390 | name: "sizes", | |
| 391 | description: | |
| 392 | "What a child forge's sizes mean: small, medium and large, each with its CPU, memory and disk, and one sentence about it.", | |
| 393 | inputSchema: { type: "object", properties: {} }, | |
| 394 | writes: false, | |
| 395 | run: () => good(sizesView()), | |
| 396 | }, | |
| 388 | 397 | { |
| 389 | 398 | name: "apps", |
| 390 | 399 | description: |
src/spawn/sizes.ts +15
| @@ -28,3 +28,18 @@ export function parseSize(v: unknown): Result<Size, string> { | ||
| 28 | 28 | export const DEFAULT_SIZE: Size = "small"; |
| 29 | 29 | |
| 30 | 30 | export const isSize = (v: unknown): v is Size => typeof v === "string" && Object.hasOwn(SIZES, v); |
| 31 | ||
| 32 | // What each size is for, never how big: the figures live only in SIZES. | |
| 33 | const SAYS: Readonly<Record<Size, string>> = { | |
| 34 | small: "For a notes app, a small tool, a place to try things.", | |
| 35 | medium: "For a busy app, or a few apps side by side.", | |
| 36 | large: "For a crew's working forge, with room to build.", | |
| 37 | }; | |
| 38 | ||
| 39 | /** What each size means, for a page or an agent to offer; read from SIZES. */ | |
| 40 | export function sizesView(): { sizes: { size: Size; cpu: number; memoryMiB: number; diskMiB: number; says: string }[]; default: Size } { | |
| 41 | return { | |
| 42 | sizes: (Object.keys(SIZES) as Size[]).map((size) => ({ size, ...SIZES[size], says: SAYS[size] })), | |
| 43 | default: DEFAULT_SIZE, | |
| 44 | }; | |
| 45 | } |
test/mcp.test.ts +1
| @@ -164,6 +164,7 @@ describe("the forge over MCP", () => { | ||
| 164 | 164 | const listed = await call(rw, "tools/list"); |
| 165 | 165 | const names = listed.body.result.tools.map((t: { name: string }) => t.name); |
| 166 | 166 | expect(names).toEqual([ |
| 167 | "sizes", | |
| 167 | 168 | "apps", |
| 168 | 169 | "repo", |
| 169 | 170 | "here", |
test/sizes-api.test.ts added +88
| @@ -0,0 +1,88 @@ | ||
| 1 | /** The sizes answer is the table in src/spawn/sizes.ts, over HTTP and over MCP. */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createToken } from "../src/core/tokens.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { SIZES, sizesView, type Size } from "../src/spawn/sizes.ts"; | |
| 12 | import { quietLogs } from "./helpers/globals.ts"; | |
| 13 | ||
| 14 | quietLogs(); | |
| 15 | ||
| 16 | describe("forge sizes", () => { | |
| 17 | let dataDir: string; | |
| 18 | let app: App; | |
| 19 | let servers: Servers; | |
| 20 | let token: string; | |
| 21 | ||
| 22 | beforeAll(async () => { | |
| 23 | dataDir = await mkdtemp(join(tmpdir(), "forge-sizes-")); | |
| 24 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 25 | servers = await serve(app); | |
| 26 | const u = await createUser(app.db, { | |
| 27 | origin: "seed", | |
| 28 | trusted: true, | |
| 29 | username: "alice", | |
| 30 | email: "[email protected]", | |
| 31 | password: "a-good-passphrase", | |
| 32 | adminIfFirst: true, | |
| 33 | }); | |
| 34 | if (!u.ok) throw new Error(u.error); | |
| 35 | token = createToken(app.db, u.value.id, "t", "ro").token; | |
| 36 | }, 120_000); | |
| 37 | ||
| 38 | afterAll(async () => { | |
| 39 | servers.stop(); | |
| 40 | app.close(); | |
| 41 | await rm(dataDir, { recursive: true, force: true }); | |
| 42 | }); | |
| 43 | ||
| 44 | test("signed out, each size answers its table row and a sentence", async () => { | |
| 45 | const res = await fetch(`${servers.url}/api/v1/forges/sizes`); | |
| 46 | expect(res.status).toBe(200); | |
| 47 | const got = (await res.json()) as ReturnType<typeof sizesView>; | |
| 48 | expect(got).toEqual(sizesView()); | |
| 49 | expect(got.sizes.map((s) => s.size)).toEqual(Object.keys(SIZES) as Size[]); | |
| 50 | for (const s of got.sizes) { | |
| 51 | const row = SIZES[s.size]; | |
| 52 | expect([s.cpu, s.memoryMiB, s.diskMiB]).toEqual([row.cpu, row.memoryMiB, row.diskMiB]); | |
| 53 | expect(s.says.length).toBeGreaterThan(10); | |
This passes for any sentence, wrong figures included, so it doesn't guard against drift. Once the figures come from the table, test that, or test that |
||
| 54 | // Figures live only in the fields, so a changed row cannot leave a sentence behind. | |
| 55 | expect(s.says).not.toMatch(/\d|\b(one|two|three|four|eight|half|quarter)\b|\b[kmgt]b\b|gigabyte|megabyte|cpus?\b/i); | |
| 56 | } | |
| 57 | }); | |
| 58 | ||
| 59 | test("it carries an ETag and answers 304 to it", async () => { | |
| 60 | const first = await fetch(`${servers.url}/api/v1/forges/sizes`); | |
| 61 | const etag = first.headers.get("etag")!; | |
| 62 | expect(etag).toBeTruthy(); | |
| 63 | const again = await fetch(`${servers.url}/api/v1/forges/sizes`, { | |
| 64 | headers: { "if-none-match": etag }, | |
| 65 | }); | |
| 66 | expect(again.status).toBe(304); | |
| 67 | }); | |
| 68 | ||
| 69 | test("the MCP tool returns the same JSON", async () => { | |
| 70 | const res = await fetch(`${servers.url}/mcp`, { | |
| 71 | method: "POST", | |
| 72 | headers: { | |
| 73 | authorization: `Bearer ${token}`, | |
| 74 | "content-type": "application/json", | |
| 75 | accept: "application/json, text/event-stream", | |
| 76 | }, | |
| 77 | body: JSON.stringify({ | |
| 78 | jsonrpc: "2.0", | |
| 79 | id: 1, | |
| 80 | method: "tools/call", | |
| 81 | params: { name: "sizes", arguments: {} }, | |
| 82 | }), | |
| 83 | }); | |
| 84 | const body = (await res.json()) as { result: { isError?: boolean; content: { text: string }[] } }; | |
| 85 | expect(body.result.isError).toBeFalsy(); | |
| 86 | expect(JSON.parse(body.result.content[0]!.text)).toEqual(sizesView()); | |
| 87 | }); | |
| 88 | }); |
new above
offline
High (fails the issue's drift checkbox): these sentences write out CPU and memory figures by hand that also live in
SIZES. ChangeSIZES.small.memoryMiBand this still says "half a gigabyte". Either build the figure part fromSIZES[size]insizesView(), or keepSAYSto what each size is for, with no numbers.