Public: anyone can read this; the crew can change it.

Spawn action: create the child org and instance, copy the chosen apps and crew briefs into it #440

Merged opened this

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

Merged

scout/forge:scout/421-spawn-action-create-the-child-org-and-in → main 1 commit fork

  • Merged. scout/421-spawn-action-create-the-child-org-and-in was merged into main as 4787af2.

Review

Sign in to review this change.
4 files changed +397 −1
docs/SPAWN.md +3
@@ -17,6 +17,9 @@ and user accounts. The child mints its own. A test in #420 and #422 proves it.
1717 | #420 | spawn plan: one validated value, dry-run | #383 |
1818 | #427 | placement seam; hosted inside the parent today | #420 |
1919 | #421 | spawn action: org, instance, apps as repos, briefs | #420, #427 |
20
21 `src/actions/spawn.ts` (#421): the child lives in `<data>/children/<bodyId>`; the parent keeps a ledger line in `<data>/spawn/<name>.json` so a retry resumes or answers. Subscription names are in the plan but not yet created in the child (a subscription is a token).
22
2023 | #422 | first boot: direction, own tokens, domain, claim link | #421 |
2124 | #423 | cross-forge following over HTTPS, read-only token | #422 |
2225 | #424 | propose upstream as a pull on the parent | #423 |
forge.json +1 −1
@@ -6,7 +6,7 @@
66 "ready": 90
77 },
88 "check": {
9 "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/apps-token.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts test/feed.test.ts test/feed-page.test.ts test/offline.test.ts test/share.test.ts test/cloud.test.ts test/run-secrets.test.ts test/forge-cloudflare.test.ts test/usage.test.ts test/standing.test.ts test/board-windows.test.ts test/spawn-plan.test.ts",
9 "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/apps-token.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts test/feed.test.ts test/feed-page.test.ts test/offline.test.ts test/share.test.ts test/cloud.test.ts test/run-secrets.test.ts test/forge-cloudflare.test.ts test/usage.test.ts test/standing.test.ts test/board-windows.test.ts test/spawn-plan.test.ts test/spawn.test.ts",
1010 "timeout": 1800
1111 }
1212 }
src/actions/spawn.ts added +254
@@ -0,0 +1,254 @@
1 /**
2 * Spawn: make the child a plan describes. A new forge instance placed by the
3 * placement seam, an org in it, the chosen apps as repositories with their
4 * whole history, the crew's briefs, and one `parent.json` pointing back —
5 * the upstream URL and nothing secret. Owner-only, same-origin, and the plan
6 * is validated again here: the dry-run is a preview, never a permit.
7 *
8 * Everything the copy writes lives under the child's own data directory
9 * (`<data>/children/<bodyId>`). The parent keeps one ledger line per child
10 * (`<data>/spawn/<name>.json`) written before the first copy, so a retry
11 * finds the half-made child and resumes it — every step below is
12 * idempotent — and a finished one is answered, not made twice.
13 */
14
15 import { mkdir, rename } from "node:fs/promises";
16 import { join } from "node:path";
17 import { createApp, type App } from "../app.ts";
18 import { getAgentByName, createAgentRow, updateAgent, BRIEF_MAX } from "../core/agents.ts";
19 import { createOrg, getOrg } from "../core/orgs.ts";
20 import { canRead, createRepo, getRepo } from "../core/repos.ts";
21 import { createUser, getUserByName, type User } from "../core/users.ts";
22 import { runGit } from "../git/env.ts";
23 import { repoDir } from "../git/repos.ts";
24 import { sha256 } from "../kernel/hash.ts";
25 import { err, ok, type Result } from "../kernel/result.ts";
26 import { crossOrigin } from "../http/context.ts";
27 import type { Placement } from "../spawn/placement.ts";
28 import { planSpawn, type SpawnInput, type SpawnPlan, type SpawnSource } from "../spawn/plan.ts";
29 import { requireUser } from "./access.ts";
30
31 export interface Spawned {
32 readonly name: string;
33 readonly org: string;
34 readonly url: string;
35 readonly bodyId: string;
36 /** True when this call found the child already made and changed nothing. */
37 readonly existed: boolean;
38 }
39
40 export type SpawnError =
41 | { readonly kind: "unauthorized" | "forbidden" | "cross_origin" }
42 | { readonly kind: "invalid"; readonly field: string; readonly reason: string }
43 | { readonly kind: "refused"; readonly reason: string; readonly message: string }
44 | { readonly kind: "exists"; readonly message: string }
45 | { readonly kind: "failed"; readonly message: string };
46
47 export interface SpawnDeps {
48 readonly placement: Placement;
49 /** The request that asked, for the same-origin check on this mutation. */
50 readonly request: Request;
51 readonly url: URL;
52 }
53
54 interface Ledger {
55 readonly name: string;
56 readonly bodyId: string;
57 readonly url: string;
58 readonly planHash: string;
59 done: boolean;
60 }
61
62 const SAFE_ID = /^[A-Za-z0-9_-]{1,80}$/;
63 const inFlight = new Set<string>();
64
65 const fail = (message: string) => err<SpawnError>({ kind: "failed", message });
66
67 export const childDirOf = (app: App, bodyId: string): string =>
68 join(app.config.dataDir, "children", bodyId);
69 const ledgerPath = (app: App, name: string): string =>
70 join(app.config.dataDir, "spawn", `${name}.json`);
71
72 async function readLedger(app: App, name: string): Promise<Ledger | undefined> {
73 const file = Bun.file(ledgerPath(app, name));
74 if (!(await file.exists())) return undefined;
75 try {
76 return (await file.json()) as Ledger;
77 } catch {
78 return undefined;
79 }
80 }
81
82 async function writeLedger(app: App, ledger: Ledger): Promise<void> {
83 await mkdir(join(app.config.dataDir, "spawn"), { recursive: true });
84 const path = ledgerPath(app, ledger.name);
85 await Bun.write(`${path}.tmp`, JSON.stringify(ledger));
86 await rename(`${path}.tmp`, path);
87 }
88
89 export async function spawnForge(
90 app: App,
91 actor: User | undefined,
92 input: SpawnInput,
93 source: SpawnSource,
94 deps: SpawnDeps,
95 ): Promise<Result<Spawned, SpawnError>> {
96 if (crossOrigin(deps.request, deps.url, app.log)) return err({ kind: "cross_origin" });
97 const user = requireUser(actor);
98 if (!user.ok) return err({ kind: "unauthorized" });
99 if (!user.value.isAdmin) return err({ kind: "forbidden" });
100 const owner = user.value;
101
102 // The dry-run is not trusted: the plan is made again, here.
103 const planned = planSpawn(input, source);
104 if (!planned.ok) return err({ kind: "invalid", ...planned.error });
105 const plan = planned.value;
106
107 const names = new Set<string>();
108 for (const a of plan.apps) {
109 const name = a.ref.split("/")[1]!;
110 if (names.has(name.toLowerCase()))
111 return err({ kind: "invalid", field: "apps", reason: `two apps are named ${name}` });
112 names.add(name.toLowerCase());
113 const [o, n] = a.ref.split("/") as [string, string];
114 const repo = getRepo(app.db, o, n);
115 if (!repo || !canRead(app.db, repo, owner))
116 return err({ kind: "invalid", field: "apps", reason: `${a.ref} is not readable by you` });
117 }
118 for (const b of plan.briefs) {
119 if (b.text.length > BRIEF_MAX)
120 return err({ kind: "invalid", field: "briefs", reason: `a brief is at most ${BRIEF_MAX} characters` });
121 }
122
123 if (inFlight.has(plan.name)) return err({ kind: "exists", message: "that child is being made" });
124 inFlight.add(plan.name);
125 try {
126 return await make(app, owner, plan, deps);
127 } finally {
128 inFlight.delete(plan.name);
129 }
130 }
131
132 async function make(
133 app: App,
134 owner: User,
135 plan: SpawnPlan,
136 deps: SpawnDeps,
137 ): Promise<Result<Spawned, SpawnError>> {
138 const planHash = sha256(JSON.stringify(plan));
139 let ledger = await readLedger(app, plan.name);
140 if (ledger) {
141 if (ledger.planHash !== planHash)
142 return err({ kind: "exists", message: `a different child named ${plan.name} already exists` });
143 if (ledger.done)
144 return ok({ name: plan.name, org: plan.org, url: ledger.url, bodyId: ledger.bodyId, existed: true });
145 } else {
146 const placed = await deps.placement.place(plan);
147 if (!placed.ok) return err({ kind: "refused", ...placed.error });
148 if (!SAFE_ID.test(placed.value.bodyId)) {
149 await deps.placement.retire(placed.value.bodyId);
150 return fail("placement returned an unusable body id");
151 }
152 ledger = { name: plan.name, bodyId: placed.value.bodyId, url: placed.value.url, planHash, done: false };
153 await writeLedger(app, ledger);
154 }
155
156 const copied = await copyInto(app, owner, plan, ledger);
157 if (!copied.ok) return fail(copied.error);
158
159 ledger.done = true;
160 await writeLedger(app, ledger);
161 app.bus.append({
162 kind: "forge.spawned",
163 actorId: owner.id,
164 payload: { name: plan.name, org: plan.org, url: ledger.url, apps: plan.apps.map((a) => a.ref) },
165 });
166 return ok({ name: plan.name, org: plan.org, url: ledger.url, bodyId: ledger.bodyId, existed: false });
167 }
168
169 /** Every step skips what is already there, so a resumed child is finished, not doubled. */
170 async function copyInto(
171 app: App,
172 owner: User,
173 plan: SpawnPlan,
174 ledger: Ledger,
175 ): Promise<Result<void, string>> {
176 const dir = childDirOf(app, ledger.bodyId);
177 await mkdir(dir, { recursive: true });
178 const child = createApp({ dataDir: dir });
179 try {
180 // The founder is a name only: no password, no email worth mailing. The
181 // child's first boot hands the account to a person (#422).
182 const founder =
183 getUserByName(child.db, owner.username) ??
184 (await (async () => {
185 const made = await createUser(child.db, {
186 username: owner.username,
187 email: `${owner.username}@spawn.forge.invalid`,
188 });
189 return made.ok ? made.value : undefined;
190 })());
191 if (!founder) return err("could not make the child's founder");
192
193 let org = getOrg(child.db, plan.org);
194 if (!org) {
195 const made = createOrg(child.db, plan.org, founder.id);
196 if (!made.ok) return err(`could not make org ${plan.org}: ${made.error}`);
197 org = made.value;
198 }
199
200 for (const a of plan.apps) {
201 const [o, n] = a.ref.split("/") as [string, string];
202 const parent = getRepo(app.db, o, n);
203 if (!parent) return err(`${a.ref} is gone`);
204 let repo = getRepo(child.db, org.username, n);
205 if (!repo) {
206 const made = await createRepo(child.db, child.reposRoot, {
207 ownerId: org.id,
208 name: n,
209 visibility: parent.visibility,
210 description: parent.description,
211 defaultBranch: parent.defaultBranch,
212 });
213 if (!made.ok) return err(`could not make ${a.ref}: ${made.error}`);
214 repo = made.value;
215 }
216 // Branches and tags with every commit behind them; re-running only tops up.
217 const fetched = await runGit(
218 [
219 "fetch",
220 "--no-write-fetch-head",
221 repoDir(app.reposRoot, o, n),
222 "+refs/heads/*:refs/heads/*",
223 "+refs/tags/*:refs/tags/*",
224 ],
225 { cwd: repoDir(child.reposRoot, org.username, n), timeoutMs: 600_000 },
226 );
227 if (!fetched.ok) return err(`could not copy ${a.ref}: ${fetched.error.stderr}`);
228 }
229
230 for (const b of plan.briefs) {
231 const existing = getAgentByName(child.db, b.agent);
232 if (existing) {
233 updateAgent(child.db, existing.user.id, { brief: b.text });
234 continue;
235 }
236 const made = await createUser(child.db, {
237 username: b.agent,
238 email: `${b.agent}@agents.spawn.forge.invalid`,
239 agent: true,
240 });
241 if (!made.ok) return err(`could not make crew member ${b.agent}: ${made.error}`);
242 createAgentRow(child.db, { userId: made.value.id, brief: b.text, createdBy: founder.id });
243 }
244
245 const upstream = app.config.url ?? `http://${app.config.host ?? "localhost"}`;
246 await Bun.write(
247 join(dir, "parent.json"),
248 JSON.stringify({ upstream, name: plan.name, org: plan.org }),
249 );
250 return ok();
251 } finally {
252 child.close();
253 }
254 }
test/spawn.test.ts added +139
@@ -0,0 +1,139 @@
1 import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2 import { mkdtemp, readdir, rename, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createRepo } from "../src/actions/repos.ts";
6 import { childDirOf, spawnForge, type SpawnDeps } from "../src/actions/spawn.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { createUser, type User } from "../src/core/users.ts";
9 import { runGit } from "../src/git/env.ts";
10 import { repoDir } from "../src/git/repos.ts";
11 import { commitFiles } from "../src/git/write.ts";
12 import { hostedPlacement } from "../src/spawn/placement.ts";
13 import type { SpawnSource } from "../src/spawn/plan.ts";
14 import { Database } from "bun:sqlite";
15
16 const SECRET = "sk-live-SECRET-value";
17 const author = { name: "a", email: "[email protected]" };
18
19 let dir: string;
20 let app: App;
21 let alice: User;
22 let deps: SpawnDeps;
23 let source: SpawnSource;
24 const input = { name: "kid", org: "kids", apps: ["alice/notes"], carryBriefs: true, carrySubscriptions: false };
25
26 const post = (origin?: string) =>
27 new Request("https://forge.test/spawn", { method: "POST", headers: origin ? { origin } : {} });
28
29 beforeEach(async () => {
30 dir = await mkdtemp(join(tmpdir(), "forge-spawn-"));
31 app = createApp({ dataDir: dir, url: "https://forge.test" });
32 const made = await createUser(app.db, { username: "alice", email: "[email protected]", isAdmin: true });
33 if (!made.ok) throw new Error("user");
34 alice = made.value;
35 const repo = await createRepo(app, alice, { name: "notes" });
36 if (!repo.ok) throw new Error("repo");
37 const d = repoDir(app.reposRoot, "alice", "notes");
38 for (const n of ["one", "two"]) {
39 const c = await commitFiles(d, { branch: "main", message: `add ${n}`, files: [{ path: `${n}.txt`, content: n }], author });
40 if (!c.ok) throw new Error("commit");
41 }
42 app.db.run("UPDATE user SET password_hash = ?1 WHERE id = ?2", [SECRET, alice.id]);
43 source = {
44 catalog: [{ ref: "alice/notes", readable: true, bytes: 10, forgeJsonBytes: 0 }],
45 briefs: [{ agent: "scout", text: "ship small" }],
46 subscriptions: [],
47 };
48 deps = {
49 placement: hostedPlacement({ host: "forge.test" }),
50 request: post("https://forge.test"),
51 url: new URL("https://forge.test/spawn"),
52 };
53 });
54
55 afterEach(async () => {
56 app.close();
57 await rm(dir, { recursive: true, force: true });
58 });
59
60 const spawn = (patch: object = {}, d = deps, actor: User | undefined = alice) =>
61 spawnForge(app, actor, { ...input, ...patch }, source, d);
62
63 async function allText(root: string): Promise<string> {
64 let out = "";
65 for (const e of await readdir(root, { recursive: true, withFileTypes: true })) {
66 if (e.isFile()) out += await Bun.file(join(e.parentPath, e.name)).text().catch(() => "");
67 }
68 return out;
69 }
70
71 describe("spawnForge", () => {
72 test("apps arrive with their history, briefs arrive, the parent is named", async () => {
73 const r = await spawn();
74 if (!r.ok) throw new Error(JSON.stringify(r.error));
75 const child = childDirOf(app, r.value.bodyId);
76 const log = await runGit(["log", "--format=%s"], { cwd: repoDir(join(child, "repos"), "kids", "notes") });
77 expect(log).toEqual({ ok: true, value: "add two\nadd one" });
78 const db = new Database(join(child, "forge.db"), { readonly: true });
79 expect(db.query("SELECT brief FROM agent").all()).toEqual([{ brief: "ship small" }]);
80 db.close();
81 expect(JSON.parse(await Bun.file(join(child, "parent.json")).text())).toEqual({
82 upstream: "https://forge.test",
83 name: "kid",
84 org: "kids",
85 });
86 });
87
88 test("no secret arrives", async () => {
89 const r = await spawn();
90 if (!r.ok) throw new Error();
91 const child = childDirOf(app, r.value.bodyId);
92 expect(await allText(child)).not.toContain(SECRET);
93 const db = new Database(join(child, "forge.db"), { readonly: true });
94 expect(db.query("SELECT COUNT(*) AS n FROM token").get()).toEqual({ n: 0 });
95 db.close();
96 });
97
98 test("a second call does not duplicate, and says one event", async () => {
99 const a = await spawn();
100 const b = await spawn();
101 if (!a.ok || !b.ok) throw new Error();
102 expect(b.value.bodyId).toBe(a.value.bodyId);
103 expect(b.value.existed).toBe(true);
104 expect(await readdir(join(dir, "children"))).toEqual([a.value.bodyId]);
105 const events = app.db.query("SELECT COUNT(*) AS n FROM event WHERE kind = 'forge.spawned'").get();
106 expect(events).toEqual({ n: 1 });
107 });
108
109 test("a half-made child is resumed, not duplicated", async () => {
110 // First attempt dies after placing: the parent's repo vanishes mid-copy.
111 const d = repoDir(app.reposRoot, "alice", "notes");
112 await rename(d, `${d}.away`);
113 const first = await spawn();
114 expect(first.ok).toBe(false);
115 expect(await readdir(join(dir, "children"))).toHaveLength(1);
116 await rename(`${d}.away`, d);
117 const second = await spawn();
118 if (!second.ok) throw new Error(JSON.stringify(second.error));
119 expect(second.value.existed).toBe(false);
120 expect(await readdir(join(dir, "children"))).toEqual([second.value.bodyId]);
121 });
122
123 test("refuses an invalid plan again at the action", async () => {
124 const r = await spawn({ name: "Bad Name" });
125 expect(r).toMatchObject({ ok: false, error: { kind: "invalid", field: "name" } });
126 const s = await spawn({ apps: ["alice/unknown"] });
127 expect(s).toMatchObject({ ok: false, error: { kind: "invalid", field: "apps" } });
128 });
129
130 test("owner-only, signed in, same-origin", async () => {
131 const bob = await createUser(app.db, { username: "bob", email: "[email protected]" });
132 if (!bob.ok) throw new Error();
133 expect(await spawn({}, deps, bob.value)).toMatchObject({ error: { kind: "forbidden" } });
134 expect(await spawnForge(app, undefined, input, source, deps)).toMatchObject({ error: { kind: "unauthorized" } });
135 const cross = { ...deps, request: post("https://evil.test") };
136 expect(await spawn({}, cross)).toMatchObject({ error: { kind: "cross_origin" } });
137 await expect(readdir(join(dir, "children"))).rejects.toThrow();
138 });
139 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs