Public: anyone can read this; the crew can change it.

Knobs: one panel on the crew page with five plain dials, each showing its live effect, editable in place #10599

Merged opened this

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

Merged

scout/forge:scout/10461-knobs-one-panel-on-the-crew-page-with-fi → main 10 commits fork 8 threads · 8 unresolved

  • Merged. scout/10461-knobs-one-panel-on-the-crew-page-with-fi was merged into main as 27a95da.

Review

Sign in to review this change.
24 files changed +776 −8
docs/design/areas/knobs.md added +5
@@ -0,0 +1,5 @@
1 # Knobs
2
3 The crew page's Knobs (`knobsCard()` in `src/web/pages/knobs.ts`) is a `dl.status-rows` list: one `.status-row` per knob, its name, its live effect, and the control in the row's `.status-act`. The `Set` button is a quiet `ghost` button, so the page keeps its one accent.
4
5 - `.input-num` — a number input at five characters' width (`.input.input-num`), for a count or a percent beside its button.
docs/features/knobs.md added +14
@@ -0,0 +1,14 @@
1 # Knobs
2
3 Five dials for the crew, on **Settings › Crew** (admin), in the terminal's palette (`Knobs`; `e` turns the one under the cursor) and in the MCP `knobs` tool, in the same words (#10461).
4
5 1. **Speed**: crew runs at once (`crew.concurrency`).
6 2. **Weekly budget**: percent of the week to use (`week.use`, default 90); the rest is the reserve the pacer keeps.
7 3. **Polish share**: percent of the week's spend polish may take (`polishShare`, applied to every repository the crew works).
8 4. **Autonomy**: per repository, `off`, `ask` (plans need a nod, decisions come to you) or `go` (the decider answers, plans open).
9 5. **Feedback first**: always on; shown, not tuned.
10
11 A change applies on the next pump and leaves the event the setting's own page leaves (`capacity.changed`, `repo.settings_updated`).
12 Not done: the per-repository dollar ceiling (`autonomyCeilingUsd`) and `reserve.ts`'s two-per-slot reserve still stand.
13
14 The pipeline's own dials (`queue.depth`, `pace.floor`, `slots.smallShare`, `polish.capMinutes`) are untouched: they stay on the capacity page and nothing in the panel derives them. Only `week.use` is new, and a stored or `FORGE_WEEK_USE` value is read as the budget; no stored value is dropped.
src/actions/knobs.ts added +59
@@ -0,0 +1,59 @@
1 /**
2 * Turning a knob (#10461), an admin's: each one writes through the setting
3 * the forge already keeps and leaves the same event that setting's own page
4 * leaves (`capacity.changed`, `repo.settings_updated`), so the feed says who
5 * turned what. The next pump reads the change; nothing restarts.
6 */
7
8 import type { App } from "../app.ts";
9 import { changeCapacity } from "./capacity.ts";
10 import { updateRepoSettings } from "./repos.ts";
11 import { getRepo } from "../core/repos.ts";
12 import { AUTONOMY_WORDS, knobRepos, type AutonomyWord, type KnobId } from "../crew/knobs.ts";
13 import { validPolishShare } from "../crew/small_share.ts";
14 import { err, ok, type Result } from "../kernel/result.ts";
15 import type { User } from "../core/users.ts";
16
17 export type KnobError = "not_found" | "no_such_knob" | "invalid_value";
18
19 const WORDS: Record<AutonomyWord, { autonomy: "off" | "autonomous"; plansNeedNod: boolean; decisionsBy: "me" | "decider" }> = {
20 off: { autonomy: "off", plansNeedNod: true, decisionsBy: "me" },
21 ask: { autonomy: "autonomous", plansNeedNod: true, decisionsBy: "me" },
22 go: { autonomy: "autonomous", plansNeedNod: false, decisionsBy: "decider" },
23 };
24
25 export async function setKnob(
26 app: App,
27 actor: User | undefined,
28 knob: string,
29 value: string,
30 repoPath?: string,
31 ): Promise<Result<{ knob: KnobId; value: string }, KnobError>> {
32 if (actor?.isAdmin !== true || actor.kind === "agent") return err("not_found");
33 const v = value.trim();
34 if (knob === "speed" || knob === "budget") {
35 const changed = changeCapacity(app, actor, knob === "speed" ? "crew.concurrency" : "week.use", v);
36 if (!changed.ok) return err(changed.error === "not_found" ? "not_found" : "invalid_value");
37 return ok({ knob, value: v });
38 }
39 if (knob === "polish") {
40 const n = /^\d+$/.test(v) ? Number(v) : Number.NaN;
41 if (!validPolishShare(n)) return err("invalid_value");
42 // The value is checked above, and an admin actor passes every repository's check alike, so a write
43 // cannot fail for one repository and not the rest.
44 for (const repo of knobRepos(app).filter((r) => r.autonomy !== "off")) {
45 const done = await updateRepoSettings(app, actor, repo, { polishShare: n });
46 if (!done.ok) return err("invalid_value");
47 }
48 return ok({ knob, value: v });
49 }
50 if (knob === "autonomy") {
51 if (!(AUTONOMY_WORDS as readonly string[]).includes(v)) return err("invalid_value");
52 const [owner, name] = (repoPath ?? "").split("/");
53 const repo = owner === undefined || name === undefined ? undefined : getRepo(app.db, owner, name);
54 if (repo === undefined) return err("not_found");
55 const done = await updateRepoSettings(app, actor, repo, WORDS[v as AutonomyWord]);
56 return done.ok ? ok({ knob, value: v }) : err("invalid_value");
57 }
58 return err("no_such_knob");
59 }
src/core/capacity.ts +13
@@ -21,6 +21,7 @@ export type CapacityKey =
2121 | "checks.shards"
2222 | "queue.depth"
2323 | "queue.batch"
24 | "week.use"
2425 | "children.max"
2526 | "cells.egress"
2627 | "children.modelEgress"
@@ -85,6 +86,13 @@ export const CAPACITY: Record<CapacityKey, Definition> = {
8586 fallback: "8",
8687 help: "The most pulls one merge-queue candidate carries. A bigger batch lands more pulls per green gate; a red one costs more search gates to find its culprit (one bad pull in 16 is found in two rounds, at most √16 = 4 gates each). The queue starts each line here, halves the batch after a red gate and doubles it back after two green gates in a row, never past this. Read on every sweep.",
8788 },
89 "week.use": {
90 label: "Share of the week to use (%)",
91 env: "FORGE_WEEK_USE",
92 kind: { type: "count", min: 10, max: 100 },
93 fallback: "90",
94 help: "The weekly budget knob: how much of the week's subscription the crew may spend, evenly to the reset. The rest is kept for conversations and a person's own requests; the crew stops at it. Read on every pump.",
95 },
8896 "children.max": {
8997 label: "Forges this one may send",
9098 env: "FORGE_CHILDREN_MAX",
@@ -370,6 +378,11 @@ export function paceFloor(db: Database): number {
370378 return Number(capacityValue(db, "pace.floor"));
371379 }
372380
381 /** The share of the week the crew may use, in percent (`week.use`); the rest is the reserve. */
382 export function weekUse(db: Database): number {
383 return Number(capacityValue(db, "week.use"));
384 }
385
373386 /** How many hosted children this forge may hold now; read on every use. */
374387 export function maxChildren(db: Database): number {
375388 return Number(capacityValue(db, "children.max"));
src/crew/knobs.ts added +139
@@ -0,0 +1,139 @@
1 /**
2 * The five knobs (#10461): Speed, Weekly budget, Polish share, Autonomy per
3 * repository, Feedback first. Plain data out: one row each with its name, a
4 * line on what it does, the value, a control kind and its live effect. The
5 * crew page, the terminal and the MCP `knobs` tool print these same rows;
6 * `actions/knobs.ts` is the one place that turns them. The pipeline's own
7 * dials (queue depth, pace floor, small share, polish cap) stay on the
8 * capacity page.
9 */
10
11 import type { App } from "../app.ts";
12 import { capacityValue } from "../core/capacity.ts";
13 import { labelsForIssues } from "../core/issues.ts";
14 import { getRepoById, type Repo } from "../core/repos.ts";
15 import { listActive } from "../core/runs.ts";
16 import { waitingOnPeople } from "./people_first.ts";
17 import { POLISH_SHARE_DEFAULT, isPolish, polishSpend } from "./small_share.ts";
18 import { budgetEffect } from "./week_read.ts";
19
20 export type KnobId = "speed" | "budget" | "polish" | "autonomy" | "feedback";
21 export type AutonomyWord = "off" | "ask" | "go";
22 export const AUTONOMY_WORDS: readonly AutonomyWord[] = ["off", "ask", "go"];
23
24 export interface KnobRow {
25 readonly id: KnobId;
26 readonly name: string;
27 readonly does: string;
28 /** The value as typed back: a number, a percent's number, or one of the words. */
29 readonly value: string;
30 readonly control: "number" | "percent" | "words" | "fixed";
31 readonly min?: number;
32 readonly max?: number;
33 readonly words?: readonly string[];
34 /** What it does right now, in a line. */
35 readonly effect: string;
36 /** For Autonomy: the repository (`owner/name`) this row is for. */
37 readonly repo?: string;
38 }
39
40 /** A repository's autonomy in the knob's three words. */
41 export function autonomyWord(repo: Repo): AutonomyWord {
42 if (repo.autonomy === "off") return "off";
43 return !repo.plansNeedNod && repo.decisionsBy !== "me" ? "go" : "ask";
44 }
45
46 function runsLine(app: App): string {
47 const active = listActive(app.db, "agent");
48 const running = active.filter((r) => r.state === "running").length;
49 return `${running} running · ${active.length - running} queued`;
50 }
51
52 function polishWaiting(app: App, repos: readonly Repo[]): number {
53 let n = 0;
54 for (const repo of repos) {
55 const rows = app.db
56 .query<{ id: string; title: string }, [string]>("SELECT id, title FROM issue WHERE repo_id = ?1 AND state = 'open' AND assignee_id IS NULL")
57 .all(repo.id);
58 const labels = labelsForIssues(app.db, rows.map((r) => r.id));
59 n += rows.filter((r) => isPolish(r.title, (labels.get(r.id) ?? []).map((l) => l.name))).length;
60 }
61 return n;
62 }
63
64 /** The repositories the Autonomy knob lists: awake ones, those with the crew on first. */
65 export function knobRepos(app: App, limit = 12): Repo[] {
66 return app.db
67 .query<{ id: string }, [number]>("SELECT id FROM repo WHERE archived_at IS NULL ORDER BY autonomy = 'off', name LIMIT ?1")
68 .all(limit)
69 .flatMap((r) => getRepoById(app.db, r.id) ?? []);
70 }
71
72 /** The five knobs now (Autonomy is one row per repository). */
73 export function readKnobs(app: App, now: Date = new Date()): KnobRow[] {
74 const repos = knobRepos(app);
75 const live = repos.filter((r) => r.autonomy !== "off");
76 const share = live[0]?.polishShare ?? POLISH_SHARE_DEFAULT;
77 const spend = live[0] === undefined ? undefined : polishSpend(app.db, live[0].id, share, now);
78 const waiting = waitingOnPeople(app.db, listActive(app.db, "agent").filter((r) => r.state === "queued"));
79 const hhmm = waiting.since === null ? "" : ` since ${waiting.since.slice(11, 16)}`;
80 const rows: KnobRow[] = [
81 {
82 id: "speed",
83 name: "Speed",
84 does: "How many crew runs go at once.",
85 value: capacityValue(app.db, "crew.concurrency"),
86 control: "number",
87 min: 1,
88 max: 16,
89 effect: runsLine(app),
90 },
91 {
92 id: "budget",
93 name: "Weekly budget",
94 does: "How much of the week's subscription to use, evenly to the reset.",
95 value: capacityValue(app.db, "week.use"),
96 control: "percent",
97 min: 10,
98 max: 100,
99 effect: budgetEffect(app, now),
100 },
101 {
102 id: "polish",
103 name: "Polish share",
104 does: "The share of the week's spend polish may take.",
105 value: String(share),
106 control: "percent",
107 min: 0,
108 max: 50,
109 effect: `polish ${spend === undefined ? 0 : Math.round(spend.percent)}% of this week's spend · ${polishWaiting(app, live)} polish issues waiting`,
110 },
111 ];
112 for (const repo of repos) {
113 const word = autonomyWord(repo);
114 rows.push({
115 id: "autonomy",
116 name: "Autonomy",
117 does: "off: the crew leaves it alone · ask: plans need a nod, decisions come to you · go: the decider answers, plans open.",
118 value: word,
119 control: "words",
120 words: AUTONOMY_WORDS,
121 repo: `${repo.ownerName}/${repo.name}`,
122 effect: word === "off" ? "off · the crew does not work here" : `${word} · ${repo.autonomySlots} builder slots`,
123 });
124 }
125 rows.push({
126 id: "feedback",
127 name: "Feedback first",
128 does: "A person's comment, assignment or note from an app starts ahead of everything. Always on.",
129 value: "on",
130 control: "fixed",
131 effect: waiting.count === 0 ? "nothing from a person waiting" : `${waiting.count} waiting${hhmm}`,
132 });
133 return rows;
134 }
135
136 /** The knobs as the plain lines the terminal and the MCP print. */
137 export function knobLines(rows: readonly KnobRow[]): string[] {
138 return rows.map((r) => `${r.name}${r.repo === undefined ? "" : ` (${r.repo})`}: ${r.value}${r.control === "percent" ? "%" : ""} — ${r.effect}`);
139 }
src/crew/people_first.ts +15
@@ -55,3 +55,18 @@ export function peopleFirst(
5555 if (first.length === 0 || first.length === queued.length) return [...queued];
5656 return [...first, ...queued.filter((r) => !personal(r))];
5757 }
58
59 /**
60 * The queued runs that carry a person's ask and the time the oldest one
61 * began waiting (the Feedback-first knob's live effect, #10461): a person's
62 * comment, a note from an app, or work a person assigned.
63 */
64 export function waitingOnPeople(db: Database, queued: readonly Run[]): { count: number; since: string | null } {
65 const fromApp = fromAppIssues(db, queued.flatMap((r) => (r.issueId === null ? [] : [r.issueId])));
66 const spoke = people(db, queued.flatMap((r) => (r.actorId !== null && (r.reason === "feedback" || r.reason === "assigned") ? [r.actorId] : [])));
67 const mine = queued.filter(
68 (r) => (r.issueId !== null && fromApp.has(r.issueId)) || ((r.reason === "feedback" || r.reason === "assigned") && r.actorId !== null && spoke.has(r.actorId)),
69 );
70 const since = mine.map((r) => r.createdAt).filter((t): t is string => typeof t === "string").sort()[0] ?? null;
71 return { count: mine.length, since };
72 }
src/crew/week.ts +3 −1
@@ -70,6 +70,8 @@ export interface WeekInput {
7070 * is a meter too coarse to read, not a crew that is behind (#10440).
7171 */
7272 readonly crewRan?: boolean;
73 /** The share of the week kept for people (1 − the weekly budget knob); `WEEK.reserve` when absent. */
74 readonly reserve?: number;
7375 }
7476
7577 export interface WeekDecision {
@@ -116,7 +118,7 @@ export function weekDecision(input: WeekInput): WeekDecision | null {
116118 const hours = left / 3_600_000;
117119 const capPct = Math.max(0, 100 - meter.keptPct);
118120 const used = 100 - meter.freePct;
119 const wall = capPct * (1 - WEEK.reserve);
121 const wall = capPct * (1 - (input.reserve ?? WEEK.reserve));
120122 const targetPctPerHour = Math.max(0, capPct - used) / hours;
121123
122124 // Allowed: what the crew may still spend before the reserve, over the hours left.
src/crew/week_read.ts +20 −3
@@ -10,7 +10,7 @@ import { measureFor } from "../core/measure.ts";
1010 import { getHeadroom, WINDOW_MS } from "../core/standing.ts";
1111 import { grantedAccounts, PLATFORM } from "../core/subscriptions.ts";
1212 import { burnFrom, noteReading, readingsSince } from "./week_burn.ts";
13 import { belowCeiling, capacityCount, capacityValue, paceFloor } from "../core/capacity.ts";
13 import { belowCeiling, capacityCount, capacityValue, paceFloor, weekUse } from "../core/capacity.ts";
1414 import { efficiencyWords, WEEK, weekDecision, type Efficiency, type WeekDecision, type WeekMeter } from "./week.ts";
1515
1616 /** The week as the provider's meter reads it: a meter, or null while no account's limit is known. */
@@ -132,7 +132,7 @@ export function weekFor(
132132 seen: { idleSlots: number; startable: number; gated: number } = { idleSlots: 0, startable: 0, gated: 0 },
133133 ): WeekDecision | null {
134134 const week = weekOf(app, repo, now);
135 return weekDecision({ ...week, now, ceiling: repo.autonomySlots, floorSlots: paceFloor(app.db), ...seen });
135 return weekDecision({ ...week, now, ceiling: repo.autonomySlots, floorSlots: paceFloor(app.db), reserve: 1 - weekUse(app.db) / 100, ...seen });
136136 }
137137
138138 /** The key of the accounts a repository draws its week from. */
@@ -158,7 +158,7 @@ export function crewCeiling(app: App, repo: Repo): number {
158158 */
159159 export function crewWeekFor(app: App, repo: Repo, now: Date): WeekDecision | null {
160160 const week = weekOf(app, repo, now);
161 return weekDecision({ ...week, now, ceiling: crewCeiling(app, repo), floorSlots: paceFloor(app.db), idleSlots: 0, startable: 0, gated: 0 });
161 return weekDecision({ ...week, now, ceiling: crewCeiling(app, repo), floorSlots: paceFloor(app.db), reserve: 1 - weekUse(app.db) / 100, idleSlots: 0, startable: 0, gated: 0 });
162162 }
163163
164164 /** A sentence saying the crew's cap is lowered, so pacing is not mistaken for it; empty at the ceiling. */
@@ -240,3 +240,20 @@ export function weekBlock(app: App, now: Date = new Date()): string[] {
240240 const words = weekWords(app, now, crew !== null);
241241 return [...(crew === null ? [] : [crew]), ...(words === null ? [] : [words])];
242242 }
243
244 /**
245 * The weekly-budget knob's live effect (#10461): `25% used, 73% of the time
246 * left · on pace · lasts to the reset Sat 02:00 UTC` — the busiest repository's
247 * meter, the same week `weekWords` states. Plain words while it is unmeasured.
248 */
249 export function budgetEffect(app: App, now: Date = new Date()): string {
250 const words = busiest(app, now, (repo, week) => {
251 const meter = weekOf(app, repo, now).meter;
252 if (meter === null) return null;
253 const used = Math.round(Math.min(100, Math.max(0, 100 - meter.freePct)));
254 const left = Math.round(Math.min(100, Math.max(0, ((meter.resetsAt.getTime() - now.getTime()) / WINDOW_MS.week) * 100)));
255 const lasts = week.runsOutAt === null ? `lasts to ${dayTime(meter.resetsAt)}` : `runs out ${dayTime(week.runsOutAt)}`;
256 return `${used}% used, ${left}% of the time left · ${week.status} · ${lasts}`;
257 });
258 return words ?? "the week is not measured yet · the crew holds at a few slots until it is";
259 }
src/http/mcp/tools/index.ts +1
@@ -85,6 +85,7 @@ export { tool as issue_teams } from "./issue_teams.ts";
8585 export { tool as issues } from "./issues.ts";
8686 export { tool as iterations } from "./iterations.ts";
8787 export { tool as keep_plan } from "./keep_plan.ts";
88 export { tool as knobs } from "./knobs.ts";
8889 export { tool as labels } from "./labels.ts";
8990 export { tool as land_past_gate } from "./land_past_gate.ts";
9091 export { tool as lift } from "./lift.ts";
src/http/mcp/tools/knobs.ts added +37
@@ -0,0 +1,37 @@
1 import { setKnob } from "../../../actions/knobs.ts";
2 import { knobLines, readKnobs } from "../../../crew/knobs.ts";
3 import { scopeAllows } from "../../../core/tokens.ts";
4 import { good, bad, type Tool } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "knobs",
8 doc: "`{ knob?, value?, repo?, dryRun? }` (forge admin): the crew's five knobs in the page's words, each with its live effect: `speed` (runs at once, 1-16), `budget` (percent of the week to use, 10-100), `polish` (percent share, 0-50), `autonomy` (`off`, `ask` or `go`, with `repo` as `owner/name`), and `feedback` (always on). With `knob` and `value`, planned first (`dryRun` is true unless `false`); applying needs an admin-scope token",
9 description: "The crew's five knobs and what each does now. With `knob` and `value`, plans.",
10 inputSchema: {
11 type: "object",
12 properties: {
13 knob: { type: "string", enum: ["speed", "budget", "polish", "autonomy"] },
14 value: { type: "string", maxLength: 16 },
15 repo: { type: "string", maxLength: 100 },
16 dryRun: { type: "boolean" },
17 },
18 },
19 writes: true,
20 dryByDefault: true,
21 async run(ctx, user, args) {
22 if (user?.isAdmin !== true || user.kind === "agent") return bad("not_found", "no such tool here");
23 const knob = typeof args["knob"] === "string" ? args["knob"] : undefined;
24 if (knob === undefined) {
25 const rows = readKnobs(ctx.app);
26 return good({ knobs: rows, says: knobLines(rows) });
27 }
28 const value = typeof args["value"] === "string" ? args["value"] : "";
29 const repo = typeof args["repo"] === "string" ? args["repo"] : undefined;
30 if (args["dryRun"] !== false)
31 return good({ says: `Would set ${knob}${repo === undefined ? "" : ` for ${repo}`} to ${value}. Send dryRun: false to change it.` });
32 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) return bad("forbidden", "turning a knob needs an admin-scope token");
33 const set = await setKnob(ctx.app, user, knob, value, repo);
34 if (!set.ok) return bad(set.error, "That knob or value is not one the knobs take.");
35 return good({ says: `${knob}: ${set.value.value}, from the next pump on.`, ...set.value });
36 },
37 };
src/http/settings_routes.ts +2
@@ -11,6 +11,7 @@ import type { Handler } from "./context.ts";
1111 import type { Router } from "./router.ts";
1212 import { parentLinkStart } from "./rp.ts";
1313 import { accessPage } from "../web/pages/access.ts";
14 import { knobSet } from "../web/pages/knobs.ts";
1415 import { capacityPage, capacitySet } from "../web/pages/capacity.ts";
1516 import { teamPage, teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts";
1617 import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts";
@@ -249,6 +250,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
249250 router.add("POST", "/settings/accounts/:username/retire", retireAct);
250251 router.add("POST", "/settings/accounts/:username/verify", accountAct("verify"));
251252 router.add("GET", "/settings/crew", crewPage);
253 router.add("POST", "/settings/crew/knobs", knobSet);
252254 router.add("POST", "/settings/crew", agentCreate);
253255 router.add("GET", "/settings/teams", teamsPage);
254256 router.add("GET", "/settings/teams/stream", teamsStream);
src/tui/screens/entries.ts +2
@@ -21,6 +21,7 @@ import { entry as cloud } from "./cloud.ts";
2121 import { entry as connections } from "./connections.ts";
2222 import { entry as crewHandover } from "./crewhandover.ts";
2323 import { entry as crewSplits } from "./crewsplits.ts";
24 import { entry as knobs } from "./knobs.ts";
2425 import { entry as displays } from "./displays.ts";
2526 import { entry as domains } from "./domains.ts";
2627 import { entry as edgeOnly } from "./edgeonly.ts";
@@ -101,6 +102,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
101102 securityControls,
102103 secrets,
103104 crewSplits,
105 knobs,
104106 crewHandover,
105107 scorecard,
106108 modelGrantEntry,
src/tui/screens/knobs.ts added +73
@@ -0,0 +1,73 @@
1 /**
2 * The crew's five knobs in the terminal (#10461), a forge admin's: the same
3 * rows the crew page and the MCP `knobs` tool print (`readKnobs`). `e` turns
4 * the one under the cursor through `setKnob`, the one place that turns them,
outdated

Both doors: 'turning one is the crew page's or the tool's' is the gap. The terminal must edit too (admin only, through setKnob), reaching web viewers live. Add a test for it.

5 * so the page and the feed see it as they see the page's own change.
6 */
7
8 import { setKnob } from "../../actions/knobs.ts";
9 import { knobLines, readKnobs, type KnobRow } from "../../crew/knobs.ts";
10 import type { Ctx, Screen } from "../app/app.ts";
11 import { binding } from "../app/keys.ts";
12 import { style } from "../term/style.ts";
13 import { truncate } from "../term/width.ts";
14 import type { Session } from "../session.ts";
15 import { fill, len, split } from "../ui/layout.ts";
16 import { ListState, drawList, later, promptScreen, type ListRow } from "./common.ts";
17 import type { PaletteEntry } from "./entries.ts";
18
19 const REFUSED = "That value is outside what this knob takes.";
20
21 function bounds(r: KnobRow): string {
22 return r.control === "words" ? (r.words ?? []).join(" / ") : `${r.min ?? 0}–${r.max ?? 100}${r.control === "percent" ? " %" : ""}`;
23 }
24
25 export function knobsScreen(f: Session, ctx: Ctx): Screen {
26 const state = new ListState();
27 const allowed = f.user?.isAdmin === true && f.user.kind !== "agent";
28 const read = (): KnobRow[] => (allowed ? readKnobs(f.app) : []);
29 let rows = read();
30 const screen: Screen = {
31 title: "Knobs",
32 keys: allowed ? [binding(["e"], "turn the knob"), binding(["j", "k"], "move cursor")] : [binding(["j", "k"], "move cursor")],
33 render(area, s) {
34 const c = ctx.theme.c;
35 const [head, list] = split(area, "v", [len(1), fill(1)]);
36 const text = allowed ? "Five dials for the crew; e turns the one under the cursor." : "The knobs are a forge admin's.";
37 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
38 const lines: ListRow[] = knobLines(rows).map((title) => ({ title }));
39 drawList(s, list!, c, state, lines);
40 },
41 key(e) {
42 if (e.key !== "e") return state.key(e, rows.length);
43 const row = rows[state.index];
44 if (row === undefined) return true;
45 if (row.control === "fixed") return ctx.toast(`${row.name} is always on.`, "success"), true;
46 later(ctx, () =>
47 promptScreen(ctx, screen, {
48 title: row.name,
49 label: `${row.repo ?? row.name} (${bounds(row)})`,
50 value: row.value,
51 onSubmit: async (typed) => {
52 const done = await setKnob(f.app, f.user, row.id, typed, row.repo);
53 if (!done.ok) return ctx.toast(done.error === "invalid_value" ? REFUSED : "That could not be done.", "danger");
54 rows = read();
55 ctx.toast(`${row.name}: ${done.value.value}, from the next pump on.`, "success");
56 ctx.invalidate();
57 },
58 }),
59 );
60 return true;
61 },
62 };
63 return screen;
64 }
65
66 export const entry: PaletteEntry = {
67 id: "knobs",
68 title: "Knobs",
69 hint: "speed, weekly budget, polish, autonomy, feedback first",
70 after: ["crew-scorecard", "usage"],
71 offered: (session) => session.user?.isAdmin === true,
72 run: (session, ctx) => ctx.push(knobsScreen(session, ctx)),
73 };
src/web/pages/crew.ts +2
@@ -37,6 +37,7 @@ import {
3737 type SplitScorecard,
3838 } from "../../core/scorecard.ts";
3939 import { keeperDays, type KeeperDay } from "../../flow/keeper_rate.ts";
40 import { knobsCard } from "./knobs.ts";
4041 import { decideChange, readReview } from "../../actions/model_review.ts";
4142 import { wastedDays, type WastedDay } from "../../core/wasted_days.ts";
4243 import { modelDays, type ModelDay } from "../../core/model_days.ts";
@@ -602,6 +603,7 @@ function crewView(
602603 error !== undefined && /username/i.test(error) ? error : undefined;
603604 const handover = crewHandover(ctx.app.db);
604605 return html`${crewReadiness(ctx)}
606 ${knobsCard(ctx)}
605607 ${when(spending.length > 0, () =>
606608 spendingCard(spending, forgeCeiling(ctx.app.config) ?? undefined),
607609 )}
src/web/pages/knobs.ts added +67
@@ -0,0 +1,67 @@
1 /**
2 * The knobs card on the crew page (#10461): one row per knob with its name,
3 * what it does, its value, its live effect and the control right there. A
4 * change posts to `knobSet`, which turns it through `actions/knobs.ts` and
5 * leaves the event the setting's own page leaves.
6 */
7
8 import { setKnob } from "../../actions/knobs.ts";
9 import { readKnobs, type KnobRow } from "../../crew/knobs.ts";
10 import type { Ctx } from "../../http/context.ts";
11 import { notFoundPage, signInFirst } from "../chrome.ts";
12 import { field, readForm } from "../form.ts";
13 import { html, type Html } from "../html.ts";
14 import { flashTo } from "../layout.ts";
15 import { btn } from "../ui.ts";
16
17 function control(r: KnobRow): Html {
18 if (r.control === "fixed") return html`<span class="badge">${r.value}</span>`;
19 const label = `${r.name}${r.repo === undefined ? "" : ` for ${r.repo}`}`;
20 const input =
21 r.control === "words"
22 ? html`<select class="input" name="value" aria-label="${label}">${(r.words ?? []).map(
23 (w) => html`<option value="${w}" ${w === r.value ? "selected" : ""}>${w}</option>`,
24 )}</select>`

Principle 4 (tokens, not hand-rolled markup): inline style="width:5rem" bypasses the design tokens. Use a named input size class.

25 : html`<input class="input input-num mono" type="number" name="value" value="${r.value}" min="${r.min ?? 0}" max="${r.max ?? 100}" aria-label="${label}">${r.control === "percent" ? html` %` : html``}`;
26 return html`<form method="post" action="/settings/crew/knobs" class="status-act">
27 <input type="hidden" name="knob" value="${r.id}">
28 <input type="hidden" name="repo" value="${r.repo ?? ""}">

Principle 5: one accent per page. A primary Set button on every row is many accents. Use a quiet button variant, or submit on change with a plain button as the no-JS fallback.

29 ${input} ${btn("Set", { variant: "ghost", size: "sm" })}
30 </form>`;
31 }
32
33 /** The knobs: one row each, name, live effect, the control in place. */
34 export function knobsCard(ctx: Ctx): Html {
35 const rows = readKnobs(ctx.app);

Principles 6 and 7: a settings-block section with an h2 and div.knob blocks is a box around a list. Use rows (a dl row or the named row component) with hairlines between them, not a card. .knob has no style in the design system.

36 return html`<section id="knobs">
37 <h2 class="mb">Knobs</h2>
outdated

Principles 1 and 8: this intro, then does, effect and derived on every row, say the same thing again. Keep one sentence for the page and one line per row (name, value, live effect). Drop does, or fold it into the tooltip or the doc.

38 <p class="muted small">Five dials for the crew; a change applies on the next pump.</p>
39 <dl class="status-rows">
40 ${rows.map(
41 (r) => html`<div class="status-row" data-knob="${r.id}">
42 <dt title="${r.does}">${r.name}${r.repo === undefined ? html`` : html` <span class="mono muted">${r.repo}</span>`}</dt>
43 <dd>${r.effect}</dd>
44 ${control(r)}
45 </div>`,
46 )}
47 </dl>
48 </section>`;
49 }
50
51 // POST /settings/crew/knobs
52 export async function knobSet(ctx: Ctx): Promise<Response> {
53 if (!ctx.auth.user) return signInFirst(ctx);
54 if (!ctx.auth.user.isAdmin) return notFoundPage(ctx);
55 const form = await readForm(ctx, "admin");
56 if (form instanceof Response) return form;
57 const knob = field(form, "knob");
58 const set = await setKnob(ctx.app, ctx.auth.user, knob, field(form, "value"), field(form, "repo"));
59 if (!set.ok && set.error === "not_found") return notFoundPage(ctx);
60 return flashTo(
61 ctx,
62 "/settings/crew",
63 set.ok
64 ? { message: `${knob}: ${set.value.value}, from the next pump on.` }
65 : { tone: "danger", message: "That value is outside what this knob takes." },
66 );
67 }
src/web/style/components.ts +1
@@ -80,6 +80,7 @@ export const components = /* css */ `
8080 background: var(--card); color: var(--fg);
8181 transition: border-color 140ms var(--ease), box-shadow 140ms var(--ease);
8282 }
83 .input.input-num { width: 5rem; }
8384 .input:hover { border-color: var(--border-strong); }
8485 .input:focus-visible {
8586 outline: none; border-color: var(--accent);
test/capacity-timing.test.ts +2 −2
@@ -83,8 +83,8 @@ test("render does not include the time already named as errors", async () => {
8383 // A warm answer costs a fixed, small number of SQL statements (#9725): work is
8484 // counted, not timed (#7824), so a regression — an N+1 over the settings or
8585 // the change history, a lost cache — fails every run, not a lucky one.
86 // 58 warm today, 59 once a setting is away from its default; the rest is margin.
87 const WARM_STATEMENTS = 64;
86 // 59 warm today (one more with the week.use setting), 60 once a setting is away from its default; the rest is margin.
87 const WARM_STATEMENTS = 68;
8888
8989 test("a warm capacity page costs a bounded number of statements, however many changes it lists", async () => {
9090 const url = `${servers.url}/settings/capacity`;
test/conversation-confirm.test.ts +1 −1
@@ -123,7 +123,7 @@ describe("the one list of what asks first", () => {
123123 "access", "account_verify", "app_members", "app_notes", "asks_act", "asks_decide", "asks_proposal", "asks_undecide", "atlas",
124124 "assign", "attach_look", "bet", "board_pins", "board_write", "branches", "capacity", "valves", "cloud_accounts", "close", "confirm_wait",
125125 "crew", "decisions_by", "detach", "economics", "edit", "email_verify", "feed_chips", "feed_dismiss", "feed_like", "flag_set", "flag_winner",
126 "follow_mode", "fork", "front_page", "hold", "issue_teams", "keep_plan", "labels", "lift", "limb", "maintenance", "make_it_yours", "map", "merge", "new_app",
126 "follow_mode", "fork", "front_page", "hold", "issue_teams", "keep_plan", "knobs", "labels", "lift", "limb", "maintenance", "make_it_yours", "map", "merge", "new_app",
127127 "next_up", "week", "notify", "open_pull", "org_create", "origin_apps", "pins", "plan", "profile", "propose_to_parent_forge", "repo_labels", "reports",
128128 "restore_drill", "review", "say", "signup", "snapshot_now", "spend", "steer", "sync", "take_back", "take_updates",
129129 "teams", "tell", "threads", "trail", "upstream", "variant", "wait_for", "watch", "whoami_can", "write_file",
test/knobs-page.test.ts added +115
@@ -0,0 +1,115 @@
1 /** The knobs card on the crew page (#10461): five rows, edited in place by a same-origin POST. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { capacityValue } from "../src/core/capacity.ts";
8 import { serve, type Servers } from "../src/http/server.ts";
9 import { createRepo } from "../src/actions/repos.ts";
10 import { getRepoById } from "../src/core/repos.ts";
11 import { createUser, getUserByName } from "../src/core/users.ts";
12 import { createToken } from "../src/core/tokens.ts";
13 import { seedAdmin } from "./helpers/admin.ts";
14
15 let dataDir = "";
16 let app: App, servers: Servers, bearer = "";
17
18 beforeAll(async () => {
19 dataDir = await mkdtemp(join(tmpdir(), "forge-knobs-page-"));
20 app = createApp({ dataDir, port: 0 });
21 servers = await serve(app);
22 const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
23 if (!admin.ok) throw new Error(admin.error);
24 const minted = await fetch(`${servers.url}/api/v1/tokens`, {
25 method: "POST",
26 headers: { "content-type": "application/json" },
27 body: JSON.stringify({ adminConfirm: true, username: "alice", password: "correct-horse-battery", name: "cli", scope: "admin" }),
28 });
29 bearer = ((await minted.json()) as { token: string }).token;
30 });
31 afterAll(async () => {
32 servers.stop();
33 app.close();
34 await rm(dataDir, { recursive: true, force: true });
35 });
36
37 const post = (origin: string, body: Record<string, string>) =>
38 fetch(`${servers.url}/settings/crew/knobs`, {
39 method: "POST",
40 redirect: "manual",
41 headers: { authorization: `Bearer ${bearer}`, "content-type": "application/x-www-form-urlencoded", origin },
42 body: new URLSearchParams(body),
43 });
44
45 test("the crew page shows the knobs, and a same-origin post turns one", async () => {
46 const res = await fetch(`${servers.url}/settings/crew`, { headers: { authorization: `Bearer ${bearer}` } });
47 const body = await res.text();
48 for (const id of ["speed", "budget", "polish", "feedback"]) expect(body).toContain(`data-knob="${id}"`);
49 expect(body).toContain("nothing from a person waiting");
50 const done = await post(servers.url, { knob: "speed", value: "3", repo: "" });
51 expect(done.status).toBe(303);
52 expect(capacityValue(app.db, "crew.concurrency")).toBe("3");
53 });
54
55 test("another origin changes nothing", async () => {
56 await post("https://evil.example", { knob: "speed", value: "5", repo: "" });
57 expect(capacityValue(app.db, "crew.concurrency")).toBe("3");
58 });
59
60 test("a guest post is sent to sign in and changes nothing", async () => {
61 const res = await fetch(`${servers.url}/settings/crew/knobs`, {
62 method: "POST",
63 redirect: "manual",
64 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
65 body: new URLSearchParams({ knob: "speed", value: "6", repo: "" }),
66 });
67 expect(res.status).not.toBe(200);
68 expect(capacityValue(app.db, "crew.concurrency")).toBe("3");
69 });
70
71 test("a member who is not an admin is refused and changes nothing", async () => {
72 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "correct-horse-battery" });
73 if (!made.ok) throw new Error(made.error);
74 const token = createToken(app.db, made.value.id, "t", "rw").token;
75 const res = await fetch(`${servers.url}/settings/crew/knobs`, {
76 method: "POST",
77 redirect: "manual",
78 headers: { authorization: `Bearer ${token}`, "content-type": "application/x-www-form-urlencoded", origin: servers.url },
79 body: new URLSearchParams({ knob: "speed", value: "6", repo: "" }),
80 });
81 expect(res.status).toBe(404);
82 expect(capacityValue(app.db, "crew.concurrency")).toBe("3");
83 });
84
85 test("an out-of-range value, an unknown knob and a removed dial change nothing", async () => {
86 for (const body of [
87 { knob: "speed", value: "99", repo: "" },
88 { knob: "speed", value: "abc", repo: "" },
89 { knob: "budget", value: "5", repo: "" },
90 { knob: "polish", value: "70", repo: "" },
91 { knob: "queue.depth", value: "4", repo: "" },
92 ]) {
93 const res = await post(servers.url, body);
94 expect([303, 404]).toContain(res.status);
95 }
96 expect(capacityValue(app.db, "crew.concurrency")).toBe("3");
97 expect(capacityValue(app.db, "week.use")).toBe("90");
98 expect(capacityValue(app.db, "queue.depth")).toBe("2");
99 });
100
101 test("Autonomy and Polish share turn from the page", async () => {
102 const made = await createRepo(app, (await listAdmin())!, { name: "site", visibility: "private" });
103 if (!made.ok) throw new Error(String(made.error));
104 expect((await post(servers.url, { knob: "autonomy", value: "ask", repo: "alice/site" })).status).toBe(303);
105 expect(getRepoById(app.db, made.value.id)!.autonomy).toBe("autonomous");
106 expect((await post(servers.url, { knob: "polish", value: "20", repo: "" })).status).toBe(303);
107 expect(getRepoById(app.db, made.value.id)!.polishShare).toBe(20);
108 const page = await (await fetch(`${servers.url}/settings/crew`, { headers: { authorization: `Bearer ${bearer}` } })).text();
109 expect(page).toContain('data-knob="autonomy"');
110 expect(page).toContain('data-variant="ghost"');
111 });
112
113 async function listAdmin() {
114 return getUserByName(app.db, "alice");
115 }
test/knobs.test.ts added +117
@@ -0,0 +1,117 @@
1 /**
2 * The five knobs (#10461): read in plain words, turned in place, and each
3 * turn read by the next pump; the pipeline's own dials stay on the capacity page.
4 */
5
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createRepo } from "../src/actions/repos.ts";
11 import { setKnob } from "../src/actions/knobs.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { CAPACITY_KEYS, capacityCount, setCapacity, weekUse } from "../src/core/capacity.ts";
14 import { listEvents } from "../src/core/events.ts";
15 import { getRepoById } from "../src/core/repos.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import { knobLines, readKnobs } from "../src/crew/knobs.ts";
18 import { weekDecision } from "../src/crew/week.ts";
19 import { tool } from "../src/http/mcp/tools/knobs.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21
22 quietLogs();
23
24 let dir: string;
25 let app: App;
26 let admin: User;
27 let bob: User;
28 let repoId: string;
29
30 beforeAll(async () => {
31 dir = await mkdtemp(join(tmpdir(), "forge-knobs-"));
32 app = createApp({ dataDir: dir, port: 0 });
33 const mk = async (username: string, first = false) => {
34 const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first });
35 if (!r.ok) throw new Error(r.error);
36 return r.value;
37 };
38 admin = await mk("carol", true);
39 bob = await mk("bob");
40 const made = await createRepo(app, admin, { name: "site", visibility: "private" });
41 if (!made.ok) throw new Error(String(made.error));
42 repoId = made.value.id;
43 }, 30_000);
44 afterAll(async () => {
45 app.close();
46 await rm(dir, { recursive: true, force: true });
47 });
48
49 test("the panel has Speed, Weekly budget, Polish share, Autonomy per repository and Feedback first, each with an effect", () => {
50 const rows = readKnobs(app);
51 expect(rows.map((r) => r.id)).toEqual(["speed", "budget", "polish", "autonomy", "feedback"]);
52 for (const r of rows) {
53 expect(r.does.length).toBeGreaterThan(0);
54 expect(r.effect.length).toBeGreaterThan(0);
55 }
56 expect(rows[0]!.effect).toMatch(/\d+ running · \d+ queued/);
57 expect(rows[4]!.effect).toBe("nothing from a person waiting");
58 expect(knobLines(rows)).toHaveLength(5);
59 });
60
61 test("Speed 4 → 2 is read by the next pump", async () => {
62 setCapacity(app.db, "crew.concurrency", "4", admin.id);
63 expect((await setKnob(app, admin, "speed", "2")).ok).toBe(true);
64 // The pump asks exactly this, with the number it booted on as the fallback.
65 expect(capacityCount(app.db, "crew.concurrency", 4)).toBe(2);
66 expect(readKnobs(app)[0]!.value).toBe("2");
67 expect((await setKnob(app, admin, "speed", "99")).ok).toBe(false);
68 expect(listEvents(app.db, {}).some((e) => e.kind === "capacity.changed")).toBe(true);
69 });
70
71 test("the pipeline's dials stay on capacity and no knob reaches them", async () => {
72 for (const key of ["slots.smallShare", "polish.capMinutes", "pace.floor", "queue.depth"]) expect(CAPACITY_KEYS as string[]).toContain(key);
73 for (const knob of ["queue.depth", "pace.floor", "slots.smallShare"]) expect((await setKnob(app, admin, knob, "1")).ok).toBe(false);
74 });
75
76 test("Weekly budget sets the reserve the pacer holds back", () => {
77 expect(weekUse(app.db)).toBe(90);
78 const week = (reserve: number) =>
79 weekDecision({
80 meter: { freePct: 20, keptPct: 0, burnPctPerHour: 1, resetsAt: new Date(Date.now() + 3 * 86_400_000) },
81 now: new Date(),
82 ceiling: 4,
83 floorSlots: 1,
84 idleSlots: 0,
85 startable: 0,
86 gated: 0,
87 reserve,
88 })!;
89 expect(week(0.1).slots).toBeGreaterThan(0);
90 expect(week(0.9).slots).toBe(0);
91 });
92
93 test("Autonomy turns ask and go into the repository's settings; Polish share sets its share", async () => {
94 expect((await setKnob(app, admin, "autonomy", "ask", "carol/site")).ok).toBe(true);
95 let repo = getRepoById(app.db, repoId)!;
96 expect([repo.autonomy, repo.plansNeedNod, repo.decisionsBy]).toEqual(["autonomous", true, "me"]);
97 expect((await setKnob(app, admin, "autonomy", "go", "carol/site")).ok).toBe(true);
98 repo = getRepoById(app.db, repoId)!;
99 expect([repo.plansNeedNod, repo.decisionsBy]).toEqual([false, "decider"]);
100 expect(readKnobs(app).find((r) => r.id === "autonomy" && r.repo === "carol/site")!.value).toBe("go");
101 expect((await setKnob(app, admin, "polish", "20")).ok).toBe(true);
102 expect(getRepoById(app.db, repoId)!.polishShare).toBe(20);
103 expect((await setKnob(app, admin, "polish", "70")).ok).toBe(false);
104 expect((await setKnob(app, admin, "autonomy", "maybe", "carol/site")).ok).toBe(false);
105 expect(listEvents(app.db, {}).some((e) => e.kind === "repo.settings_updated")).toBe(true);
106 expect((await setKnob(app, admin, "autonomy", "off", "carol/site")).ok).toBe(true);
107 });
108
109 test("only a forge admin turns a knob, and the MCP tool says the same words", async () => {
110 expect((await setKnob(app, bob, "speed", "3")).ok).toBe(false);
111 const ctx = { app, auth: { scope: "admin" } } as never;
112 const read = (await tool.run(ctx, admin, {})) as { content?: unknown };
113 expect(JSON.stringify(read)).toContain("Weekly budget");
114 const dry = await tool.run(ctx, admin, { knob: "speed", value: "3" });
115 expect(JSON.stringify(dry)).toContain("Would set speed");
116 expect(JSON.stringify(await tool.run(ctx, bob, {}))).toContain("not_found");
117 });
test/mcp.test.ts +1
@@ -247,6 +247,7 @@ describe("the forge over MCP", () => {
247247 "issues",
248248 "iterations",
249249 "keep_plan",
250 "knobs",
250251 "orgs",
251252 "now",
252253 "open_pull",
test/pages-budget.test.ts +1 −1
@@ -37,7 +37,7 @@ const HEAVIER: Record<string, { statements?: number; git?: number }> = {
3737 "/:owner/:name/pulls/:number": { statements: 106 },
3838 "/admin/limits": { statements: 80 },
3939 "/board": { statements: 64 },
40 "/settings/capacity": { statements: 65 },
40 "/settings/capacity": { statements: 71 },
4141 };
4242
4343 /** Patterns with no fixture today: lower this as fixtures are added, never raise it silently. */
test/palette-roster.test.ts +2
@@ -118,6 +118,7 @@ test("a site-admin person is offered the whole roster", () => {
118118 ["trusted-hosts", "Trusted hosts", "internal hosts an origin may reach"],
119119 ["usage", "Usage", "the forge's spend and headroom; an admin also sees every route"],
120120 ["crew-scorecard", "Crew scorecard", "agent runs per model and per prompt version"],
121 ["knobs", "Knobs", "speed, weekly budget, polish, autonomy, feedback first"],
121122 ["crew-handover", "Crew hand-overs", "issues parked twice, and the last restore drill"],
122123 ["scorecard", "Crew scorecard per agent", "each agent's pulls landed, review rounds, red checks, reverts, time to land"],
123124 ["forge-spend", "Forge spend", "today and the last 24 hours, headroom per subscription"],
@@ -208,6 +209,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
208209 ["secrets", "Secrets", "sealed secrets by name; set or remove one"],
209210 ["usage", "Usage", "the forge's spend and headroom; an admin also sees every route"],
210211 ["crew-scorecard", "Crew scorecard", "agent runs per model and per prompt version"],
212 ["knobs", "Knobs", "speed, weekly budget, polish, autonomy, feedback first"],
211213 ["crew-handover", "Crew hand-overs", "issues parked twice, and the last restore drill"],
212214 ["scorecard", "Crew scorecard per agent", "each agent's pulls landed, review rounds, red checks, reverts, time to land"],
213215 ["forge-spend", "Forge spend", "today and the last 24 hours, headroom per subscription"],
test/tui-knobs.test.ts added +84
@@ -0,0 +1,84 @@
1 /**
2 * The knobs in the terminal (#10461): a forge admin turns one with `e`
3 * through `setKnob`; a value out of range is refused and changes nothing; a
4 * member is shown nothing to turn.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { capacityValue } from "../src/core/capacity.ts";
12 import { listEvents } from "../src/core/events.ts";
13 import { createUser, type User } from "../src/core/users.ts";
14 import { createForgeApp } from "../src/tui/forge.ts";
15 import { knobsScreen } from "../src/tui/screens/knobs.ts";
16 import { headlessTerminal } from "../src/tui/term/terminal.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18 import { settleApp } from "./helpers/tui.ts";
19
20 quietLogs();
21
22 let dir: string;
23 let app: App;
24 let carol: User;
25 let bob: User;
26
27 const identity = (user: User) => ({ sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user });
28
29 async function boot(user: User) {
30 const { app: tui, session } = createForgeApp(app, identity(user), { terminal: headlessTerminal(140, 40), exitHooks: false });
31 tui.start();
32 tui.ctx.push(knobsScreen(session, tui.ctx));
33 await settleApp(tui);
34 const press = async (...keys: string[]) => {
35 for (const key of keys) tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
36 await settleApp(tui);
37 };
38 return { press, text: () => tui.screen.toText(), stop: () => tui.stop?.() };
39 }
40
41 beforeAll(async () => {
42 dir = await mkdtemp(join(tmpdir(), "forge-tui-knobs-"));
43 app = createApp({ dataDir: dir, port: 0 });
44 const mk = async (username: string, first = false) => {
45 const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first });
46 if (!r.ok) throw new Error(r.error);
47 return r.value;
48 };
49 carol = await mk("carol", true);
50 bob = await mk("bob");
51 }, 30_000);
52 afterAll(async () => {
53 app.close();
54 await rm(dir, { recursive: true, force: true });
55 });
56
57 test("an admin turns Speed with e, and the capacity event is left", async () => {
58 const h = await boot(carol);
59 expect(h.text()).toContain("Speed");
60 await h.press("e");
61 for (let i = 0; i < 4; i++) await h.press("backspace");
62 await h.press("3", "enter");
63 expect(capacityValue(app.db, "crew.concurrency")).toBe("3");
64 expect(h.text()).toContain("Speed: 3");
65 expect(listEvents(app.db, {}).some((e) => e.kind === "capacity.changed")).toBe(true);
66 h.stop();
67 });
68
69 test("a value out of range changes nothing", async () => {
70 const h = await boot(carol);
71 await h.press("e");
72 for (let i = 0; i < 4; i++) await h.press("backspace");
73 await h.press("9", "9", "enter");
74 expect(capacityValue(app.db, "crew.concurrency")).toBe("3");
75 h.stop();
76 });
77
78 test("a member sees no knobs and e turns nothing", async () => {
79 const h = await boot(bob);
80 expect(h.text()).toContain("a forge admin's");
81 await h.press("e", "9", "enter");
82 expect(capacityValue(app.db, "crew.concurrency")).toBe("3");
83 h.stop();
84 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs