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

Merged opened this

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

Merged

scout/forge:scout/3205-get-api-v1-forges-sizes-and-mcp-sizes-sa → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/3205-get-api-v1-forges-sizes-and-mcp-sizes-sa was merged into main as 69e852e.

Review

Sign in to review this change.
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.
516516
517517 A spawned forge's own code is a fork of its parent's (`<org>/forge`, chosen
518518 `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.
520520 Once an hour, and
521521 on demand, a forge that follows and has not severed fetches its parent's code into
522522 `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";
358358 import { blobForRender, commitLog, listRefs, listTree } from "../git/read.ts";
359359 import { repoDir } from "../git/repos.ts";
360360 import type { Result } from "../kernel/result.ts";
361 import { sizesView } from "../spawn/sizes.ts";
362 import { holds, weakEtag } from "./etag.ts";
361363 import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../actions/invites.ts";
362364 import { originForRepo, type OriginRepoRow } from "../core/origins.ts";
363365 import { subscribe as subscribeJob, type MirrorJob } from "../origins/jobs.ts";
@@ -4885,6 +4887,15 @@ export function apiRoutes(router: Router<Handler>): void {
48854887 return json(await familyView(ctx.app, ctx.auth.user?.isAdmin === true));
48864888 });
48874889
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
48884899 // A forge's children (docs/SPAWN.md): an admin's, planned before made.
48894900 router.add("GET", "/api/v1/forges", async (ctx) => {
48904901 const denied = requireScope(ctx, "ro");
src/http/mcp.ts +9
@@ -182,6 +182,7 @@ import { parentForgeFor } from "../core/parent_releases.ts";
182182 import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts";
183183 import { resourceMetadataUrl } from "./oauth.ts";
184184 import { body, boundedBody, json } from "./respond.ts";
185 import { sizesView } from "../spawn/sizes.ts";
185186 import type { Router } from "./router.ts";
186187 import { mayReadRuns, runForReader } from "../actions/runs.ts";
187188 import { manifestFor } from "../actions/here.ts";
@@ -385,6 +386,14 @@ function boardFailed(e: BoardError): Answer {
385386 }
386387
387388 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 },
388397 {
389398 name: "apps",
390399 description:
src/spawn/sizes.ts +15
@@ -28,3 +28,18 @@ export function parseSize(v: unknown): Result<Size, string> {
2828 export const DEFAULT_SIZE: Size = "small";
2929
3030 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>> = {
outdated

High (fails the issue's drift checkbox): these sentences write out CPU and memory figures by hand that also live in SIZES. Change SIZES.small.memoryMiB and this still says "half a gigabyte". Either build the figure part from SIZES[size] in sizesView(), or keep SAYS to what each size is for, with no numbers.

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", () => {
164164 const listed = await call(rw, "tools/list");
165165 const names = listed.body.result.tools.map((t: { name: string }) => t.name);
166166 expect(names).toEqual([
167 "sizes",
167168 "apps",
168169 "repo",
169170 "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 says has no digits or number words when they live only in the fields.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs