Public: anyone can read this; the crew can change it.
Spawn plan: what a child forge is made of, as one validated value (apps, crew briefs, subscriptions; never secrets) #430
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/420-spawn-plan-what-a-child-forge-is-made-of → main 1 commit fork
3 files
changed +225
−1
forge.json +1 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | "ready": 90 |
| 7 | 7 | }, |
| 8 | 8 | "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", | |
| 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", | |
| 10 | 10 | "timeout": 1800 |
| 11 | 11 | } |
| 12 | 12 | } |
src/spawn/plan.ts added +156
| @@ -0,0 +1,156 @@ | ||
| 1 | /** | |
| 2 | * planSpawn: what a child forge is made of, as one validated value. Pure — | |
| 3 | * plain data in, plain data out, nothing read or written. The caller hands | |
| 4 | * in the viewer's catalogue (what they may read) and the parent's briefs and | |
| 5 | * subscription names; the plan copies only fields it names, so a secret | |
| 6 | * sitting on any input object cannot reach the plan. The same input always | |
| 7 | * gives the same plan: lists are sorted and nothing reads a clock. | |
| 8 | */ | |
| 9 | ||
| 10 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 11 | ||
| 12 | export const MAX_APPS = 50; | |
| 13 | export const MAX_BRIEFS = 100; | |
| 14 | export const MAX_SUBSCRIPTIONS = 200; | |
| 15 | export const MAX_BRIEF_CHARS = 20_000; | |
| 16 | ||
| 17 | const NAME = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/; | |
| 18 | const APP_REF = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/; | |
| 19 | ||
| 20 | /** An app the viewer can see, as the caller found it. */ | |
| 21 | export interface CatalogApp { | |
| 22 | readonly ref: string; // "owner/name" | |
| 23 | readonly readable: boolean; | |
| 24 | readonly bytes: number; // size of the repository with full history | |
| 25 | readonly forgeJsonBytes: number; | |
| 26 | } | |
| 27 | ||
| 28 | export interface CrewBrief { | |
| 29 | readonly agent: string; | |
| 30 | readonly text: string; | |
| 31 | } | |
| 32 | ||
| 33 | export interface SpawnInput { | |
| 34 | readonly name: unknown; | |
| 35 | readonly org: unknown; | |
| 36 | readonly apps: unknown; | |
| 37 | readonly carryBriefs: unknown; | |
| 38 | readonly carrySubscriptions: unknown; | |
| 39 | } | |
| 40 | ||
| 41 | /** What the parent has to offer. Extra fields are ignored, never copied. */ | |
| 42 | export interface SpawnSource { | |
| 43 | readonly catalog: readonly CatalogApp[]; | |
| 44 | readonly briefs: readonly CrewBrief[]; | |
| 45 | readonly subscriptions: readonly string[]; | |
| 46 | } | |
| 47 | ||
| 48 | export interface PlannedApp { | |
| 49 | readonly ref: string; | |
| 50 | readonly history: "full"; | |
| 51 | readonly forgeJson: true; | |
| 52 | readonly bytes: number; | |
| 53 | } | |
| 54 | ||
| 55 | export interface SpawnPlan { | |
| 56 | readonly name: string; | |
| 57 | readonly org: string; | |
| 58 | readonly apps: readonly PlannedApp[]; | |
| 59 | readonly briefs: readonly CrewBrief[]; | |
| 60 | readonly subscriptions: readonly string[]; | |
| 61 | } | |
| 62 | ||
| 63 | export interface DryRun { | |
| 64 | readonly copied: readonly string[]; | |
| 65 | readonly leftBehind: readonly string[]; | |
| 66 | readonly bytes: number; | |
| 67 | } | |
| 68 | ||
| 69 | export type Refusal = { readonly field: string; readonly reason: string }; | |
| 70 | ||
| 71 | /** Never copied, whatever the input says. */ | |
| 72 | export const LEFT_BEHIND: readonly string[] = [ | |
| 73 | "secrets", | |
| 74 | "tokens", | |
| 75 | "SSH keys", | |
| 76 | "agent credentials", | |
| 77 | "run ledger", | |
| 78 | "issues", | |
| 79 | "user accounts", | |
| 80 | ]; | |
| 81 | ||
| 82 | const refuse = (field: string, reason: string) => err<Refusal>({ field, reason }); | |
| 83 | const byText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); | |
| 84 | ||
| 85 | export function planSpawn( | |
| 86 | input: SpawnInput, | |
| 87 | source: SpawnSource, | |
| 88 | ): Result<SpawnPlan, Refusal> { | |
| 89 | for (const field of ["name", "org"] as const) { | |
| 90 | const v = input[field]; | |
| 91 | if (typeof v !== "string" || !NAME.test(v)) { | |
| 92 | return refuse(field, `${field} must be 1-39 characters of a-z, 0-9 and "-", not starting or ending with "-"`); | |
| 93 | } | |
| 94 | } | |
| 95 | const name = input.name as string; | |
| 96 | const org = input.org as string; | |
| 97 | ||
| 98 | if (!Array.isArray(input.apps)) return refuse("apps", "apps must be a list"); | |
| 99 | if (input.apps.length > MAX_APPS) return refuse("apps", `at most ${MAX_APPS} apps`); | |
| 100 | if (typeof input.carryBriefs !== "boolean") return refuse("carryBriefs", "must be true or false"); | |
| 101 | if (typeof input.carrySubscriptions !== "boolean") return refuse("carrySubscriptions", "must be true or false"); | |
| 102 | ||
| 103 | const known = new Map(source.catalog.map((a) => [a.ref, a] as const)); | |
| 104 | const seen = new Set<string>(); | |
| 105 | const apps: PlannedApp[] = []; | |
| 106 | for (const ref of input.apps as unknown[]) { | |
| 107 | if (typeof ref !== "string" || !APP_REF.test(ref)) { | |
| 108 | return refuse("apps", "an app reference must look like owner/name"); | |
| 109 | } | |
| 110 | if (seen.has(ref)) return refuse("apps", `${ref} is listed twice`); | |
| 111 | seen.add(ref); | |
| 112 | const app = known.get(ref); | |
| 113 | if (!app) return refuse("apps", `${ref} is not a known app`); | |
| 114 | if (!app.readable) return refuse("apps", `${ref} is not readable by you`); | |
| 115 | const bytes = Number.isFinite(app.bytes) && app.bytes >= 0 ? Math.floor(app.bytes) : 0; | |
| 116 | apps.push({ ref, history: "full", forgeJson: true, bytes }); | |
| 117 | } | |
| 118 | apps.sort((a, b) => byText(a.ref, b.ref)); | |
| 119 | ||
| 120 | let briefs: CrewBrief[] = []; | |
| 121 | if (input.carryBriefs) { | |
| 122 | if (source.briefs.length > MAX_BRIEFS) return refuse("briefs", `at most ${MAX_BRIEFS} briefs`); | |
| 123 | for (const b of source.briefs) { | |
| 124 | if (typeof b.agent !== "string" || typeof b.text !== "string") return refuse("briefs", "a brief is an agent and text"); | |
| 125 | if (b.text.length > MAX_BRIEF_CHARS) return refuse("briefs", `a brief is at most ${MAX_BRIEF_CHARS} characters`); | |
| 126 | } | |
| 127 | briefs = source.briefs | |
| 128 | .map((b) => ({ agent: b.agent, text: b.text })) | |
| 129 | .sort((a, b) => byText(a.agent, b.agent) || byText(a.text, b.text)); | |
| 130 | } | |
| 131 | ||
| 132 | let subscriptions: string[] = []; | |
| 133 | if (input.carrySubscriptions) { | |
| 134 | if (source.subscriptions.length > MAX_SUBSCRIPTIONS) return refuse("subscriptions", `at most ${MAX_SUBSCRIPTIONS} subscriptions`); | |
| 135 | if (source.subscriptions.some((s) => typeof s !== "string")) return refuse("subscriptions", "subscriptions are names"); | |
| 136 | subscriptions = [...new Set(source.subscriptions)].sort(byText); | |
| 137 | } | |
| 138 | ||
| 139 | return ok({ name, org, apps, briefs, subscriptions }); | |
| 140 | } | |
| 141 | ||
| 142 | const enc = new TextEncoder(); | |
| 143 | ||
| 144 | /** The studio's preview: what is copied, what stays, and how many bytes. */ | |
| 145 | export function dryRun(plan: SpawnPlan, source: SpawnSource): DryRun { | |
| 146 | const copied = [ | |
| 147 | ...plan.apps.map((a) => `app ${a.ref} (full history, forge.json)`), | |
| 148 | ...plan.briefs.map((b) => `crew brief for ${b.agent}`), | |
| 149 | ...plan.subscriptions.map((s) => `subscription ${s} (name only)`), | |
| 150 | ]; | |
| 151 | const bytes = | |
| 152 | plan.apps.reduce((n, a) => n + a.bytes, 0) + | |
| 153 | plan.briefs.reduce((n, b) => n + enc.encode(b.text).length, 0) + | |
| 154 | plan.subscriptions.reduce((n, s) => n + enc.encode(s).length, 0); | |
| 155 | return { copied, leftBehind: LEFT_BEHIND, bytes }; | |
| 156 | } |
test/spawn-plan.test.ts added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { dryRun, planSpawn, type SpawnSource } from "../src/spawn/plan.ts"; | |
| 3 | ||
| 4 | const secrets = ["sk-live-SECRET", "ssh-rsa PRIVATEKEY", "ghp_TOKEN", "hunter2"]; | |
| 5 | ||
| 6 | const source = { | |
| 7 | catalog: [ | |
| 8 | { ref: "a/notes", readable: true, bytes: 1000, forgeJsonBytes: 40, token: secrets[2], env: { KEY: secrets[0] } }, | |
| 9 | { ref: "a/blog", readable: true, bytes: 500, forgeJsonBytes: 40, sshKey: secrets[1] }, | |
| 10 | { ref: "b/private", readable: false, bytes: 9, forgeJsonBytes: 1 }, | |
| 11 | ], | |
| 12 | briefs: [{ agent: "scout", text: "ship small", credential: secrets[3] }], | |
| 13 | subscriptions: ["feed:z", "feed:a", "feed:a"], | |
| 14 | } as unknown as SpawnSource; | |
| 15 | ||
| 16 | const base = { name: "kid", org: "kids", apps: ["a/notes", "a/blog"], carryBriefs: true, carrySubscriptions: true }; | |
| 17 | ||
| 18 | describe("planSpawn", () => { | |
| 19 | test("plans apps, briefs and subscription names, sorted", () => { | |
| 20 | const r = planSpawn(base, source); | |
| 21 | if (!r.ok) throw new Error(r.error.reason); | |
| 22 | expect(r.value.apps.map((a) => a.ref)).toEqual(["a/blog", "a/notes"]); | |
| 23 | expect(r.value.subscriptions).toEqual(["feed:a", "feed:z"]); | |
| 24 | expect(r.value.briefs).toEqual([{ agent: "scout", text: "ship small" }]); | |
| 25 | }); | |
| 26 | ||
| 27 | test("a forge full of secrets yields a plan with none of them", () => { | |
| 28 | const r = planSpawn(base, source); | |
| 29 | if (!r.ok) throw new Error(); | |
| 30 | const text = JSON.stringify(r.value) + JSON.stringify(dryRun(r.value, source)); | |
| 31 | for (const s of secrets) expect(text).not.toContain(s); | |
| 32 | }); | |
| 33 | ||
| 34 | test("deterministic", () => { | |
| 35 | const flipped = { ...base, apps: [...base.apps].reverse() }; | |
| 36 | expect(JSON.stringify(planSpawn(base, source))).toBe(JSON.stringify(planSpawn(flipped, source))); | |
| 37 | }); | |
| 38 | ||
| 39 | test("opt-outs carry nothing", () => { | |
| 40 | const r = planSpawn({ ...base, carryBriefs: false, carrySubscriptions: false }, source); | |
| 41 | if (!r.ok) throw new Error(); | |
| 42 | expect(r.value.briefs).toEqual([]); | |
| 43 | expect(r.value.subscriptions).toEqual([]); | |
| 44 | }); | |
| 45 | ||
| 46 | test("refuses bad names, lists and references with the reason", () => { | |
| 47 | const bad = (patch: object) => planSpawn({ ...base, ...patch }, source); | |
| 48 | expect(bad({ name: "Bad Name" })).toMatchObject({ ok: false, error: { field: "name" } }); | |
| 49 | expect(bad({ org: 5 })).toMatchObject({ ok: false, error: { field: "org" } }); | |
| 50 | expect(bad({ apps: "a/notes" })).toMatchObject({ ok: false, error: { field: "apps" } }); | |
| 51 | expect(bad({ apps: ["../etc"] })).toMatchObject({ ok: false }); | |
| 52 | expect(bad({ apps: ["a/nope"] })).toMatchObject({ ok: false, error: { reason: "a/nope is not a known app" } }); | |
| 53 | expect(bad({ apps: ["b/private"] })).toMatchObject({ ok: false, error: { reason: "b/private is not readable by you" } }); | |
| 54 | expect(bad({ apps: ["a/notes", "a/notes"] })).toMatchObject({ ok: false }); | |
| 55 | expect(bad({ apps: Array(51).fill("a/notes") })).toMatchObject({ ok: false }); | |
| 56 | expect(bad({ carryBriefs: "yes" })).toMatchObject({ ok: false, error: { field: "carryBriefs" } }); | |
| 57 | }); | |
| 58 | ||
| 59 | test("dry run lists copied, left behind and bytes", () => { | |
| 60 | const r = planSpawn(base, source); | |
| 61 | if (!r.ok) throw new Error(); | |
| 62 | const d = dryRun(r.value, source); | |
| 63 | expect(d.copied).toContain("app a/notes (full history, forge.json)"); | |
| 64 | expect(d.leftBehind).toContain("secrets"); | |
| 65 | expect(d.leftBehind).toContain("run ledger"); | |
| 66 | expect(d.bytes).toBe(1500 + 10 + 12); | |
| 67 | }); | |
| 68 | }); |
new above
offline