Public: anyone can read this; the crew can change it.
small: the knobs panel shows the reserve as a read-only row beside Weekly budget #10684
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10679-small-the-knobs-panel-shows-the-reserve → main 3 commits fork 4 threads · 4 unresolved
8 files
changed +90
−15
docs/features/knobs.md +3 −2
| @@ -16,8 +16,9 @@ A change applies on the next pump and leaves the event the setting's own page le | ||
| 16 | 16 | are computed from Speed every time they are read. They show with their value, read-only, on |
| 17 | 17 | the knobs panel and under Settings › Capacity; a POST that tries to set one is refused with |
| 18 | 18 | a plain line, and `FORGE_PACE_FLOOR`, `FORGE_QUEUE_DEPTH` and any stored value are ignored. |
| 19 | The reserve is not one of them: it is what Weekly budget leaves (100 minus `week.use`), so it | |
| 20 | moves with that dial, not with Speed. The small share and the polish run cap are gone; a | |
| 19 | Reserve shows as a read-only row after Weekly budget: it is what Weekly budget leaves (100 | |
| 20 | minus `week.use`), so it moves with that dial, not with Speed. A set is refused with the plain | |
| 21 | line "That dial follows Weekly budget: turn Weekly budget and it moves."; nothing is stored. The small share and the polish run cap are gone; a | |
| 21 | 22 | stored old value is ignored. |
| 22 | 23 | |
| 23 | 24 | `week.use` is new; a stored or `FORGE_WEEK_USE` value is read as the budget. |
src/crew/knobs.ts +39 −4
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | 12 | import type { App } from "../app.ts"; |
| 13 | import { capacityValue, paceFloor, queueDepth } from "../core/capacity.ts"; | |
| 13 | import { capacityValue, paceFloor, queueDepth, weekUse } from "../core/capacity.ts"; | |
| 14 | 14 | import { labelsForIssues } from "../core/issues.ts"; |
| 15 | 15 | import { getRepoById, type Repo } from "../core/repos.ts"; |
| 16 | 16 | import { listActive } from "../core/runs.ts"; |
| @@ -18,9 +18,20 @@ import { waitingOnPeople } from "./people_first.ts"; | ||
| 18 | 18 | import { POLISH_SHARE_DEFAULT, isPolish, polishSpend } from "./polish_share.ts"; |
| 19 | 19 | import { budgetEffect } from "./week_read.ts"; |
| 20 | 20 | |
| 21 | export type KnobId = "speed" | "budget" | "polish" | "autonomy" | "feedback" | "floor" | "depth"; | |
| 21 | export type KnobId = "speed" | "budget" | "polish" | "autonomy" | "feedback" | "floor" | "depth" | "reserve"; | |
| 22 | 22 | /** The dials Speed sets: shown on the panel with their value, never turned there. */ |
| 23 | export const DERIVED_KNOBS: readonly string[] = ["floor", "depth", "pace.floor", "queue.depth"]; | |
| 23 | const FOLLOWS: Readonly<Record<string, string>> = { | |
| 24 | floor: "Speed", | |
| 25 | depth: "Speed", | |
| 26 | "pace.floor": "Speed", | |
| 27 | "queue.depth": "Speed", | |
| 28 | reserve: "Weekly budget", | |
| 29 | }; | |
| 30 | export const DERIVED_KNOBS: readonly string[] = Object.keys(FOLLOWS); | |
| 31 | /** The dial that moves a derived one, by its knob id or capacity key; undefined for a dial that is set. */ | |
| 32 | export function followsOf(knob: string): string | undefined { | |
| 33 | return Object.hasOwn(FOLLOWS, knob) ? FOLLOWS[knob] : undefined; | |
| 34 | } | |
| 24 | 35 | export type AutonomyWord = "off" | "ask" | "go"; |
| 25 | 36 | export const AUTONOMY_WORDS: readonly AutonomyWord[] = ["off", "ask", "go"]; |
| 26 | 37 | |
| @@ -38,6 +49,16 @@ export interface KnobRow { | ||
| 38 | 49 | readonly effect: string; |
| 39 | 50 | /** For Autonomy: the repository (`owner/name`) this row is for. */ |
| 40 | 51 | readonly repo?: string; |
| 52 | /** For a derived row: the dial that moves it. */ | |
| 53 | readonly follows?: string; | |
| 54 | /** A unit printed after the value. */ | |
| 55 | readonly unit?: string; | |
| 56 | } | |
| 57 | ||
| 58 | /** The plain line a refused set of a derived dial answers. */ | |
| 59 | export function derivedLine(knob: string): string { | |
| 60 | const follows = followsOf(knob) ?? knob; | |
| 61 | return `That dial follows ${follows}: turn ${follows} and it moves.`; | |
| 41 | 62 | } |
| 42 | 63 | |
| 43 | 64 | /** A repository's autonomy in the knob's three words. */ |
| @@ -97,6 +118,7 @@ export function readKnobs(app: App, now: Date = new Date()): KnobRow[] { | ||
| 97 | 118 | does: "The slots the pace always leaves reviews and assigned work. Derived from Speed: half of it, at least 1.", |
| 98 | 119 | value: String(paceFloor(app.db)), |
| 99 | 120 | control: "derived", |
| 121 | follows: "Speed", | |
| 100 | 122 | effect: `${paceFloor(app.db)} slots`, |
| 101 | 123 | }, |
| 102 | 124 | { |
| @@ -105,6 +127,7 @@ export function readKnobs(app: App, now: Date = new Date()): KnobRow[] { | ||
| 105 | 127 | does: "How many merge-queue candidates of one repository gate at once. Derived from Speed: half of it rounded up, 1 to 4.", |
| 106 | 128 | value: String(queueDepth(app.db)), |
| 107 | 129 | control: "derived", |
| 130 | follows: "Speed", | |
| 108 | 131 | effect: `${queueDepth(app.db)} candidates`, |
| 109 | 132 | }, |
| 110 | 133 | { |
| @@ -113,16 +136,28 @@ export function readKnobs(app: App, now: Date = new Date()): KnobRow[] { | ||
| 113 | 136 | does: "How much of the week's subscription to use, evenly to the reset.", |
| 114 | 137 | value: capacityValue(app.db, "week.use"), |
| 115 | 138 | control: "percent", |
| 139 | unit: "%", | |
| 116 | 140 | min: 10, |
| 117 | 141 | max: 100, |
| 118 | 142 | effect: budgetEffect(app, now), |
| 119 | 143 | }, |
| 144 | { | |
| 145 | id: "reserve", | |
| 146 | name: "Reserve", | |
| 147 | does: "What Weekly budget leaves: it moves with that dial, not with Speed.", | |
| 148 | value: String(100 - weekUse(app.db)), | |
| 149 | control: "derived", | |
| 150 | follows: "Weekly budget", | |
| 151 | unit: "%", | |
| 152 | effect: "kept for conversations and a person's own requests", | |
| 153 | }, | |
| 120 | 154 | { |
| 121 | 155 | id: "polish", |
| 122 | 156 | name: "Polish share", |
| 123 | 157 | does: "The share of the week's spend polish may take.", |
| 124 | 158 | value: String(share), |
| 125 | 159 | control: "percent", |
| 160 | unit: "%", | |
| 126 | 161 | min: 0, |
| 127 | 162 | max: 50, |
| 128 | 163 | effect: `polish ${spend === undefined ? 0 : Math.round(spend.percent)}% of this week's spend · ${polishWaiting(app, live)} polish issues waiting`, |
| @@ -154,5 +189,5 @@ export function readKnobs(app: App, now: Date = new Date()): KnobRow[] { | ||
| 154 | 189 | |
| 155 | 190 | /** The knobs as the plain lines the terminal and the MCP print. */ |
| 156 | 191 | export function knobLines(rows: readonly KnobRow[]): string[] { |
| 157 | return rows.map((r) => `${r.name}${r.repo === undefined ? "" : ` (${r.repo})`}: ${r.value}${r.control === "percent" ? "%" : ""} — ${r.effect}`); | |
| 192 | return rows.map((r) => `${r.name}${r.repo === undefined ? "" : ` (${r.repo})`}: ${r.value}${r.unit ?? ""} — ${r.effect}`); | |
| 158 | 193 | } |
src/http/mcp/tools/knobs.ts +2 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { setKnob } from "../../../actions/knobs.ts"; |
| 2 | import { knobLines, readKnobs } from "../../../crew/knobs.ts"; | |
| 2 | import { derivedLine, knobLines, readKnobs } from "../../../crew/knobs.ts"; | |
| 3 | 3 | import { scopeAllows } from "../../../core/tokens.ts"; |
| 4 | 4 | import { good, bad, type Tool } from "../shared.ts"; |
| 5 | 5 | |
| @@ -31,6 +31,7 @@ export const tool: Tool = { | ||
| 31 | 31 | return good({ says: `Would set ${knob}${repo === undefined ? "" : ` for ${repo}`} to ${value}. Send dryRun: false to change it.` }); |
| 32 | 32 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) return bad("forbidden", "turning a knob needs an admin-scope token"); |
| 33 | 33 | const set = await setKnob(ctx.app, user, knob, value, repo); |
| 34 | if (!set.ok && set.error === "derived") return bad("derived", derivedLine(knob)); | |
| 34 | 35 | if (!set.ok) return bad(set.error, "That knob or value is not one the knobs take."); |
| 35 | 36 | return good({ says: `${knob}: ${set.value.value}, from the next pump on.`, ...set.value }); |
| 36 | 37 | }, |
src/tui/screens/knobs.ts +1 −1
| @@ -42,7 +42,7 @@ export function knobsScreen(f: Session, ctx: Ctx): Screen { | ||
| 42 | 42 | if (e.key !== "e") return state.key(e, rows.length); |
| 43 | 43 | const row = rows[state.index]; |
| 44 | 44 | if (row === undefined) return true; |
| 45 | if (row.control === "derived") return ctx.toast(`${row.name} follows Speed.`, "success"), true; | |
| 45 | if (row.control === "derived") return ctx.toast(`${row.name} follows ${row.follows}.`, "success"), true; | |
| 46 | 46 | if (row.control === "fixed") return ctx.toast(`${row.name} is always on.`, "success"), true; |
| 47 | 47 | later(ctx, () => |
| 48 | 48 | promptScreen(ctx, screen, { |
src/web/pages/knobs.ts +3 −3
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { setKnob } from "../../actions/knobs.ts"; |
| 9 | import { readKnobs, type KnobRow } from "../../crew/knobs.ts"; | |
| 9 | import { derivedLine, readKnobs, type KnobRow } from "../../crew/knobs.ts"; | |
| 10 | 10 | import type { Ctx } from "../../http/context.ts"; |
| 11 | 11 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| 12 | 12 | import { field, readForm } from "../form.ts"; |
| @@ -16,7 +16,7 @@ import { btn } from "../ui.ts"; | ||
| 16 | 16 | |
| 17 | 17 | function control(r: KnobRow): Html { |
| 18 | 18 | if (r.control === "fixed") return html`<span class="badge">${r.value}</span>`; |
| 19 | if (r.control === "derived") return html`<span class="mono" data-derived="${r.id}" title="Read-only: Speed sets it">${r.value}</span>`; | |
| 19 | if (r.control === "derived") return html`<span class="mono" data-derived="${r.id}" title="Read-only: ${r.follows} sets it">${r.value}${r.unit ?? ""}</span>`; | |
outdated
Same |
||
| 20 | 20 | const label = `${r.name}${r.repo === undefined ? "" : ` for ${r.repo}`}`; |
| 21 | 21 | const input = |
| 22 | 22 | r.control === "words" |
| @@ -65,7 +65,7 @@ export async function knobSet(ctx: Ctx): Promise<Response> { | ||
| 65 | 65 | ? { message: `${knob}: ${set.value.value}, from the next pump on.` } |
| 66 | 66 | : { |
| 67 | 67 | tone: "danger", |
| 68 | message: set.error === "derived" ? "That dial follows Speed: turn Speed and it moves." : "That value is outside what this knob takes.", | |
| 68 | message: set.error === "derived" ? derivedLine(knob) : "That value is outside what this knob takes.", | |
outdated
This ternary on the knob name is duplicated in src/http/mcp/tools/knobs.ts. Look up the row's |
||
| 69 | 69 | }, |
| 70 | 70 | ); |
| 71 | 71 | } |
test/knobs-page.test.ts +11
| @@ -52,6 +52,17 @@ test("the crew page shows the knobs, and a same-origin post turns one", async () | ||
| 52 | 52 | expect(capacityValue(app.db, "crew.concurrency")).toBe("3"); |
| 53 | 53 | }); |
| 54 | 54 | |
| 55 | test("a post to Reserve is refused with the plain line and stores nothing", async () => { | |
| 56 | const res = await post(servers.url, { knob: "reserve", value: "20", repo: "" }); | |
| 57 | expect(res.status).toBe(303); | |
| 58 | const cookie = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 59 | const page = await fetch(`${servers.url}/settings/crew`, { headers: { authorization: `Bearer ${bearer}`, cookie } }); | |
| 60 | const body = (await page.text()).replaceAll("'", "'"); | |
| 61 | expect(body).toContain("That dial follows Weekly budget: turn Weekly budget and it moves."); | |
| 62 | expect(body).toContain('data-derived="reserve"'); | |
| 63 | expect(capacityValue(app.db, "week.use")).toBe("90"); | |
| 64 | }); | |
| 65 | ||
| 55 | 66 | test("another origin changes nothing", async () => { |
| 56 | 67 | await post("https://evil.example", { knob: "speed", value: "5", repo: "" }); |
| 57 | 68 | expect(capacityValue(app.db, "crew.concurrency")).toBe("3"); |
test/knobs.test.ts +24 −4
| @@ -12,7 +12,7 @@ import { createRepo } from "../src/actions/repos.ts"; | ||
| 12 | 12 | import { setKnob } from "../src/actions/knobs.ts"; |
| 13 | 13 | import { createApp, type App } from "../src/app.ts"; |
| 14 | 14 | import { listCapacity } from "../src/actions/capacity.ts"; |
| 15 | import { CAPACITY_KEYS, capacityCount, paceFloor, queueDepth, setCapacity, weekUse } from "../src/core/capacity.ts"; | |
| 15 | import { CAPACITY_KEYS, capacityCount, capacityValue, paceFloor, queueDepth, setCapacity, weekUse } from "../src/core/capacity.ts"; | |
| 16 | 16 | import { listEvents } from "../src/core/events.ts"; |
| 17 | 17 | import { getRepoById } from "../src/core/repos.ts"; |
| 18 | 18 | import { createUser, type User } from "../src/core/users.ts"; |
| @@ -50,14 +50,14 @@ afterAll(async () => { | ||
| 50 | 50 | |
| 51 | 51 | test("the panel has Speed, the derived pace floor and queue depth, Weekly budget, Polish share, Autonomy per repository and Feedback first, each with an effect", () => { |
| 52 | 52 | const rows = readKnobs(app); |
| 53 | expect(rows.map((r) => r.id)).toEqual(["speed", "floor", "depth", "budget", "polish", "autonomy", "feedback"]); | |
| 53 | expect(rows.map((r) => r.id)).toEqual(["speed", "floor", "depth", "budget", "reserve", "polish", "autonomy", "feedback"]); | |
| 54 | 54 | for (const r of rows) { |
| 55 | 55 | expect(r.does.length).toBeGreaterThan(0); |
| 56 | 56 | expect(r.effect.length).toBeGreaterThan(0); |
| 57 | 57 | } |
| 58 | 58 | expect(rows[0]!.effect).toMatch(/\d+ running · \d+ queued/); |
| 59 | expect(rows[6]!.effect).toBe("nothing from a person waiting"); | |
| 60 | expect(knobLines(rows)).toHaveLength(7); | |
| 59 | expect(rows[7]!.effect).toBe("nothing from a person waiting"); | |
| 60 | expect(knobLines(rows)).toHaveLength(8); | |
| 61 | 61 | }); |
| 62 | 62 | |
| 63 | 63 | test("Speed 4 → 2 is read by the next pump", async () => { |
| @@ -90,6 +90,26 @@ test("no knob or capacity POST sets the pace floor or the queue depth: a plain l | ||
| 90 | 90 | } |
| 91 | 91 | }); |
| 92 | 92 | |
| 93 | test("Reserve is a read-only row: 100 minus Weekly budget, and a set is refused with a plain line", async () => { | |
| 94 | const before = capacityValue(app.db, "week.use"); | |
| 95 | // Weekly budget cannot go below 10, so Reserve tops out at 90 here; 0 is not a value a set can reach. | |
| 96 | for (const use of ["10", "50", "100"]) { | |
| 97 | expect((await setKnob(app, admin, "budget", use)).ok).toBe(true); | |
| 98 | const row = readKnobs(app).find((r) => r.id === "reserve")!; | |
| 99 | expect(row.control).toBe("derived"); | |
| 100 | expect(row.value).toBe(String(100 - Number(use))); | |
| 101 | expect(row.does).toContain("What Weekly budget leaves"); | |
| 102 | expect(row.follows).toBe("Weekly budget"); | |
| 103 | expect(knobLines([row])[0]).toContain(`Reserve: ${100 - Number(use)}%`); | |
| 104 | } | |
| 105 | const set = await setKnob(app, admin, "reserve", "20"); | |
| 106 | expect(!set.ok && set.error).toBe("derived"); | |
| 107 | const mcp = await tool.run({ app, auth: { scope: "admin" } } as never, admin, { knob: "reserve", value: "20", dryRun: false }); | |
| 108 | expect(JSON.stringify(mcp)).toContain("Weekly budget"); | |
| 109 | expect(weekUse(app.db)).toBe(100); | |
| 110 | setCapacity(app.db, "week.use", before, admin.id); | |
| 111 | }); | |
| 112 | ||
| 93 | 113 | test("Weekly budget sets the reserve the pacer holds back", () => { |
| 94 | 114 | expect(weekUse(app.db)).toBe(90); |
| 95 | 115 | const week = (reserve: number) => |
test/tui-knobs.test.ts +7
| @@ -66,6 +66,13 @@ test("an admin turns Speed with e, and the capacity event is left", async () => | ||
| 66 | 66 | h.stop(); |
| 67 | 67 | }); |
| 68 | 68 | |
| 69 | test("e on Reserve says which dial it follows and changes nothing", async () => { | |
| 70 | const h = await boot(carol); | |
| 71 | await h.press("down", "down", "down", "down", "e"); | |
| 72 | expect(h.text()).toContain("Reserve follows Weekly budget."); | |
| 73 | h.stop(); | |
| 74 | }); | |
| 75 | ||
| 69 | 76 | test("a value out of range changes nothing", async () => { |
| 70 | 77 | const h = await boot(carol); |
| 71 | 78 | await h.press("e"); |
Principle 1:
effectrepeatsdoesword for word. Say it once.