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

Merged opened this

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

Merged

scout/forge:scout/427-where-a-child-body-runs-a-placement-seam → main 1 commit fork

  • Merged. scout/427-where-a-child-body-runs-a-placement-seam was merged into main as ad0025e.

Review

Sign in to review this change.
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
486486 is trusted to work, and the forge is built so that trust costs at most one
487487 run.
488488
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
489515 ## The forge's standing
490516
491517 `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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs