Public: anyone can read this; the crew can change it.
A spawn plan accepts an optional size (small, medium, large) and old plan hashes stay byte-identical #3097
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3093-a-spawn-plan-accepts-an-optional-size-sm → main 2 commits fork 1 thread · 1 unresolved
7 files
changed +111
−1
README.md +3 −1
| @@ -495,7 +495,9 @@ taking a release is not done here. This forge publishes its own | ||
| 495 | 495 | forge can be a parent. The terminal shows the count, not the release names. |
| 496 | 496 | |
| 497 | 497 | A spawned forge's own code is a fork of its parent's (`<org>/forge`, chosen |
| 498 | `followMode` `tell-me` or `take-updates`; in `take-updates` the hourly check puts what the parent gained in a pull. Once an hour, and | |
| 498 | `followMode` `tell-me` or `take-updates`; in `take-updates` the hourly check puts what the parent gained in a pull. | |
| 499 | 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. | |
| 500 | Once an hour, and | |
| 499 | 501 | on demand, a forge that follows and has not severed fetches its parent's code into |
| 500 | 502 | `refs/upstream/*` of its own `<org>/forge` (no branch of the fork moves) and keeps |
| 501 | 503 | `{ behind, parentTip, checkedAt, error }` in `upstream.json` under `FORGE_APP_DATA`; |
src/http/mcp.ts +6
| @@ -1490,6 +1490,12 @@ const TOOLS: readonly Tool[] = [ | ||
| 1490 | 1490 | description: |
| 1491 | 1491 | "How the child follows this forge's code: tell-me (default) says when the parent has moved; take-updates takes them.", |
| 1492 | 1492 | }, |
| 1493 | size: { | |
| 1494 | type: "string", | |
| 1495 | enum: ["small", "medium", "large"], | |
| 1496 | description: | |
| 1497 | "How big the child is (optional). Recorded in the plan only: nothing enforces its limits yet.", | |
| 1498 | }, | |
| 1493 | 1499 | keepAccess: { |
| 1494 | 1500 | type: "boolean", |
| 1495 | 1501 | description: |
src/spawn/door.ts +1
| @@ -257,6 +257,7 @@ export async function spawnDoor( | ||
| 257 | 257 | budgetUsd: raw["budgetUsd"], |
| 258 | 258 | fresh: raw["fresh"], |
| 259 | 259 | followMode: raw["followMode"], |
| 260 | size: raw["size"], | |
| 260 | 261 | ...(anon ? {} : { for: raw["for"] }), |
| 261 | 262 | }; |
| 262 | 263 |
src/spawn/plan.ts +14
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | |
| 10 | 10 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 11 | 11 | import { DEFAULT_MODE, type FollowMode } from "./chain.ts"; |
| 12 | import { parseSize, type Size } from "./sizes.ts"; | |
| 12 | 13 | |
| 13 | 14 | export const MAX_APPS = 50; |
| 14 | 15 | export const MAX_BRIEFS = 100; |
| @@ -69,6 +70,8 @@ export interface SpawnInput { | ||
| 69 | 70 | readonly fresh?: unknown; |
| 70 | 71 | /** How the child follows the forge's code: `tell-me` (default) or `take-updates`. */ |
| 71 | 72 | readonly followMode?: unknown; |
| 73 | /** How big the child is: `small`, `medium` or `large`; optional. */ | |
| 74 | readonly size?: unknown; | |
| 72 | 75 | } |
| 73 | 76 | |
| 74 | 77 | /** What the parent has to offer. Extra fields are ignored, never copied. */ |
| @@ -109,6 +112,8 @@ export interface SpawnPlan { | ||
| 109 | 112 | readonly fresh?: PlannedFresh; |
| 110 | 113 | /** How the child follows its parent's forge code; absent means `tell-me`. */ |
| 111 | 114 | readonly followMode?: FollowMode; |
| 115 | /** The child's size, present only when chosen. */ | |
| 116 | readonly size?: Size; | |
| 112 | 117 | } |
| 113 | 118 | |
| 114 | 119 | export interface DryRun { |
| @@ -250,6 +255,14 @@ export function planSpawn( | ||
| 250 | 255 | if (input.followMode !== DEFAULT_MODE) followMode = input.followMode; |
| 251 | 256 | } |
| 252 | 257 | |
| 258 | // Checked last, so every older field's refusal comes first. | |
| 259 | let size: Size | undefined; | |
| 260 | if (input.size !== undefined && input.size !== null && input.size !== "") { | |
| 261 | const parsed = parseSize(input.size); | |
| 262 | if (!parsed.ok) return refuse("size", parsed.error); | |
| 263 | size = parsed.value; | |
| 264 | } | |
| 265 | ||
| 253 | 266 | // `forWhom` only when said, so a plan without it hashes as it always did. |
| 254 | 267 | return ok({ |
| 255 | 268 | name, |
| @@ -263,6 +276,7 @@ export function planSpawn( | ||
| 263 | 276 | ...(forWhom === "" ? {} : { forWhom }), |
| 264 | 277 | ...(fresh === undefined ? {} : { fresh }), |
| 265 | 278 | ...(followMode === undefined ? {} : { followMode }), |
| 279 | ...(size === undefined ? {} : { size }), | |
| 266 | 280 | }); |
| 267 | 281 | } |
| 268 | 282 |
src/spawn/sizes.ts added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | /** | |
| 2 | * Sizes: what small, medium and large mean for a child, in one table. | |
| 3 | * Plain data in, plain data out; nothing here reads or enforces a limit. | |
| 4 | */ | |
| 5 | ||
| 6 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 7 | ||
| 8 | export type Size = "small" | "medium" | "large"; | |
| 9 | ||
| 10 | export interface SizeLimits { | |
| 11 | readonly cpu: number; | |
| 12 | readonly memoryMiB: number; | |
| 13 | readonly diskMiB: number; | |
| 14 | } | |
| 15 | ||
| 16 | export const SIZES: Readonly<Record<Size, SizeLimits>> = { | |
| 17 | small: { cpu: 1, memoryMiB: 512, diskMiB: 2048 }, | |
| 18 | medium: { cpu: 2, memoryMiB: 2048, diskMiB: 10240 }, | |
| 19 | large: { cpu: 4, memoryMiB: 8192, diskMiB: 40960 }, | |
| 20 | }; | |
| 21 | ||
| 22 | /** A size, or one sentence saying which are allowed. */ | |
| 23 | export function parseSize(v: unknown): Result<Size, string> { | |
| 24 | if (typeof v === "string" && Object.hasOwn(SIZES, v)) return ok(v as Size); | |
| 25 | return err("size must be small, medium or large"); | |
| 26 | } |
test/spawn-api.test.ts +14
| @@ -74,6 +74,20 @@ describe("spawn over the API and MCP", () => { | ||
| 74 | 74 | await rm(dataDir, { recursive: true, force: true }); |
| 75 | 75 | }); |
| 76 | 76 | |
| 77 | test("size passes through the door into the plan; a bad size is a 422 on size", async () => { | |
| 78 | const good = await post(rw, { name: "kid", apps: ["alice/notes"], size: "medium" }); | |
| 79 | expect(good.status).toBe(200); | |
| 80 | expect(((await good.json()) as any).plan.size).toBe("medium"); | |
| 81 | const none = await post(rw, { name: "kid", apps: ["alice/notes"] }); | |
| 82 | expect("size" in ((await none.json()) as any).plan).toBe(false); | |
| 83 | const bad = await post(rw, { name: "kid", apps: ["alice/notes"], size: "huge" }); | |
| 84 | expect(bad.status).toBe(422); | |
| 85 | expect(((await bad.json()) as any).message).toContain("size:"); | |
| 86 | const viaMcp = await mcp(rw, { name: "kid", apps: ["alice/notes"], size: "huge" }); | |
| 87 | expect(viaMcp.error).toBe(true); | |
| 88 | expect(viaMcp.text).toContain("size:"); | |
| 89 | }); | |
| 90 | ||
| 77 | 91 | test("dry-run is the default: the plan comes back and nothing is made", async () => { |
| 78 | 92 | const res = await post(rw, { name: "kid", apps: ["alice/notes"], crew: ["scout"] }); |
| 79 | 93 | expect(res.status).toBe(200); |
test/spawn-sizes.test.ts added +47
| @@ -0,0 +1,47 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { sha256 } from "../src/kernel/hash.ts"; | |
| 3 | import { planSpawn } from "../src/spawn/plan.ts"; | |
| 4 | import { parseSize, SIZES } from "../src/spawn/sizes.ts"; | |
| 5 | ||
| 6 | const source = { catalog: [], briefs: [], subscriptions: [] }; | |
| 7 | const base = { name: "kid", org: "kids", apps: [], carryBriefs: false, carrySubscriptions: false }; | |
| 8 | ||
| 9 | describe("sizes", () => { | |
| 10 | test("the table holds three sizes, each bigger than the last", () => { | |
| 11 | const [s, m, l] = [SIZES.small, SIZES.medium, SIZES.large]; | |
| 12 | expect(s.cpu < m.cpu && m.cpu < l.cpu).toBe(true); | |
| 13 | expect(s.memoryMiB < m.memoryMiB && m.memoryMiB < l.memoryMiB).toBe(true); | |
| 14 | expect(s.diskMiB < m.diskMiB && m.diskMiB < l.diskMiB).toBe(true); | |
| 15 | }); | |
| 16 | ||
| 17 | test("parseSize takes a size and refuses the rest in one sentence", () => { | |
| 18 | expect(parseSize("medium")).toEqual({ ok: true, value: "medium" }); | |
| 19 | for (const bad of ["huge", "Small", "toString", "__proto__", 3, null, ""]) { | |
| 20 | const r = parseSize(bad); | |
| 21 | expect(r.ok).toBe(false); | |
| 22 | if (!r.ok) expect(r.error).toBe("size must be small, medium or large"); | |
| 23 | } | |
| 24 | }); | |
| 25 | ||
| 26 | test("a plan without a size hashes as it always did", () => { | |
| 27 | const r = planSpawn(base, source); | |
| 28 | if (!r.ok) throw new Error(r.error.reason); | |
| 29 | expect("size" in r.value).toBe(false); | |
| 30 | expect(sha256(JSON.stringify(r.value))).toBe( | |
| 31 | "8ae7676ae733670fbece93350800452b403c5373ec544f4c2c6104b17ca3767b", | |
| 32 | ); | |
| 33 | }); | |
| 34 | ||
| 35 | test("a chosen size is in the plan", () => { | |
| 36 | const r = planSpawn({ ...base, size: "large" }, source); | |
| 37 | if (!r.ok) throw new Error(r.error.reason); | |
| 38 | expect(r.value.size).toBe("large"); | |
| 39 | }); | |
| 40 | ||
| 41 | test("an unknown size is refused, after the other fields", () => { | |
| 42 | const r = planSpawn({ ...base, size: "huge" }, source); | |
| 43 | expect(r).toEqual({ ok: false, error: { field: "size", reason: "size must be small, medium or large" } }); | |
| 44 | const first = planSpawn({ ...base, size: "huge", direction: 5 }, source); | |
| 45 | expect(first.ok === false && first.error.field).toBe("direction"); | |
| 46 | }); | |
| 47 | }); |
new above
offline
Nit: the paragraph split leaves
Once an hour, andalone on its own line with a leading space, and the(opened two lines up before<org>/forgenever closes. Join this line back onto the next sentence, and close the parenthesis afterin a pull., the next time this file is touched.