Public: anyone can read this; the crew can change it.
The New forge form offers the size, records it only when it is not the default, and shows an unknown size beside the field #5683
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5680-the-new-forge-form-offers-the-size-recor → main 3 commits fork 1 thread · 1 unresolved
7 files
changed +176
−8
README.md +2 −2
| @@ -456,7 +456,7 @@ own for it: the same asks are the dashboard's On you card. | ||
| 456 | 456 | A client's forge: `Ctrl+K`, then **New forge** (any signed-in person's, as the |
| 457 | 457 | page is), is `/forges/new` in the terminal — name, for whom, what it is for, |
| 458 | 458 | apps to fork (`owner/name`, comma-separated), a fresh one-line app, model key |
| 459 | (hidden as typed), API base URL and budget, through the same `spawnDoor` as the | |
| 459 | (hidden as typed), API base URL, budget, how it follows the code and a size (`small` by default, not sent; `medium` or `large` is), through the same `spawnDoor` as the | |
| 460 | 460 | page, `POST /api/v1/forges` and the MCP `spawn`. `Ctrl+P` shows the plan and |
| 461 | 461 | makes nothing; `enter` on the plan spawns, and the claim link is shown once, in |
| 462 | 462 | a dialog, never kept. |
| @@ -603,7 +603,7 @@ forge can be a parent. The terminal shows the count, not the release names. | ||
| 603 | 603 | |
| 604 | 604 | A spawned forge's own code is a fork of its parent's (`<org>/forge`, chosen |
| 605 | 605 | `followMode` `tell-me` or `take-updates`; in `take-updates` the hourly check puts what the parent gained in a pull. |
| 606 | 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`. 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. | |
| 606 | 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. | |
| 607 | 607 | Once an hour, and |
| 608 | 608 | on demand, a forge that follows and has not severed fetches its parent's code into |
| 609 | 609 | `refs/upstream/*` of its own `<org>/forge` (no branch of the fork moves) and keeps |
src/tui/screens/newforge.ts +4
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { childUrlOf, spawnDoor } from "../../spawn/door.ts"; |
| 11 | import { DEFAULT_SIZE } from "../../spawn/sizes.ts"; | |
| 11 | 12 | import { MAX_BUDGET_USD, MIN_BUDGET_USD } from "../../spawn/plan.ts"; |
| 12 | 13 | import type { Ctx, Screen } from "../app/app.ts"; |
| 13 | 14 | import { binding } from "../app/keys.ts"; |
| @@ -34,6 +35,7 @@ const SPECS: readonly Spec[] = [ | ||
| 34 | 35 | { key: "modelEndpoint", label: "API base URL", hint: "A gateway for the key; empty for Anthropic. Optional." }, |
| 35 | 36 | { key: "budgetUsd", label: "Budget in dollars", hint: `${MIN_BUDGET_USD}–${MAX_BUDGET_USD}, for the key's whole life. Needed with a key.` }, |
| 36 | 37 | { key: "followMode", label: "Follows this forge's code", hint: "tell-me (default) or take-updates. Recorded for now: nothing tells you or takes updates yet." }, |
| 38 | { key: "size", label: "Size", hint: "small (default, not recorded), medium or large. Optional." }, | |
| 37 | 39 | ]; |
| 38 | 40 | |
| 39 | 41 | /** The door's "field: reason" refusal, as the field it names and the reason. */ |
| @@ -65,6 +67,8 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen { | ||
| 65 | 67 | ...(val("modelKey") === "" ? {} : { modelKey: val("modelKey") }), |
| 66 | 68 | ...(val("modelEndpoint") === "" ? {} : { modelEndpoint: val("modelEndpoint") }), |
| 67 | 69 | ...(val("budgetUsd") === "" ? {} : { budgetUsd: Number(val("budgetUsd")) }), |
| 70 | // Small is the default: it is not recorded, as on the page. | |
| 71 | ...(val("size") === "" || val("size") === DEFAULT_SIZE ? {} : { size: val("size") }), | |
| 68 | 72 | dryRun, |
| 69 | 73 | }; |
| 70 | 74 | }; |
src/web/pages/new-forge.ts +27 −1
| @@ -22,11 +22,12 @@ import { | ||
| 22 | 22 | MAX_BUDGET_USD, |
| 23 | 23 | MIN_BUDGET_USD, |
| 24 | 24 | } from "../../spawn/plan.ts"; |
| 25 | import { DEFAULT_SIZE, SIZES, sizesView } from "../../spawn/sizes.ts"; | |
| 25 | 26 | import { notFoundPage, pageHead } from "../chrome.ts"; |
| 26 | 27 | import { field, readForm, type Form } from "../form.ts"; |
| 27 | 28 | import { html, type Html } from "../html.ts"; |
| 28 | 29 | import { page } from "../layout.ts"; |
| 29 | import { btn, card, field as formField, row } from "../ui.ts"; | |
| 30 | import { btn, card, field as formField, row, selectField } from "../ui.ts"; | |
| 30 | 31 | |
| 31 | 32 | interface Values { |
| 32 | 33 | readonly name: string; |
| @@ -39,6 +40,7 @@ interface Values { | ||
| 39 | 40 | readonly keepAccess: boolean; |
| 40 | 41 | readonly budgetUsd: string; |
| 41 | 42 | readonly modelEndpoint: string; |
| 43 | readonly size: string; | |
| 42 | 44 | /** The last post carried a key: it is never sent back, so it is asked for again. */ |
| 43 | 45 | readonly hadKey: boolean; |
| 44 | 46 | } |
| @@ -54,6 +56,7 @@ const EMPTY: Values = { | ||
| 54 | 56 | keepAccess: false, |
| 55 | 57 | budgetUsd: "", |
| 56 | 58 | modelEndpoint: "", |
| 59 | size: DEFAULT_SIZE, | |
| 57 | 60 | hadKey: false, |
| 58 | 61 | }; |
| 59 | 62 | |
| @@ -117,6 +120,19 @@ function readsFor(ctx: Ctx, user: User | undefined): Reads { | ||
| 117 | 120 | return { full, repos, crew }; |
| 118 | 121 | } |
| 119 | 122 | |
| 123 | /** The three sizes, the one asked for chosen (small when the value is not one of them). */ | |
| 124 | const lower = (t: string): string => t.charAt(0).toLowerCase() + t.slice(1); | |
| 125 | ||
| 126 | function sizeOptions(chosen: string): { value: string; label: string; selected?: boolean }[] { | |
| 127 | const says = new Map(sizesView().sizes.map((s) => [s.size, s.says])); | |
| 128 | const at = Object.hasOwn(SIZES, chosen) ? chosen : DEFAULT_SIZE; | |
| 129 | return Object.keys(SIZES).map((size) => ({ | |
| 130 | value: size, | |
| 131 | label: `${size}: ${lower(says.get(size as keyof typeof SIZES) ?? "")}`, | |
| 132 | selected: size === at, | |
| 133 | })); | |
| 134 | } | |
| 135 | ||
| 120 | 136 | function formView( |
| 121 | 137 | ctx: Ctx, |
| 122 | 138 | user: User | undefined, |
| @@ -290,6 +306,13 @@ function formView( | ||
| 290 | 306 | hint: "For the key's whole life; the child cannot raise it. Needed with a key.", |
| 291 | 307 | ...(errors["budgetUsd"] ? { error: errors["budgetUsd"] } : {}), |
| 292 | 308 | })} |
| 309 | ${selectField({ | |
| 310 | label: "Size (optional)", | |
| 311 | name: "size", | |
| 312 | hint: "Room for the child to grow into. Small is the default and is not recorded.", | |
| 313 | options: sizeOptions(values.size), | |
| 314 | ...(errors["size"] ? { error: errors["size"] } : {}), | |
| 315 | })} | |
| 293 | 316 | <p class="muted small"> |
| 294 | 317 | Never copied: ${LEFT_BEHIND.join(", ")}. The child mints its own. |
| 295 | 318 | </p> |
| @@ -421,6 +444,7 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> { | ||
| 421 | 444 | keepAccess: asBool(form, "keepAccess"), |
| 422 | 445 | budgetUsd: field(form, "budgetUsd").trim(), |
| 423 | 446 | modelEndpoint: field(form, "modelEndpoint").trim(), |
| 447 | size: field(form, "size").trim() || DEFAULT_SIZE, | |
| 424 | 448 | hadKey: field(form, "modelKey").trim() !== "", |
| 425 | 449 | }; |
| 426 | 450 | const key = field(form, "modelKey").trim(); |
| @@ -437,6 +461,8 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> { | ||
| 437 | 461 | ...(key === "" ? {} : { modelKey: key }), |
| 438 | 462 | ...(values.modelEndpoint === "" ? {} : { modelEndpoint: values.modelEndpoint }), |
| 439 | 463 | ...(values.budgetUsd === "" ? {} : { budgetUsd: Number(values.budgetUsd) }), |
| 464 | // Small is the default: it is not recorded, so the plan stays as it was. | |
| 465 | ...(values.size === DEFAULT_SIZE ? {} : { size: values.size }), | |
| 440 | 466 | dryRun: planOnly, |
| 441 | 467 | }); |
| 442 | 468 | if (door.status >= 400) |
src/web/ui.ts +13 −4
| @@ -1198,16 +1198,22 @@ ${o.value ?? ""}</textarea | ||
| 1198 | 1198 | </div>`; |
| 1199 | 1199 | } |
| 1200 | 1200 | |
| 1201 | /** A `<select>` with the same anatomy as `field`. */ | |
| 1201 | /** A `<select>` with the same anatomy as `field`: hint and error are linked and marked. */ | |
| 1202 | 1202 | export function selectField( |
| 1203 | 1203 | o: Omit<FieldOptions, "type"> & { |
| 1204 | 1204 | options: { value: string; label: string; selected?: boolean }[]; |
| 1205 | 1205 | }, |
| 1206 | 1206 | ): Html { |
| 1207 | 1207 | const id = o.id ?? `f-${o.name}`; |
| 1208 | return html`<div class="field"> | |
| 1208 | const described = [o.hint ? `${id}-hint` : "", o.error ? `${id}-err` : ""] | |
| 1209 | .filter(Boolean) | |
| 1210 | .join(" "); | |
| 1211 | return html`<div class="field" ${o.error ? raw("data-invalid") : null}> | |
| 1209 | 1212 | <label for="${id}">${o.label}</label> |
| 1210 | <select class="input" id="${id}" name="${o.name}"> | |
| 1213 | <select class="input" id="${id}" name="${o.name}" | |
| 1214 | ${described !== "" ? html`aria-describedby="${described}"` : null} | |
| 1215 | ${o.error ? raw('aria-invalid="true"') : null} | |
| 1216 | > | |
| 1211 | 1217 | ${o.options.map( |
| 1212 | 1218 | (opt) => |
| 1213 | 1219 | html`<option |
| @@ -1218,7 +1224,10 @@ export function selectField( | ||
| 1218 | 1224 | </option>`, |
| 1219 | 1225 | )} |
| 1220 | 1226 | </select> |
| 1221 | ${o.hint ? html`<span class="hint">${o.hint}</span>` : null} | |
| 1227 | ${o.hint ? html`<span class="hint" id="${id}-hint">${o.hint}</span>` : null} | |
| 1228 | ${o.error | |
| 1229 | ? html`<span class="error" id="${id}-err">${o.error}</span>` | |
| 1230 | : null} | |
| 1222 | 1231 | </div>`; |
| 1223 | 1232 | } |
| 1224 | 1233 |
test/boards-page.test.ts +1 −1
| @@ -75,7 +75,7 @@ describe("/boards forms", () => { | ||
| 75 | 75 | for (const name of ["chart", "source", "aggregate", "column", "by", "series", "from", "to", "fcolumn", "fop", "fvalue"]) { |
| 76 | 76 | expect(body).toMatch(new RegExp(`<div class="field"[^>]*>\\s*<label for="f-${name}-\\d+">[^]*?name="${name}"`)); |
| 77 | 77 | } |
| 78 | expect(body).toMatch(/<span class="hint">Column to aggregate<\/span>/); | |
| 78 | expect(body).toMatch(/<span class="hint" id="[^"]*-hint">Column to aggregate<\/span>/); | |
| 79 | 79 | expect(body).toContain('name="slug"'); |
| 80 | 80 | expect(body).toContain('<label for="f-pin-title">Panel title</label>'); |
| 81 | 81 | }); |
test/new-forge-size.test.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | /** | |
| 2 | * The New forge form offers a size (#5680): small is chosen and never | |
| 3 | * recorded, medium or large reaches the plan and the child's parent.json, | |
| 4 | * and an unknown size is a 422 beside the field with the other values kept. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test"; | |
| 8 | import { mkdtemp, readdir, 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 { createUser } from "../src/core/users.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | import * as door from "../src/spawn/door.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | const PASSWORD = "a-long-new-forge-passphrase"; | |
| 20 | ||
| 21 | describe("New forge size", () => { | |
| 22 | let dir = ""; | |
| 23 | let app: App; | |
| 24 | let servers: Servers; | |
| 25 | let cookie = ""; | |
| 26 | ||
| 27 | const post = (form: [string, string][]) => | |
| 28 | fetch(`${servers.url}/forges/new`, { | |
| 29 | method: "POST", | |
| 30 | headers: { cookie, origin: "https://forge.test" }, | |
| 31 | body: new URLSearchParams(form), | |
| 32 | redirect: "manual", | |
| 33 | }); | |
| 34 | ||
| 35 | beforeAll(async () => { | |
| 36 | dir = await mkdtemp(join(tmpdir(), "forge-new-forge-size-")); | |
| 37 | app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 }); | |
| 38 | const a = await createUser(app.db, { | |
| 39 | origin: "seed", | |
| 40 | trusted: true, | |
| 41 | username: "alice", | |
| 42 | email: "[email protected]", | |
| 43 | password: PASSWORD, | |
| 44 | isAdmin: true, | |
| 45 | }); | |
| 46 | if (!a.ok) throw new Error("user"); | |
| 47 | servers = await serve(app); | |
| 48 | const res = await fetch(`${servers.url}/signin`, { | |
| 49 | method: "POST", | |
| 50 | body: new URLSearchParams({ username: "alice", password: PASSWORD }), | |
| 51 | redirect: "manual", | |
| 52 | }); | |
| 53 | cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 54 | }, 30_000); | |
| 55 | ||
| 56 | afterAll(async () => { | |
| 57 | servers.stop(); | |
| 58 | app.close(); | |
| 59 | await rm(dir, { recursive: true, force: true }); | |
| 60 | }); | |
| 61 | ||
| 62 | test("the select is there with small chosen", async () => { | |
| 63 | const res = await fetch(`${servers.url}/forges/new`, { headers: { cookie } }); | |
| 64 | const html = (await res.text()).replace(/\s+/g, " "); | |
| 65 | expect(html).toMatch(/<select class="input" id="f-size" name="size"\s+aria-describedby="f-size-hint"/); | |
| 66 | expect(html).toMatch(/<option value="small" selected >/); | |
| 67 | expect(html).toMatch(/<option value="medium" >/); | |
| 68 | expect(html).toMatch(/<option value="large" >/); | |
| 69 | }); | |
| 70 | ||
| 71 | test("small sends no size; medium sends it and reaches parent.json", async () => { | |
| 72 | const spy = spyOn(door, "spawnDoor"); | |
| 73 | try { | |
| 74 | await post([["name", "tiny"], ["size", "small"], ["do", "plan"]]); | |
| 75 | const small = spy.mock.calls.at(-1)![2]; | |
| 76 | expect("size" in small).toBe(false); | |
| 77 | ||
| 78 | const res = await post([["name", "roomy"], ["size", "medium"], ["do", "spawn"]]); | |
| 79 | expect(res.status).toBe(201); | |
| 80 | expect(spy.mock.calls.at(-1)![2]["size"]).toBe("medium"); | |
| 81 | const docs = await Promise.all( | |
| 82 | (await readdir(join(dir, "children"))).map((d) => | |
| 83 | Bun.file(join(dir, "children", d, "parent.json")).json().catch(() => undefined), | |
| 84 | ), | |
| 85 | ); | |
| 86 | expect(docs.find((d) => d?.name === "roomy")?.size).toBe("medium"); | |
| 87 | } finally { | |
| 88 | spy.mockRestore(); | |
| 89 | } | |
| 90 | }); | |
| 91 | ||
| 92 | test("an unknown size is a 422 with the message and the other values kept", async () => { | |
| 93 | const res = await post([ | |
| 94 | ["name", "odd"], | |
| 95 | ["direction", "Keep this text."], | |
| 96 | ["size", "huge"], | |
| 97 | ["do", "plan"], | |
| 98 | ]); | |
| 99 | expect(res.status).toBe(422); | |
| 100 | const html = (await res.text()).replace(/\s+/g, " "); | |
| 101 | expect(html).toContain('<span class="error" id="f-size-err">size must be small, medium or large</span>'); | |
| 102 | expect(html).toMatch(/<div class="field" data-invalid>\s*<label for="f-size">/); | |
| 103 | expect(html).toMatch(/id="f-size"\s+name="size"\s+aria-describedby="f-size-hint f-size-err"\s+aria-invalid="true"/); | |
| 104 | expect(html).toContain('value="odd"'); | |
| 105 | expect(html).toContain("Keep this text."); | |
| 106 | expect(html).toMatch(/<option value="small" selected >/); | |
| 107 | }); | |
| 108 | }); |
test/tui-newforge.test.ts +21
| @@ -191,4 +191,25 @@ describe("the new forge screen", () => { | ||
| 191 | 191 | delete (app.config as { forgeRepo?: string }).forgeRepo; |
| 192 | 192 | } |
| 193 | 193 | })); |
| 194 | ||
| 195 | test("the size is a field: medium reaches parent.json, small is not sent", () => | |
| 196 | withScreen(admin, async ({ tui, terminal }) => { | |
| 197 | terminal.feed("tuisize" + "\t".repeat(9) + "medium\x10"); | |
| 198 | await settleApp(tui); | |
| 199 | terminal.feed("\r"); | |
| 200 | await settleApp(tui); | |
| 201 | const docs = await Promise.all( | |
| 202 | (await readdir(join(dir, "children"))).map((d) => | |
| 203 | Bun.file(join(dir, "children", d, "parent.json")).json().catch(() => undefined), | |
| 204 | ), | |
| 205 | ); | |
| 206 | expect(docs.find((d) => d?.name === "tuisize")?.size).toBe("medium"); | |
| 207 | })); | |
| 208 | ||
| 209 | test("an unknown size is refused beside the field", () => | |
| 210 | withScreen(admin, async ({ tui, terminal }) => { | |
| 211 | terminal.feed("tuibig" + "\t".repeat(9) + "huge\x10"); | |
| 212 | await settleApp(tui); | |
| 213 | expect(tui.screen.toText()).toContain("size must be small, medium or large"); | |
| 214 | })); | |
| 194 | 215 | }); |
new above
offline
Hand-placed error span outside the field. Give selectField an
erroroption so the message is inside.fieldand linked with aria-describedby/aria-invalid, as formField does.