Public: anyone can read this; the crew can change it.
Where a child body runs: a placement seam, hosted inside the parent today, Cloudflare/Fly/metal when the tofu spike lands #434
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/427-where-a-child-body-runs-a-placement-seam → main 1 commit fork
3 files
changed +172
−0
docs/ARCHITECTURE.md +26
| @@ -486,6 +486,32 @@ budget, a deadline. Containment is the forge's, not the agent's; the agent | ||
| 486 | 486 | is trusted to work, and the forge is built so that trust costs at most one |
| 487 | 487 | run. |
| 488 | 488 | |
| 489 | ## Where a child body runs | |
| 490 | ||
| 491 | `spawn/placement.ts` is the seam: spawn builds a `SpawnPlan` and hands it to | |
| 492 | a `Placement` — `place(plan) -> { url, bodyId }` and `retire(bodyId)` — and | |
| 493 | never learns where the child runs. Both return a `Result`; refusals carry a | |
| 494 | reason (`children`, `depth`, `exists`, `unknown`). `retire` is idempotent. | |
| 495 | ||
| 496 | The one implementation today is `hostedPlacement`: the child is an app inside | |
| 497 | the parent at `https://<name>.<host>`, with no SSH and one agent at a time. | |
| 498 | Limits: `MAX_CHILDREN` (5) hosted children per forge and `MAX_DEPTH` (2) | |
| 499 | levels of nesting, so a child cannot spawn without bound. Bodies are held | |
| 500 | behind a `BodyStore`; the default is in memory, and persisting it under | |
| 501 | `FORGE_APP_DATA` is left to the wiring that calls spawn. | |
| 502 | ||
| 503 | An external placement (Cloudflare Worker + container, Fly, metal — chosen by | |
| 504 | spike #339) implements the same interface and must provide: | |
| 505 | ||
| 506 | - **create**: `place`, returning the child's URL and a stable `bodyId`; | |
| 507 | enforcing the same child and depth limits, or stricter. | |
| 508 | - **health**: a way to tell whether a body is up (to be added to the | |
| 509 | interface when the first external module needs it). | |
| 510 | - **retire**: tear the body down and release everything it held. | |
| 511 | - **bucket and credential per child**: its own storage bucket and its own | |
| 512 | credential, never shared with the parent or siblings, created in `place` | |
| 513 | and revoked in `retire`; credentials never appear in a plan or a log. | |
| 514 | ||
| 489 | 515 | ## The forge's standing |
| 490 | 516 | |
| 491 | 517 | `GET /api/v1/health` (`kernel/vitals.ts`) answers a site admin, by session or |
src/spawn/placement.ts added +101
| @@ -0,0 +1,101 @@ | ||
| 1 | /** | |
| 2 | * Placement: where a child forge's body runs, behind one seam. Spawn hands a | |
| 3 | * validated SpawnPlan to a Placement and gets back where the child lives; | |
| 4 | * it never learns whether that is inside this process or on someone else's | |
| 5 | * machine. `hosted` is the only implementation today: the child is an app | |
| 6 | * inside the parent, reached by a subdomain, with no SSH and one agent at a | |
| 7 | * time. An external placement (Cloudflare, Fly, metal — spike #339) plugs | |
| 8 | * in by implementing the same interface. | |
| 9 | */ | |
| 10 | ||
| 11 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 12 | import type { SpawnPlan } from "./plan.ts"; | |
| 13 | ||
| 14 | export interface Placed { | |
| 15 | readonly url: string; | |
| 16 | readonly bodyId: string; | |
| 17 | } | |
| 18 | ||
| 19 | export type PlacementRefusal = { readonly reason: "children" | "depth" | "exists" | "unknown"; readonly message: string }; | |
| 20 | ||
| 21 | export interface Placement { | |
| 22 | place(plan: SpawnPlan): Promise<Result<Placed, PlacementRefusal>>; | |
| 23 | /** Idempotent: retiring a body that is gone is a success. */ | |
| 24 | retire(bodyId: string): Promise<Result<void, PlacementRefusal>>; | |
| 25 | } | |
| 26 | ||
| 27 | /** What the hosted placement remembers about each child it holds. */ | |
| 28 | export interface Body { | |
| 29 | readonly bodyId: string; | |
| 30 | readonly name: string; | |
| 31 | readonly url: string; | |
| 32 | readonly depth: number; | |
| 33 | /** A hosted child runs one agent at a time and offers no SSH. */ | |
| 34 | readonly agents: 1; | |
| 35 | readonly ssh: false; | |
| 36 | } | |
| 37 | ||
| 38 | export interface BodyStore { | |
| 39 | list(): readonly Body[]; | |
| 40 | add(body: Body): void; | |
| 41 | remove(bodyId: string): void; | |
| 42 | } | |
| 43 | ||
| 44 | export function memoryBodies(): BodyStore { | |
| 45 | const bodies = new Map<string, Body>(); | |
| 46 | return { | |
| 47 | list: () => [...bodies.values()], | |
| 48 | add: (b) => void bodies.set(b.bodyId, b), | |
| 49 | remove: (id) => void bodies.delete(id), | |
| 50 | }; | |
| 51 | } | |
| 52 | ||
| 53 | export const MAX_CHILDREN = 5; | |
| 54 | export const MAX_DEPTH = 2; | |
| 55 | ||
| 56 | export interface HostedOptions { | |
| 57 | /** The parent forge's public host, e.g. "forge.example.com". */ | |
| 58 | readonly host: string; | |
| 59 | /** How deep this forge itself is: 0 for a root forge. */ | |
| 60 | readonly depth?: number; | |
| 61 | readonly maxChildren?: number; | |
| 62 | readonly maxDepth?: number; | |
| 63 | readonly store?: BodyStore; | |
| 64 | /** Injectable so tests need no randomness. */ | |
| 65 | readonly newId?: () => string; | |
| 66 | } | |
| 67 | ||
| 68 | const refuse = (reason: PlacementRefusal["reason"], message: string) => | |
| 69 | err<PlacementRefusal>({ reason, message }); | |
| 70 | ||
| 71 | export function hostedPlacement(opts: HostedOptions): Placement { | |
| 72 | const store = opts.store ?? memoryBodies(); | |
| 73 | const depth = opts.depth ?? 0; | |
| 74 | const maxChildren = opts.maxChildren ?? MAX_CHILDREN; | |
| 75 | const maxDepth = opts.maxDepth ?? MAX_DEPTH; | |
| 76 | const newId = opts.newId ?? (() => `hosted-${crypto.randomUUID()}`); | |
| 77 | ||
| 78 | return { | |
| 79 | async place(plan) { | |
| 80 | // A child lives one level below this forge; it may not exceed the limit. | |
| 81 | if (depth + 1 > maxDepth) { | |
| 82 | return refuse("depth", `forges nest at most ${maxDepth} deep; this one is already at ${depth}`); | |
| 83 | } | |
| 84 | const held = store.list(); | |
| 85 | if (held.length >= maxChildren) { | |
| 86 | return refuse("children", `a forge holds at most ${maxChildren} hosted children`); | |
| 87 | } | |
| 88 | if (held.some((b) => b.name === plan.name)) { | |
| 89 | return refuse("exists", `a child named ${plan.name} already exists`); | |
| 90 | } | |
| 91 | const url = `https://${plan.name}.${opts.host}`; | |
| 92 | const bodyId = newId(); | |
| 93 | store.add({ bodyId, name: plan.name, url, depth: depth + 1, agents: 1, ssh: false }); | |
| 94 | return ok({ url, bodyId }); | |
| 95 | }, | |
| 96 | async retire(bodyId) { | |
| 97 | store.remove(bodyId); | |
| 98 | return ok(); | |
| 99 | }, | |
| 100 | }; | |
| 101 | } |
test/spawn-placement.test.ts added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { hostedPlacement, memoryBodies } from "../src/spawn/placement.ts"; | |
| 3 | import type { SpawnPlan } from "../src/spawn/plan.ts"; | |
| 4 | ||
| 5 | const plan = (name: string): SpawnPlan => ({ name, org: "o", apps: [], briefs: [], subscriptions: [] }); | |
| 6 | const make = (o: Partial<Parameters<typeof hostedPlacement>[0]> = {}) => { | |
| 7 | let n = 0; | |
| 8 | const store = memoryBodies(); | |
| 9 | return { store, p: hostedPlacement({ host: "f.test", store, newId: () => `b${++n}`, ...o }) }; | |
| 10 | }; | |
| 11 | ||
| 12 | describe("hosted placement", () => { | |
| 13 | test("places a child as a subdomain of the parent, one agent, no SSH", async () => { | |
| 14 | const { p, store } = make(); | |
| 15 | const r = await p.place(plan("kid")); | |
| 16 | expect(r).toEqual({ ok: true, value: { url: "https://kid.f.test", bodyId: "b1" } }); | |
| 17 | expect(store.list()[0]).toMatchObject({ depth: 1, agents: 1, ssh: false }); | |
| 18 | }); | |
| 19 | ||
| 20 | test("refuses beyond the children limit, and retiring frees a slot", async () => { | |
| 21 | const { p } = make({ maxChildren: 2 }); | |
| 22 | await p.place(plan("a")); | |
| 23 | const b = await p.place(plan("b")); | |
| 24 | const c = await p.place(plan("c")); | |
| 25 | expect(!c.ok && c.error.reason).toBe("children"); | |
| 26 | if (b.ok) expect((await p.retire(b.value.bodyId)).ok).toBe(true); | |
| 27 | expect((await p.place(plan("c"))).ok).toBe(true); | |
| 28 | }); | |
| 29 | ||
| 30 | test("refuses past the nesting depth", async () => { | |
| 31 | const { p, store } = make({ depth: 2, maxDepth: 2 }); | |
| 32 | const r = await p.place(plan("deep")); | |
| 33 | expect(!r.ok && r.error.reason).toBe("depth"); | |
| 34 | expect(store.list()).toHaveLength(0); | |
| 35 | expect((await make({ depth: 1, maxDepth: 2 }).p.place(plan("ok"))).ok).toBe(true); | |
| 36 | }); | |
| 37 | ||
| 38 | test("refuses a duplicate name; retire is idempotent", async () => { | |
| 39 | const { p } = make(); | |
| 40 | await p.place(plan("a")); | |
| 41 | const r = await p.place(plan("a")); | |
| 42 | expect(!r.ok && r.error.reason).toBe("exists"); | |
| 43 | expect((await p.retire("nope")).ok).toBe(true); | |
| 44 | }); | |
| 45 | }); |
new above
offline