Public: anyone can read this; the crew can change it.
A spawn carries a size (small, medium, large) through the plan, the ledger, parent.json, GET /api/v1/forges and MCP spawn, with no form change #3007
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2995-a-spawn-carries-a-size-small-medium-larg → main 4 commits fork 1 thread · 1 unresolved
6 files
changed +50
−2
docs/SPAWN.md +1 −1
| @@ -70,7 +70,7 @@ Children report in; the parent never reaches in. Every ten minutes a spawned chi | ||
| 70 | 70 | |
| 71 | 71 | ## Callers |
| 72 | 72 | |
| 73 | `POST /api/v1/forges {name, apps[], crew[], direction?, keepAccess?, modelKey?, budgetUsd?, dryRun}` (rw token, admin only) and the MCP `spawn` tool share `src/spawn/door.ts`. `dryRun` is true unless it is `false`: the answer is the plan, what is left behind, and `firstAdmin` (the caller's username, or `null` for an anonymous plan, where whoever claims the child becomes its admin), and nothing is made. `GET /api/v1/forges` lists the ledger's children. The child's org is its name; `crew` names the agents whose briefs travel. Errors: 401 unauthorized, 403 forbidden / cross_origin, 422 invalid, 409 refused / exists, 500 failed — each with one sentence. The MCP lets an `ro` token plan, never make. | |
| 73 | `POST /api/v1/forges {name, apps[], crew[], direction?, keepAccess?, modelKey?, budgetUsd?, size?, dryRun}` (rw token, admin only) and the MCP `spawn` tool share `src/spawn/door.ts`. `size` is `small` (default), `medium` or `large`; anything else is a 422 on `size`. It is recorded in the plan, the ledger, `parent.json` and `GET /api/v1/forges`, but not enforced yet: it limits nothing. `dryRun` is true unless it is `false`: the answer is the plan, what is left behind, and `firstAdmin` (the caller's username, or `null` for an anonymous plan, where whoever claims the child becomes its admin), and nothing is made. `GET /api/v1/forges` lists the ledger's children. The child's org is its name; `crew` names the agents whose briefs travel. Errors: 401 unauthorized, 403 forbidden / cross_origin, 422 invalid, 409 refused / exists, 500 failed — each with one sentence. The MCP lets an `ro` token plan, never make. | |
| 74 | 74 | |
| 75 | 75 | ## The forge's own code, and the chain (#1085) |
| 76 | 76 |
src/actions/spawn.ts +7
| @@ -54,6 +54,7 @@ import { loadOrCreateHostKey } from "../ssh/keys.ts"; | ||
| 54 | 54 | import { claimLink, hasAdmin, mintClaim, spawnedOrg } from "../spawn/claim.ts"; |
| 55 | 55 | import { childPublicUrl, parentOrigin, type Placement } from "../spawn/placement.ts"; |
| 56 | 56 | import { planSpawn, type SpawnInput, type SpawnPlan, type SpawnSource } from "../spawn/plan.ts"; |
| 57 | import { DEFAULT_SIZE, isSize, type Size } from "../spawn/sizes.ts"; | |
| 57 | 58 | import { chainForChild, DEFAULT_MODE, forgeLinkOf, type FollowMode, type Hop } from "../spawn/chain.ts"; |
| 58 | 59 | import { requireUser } from "./access.ts"; |
| 59 | 60 | |
| @@ -108,6 +109,8 @@ interface Ledger { | ||
| 108 | 109 | readonly forWhom?: string; |
| 109 | 110 | readonly apps?: readonly string[]; |
| 110 | 111 | readonly createdAt?: string; |
| 112 | /** Absent on a ledger written before sizes; reads as small. */ | |
| 113 | readonly size?: Size; | |
| 111 | 114 | /** |
| 112 | 115 | * What the parent shows the child to learn its private facts (when anything |
| 113 | 116 | * last happened); the child keeps only its hash. Never in an answer. |
| @@ -254,6 +257,7 @@ async function make( | ||
| 254 | 257 | ...(plan.forWhom === undefined ? {} : { forWhom: plan.forWhom }), |
| 255 | 258 | apps: plan.apps.map((a) => a.ref), |
| 256 | 259 | createdAt: new Date().toISOString(), |
| 260 | size: plan.size ?? DEFAULT_SIZE, | |
| 257 | 261 | statusToken: secret(), |
| 258 | 262 | beatKey: secret(), |
| 259 | 263 | }; |
| @@ -448,6 +452,7 @@ async function copyInto( | ||
| 448 | 452 | // The child's own address: <name>--<parent's host>. |
| 449 | 453 | url: liveChildUrl(app, plan.name), |
| 450 | 454 | keepAccess: plan.keepAccess, |
| 455 | size: plan.size ?? DEFAULT_SIZE, | |
| 451 | 456 | // Each app is a fork: where it came from, so the child can follow it. |
| 452 | 457 | apps: plan.apps.map((a) => ({ repo: `${plan.org}/${a.ref.split("/")[1]}`, upstream: a.ref })), |
| 453 | 458 | ...(plan.fresh === undefined ? {} : { fresh: `${plan.org}/${plan.fresh.name}` }), |
| @@ -656,6 +661,7 @@ export interface Child { | ||
| 656 | 661 | readonly apps: readonly string[]; |
| 657 | 662 | /** When it was begun; null on a ledger older than the client list. */ |
| 658 | 663 | readonly createdAt: string | null; |
| 664 | readonly size: Size; | |
| 659 | 665 | } |
| 660 | 666 | |
| 661 | 667 | /** The children this forge has made or begun, from its ledger, by name. */ |
| @@ -674,6 +680,7 @@ export async function listChildren(app: App): Promise<Child[]> { | ||
| 674 | 680 | for: ledger.forWhom ?? null, |
| 675 | 681 | apps: ledger.apps ?? [], |
| 676 | 682 | createdAt: ledger.createdAt ?? null, |
| 683 | size: isSize(ledger.size) ? ledger.size : DEFAULT_SIZE, | |
| 677 | 684 | }); |
| 678 | 685 | } |
| 679 | 686 | return out; |
src/spawn/sizes.ts +4
| @@ -24,3 +24,7 @@ export function parseSize(v: unknown): Result<Size, string> { | ||
| 24 | 24 | if (typeof v === "string" && Object.hasOwn(SIZES, v)) return ok(v as Size); |
| 25 | 25 | return err("size must be small, medium or large"); |
| 26 | 26 | } |
| 27 | ||
| 28 | export const DEFAULT_SIZE: Size = "small"; | |
| 29 | ||
| 30 | export const isSize = (v: unknown): v is Size => typeof v === "string" && Object.hasOwn(SIZES, v); |
test/spawn-api.test.ts +36 −1
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | * fake placement stands in for where the child lives. |
| 5 | 5 | */ |
| 6 | 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 7 | import { mkdtemp, readdir, rm } from "node:fs/promises"; | |
| 7 | import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; | |
| 8 | 8 | import { tmpdir } from "node:os"; |
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { createApp, type App } from "../src/app.ts"; |
| @@ -164,6 +164,7 @@ describe("spawn over the API and MCP", () => { | ||
| 164 | 164 | for: null, |
| 165 | 165 | apps: expect.any(Array), |
| 166 | 166 | createdAt: expect.any(String), |
| 167 | size: "small", | |
| 167 | 168 | }, |
| 168 | 169 | ]); |
| 169 | 170 | // The same request again is answered, not made twice. |
| @@ -212,4 +213,38 @@ describe("spawn over the API and MCP", () => { | ||
| 212 | 213 | expect(body.claimUrl).toContain("/claim#"); |
| 213 | 214 | expect(body.says).toContain("live secret"); |
| 214 | 215 | }); |
| 216 | ||
| 217 | const ledgerOf = async (name: string) => JSON.parse(await Bun.file(join(dataDir, "spawn", `${name}.json`)).text()); | |
| 218 | const parentOf = async (bodyId: string) => JSON.parse(await Bun.file(join(dataDir, "children", bodyId, "parent.json")).text()); | |
| 219 | const sizeListed = async (name: string) => ((await (await get(ro)).json()) as any).items.find((i: any) => i.name === name)?.size; | |
| 220 | ||
| 221 | test("size travels through POST /api/v1/forges into the ledger, parent.json and the list; huge is refused", async () => { | |
| 222 | const bad = await post(rw, { name: "sized-api", apps: [], size: "huge" }); | |
| 223 | expect(bad.status).toBe(422); | |
| 224 | expect(((await bad.json()) as any).message).toStartWith("size:"); | |
| 225 | const res = await post(rw, { name: "sized-api", apps: [], size: "medium", dryRun: false }); | |
| 226 | expect(res.status).toBe(201); | |
| 227 | const ledger = await ledgerOf("sized-api"); | |
| 228 | expect(ledger.size).toBe("medium"); | |
| 229 | expect((await parentOf(ledger.bodyId)).size).toBe("medium"); | |
| 230 | expect(await sizeListed("sized-api")).toBe("medium"); | |
| 231 | }); | |
| 232 | ||
| 233 | test("size travels through MCP spawn into the ledger, parent.json and the list", async () => { | |
| 234 | const m = await mcp(rw, { name: "sized-mcp", apps: [], size: "large", dryRun: false }); | |
| 235 | expect(m.error).toBe(false); | |
| 236 | const ledger = await ledgerOf("sized-mcp"); | |
| 237 | expect(ledger.size).toBe("large"); | |
| 238 | expect((await parentOf(ledger.bodyId)).size).toBe("large"); | |
| 239 | expect(await sizeListed("sized-mcp")).toBe("large"); | |
| 240 | }); | |
| 241 | ||
| 242 | test("a ledger without a size, or with a bad one, lists as small", async () => { | |
| 243 | const dir = join(dataDir, "spawn"); | |
| 244 | await mkdir(dir, { recursive: true }); | |
| 245 | await Bun.write(join(dir, "legacy-a.json"), JSON.stringify({ name: "legacy-a", bodyId: "old-a", done: true })); | |
| 246 | await Bun.write(join(dir, "legacy-b.json"), JSON.stringify({ name: "legacy-b", bodyId: "old-b", done: true, size: "__proto__" })); | |
| 247 | expect(await sizeListed("legacy-a")).toBe("small"); | |
| 248 | expect(await sizeListed("legacy-b")).toBe("small"); | |
| 249 | }); | |
| 215 | 250 | }); |
test/spawn-keys.test.ts +1
| @@ -364,6 +364,7 @@ describe("a spawned child's own keys and domain", () => { | ||
| 364 | 364 | "keepAccess", |
| 365 | 365 | "name", |
| 366 | 366 | "org", |
| 367 | "size", | |
| 367 | 368 | // The hash of the parent's status secret (#776): the child keeps only |
| 368 | 369 | // the hash, so this names nothing in the parent and reveals no secret. |
| 369 | 370 | "statusHash", |
test/spawn.test.ts +1
| @@ -85,6 +85,7 @@ describe("spawnForge", () => { | ||
| 85 | 85 | org: "kids", |
| 86 | 86 | url: "https://kid--forge.test", |
| 87 | 87 | keepAccess: false, |
| 88 | size: "small", | |
| 88 | 89 | // A hash only: what the parent answers to stays in its own ledger. |
| 89 | 90 | statusHash: expect.stringMatching(/^[0-9a-f]{64}$/), |
| 90 | 91 | // The child signs heartbeats to its parent with it (#425). |
new above
offline
Small nit: DEFAULT_SIZE is also what the plan leaves undefined. Use it in one place if plan.ts ever defaults, so there is a single source of truth.