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

Merged opened this

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

Merged

scout/forge:scout/3093-a-spawn-plan-accepts-an-optional-size-sm → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/3093-a-spawn-plan-accepts-an-optional-size-sm was merged into main as 83bef11.

Review

Sign in to review this change.
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
495495 forge can be a parent. The terminal shows the count, not the release names.
496496
497497 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

Nit: the paragraph split leaves Once an hour, and alone on its own line with a leading space, and the ( opened two lines up before <org>/forge never closes. Join this line back onto the next sentence, and close the parenthesis after in a pull., the next time this file is touched.

499501 on demand, a forge that follows and has not severed fetches its parent's code into
500502 `refs/upstream/*` of its own `<org>/forge` (no branch of the fork moves) and keeps
501503 `{ behind, parentTip, checkedAt, error }` in `upstream.json` under `FORGE_APP_DATA`;
src/http/mcp.ts +6
@@ -1490,6 +1490,12 @@ const TOOLS: readonly Tool[] = [
14901490 description:
14911491 "How the child follows this forge's code: tell-me (default) says when the parent has moved; take-updates takes them.",
14921492 },
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 },
14931499 keepAccess: {
14941500 type: "boolean",
14951501 description:
src/spawn/door.ts +1
@@ -257,6 +257,7 @@ export async function spawnDoor(
257257 budgetUsd: raw["budgetUsd"],
258258 fresh: raw["fresh"],
259259 followMode: raw["followMode"],
260 size: raw["size"],
260261 ...(anon ? {} : { for: raw["for"] }),
261262 };
262263
src/spawn/plan.ts +14
@@ -9,6 +9,7 @@
99
1010 import { err, ok, type Result } from "../kernel/result.ts";
1111 import { DEFAULT_MODE, type FollowMode } from "./chain.ts";
12 import { parseSize, type Size } from "./sizes.ts";
1213
1314 export const MAX_APPS = 50;
1415 export const MAX_BRIEFS = 100;
@@ -69,6 +70,8 @@ export interface SpawnInput {
6970 readonly fresh?: unknown;
7071 /** How the child follows the forge's code: `tell-me` (default) or `take-updates`. */
7172 readonly followMode?: unknown;
73 /** How big the child is: `small`, `medium` or `large`; optional. */
74 readonly size?: unknown;
7275 }
7376
7477 /** What the parent has to offer. Extra fields are ignored, never copied. */
@@ -109,6 +112,8 @@ export interface SpawnPlan {
109112 readonly fresh?: PlannedFresh;
110113 /** How the child follows its parent's forge code; absent means `tell-me`. */
111114 readonly followMode?: FollowMode;
115 /** The child's size, present only when chosen. */
116 readonly size?: Size;
112117 }
113118
114119 export interface DryRun {
@@ -250,6 +255,14 @@ export function planSpawn(
250255 if (input.followMode !== DEFAULT_MODE) followMode = input.followMode;
251256 }
252257
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
253266 // `forWhom` only when said, so a plan without it hashes as it always did.
254267 return ok({
255268 name,
@@ -263,6 +276,7 @@ export function planSpawn(
263276 ...(forWhom === "" ? {} : { forWhom }),
264277 ...(fresh === undefined ? {} : { fresh }),
265278 ...(followMode === undefined ? {} : { followMode }),
279 ...(size === undefined ? {} : { size }),
266280 });
267281 }
268282
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", () => {
7474 await rm(dataDir, { recursive: true, force: true });
7575 });
7676
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
7791 test("dry-run is the default: the plan comes back and nothing is made", async () => {
7892 const res = await post(rw, { name: "kid", apps: ["alice/notes"], crew: ["scout"] });
7993 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs