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

Merged opened this

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

Merged

scout/forge:scout/10679-small-the-knobs-panel-shows-the-reserve → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/10679-small-the-knobs-panel-shows-the-reserve was merged into main as fd576fc.

Review

Sign in to review this change.
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
1616 are computed from Speed every time they are read. They show with their value, read-only, on
1717 the knobs panel and under Settings › Capacity; a POST that tries to set one is refused with
1818 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
2122 stored old value is ignored.
2223
2324 `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 @@
1010 */
1111
1212 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";
1414 import { labelsForIssues } from "../core/issues.ts";
1515 import { getRepoById, type Repo } from "../core/repos.ts";
1616 import { listActive } from "../core/runs.ts";
@@ -18,9 +18,20 @@ import { waitingOnPeople } from "./people_first.ts";
1818 import { POLISH_SHARE_DEFAULT, isPolish, polishSpend } from "./polish_share.ts";
1919 import { budgetEffect } from "./week_read.ts";
2020
21 export type KnobId = "speed" | "budget" | "polish" | "autonomy" | "feedback" | "floor" | "depth";
21 export type KnobId = "speed" | "budget" | "polish" | "autonomy" | "feedback" | "floor" | "depth" | "reserve";
2222 /** 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 }
2435 export type AutonomyWord = "off" | "ask" | "go";
2536 export const AUTONOMY_WORDS: readonly AutonomyWord[] = ["off", "ask", "go"];
2637
@@ -38,6 +49,16 @@ export interface KnobRow {
3849 readonly effect: string;
3950 /** For Autonomy: the repository (`owner/name`) this row is for. */
4051 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.`;
4162 }
4263
4364 /** A repository's autonomy in the knob's three words. */
@@ -97,6 +118,7 @@ export function readKnobs(app: App, now: Date = new Date()): KnobRow[] {
97118 does: "The slots the pace always leaves reviews and assigned work. Derived from Speed: half of it, at least 1.",
98119 value: String(paceFloor(app.db)),
99120 control: "derived",
121 follows: "Speed",
100122 effect: `${paceFloor(app.db)} slots`,
101123 },
102124 {
@@ -105,6 +127,7 @@ export function readKnobs(app: App, now: Date = new Date()): KnobRow[] {
105127 does: "How many merge-queue candidates of one repository gate at once. Derived from Speed: half of it rounded up, 1 to 4.",
106128 value: String(queueDepth(app.db)),
107129 control: "derived",
130 follows: "Speed",
108131 effect: `${queueDepth(app.db)} candidates`,
109132 },
outdated

Principle 1: effect repeats does word for word. Say it once.

110133 {
@@ -113,16 +136,28 @@ export function readKnobs(app: App, now: Date = new Date()): KnobRow[] {
113136 does: "How much of the week's subscription to use, evenly to the reset.",
114137 value: capacityValue(app.db, "week.use"),
115138 control: "percent",
139 unit: "%",
116140 min: 10,
117141 max: 100,
118142 effect: budgetEffect(app, now),
119143 },
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 },
120154 {
121155 id: "polish",
122156 name: "Polish share",
123157 does: "The share of the week's spend polish may take.",
124158 value: String(share),
125159 control: "percent",
160 unit: "%",
126161 min: 0,
127162 max: 50,
128163 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[] {
154189
155190 /** The knobs as the plain lines the terminal and the MCP print. */
156191 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}`);
158193 }
src/http/mcp/tools/knobs.ts +2 −1
@@ -1,5 +1,5 @@
11 import { setKnob } from "../../../actions/knobs.ts";
2 import { knobLines, readKnobs } from "../../../crew/knobs.ts";
2 import { derivedLine, knobLines, readKnobs } from "../../../crew/knobs.ts";
33 import { scopeAllows } from "../../../core/tokens.ts";
44 import { good, bad, type Tool } from "../shared.ts";
55
@@ -31,6 +31,7 @@ export const tool: Tool = {
3131 return good({ says: `Would set ${knob}${repo === undefined ? "" : ` for ${repo}`} to ${value}. Send dryRun: false to change it.` });
3232 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) return bad("forbidden", "turning a knob needs an admin-scope token");
3333 const set = await setKnob(ctx.app, user, knob, value, repo);
34 if (!set.ok && set.error === "derived") return bad("derived", derivedLine(knob));
3435 if (!set.ok) return bad(set.error, "That knob or value is not one the knobs take.");
3536 return good({ says: `${knob}: ${set.value.value}, from the next pump on.`, ...set.value });
3637 },
src/tui/screens/knobs.ts +1 −1
@@ -42,7 +42,7 @@ export function knobsScreen(f: Session, ctx: Ctx): Screen {
4242 if (e.key !== "e") return state.key(e, rows.length);
4343 const row = rows[state.index];
4444 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;
4646 if (row.control === "fixed") return ctx.toast(`${row.name} is always on.`, "success"), true;
4747 later(ctx, () =>
4848 promptScreen(ctx, screen, {
src/web/pages/knobs.ts +3 −3
@@ -6,7 +6,7 @@
66 */
77
88 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";
1010 import type { Ctx } from "../../http/context.ts";
1111 import { notFoundPage, signInFirst } from "../chrome.ts";
1212 import { field, readForm } from "../form.ts";
@@ -16,7 +16,7 @@ import { btn } from "../ui.ts";
1616
1717 function control(r: KnobRow): Html {
1818 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 r.id === "reserve" special case, plus a ?? "Speed" fallback. Give every derived row a follows and a unit, and render from those.

2020 const label = `${r.name}${r.repo === undefined ? "" : ` for ${r.repo}`}`;
2121 const input =
2222 r.control === "words"
@@ -65,7 +65,7 @@ export async function knobSet(ctx: Ctx): Promise<Response> {
6565 ? { message: `${knob}: ${set.value.value}, from the next pump on.` }
6666 : {
6767 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 follows once (a helper in crew/knobs.ts) and use it in both.

6969 },
7070 );
7171 }
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 ()
5252 expect(capacityValue(app.db, "crew.concurrency")).toBe("3");
5353 });
5454
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("&#39;", "'");
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
5566 test("another origin changes nothing", async () => {
5667 await post("https://evil.example", { knob: "speed", value: "5", repo: "" });
5768 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";
1212 import { setKnob } from "../src/actions/knobs.ts";
1313 import { createApp, type App } from "../src/app.ts";
1414 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";
1616 import { listEvents } from "../src/core/events.ts";
1717 import { getRepoById } from "../src/core/repos.ts";
1818 import { createUser, type User } from "../src/core/users.ts";
@@ -50,14 +50,14 @@ afterAll(async () => {
5050
5151 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", () => {
5252 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"]);
5454 for (const r of rows) {
5555 expect(r.does.length).toBeGreaterThan(0);
5656 expect(r.effect.length).toBeGreaterThan(0);
5757 }
5858 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);
6161 });
6262
6363 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
9090 }
9191 });
9292
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
93113 test("Weekly budget sets the reserve the pacer holds back", () => {
94114 expect(weekUse(app.db)).toBe(90);
95115 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 () =>
6666 h.stop();
6767 });
6868
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
6976 test("a value out of range changes nothing", async () => {
7077 const h = await boot(carol);
7178 await h.press("e");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs