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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10461-knobs-one-panel-on-the-crew-page-with-fi → main 10 commits fork 8 threads · 8 unresolved
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 = | ||
| 21 | 21 | | "checks.shards" |
| 22 | 22 | | "queue.depth" |
| 23 | 23 | | "queue.batch" |
| 24 | | "week.use" | |
| 24 | 25 | | "children.max" |
| 25 | 26 | | "cells.egress" |
| 26 | 27 | | "children.modelEgress" |
| @@ -85,6 +86,13 @@ export const CAPACITY: Record<CapacityKey, Definition> = { | ||
| 85 | 86 | fallback: "8", |
| 86 | 87 | 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.", |
| 87 | 88 | }, |
| 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 | }, | |
| 88 | 96 | "children.max": { |
| 89 | 97 | label: "Forges this one may send", |
| 90 | 98 | env: "FORGE_CHILDREN_MAX", |
| @@ -370,6 +378,11 @@ export function paceFloor(db: Database): number { | ||
| 370 | 378 | return Number(capacityValue(db, "pace.floor")); |
| 371 | 379 | } |
| 372 | 380 | |
| 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 | ||
| 373 | 386 | /** How many hosted children this forge may hold now; read on every use. */ |
| 374 | 387 | export function maxChildren(db: Database): number { |
| 375 | 388 | 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( | ||
| 55 | 55 | if (first.length === 0 || first.length === queued.length) return [...queued]; |
| 56 | 56 | return [...first, ...queued.filter((r) => !personal(r))]; |
| 57 | 57 | } |
| 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 { | ||
| 70 | 70 | * is a meter too coarse to read, not a crew that is behind (#10440). |
| 71 | 71 | */ |
| 72 | 72 | 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; | |
| 73 | 75 | } |
| 74 | 76 | |
| 75 | 77 | export interface WeekDecision { |
| @@ -116,7 +118,7 @@ export function weekDecision(input: WeekInput): WeekDecision | null { | ||
| 116 | 118 | const hours = left / 3_600_000; |
| 117 | 119 | const capPct = Math.max(0, 100 - meter.keptPct); |
| 118 | 120 | const used = 100 - meter.freePct; |
| 119 | const wall = capPct * (1 - WEEK.reserve); | |
| 121 | const wall = capPct * (1 - (input.reserve ?? WEEK.reserve)); | |
| 120 | 122 | const targetPctPerHour = Math.max(0, capPct - used) / hours; |
| 121 | 123 | |
| 122 | 124 | // 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"; | ||
| 10 | 10 | import { getHeadroom, WINDOW_MS } from "../core/standing.ts"; |
| 11 | 11 | import { grantedAccounts, PLATFORM } from "../core/subscriptions.ts"; |
| 12 | 12 | 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"; | |
| 14 | 14 | import { efficiencyWords, WEEK, weekDecision, type Efficiency, type WeekDecision, type WeekMeter } from "./week.ts"; |
| 15 | 15 | |
| 16 | 16 | /** 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( | ||
| 132 | 132 | seen: { idleSlots: number; startable: number; gated: number } = { idleSlots: 0, startable: 0, gated: 0 }, |
| 133 | 133 | ): WeekDecision | null { |
| 134 | 134 | 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 }); | |
| 136 | 136 | } |
| 137 | 137 | |
| 138 | 138 | /** The key of the accounts a repository draws its week from. */ |
| @@ -158,7 +158,7 @@ export function crewCeiling(app: App, repo: Repo): number { | ||
| 158 | 158 | */ |
| 159 | 159 | export function crewWeekFor(app: App, repo: Repo, now: Date): WeekDecision | null { |
| 160 | 160 | 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 }); | |
| 162 | 162 | } |
| 163 | 163 | |
| 164 | 164 | /** 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[] { | ||
| 240 | 240 | const words = weekWords(app, now, crew !== null); |
| 241 | 241 | return [...(crew === null ? [] : [crew]), ...(words === null ? [] : [words])]; |
| 242 | 242 | } |
| 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"; | ||
| 85 | 85 | export { tool as issues } from "./issues.ts"; |
| 86 | 86 | export { tool as iterations } from "./iterations.ts"; |
| 87 | 87 | export { tool as keep_plan } from "./keep_plan.ts"; |
| 88 | export { tool as knobs } from "./knobs.ts"; | |
| 88 | 89 | export { tool as labels } from "./labels.ts"; |
| 89 | 90 | export { tool as land_past_gate } from "./land_past_gate.ts"; |
| 90 | 91 | 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"; | ||
| 11 | 11 | import type { Router } from "./router.ts"; |
| 12 | 12 | import { parentLinkStart } from "./rp.ts"; |
| 13 | 13 | import { accessPage } from "../web/pages/access.ts"; |
| 14 | import { knobSet } from "../web/pages/knobs.ts"; | |
| 14 | 15 | import { capacityPage, capacitySet } from "../web/pages/capacity.ts"; |
| 15 | 16 | import { teamPage, teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts"; |
| 16 | 17 | import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts"; |
| @@ -249,6 +250,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 249 | 250 | router.add("POST", "/settings/accounts/:username/retire", retireAct); |
| 250 | 251 | router.add("POST", "/settings/accounts/:username/verify", accountAct("verify")); |
| 251 | 252 | router.add("GET", "/settings/crew", crewPage); |
| 253 | router.add("POST", "/settings/crew/knobs", knobSet); | |
| 252 | 254 | router.add("POST", "/settings/crew", agentCreate); |
| 253 | 255 | router.add("GET", "/settings/teams", teamsPage); |
| 254 | 256 | router.add("GET", "/settings/teams/stream", teamsStream); |
src/tui/screens/entries.ts +2
| @@ -21,6 +21,7 @@ import { entry as cloud } from "./cloud.ts"; | ||
| 21 | 21 | import { entry as connections } from "./connections.ts"; |
| 22 | 22 | import { entry as crewHandover } from "./crewhandover.ts"; |
| 23 | 23 | import { entry as crewSplits } from "./crewsplits.ts"; |
| 24 | import { entry as knobs } from "./knobs.ts"; | |
| 24 | 25 | import { entry as displays } from "./displays.ts"; |
| 25 | 26 | import { entry as domains } from "./domains.ts"; |
| 26 | 27 | import { entry as edgeOnly } from "./edgeonly.ts"; |
| @@ -101,6 +102,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 101 | 102 | securityControls, |
| 102 | 103 | secrets, |
| 103 | 104 | crewSplits, |
| 105 | knobs, | |
| 104 | 106 | crewHandover, |
| 105 | 107 | scorecard, |
| 106 | 108 | 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, | |
| 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 { | ||
| 37 | 37 | type SplitScorecard, |
| 38 | 38 | } from "../../core/scorecard.ts"; |
| 39 | 39 | import { keeperDays, type KeeperDay } from "../../flow/keeper_rate.ts"; |
| 40 | import { knobsCard } from "./knobs.ts"; | |
| 40 | 41 | import { decideChange, readReview } from "../../actions/model_review.ts"; |
| 41 | 42 | import { wastedDays, type WastedDay } from "../../core/wasted_days.ts"; |
| 42 | 43 | import { modelDays, type ModelDay } from "../../core/model_days.ts"; |
| @@ -602,6 +603,7 @@ function crewView( | ||
| 602 | 603 | error !== undefined && /username/i.test(error) ? error : undefined; |
| 603 | 604 | const handover = crewHandover(ctx.app.db); |
| 604 | 605 | return html`${crewReadiness(ctx)} |
| 606 | ${knobsCard(ctx)} | |
| 605 | 607 | ${when(spending.length > 0, () => |
| 606 | 608 | spendingCard(spending, forgeCeiling(ctx.app.config) ?? undefined), |
| 607 | 609 | )} |
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 |
||
| 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 |
||
| 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 |
||
| 36 | return html`<section id="knobs"> | |
| 37 | <h2 class="mb">Knobs</h2> | |
outdated
Principles 1 and 8: this intro, then |
||
| 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 */ ` | ||
| 80 | 80 | background: var(--card); color: var(--fg); |
| 81 | 81 | transition: border-color 140ms var(--ease), box-shadow 140ms var(--ease); |
| 82 | 82 | } |
| 83 | .input.input-num { width: 5rem; } | |
| 83 | 84 | .input:hover { border-color: var(--border-strong); } |
| 84 | 85 | .input:focus-visible { |
| 85 | 86 | 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 () => { | ||
| 83 | 83 | // A warm answer costs a fixed, small number of SQL statements (#9725): work is |
| 84 | 84 | // counted, not timed (#7824), so a regression — an N+1 over the settings or |
| 85 | 85 | // 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; | |
| 88 | 88 | |
| 89 | 89 | test("a warm capacity page costs a bounded number of statements, however many changes it lists", async () => { |
| 90 | 90 | 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", () => { | ||
| 123 | 123 | "access", "account_verify", "app_members", "app_notes", "asks_act", "asks_decide", "asks_proposal", "asks_undecide", "atlas", |
| 124 | 124 | "assign", "attach_look", "bet", "board_pins", "board_write", "branches", "capacity", "valves", "cloud_accounts", "close", "confirm_wait", |
| 125 | 125 | "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", | |
| 127 | 127 | "next_up", "week", "notify", "open_pull", "org_create", "origin_apps", "pins", "plan", "profile", "propose_to_parent_forge", "repo_labels", "reports", |
| 128 | 128 | "restore_drill", "review", "say", "signup", "snapshot_now", "spend", "steer", "sync", "take_back", "take_updates", |
| 129 | 129 | "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", () => { | ||
| 247 | 247 | "issues", |
| 248 | 248 | "iterations", |
| 249 | 249 | "keep_plan", |
| 250 | "knobs", | |
| 250 | 251 | "orgs", |
| 251 | 252 | "now", |
| 252 | 253 | "open_pull", |
test/pages-budget.test.ts +1 −1
| @@ -37,7 +37,7 @@ const HEAVIER: Record<string, { statements?: number; git?: number }> = { | ||
| 37 | 37 | "/:owner/:name/pulls/:number": { statements: 106 }, |
| 38 | 38 | "/admin/limits": { statements: 80 }, |
| 39 | 39 | "/board": { statements: 64 }, |
| 40 | "/settings/capacity": { statements: 65 }, | |
| 40 | "/settings/capacity": { statements: 71 }, | |
| 41 | 41 | }; |
| 42 | 42 | |
| 43 | 43 | /** 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", () => { | ||
| 118 | 118 | ["trusted-hosts", "Trusted hosts", "internal hosts an origin may reach"], |
| 119 | 119 | ["usage", "Usage", "the forge's spend and headroom; an admin also sees every route"], |
| 120 | 120 | ["crew-scorecard", "Crew scorecard", "agent runs per model and per prompt version"], |
| 121 | ["knobs", "Knobs", "speed, weekly budget, polish, autonomy, feedback first"], | |
| 121 | 122 | ["crew-handover", "Crew hand-overs", "issues parked twice, and the last restore drill"], |
| 122 | 123 | ["scorecard", "Crew scorecard per agent", "each agent's pulls landed, review rounds, red checks, reverts, time to land"], |
| 123 | 124 | ["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", | ||
| 208 | 209 | ["secrets", "Secrets", "sealed secrets by name; set or remove one"], |
| 209 | 210 | ["usage", "Usage", "the forge's spend and headroom; an admin also sees every route"], |
| 210 | 211 | ["crew-scorecard", "Crew scorecard", "agent runs per model and per prompt version"], |
| 212 | ["knobs", "Knobs", "speed, weekly budget, polish, autonomy, feedback first"], | |
| 211 | 213 | ["crew-handover", "Crew hand-overs", "issues parked twice, and the last restore drill"], |
| 212 | 214 | ["scorecard", "Crew scorecard per agent", "each agent's pulls landed, review rounds, red checks, reverts, time to land"], |
| 213 | 215 | ["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 | }); |
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.