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

Merged opened this

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

Merged

scout/forge:scout/5680-the-new-forge-form-offers-the-size-recor → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/5680-the-new-forge-form-offers-the-size-recor was merged into main as ef97452.

Review

Sign in to review this change.
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.
456456 A client's forge: `Ctrl+K`, then **New forge** (any signed-in person's, as the
457457 page is), is `/forges/new` in the terminal — name, for whom, what it is for,
458458 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
460460 page, `POST /api/v1/forges` and the MCP `spawn`. `Ctrl+P` shows the plan and
461461 makes nothing; `enter` on the plan spawns, and the claim link is shown once, in
462462 a dialog, never kept.
@@ -603,7 +603,7 @@ forge can be a parent. The terminal shows the count, not the release names.
603603
604604 A spawned forge's own code is a fork of its parent's (`<org>/forge`, chosen
605605 `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.
607607 Once an hour, and
608608 on demand, a forge that follows and has not severed fetches its parent's code into
609609 `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 @@
88 */
99
1010 import { childUrlOf, spawnDoor } from "../../spawn/door.ts";
11 import { DEFAULT_SIZE } from "../../spawn/sizes.ts";
1112 import { MAX_BUDGET_USD, MIN_BUDGET_USD } from "../../spawn/plan.ts";
1213 import type { Ctx, Screen } from "../app/app.ts";
1314 import { binding } from "../app/keys.ts";
@@ -34,6 +35,7 @@ const SPECS: readonly Spec[] = [
3435 { key: "modelEndpoint", label: "API base URL", hint: "A gateway for the key; empty for Anthropic. Optional." },
3536 { key: "budgetUsd", label: "Budget in dollars", hint: `${MIN_BUDGET_USD}–${MAX_BUDGET_USD}, for the key's whole life. Needed with a key.` },
3637 { 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." },
3739 ];
3840
3941 /** 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 {
6567 ...(val("modelKey") === "" ? {} : { modelKey: val("modelKey") }),
6668 ...(val("modelEndpoint") === "" ? {} : { modelEndpoint: val("modelEndpoint") }),
6769 ...(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") }),
6872 dryRun,
6973 };
7074 };
src/web/pages/new-forge.ts +27 −1
@@ -22,11 +22,12 @@ import {
2222 MAX_BUDGET_USD,
2323 MIN_BUDGET_USD,
2424 } from "../../spawn/plan.ts";
25 import { DEFAULT_SIZE, SIZES, sizesView } from "../../spawn/sizes.ts";
2526 import { notFoundPage, pageHead } from "../chrome.ts";
2627 import { field, readForm, type Form } from "../form.ts";
2728 import { html, type Html } from "../html.ts";
2829 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";
3031
3132 interface Values {
3233 readonly name: string;
@@ -39,6 +40,7 @@ interface Values {
3940 readonly keepAccess: boolean;
4041 readonly budgetUsd: string;
4142 readonly modelEndpoint: string;
43 readonly size: string;
4244 /** The last post carried a key: it is never sent back, so it is asked for again. */
4345 readonly hadKey: boolean;
4446 }
@@ -54,6 +56,7 @@ const EMPTY: Values = {
5456 keepAccess: false,
5557 budgetUsd: "",
5658 modelEndpoint: "",
59 size: DEFAULT_SIZE,
5760 hadKey: false,
5861 };
5962
@@ -117,6 +120,19 @@ function readsFor(ctx: Ctx, user: User | undefined): Reads {
117120 return { full, repos, crew };
118121 }
119122
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
120136 function formView(
121137 ctx: Ctx,
122138 user: User | undefined,
@@ -290,6 +306,13 @@ function formView(
290306 hint: "For the key's whole life; the child cannot raise it. Needed with a key.",
291307 ...(errors["budgetUsd"] ? { error: errors["budgetUsd"] } : {}),
292308 })}
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"] } : {}),
outdated

Hand-placed error span outside the field. Give selectField an error option so the message is inside .field and linked with aria-describedby/aria-invalid, as formField does.

315 })}
293316 <p class="muted small">
294317 Never copied: ${LEFT_BEHIND.join(", ")}. The child mints its own.
295318 </p>
@@ -421,6 +444,7 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> {
421444 keepAccess: asBool(form, "keepAccess"),
422445 budgetUsd: field(form, "budgetUsd").trim(),
423446 modelEndpoint: field(form, "modelEndpoint").trim(),
447 size: field(form, "size").trim() || DEFAULT_SIZE,
424448 hadKey: field(form, "modelKey").trim() !== "",
425449 };
426450 const key = field(form, "modelKey").trim();
@@ -437,6 +461,8 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> {
437461 ...(key === "" ? {} : { modelKey: key }),
438462 ...(values.modelEndpoint === "" ? {} : { modelEndpoint: values.modelEndpoint }),
439463 ...(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 }),
440466 dryRun: planOnly,
441467 });
442468 if (door.status >= 400)
src/web/ui.ts +13 −4
@@ -1198,16 +1198,22 @@ ${o.value ?? ""}</textarea
11981198 </div>`;
11991199 }
12001200
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. */
12021202 export function selectField(
12031203 o: Omit<FieldOptions, "type"> & {
12041204 options: { value: string; label: string; selected?: boolean }[];
12051205 },
12061206 ): Html {
12071207 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}>
12091212 <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 >
12111217 ${o.options.map(
12121218 (opt) =>
12131219 html`<option
@@ -1218,7 +1224,10 @@ export function selectField(
12181224 </option>`,
12191225 )}
12201226 </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}
12221231 </div>`;
12231232 }
12241233
test/boards-page.test.ts +1 −1
@@ -75,7 +75,7 @@ describe("/boards forms", () => {
7575 for (const name of ["chart", "source", "aggregate", "column", "by", "series", "from", "to", "fcolumn", "fop", "fvalue"]) {
7676 expect(body).toMatch(new RegExp(`<div class="field"[^>]*>\\s*<label for="f-${name}-\\d+">[^]*?name="${name}"`));
7777 }
78 expect(body).toMatch(/<span class="hint">Column to aggregate<\/span>/);
78 expect(body).toMatch(/<span class="hint" id="[^"]*-hint">Column to aggregate<\/span>/);
7979 expect(body).toContain('name="slug"');
8080 expect(body).toContain('<label for="f-pin-title">Panel title</label>');
8181 });
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", () => {
191191 delete (app.config as { forgeRepo?: string }).forgeRepo;
192192 }
193193 }));
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 }));
194215 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs