Public: anyone can read this; the crew can change it.
An agent asking `forges` sees each child's size and usage, as the machines page shows them #10917
torque/forge:torque/10903-an-agent-asking-forges-sees-each-child-s → main 4 commits fork
6 files
changed +108
−5
docs/MCP.md +1 −1
| @@ -111,7 +111,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 111 | 111 | | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 | |
| 112 | 112 | | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 | |
| 113 | 113 | | `forge_view` | — | The forge as one picture: counts, apps, runs with stations, health | 189 | |
| 114 | | `forges` | — | Forges sent from here (admin): name, address, apps, claimed, spendTodayUsd, ceilingUsd | 205 | | |
| 114 | | `forges` | — | Forges sent from here (admin): name, address, apps, claimed, size, usage, spendTodayUsd, ceilingUsd | 218 | | |
| 115 | 115 | | `fork` | — | Fork a repository into your account; for a guest, a copy to send changes back from | 232 | |
| 116 | 116 | | `fork_tree` | — | The fork tree around a repo: ancestors, siblings and descendants you may read | 347 | |
| 117 | 117 | | `forks` | — | A repo's forks you may see, paged by limit and cursor (next_cursor), each with commits ahead and behind | 345 | |
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 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 }; | |
| 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, usage ({ memory, cpu, disk } each with used and limit, MiB or cores; null when shared or not running), 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/mcp/tools/forges.ts +1 −1
| @@ -5,7 +5,7 @@ export const tool: Tool = { | ||
| 5 | 5 | name: "forges", |
| 6 | 6 | doc: "an admin's list of the forges sent from here: name, address, whom for, apps, claimed or not, last activity, whether each public address answers", |
| 7 | 7 | description: |
| 8 | "Forges sent from here (admin): name, address, apps, claimed, spendTodayUsd, ceilingUsd.", | |
| 8 | "Forges sent from here (admin): name, address, apps, claimed, size, usage, spendTodayUsd, ceilingUsd.", | |
| 9 | 9 | inputSchema: { type: "object", properties: {} }, |
| 10 | 10 | writes: false, |
| 11 | 11 | async run(ctx, user) { |
src/spawn/clients.ts +6 −2
| @@ -21,7 +21,7 @@ import { hasAdmin, linkOf, spawnedOrg, type LinkInfo, type LinkState } from "./c | ||
| 21 | 21 | import { lastHeard, type Beat } from "./heartbeat.ts"; |
| 22 | 22 | import { reachOf, reachWords, type ChildReach } from "./reach.ts"; |
| 23 | 23 | import type { Size } from "./sizes.ts"; |
| 24 | import { forgeUse, type ForgeUse } from "./usage.ts"; | |
| 24 | import { forgeUse, usageOf, type ForgeUse, type UsageFigures } from "./usage.ts"; | |
| 25 | 25 | |
| 26 | 26 | /** What a spawned forge says about itself to anyone: nothing more. */ |
| 27 | 27 | export interface ForgeState { |
| @@ -63,6 +63,8 @@ export interface ClientRow { | ||
| 63 | 63 | readonly size: Size; |
| 64 | 64 | /** What it uses against that size: its cell's reading, or "shared" when it is hosted here. */ |
| 65 | 65 | readonly use: ForgeUse; |
| 66 | /** The same reading as figures an agent can compare (MiB, cores) against the size's; null when it is shared, not running or unknown. */ | |
| 67 | readonly usage?: UsageFigures | null; | |
| 66 | 68 | /** Whether its data is a filesystem of its own, and that filesystem's size (`spawn/disk_mount.ts`). */ |
| 67 | 69 | readonly disk: ChildDisk; |
| 68 | 70 | /** When its last heartbeat reached this forge, ISO; null when none has. */ |
| @@ -225,6 +227,7 @@ export async function clientRows(app: App): Promise<ClientRow[]> { | ||
| 225 | 227 | const beats = await Promise.all(children.map((c) => lastHeard(app, c.name))); |
| 226 | 228 | // One read of the mount table for every row. |
| 227 | 229 | const mountinfo = children.length === 0 ? "" : readMountinfo(); |
| 230 | const use = children.map((c) => forgeUse(c, childDirOf(app, c.bodyId))); | |
| 228 | 231 | return orderRows( |
| 229 | 232 | children.map((c, i) => ({ |
| 230 | 233 | name: c.name, |
| @@ -242,7 +245,8 @@ export async function clientRows(app: App): Promise<ClientRow[]> { | ||
| 242 | 245 | ceilingUsd: beats[i]?.beat.ceilingUsd ?? null, |
| 243 | 246 | ...creditView(app, c.name), |
| 244 | 247 | size: c.size, |
| 245 | use: forgeUse(c, childDirOf(app, c.bodyId)), | |
| 248 | use: use[i]!, | |
| 249 | usage: usageOf(use[i]!), | |
| 246 | 250 | disk: liveChildDisk(app.config.dataDir, c.bodyId, mountinfo), |
| 247 | 251 | lastHeartbeatAt: beats[i] === undefined ? null : new Date(beats[i]!.receivedAt).toISOString(), |
| 248 | 252 | link: states[i]?.link ?? null, |
src/spawn/usage.ts +19
| @@ -240,3 +240,22 @@ export function childrenUse(children: readonly Child[], dirOf: (bodyId: string) | ||
| 240 | 240 | |
| 241 | 241 | /** The share of a limit used, percent, for a meter; 0 when unknown. */ |
| 242 | 242 | export const shareOf = (used: number | null, limit: number): number => (used === null || limit <= 0 ? 0 : (used / limit) * 100); |
| 243 | ||
| 244 | /** A cell's use beside its size's limits, as numbers: what `forges` over MCP and the API carry. */ | |
| 245 | export interface UsageFigures { | |
| 246 | readonly memory: { readonly usedMiB: number; readonly limitMiB: number }; | |
| 247 | /** Cores used lately; usedCores null until the watch can say. */ | |
| 248 | readonly cpu: { readonly usedCores: number | null; readonly limitCores: number }; | |
| 249 | /** usedMiB null until the first disk reading lands. */ | |
| 250 | readonly disk: { readonly usedMiB: number | null; readonly limitMiB: number }; | |
| 251 | } | |
| 252 | ||
| 253 | /** The figures of a running cell (the same reading the machines page shows); null for a shared or idle child. */ | |
| 254 | export function usageOf(u: ForgeUse): UsageFigures | null { | |
| 255 | if (u.kind !== "cell") return null; | |
| 256 | return { | |
| 257 | memory: { usedMiB: u.memoryMiB, limitMiB: u.limits.memoryMiB }, | |
| 258 | cpu: { usedCores: u.cores, limitCores: u.limits.cpu }, | |
| 259 | disk: { usedMiB: u.diskMiB, limitMiB: u.limits.diskMiB }, | |
| 260 | }; | |
| 261 | } |
test/mcp-forges-usage.test.ts added +80
| @@ -0,0 +1,80 @@ | ||
| 1 | /** MCP `forges` and GET /api/v1/forges carry each child's size and usage (#10903). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 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 | import { watchLabelled, cellCounters } from "../src/apps/contain.ts"; | |
| 11 | import { SIZES } from "../src/spawn/sizes.ts"; | |
| 12 | import { quietLogs } from "./helpers/globals.ts"; | |
| 13 | ||
| 14 | quietLogs(); | |
| 15 | ||
| 16 | let dataDir = ""; | |
| 17 | let app: App; | |
| 18 | let servers: Servers; | |
| 19 | let token = ""; | |
| 20 | let stop = () => {}; | |
| 21 | let watched = false; | |
| 22 | ||
| 23 | beforeAll(async () => { | |
| 24 | dataDir = mkdtempSync(join(tmpdir(), "forge-mcp-forges-usage-")); | |
| 25 | app = createApp({ dataDir, port: 0 }); | |
| 26 | mkdirSync(join(dataDir, "spawn"), { recursive: true }); | |
| 27 | const kid = (name: string, placement: string) => | |
| 28 | writeFileSync(join(dataDir, "spawn", `${name}.json`), JSON.stringify({ name, bodyId: `b-${name}`, done: true, placement, size: "small" })); | |
| 29 | kid("busy", "cell"); | |
| 30 | kid("asleep", "cell"); | |
| 31 | kid("hosted", "hosted"); | |
| 32 | servers = await serve(app); | |
| 33 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "correct horse battery 9", isAdmin: true }); | |
| 34 | if (!u.ok) throw new Error("alice"); | |
| 35 | token = createToken(app.db, u.value.id, "t", "admin").token; | |
| 36 | stop = watchLabelled(process.pid, "forge:busy"); | |
| 37 | const until = Date.now() + 5_000; | |
| 38 | while (!cellCounters().some((c) => c.label === "forge:busy") && Date.now() < until) await Bun.sleep(50); | |
| 39 | // Where /proc cannot be read (some sandboxes) the watch never sees the cell: the running-cell figures are then not asserted. | |
| 40 | watched = cellCounters().some((c) => c.label === "forge:busy"); | |
| 41 | }, 30_000); | |
| 42 | ||
| 43 | afterAll(() => { | |
| 44 | stop(); | |
| 45 | servers.stop(); | |
| 46 | app.close(); | |
| 47 | rmSync(dataDir, { recursive: true, force: true }); | |
| 48 | }); | |
| 49 | ||
| 50 | const headers = () => ({ "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }); | |
| 51 | ||
| 52 | test("each child's size and usage are read through MCP, null when it is shared or not running", async () => { | |
| 53 | const res = await fetch(`${servers.url}/mcp`, { | |
| 54 | method: "POST", | |
| 55 | headers: headers(), | |
| 56 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "forges", arguments: {} } }), | |
| 57 | }); | |
| 58 | const out = JSON.parse(((await res.json()) as any).result.content[0].text); | |
| 59 | const by = Object.fromEntries(out.forges.map((f: any) => [f.name, f])); | |
| 60 | expect(by.busy.size).toBe("small"); | |
| 61 | if (watched) { | |
| 62 | expect(by.busy.usage.memory.usedMiB).toBeGreaterThan(0); | |
| 63 | expect(by.busy.usage.memory.limitMiB).toBe(SIZES.small.memoryMiB); | |
| 64 | expect(by.busy.usage.cpu.limitCores).toBe(SIZES.small.cpu); | |
| 65 | expect(by.busy.usage.disk.usedMiB).toBeNull(); | |
| 66 | expect(by.busy.usage.disk.limitMiB).toBe(by.busy.use.limits.diskMiB); | |
| 67 | } | |
| 68 | expect(by.asleep.usage).toBeNull(); | |
| 69 | expect(by.hosted.usage).toBeNull(); | |
| 70 | expect(by.hosted.size).toBe("small"); | |
| 71 | }); | |
| 72 | ||
| 73 | test("the REST route returns the same fields", async () => { | |
| 74 | const res = await fetch(`${servers.url}/api/v1/forges`, { headers: { authorization: `Bearer ${token}` } }); | |
| 75 | const items = ((await res.json()) as any).items; | |
| 76 | const busy = items.find((i: any) => i.name === "busy"); | |
| 77 | expect(busy.size).toBe("small"); | |
| 78 | if (watched) expect(busy.usage.memory.usedMiB).toBeGreaterThan(0); | |
| 79 | expect(items.find((i: any) => i.name === "asleep").usage).toBeNull(); | |
| 80 | }); |
new above
offline