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

Merged opened this

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

Merged

scout/forge:scout/2995-a-spawn-carries-a-size-small-medium-larg → main 4 commits fork 1 thread · 1 unresolved

  • Merged. scout/2995-a-spawn-carries-a-size-small-medium-larg was merged into main as 2d1581c.

Review

Sign in to review this change.
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
7070
7171 ## Callers
7272
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.
7474
7575 ## The forge's own code, and the chain (#1085)
7676
src/actions/spawn.ts +7
@@ -54,6 +54,7 @@ import { loadOrCreateHostKey } from "../ssh/keys.ts";
5454 import { claimLink, hasAdmin, mintClaim, spawnedOrg } from "../spawn/claim.ts";
5555 import { childPublicUrl, parentOrigin, type Placement } from "../spawn/placement.ts";
5656 import { planSpawn, type SpawnInput, type SpawnPlan, type SpawnSource } from "../spawn/plan.ts";
57 import { DEFAULT_SIZE, isSize, type Size } from "../spawn/sizes.ts";
5758 import { chainForChild, DEFAULT_MODE, forgeLinkOf, type FollowMode, type Hop } from "../spawn/chain.ts";
5859 import { requireUser } from "./access.ts";
5960
@@ -108,6 +109,8 @@ interface Ledger {
108109 readonly forWhom?: string;
109110 readonly apps?: readonly string[];
110111 readonly createdAt?: string;
112 /** Absent on a ledger written before sizes; reads as small. */
113 readonly size?: Size;
111114 /**
112115 * What the parent shows the child to learn its private facts (when anything
113116 * last happened); the child keeps only its hash. Never in an answer.
@@ -254,6 +257,7 @@ async function make(
254257 ...(plan.forWhom === undefined ? {} : { forWhom: plan.forWhom }),
255258 apps: plan.apps.map((a) => a.ref),
256259 createdAt: new Date().toISOString(),
260 size: plan.size ?? DEFAULT_SIZE,
257261 statusToken: secret(),
258262 beatKey: secret(),
259263 };
@@ -448,6 +452,7 @@ async function copyInto(
448452 // The child's own address: <name>--<parent's host>.
449453 url: liveChildUrl(app, plan.name),
450454 keepAccess: plan.keepAccess,
455 size: plan.size ?? DEFAULT_SIZE,
451456 // Each app is a fork: where it came from, so the child can follow it.
452457 apps: plan.apps.map((a) => ({ repo: `${plan.org}/${a.ref.split("/")[1]}`, upstream: a.ref })),
453458 ...(plan.fresh === undefined ? {} : { fresh: `${plan.org}/${plan.fresh.name}` }),
@@ -656,6 +661,7 @@ export interface Child {
656661 readonly apps: readonly string[];
657662 /** When it was begun; null on a ledger older than the client list. */
658663 readonly createdAt: string | null;
664 readonly size: Size;
659665 }
660666
661667 /** 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[]> {
674680 for: ledger.forWhom ?? null,
675681 apps: ledger.apps ?? [],
676682 createdAt: ledger.createdAt ?? null,
683 size: isSize(ledger.size) ? ledger.size : DEFAULT_SIZE,
677684 });
678685 }
679686 return out;
src/spawn/sizes.ts +4
@@ -24,3 +24,7 @@ export function parseSize(v: unknown): Result<Size, string> {
2424 if (typeof v === "string" && Object.hasOwn(SIZES, v)) return ok(v as Size);
2525 return err("size must be small, medium or large");
2626 }
27
28 export const DEFAULT_SIZE: Size = "small";

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.

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 @@
44 * fake placement stands in for where the child lives.
55 */
66 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";
88 import { tmpdir } from "node:os";
99 import { join } from "node:path";
1010 import { createApp, type App } from "../src/app.ts";
@@ -164,6 +164,7 @@ describe("spawn over the API and MCP", () => {
164164 for: null,
165165 apps: expect.any(Array),
166166 createdAt: expect.any(String),
167 size: "small",
167168 },
168169 ]);
169170 // The same request again is answered, not made twice.
@@ -212,4 +213,38 @@ describe("spawn over the API and MCP", () => {
212213 expect(body.claimUrl).toContain("/claim#");
213214 expect(body.says).toContain("live secret");
214215 });
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 });
215250 });
test/spawn-keys.test.ts +1
@@ -364,6 +364,7 @@ describe("a spawned child's own keys and domain", () => {
364364 "keepAccess",
365365 "name",
366366 "org",
367 "size",
367368 // The hash of the parent's status secret (#776): the child keeps only
368369 // the hash, so this names nothing in the parent and reveals no secret.
369370 "statusHash",
test/spawn.test.ts +1
@@ -85,6 +85,7 @@ describe("spawnForge", () => {
8585 org: "kids",
8686 url: "https://kid--forge.test",
8787 keepAccess: false,
88 size: "small",
8889 // A hash only: what the parent answers to stays in its own ledger.
8990 statusHash: expect.stringMatching(/^[0-9a-f]{64}$/),
9091 // The child signs heartbeats to its parent with it (#425).

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs