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

Choosing a template for a new forge: the spawn path takes a `template`, records it, and `GET /api/v1/forges/templates` + MCP `templates` list what is offered #7548

Merged opened this in answer to

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

Merged

mason/forge:mason/7533-choosing-a-template-for-a-new-forge-the → main 2 commits fork

  • Merged. mason/7533-choosing-a-template-for-a-new-forge-the was merged into main as 31688d6.

Review

Sign in to review this change.
14 files changed +276 −18
docs/MCP.md +2 −1
@@ -159,7 +159,7 @@ for a tunnel and a forge that sleeps.
159159 | `signup` | `{ mode?, dryRun? }`: who may sign up: `open` (default), `invite` (only a person with an open invite link) or `closed` (no new accounts); without `mode` it reads the setting, with `mode` it plans first (`dryRun` is true unless `false`), and applying needs an admin token | yes |
160160 | `sizes` | what a child forge's sizes mean: small, medium and large, each with CPU, memory and disk | |
161161 | `snapshot_now` | no arguments: take a snapshot now, as the Backups page's button does; a forge admin with an admin-scope token; answers `{ name, repos, apps, bytes, says }` | yes |
162 | `spawn` | `{ name, apps?, crew?, dryRun? }`: a child forge, planned first (`dryRun` is true unless `false`); an admin's, and an `ro` token may plan but not make | yes |
162 | `spawn` | `{ name, apps?, crew?, template?, dryRun? }`: a child forge, planned first (`dryRun` is true unless `false`); an admin's, and an `ro` token may plan but not make | yes |
163163 | `spend` | `{ repo, who, model?, tokensIn?, tokensOut?, usd?, issue?, note? }`: report what your session spent outside forge runs; `who` and some spend (tokens or `usd`) are required; answers the entry's `id` and `usd` | yes |
164164 | `ssh_keys` | `{ action?, title?, key?, id? }`: list your own SSH keys (default), add one (`key` is an authorized_keys line, `title` optional) or remove one by `id`; the API's rules and errors, add and remove need an `rw` token | yes |
165165 | `steer` | `{ repo, run, text }`: say a word to one running crew run by its id, as the run page's steer box and `POST …/runs/:id/steer` do; a writer's (never an agent), the run an agent's, `text` not empty; answers `{ says, outcome }` (`sent` or `commented`) | yes |
@@ -178,6 +178,7 @@ for a tunnel and a forge that sleeps.
178178 | `telemetry_grant` | `{ agent, on }`: grant an agent `telemetry:read`, or take it back with `false`, as the Telemetry page does; a person admin's, with an admin token; answers `says` | yes |
179179 | `telemetry_history` | `{ hours? }` (1–72, default 6): the node's and the forge's CPU cores, memory and load over the window, `{ hours, points: [{ target, at, cpuCores, cpuCoresMax (the bucket's peak), memoryBytes, load }] }`, at most 300 points a series (15-s samples to 24 h, 5-min beyond, averaged down); a forge admin or `telemetry:read`, as `system`; the machine records no disk history | |
180180 | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes |
181 | `templates` | the templates a child forge may start from (`blank` first, the default), each with id, title, one sentence and source; pass an id as `spawn`'s `template` | |
181182 | `thought` | `{ id }`: where one of your thoughts went (its id comes from `tell` without a repo): the placement, and the earlier thoughts it repeats or takes back and the later ones that did the same; not yours and not there answer alike, `not_found` | |
182183 | `threads` | `{ repo, number, anchor?, resolved? }`: a pull's review threads with their comments; with `anchor` (from the list) resolve it (`resolved` true, the default) or reopen it (false), by the API's rules: a writer or someone who commented in the thread | yes |
183184 | `tokens` | your own API tokens, as `GET /api/v1/tokens` lists them: id, name, scope, created and last used; never a token or its hash | |
docs/SPAWN.md +9 −1
@@ -72,10 +72,18 @@ Children report in; the parent never reaches in. Every ten minutes a spawned chi
7272
7373 ## Callers
7474
75 `POST /api/v1/forges {name, apps[], crew[], direction?, keepAccess?, modelKey?, budgetUsd?, size?, dryRun}` (rw token, admin only) and the MCP `spawn` tool share `src/spawn/door.ts`. `size` is `small` (default), `medium` or `large`; anything else is a 422 on `size`. It is recorded in the plan, the ledger, `parent.json` and `GET /api/v1/forges`, but not enforced yet: it limits nothing. `dryRun` is true unless it is `false`: the answer is the plan, what is left behind, and `firstAdmin` (the caller's username, or `null` for an anonymous plan, where whoever claims the child becomes its admin), and nothing is made. `GET /api/v1/forges` lists the ledger's children. The child's org is its name; `crew` names the agents whose briefs travel. Errors: 401 unauthorized, 403 forbidden / cross_origin, 422 invalid, 409 refused / exists, 500 failed — each with one sentence. The MCP lets an `ro` token plan, never make.
75 `POST /api/v1/forges {name, apps[], crew[], direction?, keepAccess?, modelKey?, budgetUsd?, size?, template?, dryRun}` (rw token, admin only) and the MCP `spawn` tool share `src/spawn/door.ts`. `size` is `small` (default), `medium` or `large`; anything else is a 422 on `size`. It is recorded in the plan, the ledger, `parent.json` and `GET /api/v1/forges`, but not enforced yet: it limits nothing. `dryRun` is true unless it is `false`: the answer is the plan, what is left behind, and `firstAdmin` (the caller's username, or `null` for an anonymous plan, where whoever claims the child becomes its admin), and nothing is made. `GET /api/v1/forges` lists the ledger's children. The child's org is its name; `crew` names the agents whose briefs travel. Errors: 401 unauthorized, 403 forbidden / cross_origin, 422 invalid, 409 refused / exists, 500 failed — each with one sentence. The MCP lets an `ro` token plan, never make.
7676
7777 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`. The New forge page and the terminal form both offer it, small preselected and not sent; the page shows a refusal beside the select, linked with `aria-describedby`. Nothing enforces the limits yet, and the table's numbers are provisional. `GET /api/v1/forges/sizes` (no sign-in, ETag, cached for 5 minutes) and the MCP tool `sizes` give the same answer — each size's CPU, memory and disk, read from that table, and one sentence on what it is for — so a page or an agent offering a size reads it there.
7878
79 ## Choosing a template (#7533)
80
81 A template is a named starting point for a new forge's apps, listed in `src/spawn/templates.ts`: each has an `id`, a `title`, one sentence `says`, and a `source` — `builtin` (its files are this forge's code) or `repo` (read from `repo`, an `owner/name` on this forge; none yet, #7536). Built in are `blank`, the default and what a spawn has always made (the forked apps and an optional `fresh` one), and `starter`, the app `/apps/new` starts from (`scaffoldFiles`, `src/apps/scaffold.ts`).
82
83 `POST /api/v1/forges` and the MCP `spawn` take an optional `template` id. An unknown id is a 422 on `template` naming the valid ids. Absent or `blank`, the plan is the one it always was; any other id is named in the plan, the ledger line, `parent.json` and the child's row in `GET /api/v1/forges` (absent there means `blank`). Because it is in the plan, a retry with another template is refused as a different child. For now the choice is recorded only: a `starter` child starts with the same files as a `blank` one until #7535 lays the template's files down.
84
85 `GET /api/v1/forges/templates` (no sign-in, ETag, cached for 5 minutes) and the MCP tool `templates` give the same answer: `{ templates, default }`, `blank` first.
86
7987 ## The forge's own code, and the chain (#1085)
8088
8189 Every child is born with `<org>/forge`: a fork, with full history, of the
src/actions/spawn.ts +7
@@ -114,6 +114,8 @@ interface Ledger {
114114 readonly createdAt?: string;
115115 /** Absent on a ledger written before sizes; reads as small. */
116116 readonly size?: Size;
117 /** The template chosen (`spawn/templates.ts`); absent means `blank`. */
118 readonly template?: string;
117119 /** Absent on a ledger written before cells; reads as hosted. */
118120 readonly placement?: PlacementKind;
119121 /** Why a child that wanted a cell is hosted instead (`spawn/host.ts`). */
@@ -267,6 +269,7 @@ async function make(
267269 apps: plan.apps.map((a) => a.ref),
268270 createdAt: new Date().toISOString(),
269271 size: plan.size ?? DEFAULT_SIZE,
272 ...(plan.template === undefined ? {} : { template: plan.template }),
270273 placement: placed.value.kind ?? "hosted",
271274 statusToken: secret(),
272275 beatKey: secret(),
@@ -464,6 +467,7 @@ async function copyInto(
464467 url: liveChildUrl(app, plan.name),
465468 keepAccess: plan.keepAccess,
466469 size: plan.size ?? DEFAULT_SIZE,
470 ...(plan.template === undefined ? {} : { template: plan.template }),
467471 // Each app is a fork: where it came from, so the child can follow it.
468472 apps: plan.apps.map((a) => ({ repo: `${plan.org}/${a.ref.split("/")[1]}`, upstream: a.ref })),
469473 ...(plan.fresh === undefined ? {} : { fresh: `${plan.org}/${plan.fresh.name}` }),
@@ -673,6 +677,8 @@ export interface Child {
673677 /** When it was begun; null on a ledger older than the client list. */
674678 readonly createdAt: string | null;
675679 readonly size: Size;
680 /** The template it started from; absent when `blank`. */
681 readonly template?: string;
676682 readonly placement: PlacementKind;
677683 /** Why it is hosted when it asked for a cell; null otherwise. */
678684 readonly placementNote: string | null;
@@ -695,6 +701,7 @@ export async function listChildren(app: App): Promise<Child[]> {
695701 apps: ledger.apps ?? [],
696702 createdAt: ledger.createdAt ?? null,
697703 size: isSize(ledger.size) ? ledger.size : DEFAULT_SIZE,
704 ...(typeof ledger.template === "string" ? { template: ledger.template } : {}),
698705 placement: ledger.placement ?? "hosted",
699706 placementNote: ledger.placementNote ?? null,
700707 });
src/contract/openapi/routes/getApiV1ForgesTemplates.ts added +3
@@ -0,0 +1,3 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: "/api/v1/forges/templates", summary: "Which templates a new forge may start from, blank first; public, cacheable by ETag", auth: false };
src/contract/openapi/routes/index.ts +1
@@ -57,6 +57,7 @@ export { route as getApiV1FleetOpsId } from "./getApiV1FleetOpsId.ts";
5757 export { route as getApiV1Forge } from "./getApiV1Forge.ts";
5858 export { route as getApiV1Forges } from "./getApiV1Forges.ts";
5959 export { route as getApiV1ForgesSizes } from "./getApiV1ForgesSizes.ts";
60 export { route as getApiV1ForgesTemplates } from "./getApiV1ForgesTemplates.ts";
6061 export { route as getApiV1Front } from "./getApiV1Front.ts";
6162 export { route as getApiV1Health } from "./getApiV1Health.ts";
6263 export { route as getApiV1Here } from "./getApiV1Here.ts";
src/http/api/forges.ts +21 −15
@@ -1,30 +1,36 @@
11 /**
2 * A forge's children over the API: `/api/v1/forges` — the sizes, the list,
3 * a spawn (planned before made), and a fresh claim link. Moved out of
4 * `api.ts` (#6331); the rules live in `actions/spawn.ts` and `spawn/`.
2 * A forge's children over the API: `/api/v1/forges` — the sizes, the
3 * templates, the list, a spawn (planned before made), and a fresh claim
4 * link. Moved out of `api.ts` (#6331); the rules live in `actions/spawn.ts` and `spawn/`.
55 */
66
77 import { listChildren } from "../../actions/spawn.ts";
88 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
99 import { reissueDoor, spawnDoor } from "../../spawn/door.ts";
1010 import { sizesView } from "../../spawn/sizes.ts";
11 import { crossOrigin, type Handler } from "../context.ts";
11 import { templatesView } from "../../spawn/templates.ts";
12 import { crossOrigin, type Ctx, type Handler } from "../context.ts";
1213 import { holds, weakEtag } from "../etag.ts";
1314 import { apiError, body, forbidden, json, requireScope } from "../respond.ts";
1415 import type { Router } from "../router.ts";
1516
16 export function forgeRoutes(router: Router<Handler>): void {
17 // What small, medium and large mean: public, and cacheable by ETag.
18 router.add("GET", "/api/v1/forges/sizes", (ctx) => {
19 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
20 const text = timePhase(ctx.request, "sizes", () => JSON.stringify(sizesView()));
21 return timePhase(ctx.request, "render", () => {
22 const etag = weakEtag(text);
23 const headers = { etag, "cache-control": "public, max-age=300" };
24 if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers });
25 return new Response(text, { headers: { "content-type": "application/json", ...headers } });
26 });
17 /** A table any page or agent may read: the same JSON for everyone, so an ETag and five minutes' cache. */
18 function publicTable(ctx: Ctx, phase: string, view: () => unknown): Response {
19 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
20 const text = timePhase(ctx.request, phase, () => JSON.stringify(view()));
21 return timePhase(ctx.request, "render", () => {
22 const etag = weakEtag(text);
23 const headers = { etag, "cache-control": "public, max-age=300" };
24 if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers });
25 return new Response(text, { headers: { "content-type": "application/json", ...headers } });
2726 });
27 }
28
29 export function forgeRoutes(router: Router<Handler>): void {
30 // What small, medium and large mean, and which templates a child may
31 // start from: public, and cacheable by ETag.
32 router.add("GET", "/api/v1/forges/sizes", (ctx) => publicTable(ctx, "sizes", sizesView));
33 router.add("GET", "/api/v1/forges/templates", (ctx) => publicTable(ctx, "templates", templatesView));
2834
2935 // A forge's children (docs/SPAWN.md): an admin's, planned before made.
3036 router.add("GET", "/api/v1/forges", async (ctx) => {
src/http/mcp/tools/index.ts +1
@@ -147,6 +147,7 @@ export { tool as teams } from "./teams.ts";
147147 export { tool as telemetry_grant } from "./telemetry_grant.ts";
148148 export { tool as telemetry_history } from "./telemetry_history.ts";
149149 export { tool as tell } from "./tell.ts";
150 export { tool as templates } from "./templates.ts";
150151 export { tool as thought } from "./thought.ts";
151152 export { tool as threads } from "./threads.ts";
152153 export { tool as tokens } from "./tokens.ts";
src/http/mcp/tools/spawn.ts +5 −1
@@ -4,7 +4,7 @@ import { good, bad, type Tool, APP_ARG, REVEAL_ARG, LINK_KEPT, LINK_REVEALED } f
44
55 export const tool: Tool = {
66 name: "spawn",
7 doc: "`{ name, apps?, crew?, dryRun? }`: a child forge, planned first (`dryRun` is true unless `false`); an admin's, and an `ro` token may plan but not make",
7 doc: "`{ name, apps?, crew?, template?, dryRun? }`: a child forge, planned first (`dryRun` is true unless `false`); an admin's, and an `ro` token may plan but not make",
88 description:
99 "Spawn a child forge. Plans first.",
1010 inputSchema: {
@@ -38,6 +38,10 @@ export const tool: Tool = {
3838 enum: ["small", "medium", "large"],
3939 description: "Recorded only.",
4040 },
41 template: {
42 type: "string",
43 description: "An id from `templates`.",
44 },
4145 keepAccess: {
4246 type: "boolean",
4347 description: "Stay an owner.",
src/http/mcp/tools/templates.ts added +11
@@ -0,0 +1,11 @@
1 import { templatesView } from "../../../spawn/templates.ts";
2 import { good, type Tool } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "templates",
6 doc: "the templates a child forge may start from (`blank` first, the default), each with id, title, one sentence and source; pass an id as `spawn`'s `template`",
7 description: "The templates a child forge may start from.",
8 inputSchema: { type: "object", properties: {} },
9 writes: false,
10 run: () => good(templatesView()),
11 };
src/spawn/door.ts +1
@@ -268,6 +268,7 @@ export async function spawnDoor(
268268 fresh: raw["fresh"],
269269 followMode: raw["followMode"],
270270 size: raw["size"],
271 template: raw["template"],
271272 ...(anon ? {} : { for: raw["for"] }),
272273 };
273274
src/spawn/plan.ts +14
@@ -10,6 +10,7 @@
1010 import { err, ok, type Result } from "../kernel/result.ts";
1111 import { DEFAULT_MODE, type FollowMode } from "./chain.ts";
1212 import { parseSize, type Size } from "./sizes.ts";
13 import { DEFAULT_TEMPLATE, parseTemplate } from "./templates.ts";
1314
1415 export const MAX_APPS = 50;
1516 export const MAX_BRIEFS = 100;
@@ -72,6 +73,8 @@ export interface SpawnInput {
7273 readonly followMode?: unknown;
7374 /** How big the child is: `small`, `medium` or `large`; optional. */
7475 readonly size?: unknown;
76 /** Which template the child's apps start from (`spawn/templates.ts`); optional. */
77 readonly template?: unknown;
7578 }
7679
7780 /** What the parent has to offer. Extra fields are ignored, never copied. */
@@ -114,6 +117,8 @@ export interface SpawnPlan {
114117 readonly followMode?: FollowMode;
115118 /** The child's size, present only when chosen. */
116119 readonly size?: Size;
120 /** The template the child starts from, present only when chosen and not `blank`. */
121 readonly template?: string;
117122 }
118123
119124 export interface DryRun {
@@ -263,6 +268,14 @@ export function planSpawn(
263268 size = parsed.value;
264269 }
265270
271 // After size, for the same reason; `blank` is today's child, so it is never named.
272 let template: string | undefined;
273 if (input.template !== undefined && input.template !== null && input.template !== "") {
274 const parsed = parseTemplate(input.template);
275 if (!parsed.ok) return refuse("template", parsed.error);
276 if (parsed.value !== DEFAULT_TEMPLATE) template = parsed.value;
277 }
278
266279 // `forWhom` only when said, so a plan without it hashes as it always did.
267280 return ok({
268281 name,
@@ -277,6 +290,7 @@ export function planSpawn(
277290 ...(fresh === undefined ? {} : { fresh }),
278291 ...(followMode === undefined ? {} : { followMode }),
279292 ...(size === undefined ? {} : { size }),
293 ...(template === undefined ? {} : { template }),
280294 });
281295 }
282296
src/spawn/templates.ts added +49
@@ -0,0 +1,49 @@
1 /**
2 * Templates: the named starting points a new forge's apps may come from, in
3 * one list. Plain data in, plain data out; nothing here writes a file. A
4 * template is built in (its files are this forge's own code) or, one day,
5 * backed by a repository on this forge (`source: "repo"`, `repo`).
6 */
7
8 import { err, ok, type Result } from "../kernel/result.ts";
9
10 export interface Template {
11 readonly id: string;
12 readonly title: string;
13 /** One sentence on what a forge made from it starts with. */
14 readonly says: string;
15 readonly source: "builtin" | "repo";
16 /** The repository a `repo` template is read from, as `owner/name`. */
17 readonly repo?: string;
18 }
19
20 /** Today's behaviour: the forked apps and an optional fresh one, nothing more. */
21 export const DEFAULT_TEMPLATE = "blank";
22
23 // `blank` first: it is the default, and a picker lists it first.
24 export const TEMPLATES: readonly Template[] = [
25 {
26 id: DEFAULT_TEMPLATE,
27 title: "Blank",
28 says: "Starts with only the apps you fork and the fresh one you describe.",
29 source: "builtin",
30 },
31 {
32 id: "starter",
33 title: "Starter app",
34 says: "Starts with the forge's starter app: a server, a page, tests and its forge.json.",
35 source: "builtin",
36 },
37 ];
38
39 /** A template id, or one sentence naming the valid ones. */
40 export function parseTemplate(v: unknown): Result<string, string> {
41 if (typeof v === "string" && TEMPLATES.some((t) => t.id === v)) return ok(v);
42 const ids = TEMPLATES.map((t) => t.id);
43 return err(`template must be ${ids.slice(0, -1).join(", ")} or ${ids[ids.length - 1]}`);
44 }
45
46 /** What each template is, for a page or an agent to offer. */
47 export function templatesView(): { templates: readonly Template[]; default: string } {
48 return { templates: TEMPLATES, default: DEFAULT_TEMPLATE };
49 }
test/mcp.test.ts +1
@@ -191,6 +191,7 @@ describe("the forge over MCP", () => {
191191 "repo",
192192 "here",
193193 "tell",
194 "templates",
194195 "share",
195196 "say",
196197 "issue",
test/spawn-template-api.test.ts added +151
@@ -0,0 +1,151 @@
1 /**
2 * Choosing a template for a new forge (#7533): `template` passes through the
3 * spawn door into the plan only when it is not `blank`, an unknown one is a
4 * 422 on `template`, the choice is kept in the ledger, `parent.json` and the
5 * list, and `GET /api/v1/forges/templates` and MCP `templates` agree.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { childDirOf } from "../src/actions/spawn.ts";
13 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
14 import { usePlacement, useProbe } from "../src/spawn/door.ts";
15 import { planSpawn } from "../src/spawn/plan.ts";
16 import { templatesView } from "../src/spawn/templates.ts";
17 import { createToken } from "../src/core/tokens.ts";
18 import { createUser } from "../src/core/users.ts";
19 import { serve, type Servers } from "../src/http/server.ts";
20 import { ok } from "../src/kernel/result.ts";
21 import { quietLogs } from "./helpers/globals.ts";
22
23 quietLogs();
24
25 describe("choosing a template for a new forge", () => {
26 let dataDir: string;
27 let app: App;
28 let servers: Servers;
29 let base: string;
30 let rw: string;
31 let placed = 0;
32
33 const post = (payload: unknown) =>
34 fetch(`${base}/api/v1/forges`, {
35 method: "POST",
36 headers: { "content-type": "application/json", authorization: `Bearer ${rw}` },
37 body: JSON.stringify(payload),
38 });
39 const mcp = async (name: string, args: unknown) => {
40 const res = await fetch(`${base}/mcp`, {
41 method: "POST",
42 headers: { "content-type": "application/json", authorization: `Bearer ${rw}` },
43 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
44 });
45 const r = ((await res.json()) as any).result;
46 return { error: r.isError === true, text: r.content[0].text as string };
47 };
48
49 beforeAll(async () => {
50 dataDir = await mkdtemp(join(tmpdir(), "forge-spawn-template-"));
51 app = createApp({ dataDir, port: 0 });
52 servers = await serve(app);
53 base = servers.url;
54 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
55 if (!a.ok) throw new Error("user");
56 rw = createToken(app.db, a.value.id, "rw", "rw").token;
57 const repo = await createRepo(app, a.value, { name: "notes" });
58 if (!repo.ok) throw new Error("repo");
59 await commitToBranch(app, a.value, repo.value, { message: "seed", files: [{ path: "a.txt", content: "a" }] });
60 useProbe(app, () => async () => true);
61 usePlacement(app, {
62 place: async (plan) => ok({ url: `https://${plan.name}.fake.test`, bodyId: `fake-${++placed}` }),
63 retire: async () => ok(),
64 });
65 }, 30_000);
66
67 afterAll(async () => {
68 servers.stop();
69 app.close();
70 await rm(dataDir, { recursive: true, force: true });
71 });
72
73 test("plan carries the template only when it is not blank", async () => {
74 const starter = await post({ name: "kid", apps: ["alice/notes"], template: "starter", dryRun: true });
75 expect(starter.status).toBe(200);
76 expect(((await starter.json()) as any).plan.template).toBe("starter");
77
78 const none = ((await (await post({ name: "kid", apps: ["alice/notes"] })).json()) as any).plan;
79 const blank = ((await (await post({ name: "kid", apps: ["alice/notes"], template: "blank" })).json()) as any).plan;
80 expect("template" in none).toBe(false);
81 expect(blank).toEqual(none);
82
83 // The plan a build before templates gave, field for field, so old plan hashes hold.
84 const source = { catalog: [], briefs: [], subscriptions: [] };
85 const base = { name: "kid", org: "kid", apps: [], carryBriefs: false, carrySubscriptions: false };
86 const before = { name: "kid", org: "kid", apps: [], briefs: [], subscriptions: [], direction: "", keepAccess: false, budgetUsd: null };
87 expect(planSpawn(base, source)).toEqual(ok(before));
88 expect(planSpawn({ ...base, template: "blank" }, source)).toEqual(ok(before));
89 expect(JSON.stringify((planSpawn({ ...base, template: "blank" }, source) as any).value)).toBe(JSON.stringify(before));
90 });
91
92 test("an unknown template is a 422 on template", async () => {
93 for (const template of ["nope", 7, ["starter"]]) {
94 const res = await post({ name: "kid", apps: ["alice/notes"], template });
95 expect(res.status).toBe(422);
96 const body = (await res.json()) as any;
97 expect(body.error).toBe("invalid");
98 expect(body.message).toBe("template: template must be blank or starter");
99 }
100 const viaMcp = await mcp("spawn", { name: "kid", template: "nope" });
101 expect(viaMcp.error).toBe(true);
102 expect(viaMcp.text).toContain("template: template must be blank or starter");
103 });
104
105 test("the templates list is public and ETagged", async () => {
106 const res = await fetch(`${base}/api/v1/forges/templates`);
107 expect(res.status).toBe(200);
108 const got = (await res.json()) as ReturnType<typeof templatesView>;
109 expect(got).toEqual(JSON.parse(JSON.stringify(templatesView())));
110 expect(got.default).toBe("blank");
111 expect(got.templates[0]!.id).toBe("blank");
112 expect(got.templates.map((t) => t.id)).toContain("starter");
113 for (const t of got.templates) {
114 expect(["builtin", "repo"]).toContain(t.source);
115 expect(t.says.length).toBeGreaterThan(10);
116 }
117 const etag = res.headers.get("etag")!;
118 expect(etag).toBeTruthy();
119 const again = await fetch(`${base}/api/v1/forges/templates`, { headers: { "if-none-match": etag } });
120 expect(again.status).toBe(304);
121 });
122
123 test("mcp templates equals the api list", async () => {
124 const api = await (await fetch(`${base}/api/v1/forges/templates`)).json();
125 const viaMcp = await mcp("templates", {});
126 expect(viaMcp.error).toBe(false);
127 expect(JSON.parse(viaMcp.text)).toEqual(api);
128 });
129
130 test("ledger and parent.json record the template", async () => {
131 const made = await post({ name: "tpl", apps: ["alice/notes"], template: "starter", dryRun: false });
132 expect(made.status).toBe(201);
133 const body = (await made.json()) as any;
134
135 const ledger = await Bun.file(join(dataDir, "spawn", "tpl.json")).json();
136 expect(ledger.template).toBe("starter");
137 const parent = await Bun.file(join(childDirOf(app, body.bodyId), "parent.json")).json();
138 expect(parent.template).toBe("starter");
139
140 const list = (await (await fetch(`${base}/api/v1/forges`, { headers: { authorization: `Bearer ${rw}` } })).json()) as any;
141 expect(list.items.find((c: any) => c.name === "tpl").template).toBe("starter");
142
143 // Another template is another child: refused, as a different plan is.
144 const retry = await post({ name: "tpl", apps: ["alice/notes"], template: "blank", dryRun: false });
145 expect(retry.status).toBe(409);
146 expect(((await retry.json()) as any).error).toBe("exists");
147 const same = await post({ name: "tpl", apps: ["alice/notes"], template: "starter", dryRun: false });
148 expect(same.status).toBe(200);
149 expect(((await same.json()) as any).existed).toBe(true);
150 }, 60_000);
151 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs