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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7533-choosing-a-template-for-a-new-forge-the → main 2 commits fork
14 files
changed +276
−18
docs/MCP.md +2 −1
| @@ -159,7 +159,7 @@ for a tunnel and a forge that sleeps. | ||
| 159 | 159 | | `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 | |
| 160 | 160 | | `sizes` | what a child forge's sizes mean: small, medium and large, each with CPU, memory and disk | | |
| 161 | 161 | | `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 | | |
| 163 | 163 | | `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 | |
| 164 | 164 | | `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 | |
| 165 | 165 | | `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. | ||
| 178 | 178 | | `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 | |
| 179 | 179 | | `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 | | |
| 180 | 180 | | `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` | | | |
| 181 | 182 | | `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` | | |
| 182 | 183 | | `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 | |
| 183 | 184 | | `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 | ||
| 72 | 72 | |
| 73 | 73 | ## Callers |
| 74 | 74 | |
| 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. | |
| 76 | 76 | |
| 77 | 77 | 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. |
| 78 | 78 | |
| 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 | ||
| 79 | 87 | ## The forge's own code, and the chain (#1085) |
| 80 | 88 | |
| 81 | 89 | 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 { | ||
| 114 | 114 | readonly createdAt?: string; |
| 115 | 115 | /** Absent on a ledger written before sizes; reads as small. */ |
| 116 | 116 | readonly size?: Size; |
| 117 | /** The template chosen (`spawn/templates.ts`); absent means `blank`. */ | |
| 118 | readonly template?: string; | |
| 117 | 119 | /** Absent on a ledger written before cells; reads as hosted. */ |
| 118 | 120 | readonly placement?: PlacementKind; |
| 119 | 121 | /** Why a child that wanted a cell is hosted instead (`spawn/host.ts`). */ |
| @@ -267,6 +269,7 @@ async function make( | ||
| 267 | 269 | apps: plan.apps.map((a) => a.ref), |
| 268 | 270 | createdAt: new Date().toISOString(), |
| 269 | 271 | size: plan.size ?? DEFAULT_SIZE, |
| 272 | ...(plan.template === undefined ? {} : { template: plan.template }), | |
| 270 | 273 | placement: placed.value.kind ?? "hosted", |
| 271 | 274 | statusToken: secret(), |
| 272 | 275 | beatKey: secret(), |
| @@ -464,6 +467,7 @@ async function copyInto( | ||
| 464 | 467 | url: liveChildUrl(app, plan.name), |
| 465 | 468 | keepAccess: plan.keepAccess, |
| 466 | 469 | size: plan.size ?? DEFAULT_SIZE, |
| 470 | ...(plan.template === undefined ? {} : { template: plan.template }), | |
| 467 | 471 | // Each app is a fork: where it came from, so the child can follow it. |
| 468 | 472 | apps: plan.apps.map((a) => ({ repo: `${plan.org}/${a.ref.split("/")[1]}`, upstream: a.ref })), |
| 469 | 473 | ...(plan.fresh === undefined ? {} : { fresh: `${plan.org}/${plan.fresh.name}` }), |
| @@ -673,6 +677,8 @@ export interface Child { | ||
| 673 | 677 | /** When it was begun; null on a ledger older than the client list. */ |
| 674 | 678 | readonly createdAt: string | null; |
| 675 | 679 | readonly size: Size; |
| 680 | /** The template it started from; absent when `blank`. */ | |
| 681 | readonly template?: string; | |
| 676 | 682 | readonly placement: PlacementKind; |
| 677 | 683 | /** Why it is hosted when it asked for a cell; null otherwise. */ |
| 678 | 684 | readonly placementNote: string | null; |
| @@ -695,6 +701,7 @@ export async function listChildren(app: App): Promise<Child[]> { | ||
| 695 | 701 | apps: ledger.apps ?? [], |
| 696 | 702 | createdAt: ledger.createdAt ?? null, |
| 697 | 703 | size: isSize(ledger.size) ? ledger.size : DEFAULT_SIZE, |
| 704 | ...(typeof ledger.template === "string" ? { template: ledger.template } : {}), | |
| 698 | 705 | placement: ledger.placement ?? "hosted", |
| 699 | 706 | placementNote: ledger.placementNote ?? null, |
| 700 | 707 | }); |
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"; | ||
| 57 | 57 | export { route as getApiV1Forge } from "./getApiV1Forge.ts"; |
| 58 | 58 | export { route as getApiV1Forges } from "./getApiV1Forges.ts"; |
| 59 | 59 | export { route as getApiV1ForgesSizes } from "./getApiV1ForgesSizes.ts"; |
| 60 | export { route as getApiV1ForgesTemplates } from "./getApiV1ForgesTemplates.ts"; | |
| 60 | 61 | export { route as getApiV1Front } from "./getApiV1Front.ts"; |
| 61 | 62 | export { route as getApiV1Health } from "./getApiV1Health.ts"; |
| 62 | 63 | export { route as getApiV1Here } from "./getApiV1Here.ts"; |
src/http/api/forges.ts +21 −15
| @@ -1,30 +1,36 @@ | ||
| 1 | 1 | /** |
| 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/`. | |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { listChildren } from "../../actions/spawn.ts"; |
| 8 | 8 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 9 | 9 | import { reissueDoor, spawnDoor } from "../../spawn/door.ts"; |
| 10 | 10 | 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"; | |
| 12 | 13 | import { holds, weakEtag } from "../etag.ts"; |
| 13 | 14 | import { apiError, body, forbidden, json, requireScope } from "../respond.ts"; |
| 14 | 15 | import type { Router } from "../router.ts"; |
| 15 | 16 | |
| 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 } }); | |
| 27 | 26 | }); |
| 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)); | |
| 28 | 34 | |
| 29 | 35 | // A forge's children (docs/SPAWN.md): an admin's, planned before made. |
| 30 | 36 | 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"; | ||
| 147 | 147 | export { tool as telemetry_grant } from "./telemetry_grant.ts"; |
| 148 | 148 | export { tool as telemetry_history } from "./telemetry_history.ts"; |
| 149 | 149 | export { tool as tell } from "./tell.ts"; |
| 150 | export { tool as templates } from "./templates.ts"; | |
| 150 | 151 | export { tool as thought } from "./thought.ts"; |
| 151 | 152 | export { tool as threads } from "./threads.ts"; |
| 152 | 153 | 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 | ||
| 4 | 4 | |
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | 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", | |
| 8 | 8 | description: |
| 9 | 9 | "Spawn a child forge. Plans first.", |
| 10 | 10 | inputSchema: { |
| @@ -38,6 +38,10 @@ export const tool: Tool = { | ||
| 38 | 38 | enum: ["small", "medium", "large"], |
| 39 | 39 | description: "Recorded only.", |
| 40 | 40 | }, |
| 41 | template: { | |
| 42 | type: "string", | |
| 43 | description: "An id from `templates`.", | |
| 44 | }, | |
| 41 | 45 | keepAccess: { |
| 42 | 46 | type: "boolean", |
| 43 | 47 | 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( | ||
| 268 | 268 | fresh: raw["fresh"], |
| 269 | 269 | followMode: raw["followMode"], |
| 270 | 270 | size: raw["size"], |
| 271 | template: raw["template"], | |
| 271 | 272 | ...(anon ? {} : { for: raw["for"] }), |
| 272 | 273 | }; |
| 273 | 274 |
src/spawn/plan.ts +14
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 11 | 11 | import { DEFAULT_MODE, type FollowMode } from "./chain.ts"; |
| 12 | 12 | import { parseSize, type Size } from "./sizes.ts"; |
| 13 | import { DEFAULT_TEMPLATE, parseTemplate } from "./templates.ts"; | |
| 13 | 14 | |
| 14 | 15 | export const MAX_APPS = 50; |
| 15 | 16 | export const MAX_BRIEFS = 100; |
| @@ -72,6 +73,8 @@ export interface SpawnInput { | ||
| 72 | 73 | readonly followMode?: unknown; |
| 73 | 74 | /** How big the child is: `small`, `medium` or `large`; optional. */ |
| 74 | 75 | readonly size?: unknown; |
| 76 | /** Which template the child's apps start from (`spawn/templates.ts`); optional. */ | |
| 77 | readonly template?: unknown; | |
| 75 | 78 | } |
| 76 | 79 | |
| 77 | 80 | /** What the parent has to offer. Extra fields are ignored, never copied. */ |
| @@ -114,6 +117,8 @@ export interface SpawnPlan { | ||
| 114 | 117 | readonly followMode?: FollowMode; |
| 115 | 118 | /** The child's size, present only when chosen. */ |
| 116 | 119 | readonly size?: Size; |
| 120 | /** The template the child starts from, present only when chosen and not `blank`. */ | |
| 121 | readonly template?: string; | |
| 117 | 122 | } |
| 118 | 123 | |
| 119 | 124 | export interface DryRun { |
| @@ -263,6 +268,14 @@ export function planSpawn( | ||
| 263 | 268 | size = parsed.value; |
| 264 | 269 | } |
| 265 | 270 | |
| 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 | ||
| 266 | 279 | // `forWhom` only when said, so a plan without it hashes as it always did. |
| 267 | 280 | return ok({ |
| 268 | 281 | name, |
| @@ -277,6 +290,7 @@ export function planSpawn( | ||
| 277 | 290 | ...(fresh === undefined ? {} : { fresh }), |
| 278 | 291 | ...(followMode === undefined ? {} : { followMode }), |
| 279 | 292 | ...(size === undefined ? {} : { size }), |
| 293 | ...(template === undefined ? {} : { template }), | |
| 280 | 294 | }); |
| 281 | 295 | } |
| 282 | 296 |
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", () => { | ||
| 191 | 191 | "repo", |
| 192 | 192 | "here", |
| 193 | 193 | "tell", |
| 194 | "templates", | |
| 194 | 195 | "share", |
| 195 | 196 | "say", |
| 196 | 197 | "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 | }); |
new above
offline