Public: anyone can read this; the crew can change it.
The terminal lists the forge's capacity and guards live, and a forge admin changes a settable one after a plan, as MCP `capacity` does #7455
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7275-the-terminal-lists-the-forge-s-capacity → main 5 commits fork 8 threads · 8 unresolved
README.md +2
| @@ -62,6 +62,8 @@ Home opens with "Since you were here · 2 days": the pulls that landed in public | ||
| 62 | 62 | |
| 63 | 63 | In the terminal, the System screen (palette: System) shows a forge admin who is a person one more line, `telemetry:read held by: …` (the agents holding the grant), and `g` there picks an agent and asks `grant <agent> telemetry:read? y/n` (or `take back …`; the default is n). `y` calls `setTelemetryGrant`, as MCP `telemetry_grant` does; anyone that tool refuses sees neither the line nor the key hint, and `g` answers them in the tool's words. |
| 64 | 64 | |
| 65 | In the terminal, the Capacity screen (palette: Capacity, a forge admin who is a person) lists every guard with its live value and bounds, as /settings/capacity does; `e` on one asks for a value (empty restores the default), shows `key: from → to` and calls `changeCapacity` only on `y` (default n). No row is read-only here. | |
| 66 | ||
| 65 | 67 | An admin's home and the terminal's Flow dialog also say when the forge is clogged and how long the last clog took to notice and to clear: `docs/features/clog-watch.md`. A fork whose parent is archived shows one quiet line instead of its ahead/behind count, and its admin can detach it from the parent (Settings › Danger zone, the terminal's About `x`, MCP `detach`): `docs/features/detach.md`. |
| 66 | 68 | |
| 67 | 69 | A pull the merge queue holds as red alone (red on its own merge at its current head; cleared by a new head or a moved main) says so under its checks line: "red alone at <time>, waiting for a new head or a new main", on the pull page, its live checks line and the studio change step, and in the checks row of the pull's terminal screen (`redMark` in `src/checks/queue.ts`). Nothing is drawn when it is not held. |
src/actions/capacity.ts +10
| @@ -26,6 +26,16 @@ import { passkeyIds } from "../core/passkeys.ts"; | ||
| 26 | 26 | import type { User } from "../core/users.ts"; |
| 27 | 27 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 28 | 28 | |
| 29 | /** The six guards a caller may set by `key`: the MCP tool's enum and the terminal's `e`. */ | |
| 30 | export const SETTABLE_CAPACITY = [ | |
| 31 | "crew.concurrency", | |
| 32 | "checks.concurrency", | |
| 33 | "checks.shards", | |
| 34 | "cells.egress", | |
| 35 | "guard_new_accounts", | |
| 36 | "signin.edge", | |
| 37 | ] as const satisfies readonly CapacityKey[]; | |
| 38 | ||
| 29 | 39 | export interface CapacityRow { |
| 30 | 40 | readonly key: CapacityKey; |
| 31 | 41 | readonly label: string; |
src/http/mcp/tools/capacity.ts +2 −2
| @@ -1,4 +1,4 @@ | ||
| 1 | import { changeCapacity, listCapacity } from "../../../actions/capacity.ts"; | |
| 1 | import { SETTABLE_CAPACITY, changeCapacity, listCapacity } from "../../../actions/capacity.ts"; | |
| 2 | 2 | import { SETTING_WORDS } from "../../../actions/setting_words.ts"; |
| 3 | 3 | import { scopeAllows } from "../../../core/tokens.ts"; |
| 4 | 4 | import { good, bad, type Tool } from "../shared.ts"; |
| @@ -13,7 +13,7 @@ export const tool: Tool = { | ||
| 13 | 13 | properties: { |
| 14 | 14 | key: { |
| 15 | 15 | type: "string", |
| 16 | enum: ["crew.concurrency", "checks.concurrency", "checks.shards", "cells.egress", "guard_new_accounts", "signin.edge"], | |
| 16 | enum: [...SETTABLE_CAPACITY], | |
| 17 | 17 | }, |
| 18 | 18 | value: { type: "string", maxLength: 16 }, |
| 19 | 19 | dryRun: { type: "boolean" }, |
src/tui/forge.ts +9
| @@ -69,6 +69,7 @@ import { quotasScreen } from "./screens/quotas.ts"; | ||
| 69 | 69 | import { teamsScreen } from "./screens/teams.ts"; |
| 70 | 70 | import { domainsScreen } from "./screens/domains.ts"; |
| 71 | 71 | import { ownerScreen } from "./screens/owner.ts"; |
| 72 | import { capacityScreen, mayCapacity } from "./screens/capacity.ts"; | |
| 72 | 73 | import { resilienceScreen } from "./screens/resilience.ts"; |
| 73 | 74 | import { forgeCeiling } from "../flow/ceiling.ts"; |
| 74 | 75 | import { flowLines, flowScreen } from "./screens/flow.ts"; |
| @@ -386,6 +387,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 386 | 387 | }, |
| 387 | 388 | }); |
| 388 | 389 | } |
| 390 | // The forge's capacity and guards, as MCP `capacity` gates them: a forge admin who is a person. | |
| 391 | if (mayCapacity(session)) | |
| 392 | commands.push({ | |
| 393 | id: "capacity", | |
| 394 | title: "Capacity", | |
| 395 | hint: "concurrency and guards, live; e changes a settable one", | |
| 396 | run: () => ctx.push(capacityScreen(session, ctx)), | |
| 397 | }); | |
| 389 | 398 | // Where snapshots go and each THREATS.md protection's standing, as /resilience says it: a site admin's; a person admin sets or forgets the destination and the mirror here. |
| 390 | 399 | if (session.user?.isAdmin === true) |
| 391 | 400 | commands.push({ |
src/tui/screens/capacity.ts added +100
| @@ -0,0 +1,100 @@ | ||
| 1 | /** | |
| 2 | * The forge's capacity and guards in the terminal, a forge admin's who is a | |
| 3 | * person: each guard's live value and bounds, and `e` on a settable one asks | |
| 4 | * for a value, shows the plan (from → to) and changes it after a confirm, | |
| 5 | * through the same actions as MCP `capacity`. The actions decide who may. | |
| 6 | */ | |
| 7 | ||
| 8 | import { changeCapacity, listCapacity, type CapacityRow } from "../../actions/capacity.ts"; | |
| 9 | import { SETTING_WORDS } from "../../actions/setting_words.ts"; | |
| 10 | import { CAPACITY, validCapacity, type CapacityKey } from "../../core/capacity.ts"; | |
| 11 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 12 | import { binding } from "../app/keys.ts"; | |
| 13 | import type { Session } from "../session.ts"; | |
| 14 | import { style } from "../term/style.ts"; | |
| 15 | import { truncate } from "../term/width.ts"; | |
| 16 | import { fill, len, split } from "../ui/layout.ts"; | |
| 17 | import { ListState, confirmScreen, drawList, later, promptScreen, type ListRow } from "./common.ts"; | |
| 18 | ||
| 19 | /** Whether the palette offers the screen: a forge admin who is a person. */ | |
outdated
With every key editable here, this alias goes away; |
||
| 20 | export const mayCapacity = (session: Session): boolean => | |
| 21 | session.user?.isAdmin === true && session.user.kind !== "agent"; | |
| 22 | ||
| 23 | /** What a setting takes, in a few words. */ | |
| 24 | export function boundsOf(key: CapacityKey): string { | |
| 25 | const kind = CAPACITY[key].kind; | |
| 26 | return kind.type === "count" ? `${kind.min}–${kind.max}` : kind.values.join(" | "); | |
| 27 | } | |
| 28 | ||
| 29 | /** What a refused change says, in its own words. */ | |
| 30 | const refused = (error: "not_found" | "no_such_setting" | "invalid_value" | "no_credential"): string => | |
| 31 | error === "no_credential" | |
| 32 | ? "Set a password or a passkey first: turning the edge off would lock you out" | |
| 33 | : error === "invalid_value" | |
| 34 | ? SETTING_WORDS.outOfRange | |
| 35 | : "no such capacity setting"; | |
| 36 | ||
| 37 | const shown = (v: string): string => (v === "" ? "auto" : v); | |
| 38 | ||
| 39 | export function capacityScreen(f: Session, ctx: Ctx): Screen { | |
| 40 | const state = new ListState(); | |
| 41 | const rows = (): CapacityRow[] => { | |
| 42 | const listed = listCapacity(f.app, f.user); | |
| 43 | return listed.ok ? listed.value.settings : []; | |
| 44 | }; | |
| 45 | const lines = (): ListRow[] => | |
| 46 | rows().map((r) => ({ | |
| 47 | title: `${r.label} (${r.key})`, | |
| 48 | meta: `${shown(r.value)} · ${r.source} · takes ${boundsOf(r.key)}${r.source === "setting" ? ` · default ${shown(r.default)}` : ""}`, | |
| 49 | })); | |
| 50 | ||
outdated
Once every row is editable, show |
||
| 51 | const plan = (row: CapacityRow, typed: string): void => { | |
| 52 | const to = typed === "" ? row.default : typed; | |
| 53 | later(ctx, () => | |
| 54 | confirmScreen(ctx, screen, { | |
| 55 | title: "Capacity", | |
| 56 | body: [`${row.key}: ${shown(row.value)} → ${shown(to)}`, "change it? y/n"], | |
outdated
Every failure becomes outOfRange. no_credential (signin.edge off with no own credential) and not_found are different problems; word each one and test the lockout case. |
||
| 57 | actions: [{ label: "y" }, { label: "n" }], | |
| 58 | selected: 1, | |
| 59 | onChoose: (choice) => { | |
| 60 | if (choice !== 0) return; | |
| 61 | const done = changeCapacity(f.app, f.user, row.key, typed); | |
| 62 | if (!done.ok) return ctx.toast(refused(done.error), "danger"); | |
| 63 | ctx.toast(`${done.value.key}: ${shown(done.value.from)} → ${shown(done.value.to)}, from the next pick on.`, "success"); | |
| 64 | ctx.invalidate(); | |
| 65 | }, | |
| 66 | }), | |
| 67 | ); | |
| 68 | }; | |
| 69 | ||
| 70 | const screen: Screen = { | |
| 71 | title: "Capacity", | |
| 72 | keys: [binding(["e"], "change a setting"), binding(["j", "k"], "move cursor")], | |
| 73 | render(area, s) { | |
| 74 | const c = ctx.theme.c; | |
| 75 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 76 | s.text(head!.x, head!.y, truncate("e changes the guard under the cursor, as /settings/capacity does", head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 77 | drawList(s, list!, c, state, lines()); | |
| 78 | }, | |
| 79 | key(e) { | |
| 80 | const all = rows(); | |
| 81 | if (e.key !== "e") return state.key(e, all.length); | |
| 82 | const row = all[state.index]; | |
| 83 | if (row === undefined) return true; | |
| 84 | later(ctx, () => | |
| 85 | promptScreen(ctx, screen, { | |
| 86 | title: row.label, | |
| 87 | label: `${row.key} (${boundsOf(row.key)}; empty restores the default)`, | |
| 88 | value: row.value, | |
| 89 | onSubmit: (typed) => { | |
outdated
High (both doors): the web page ( |
||
| 90 | const value = typed.trim(); | |
| 91 | if (value !== "" && !validCapacity(row.key, value)) return SETTING_WORDS.outOfRange; | |
| 92 | return plan(row, value), undefined; | |
| 93 | }, | |
| 94 | }), | |
| 95 | ); | |
| 96 | return true; | |
| 97 | }, | |
| 98 | }; | |
outdated
Trim |
||
| 99 | return screen; | |
| 100 | } |
test/tui-capacity.test.ts added +185
| @@ -0,0 +1,185 @@ | ||
| 1 | /** | |
| 2 | * The forge's capacity in the terminal (#7275): a forge admin who is a person | |
| 3 | * lists the guards, changes a settable one after a plan and a confirm, and is | |
| 4 | * refused out of bounds; an agent or a member is offered nothing. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, 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 { createAgent } from "../src/actions/crew.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { listEvents } from "../src/core/events.ts"; | |
| 13 | import { changeCapacity } from "../src/actions/capacity.ts"; | |
| 14 | import { CAPACITY_KEYS, capacityValue } from "../src/core/capacity.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { createForgeApp, forgeCommands } from "../src/tui/forge.ts"; | |
| 17 | import { capacityScreen } from "../src/tui/screens/capacity.ts"; | |
| 18 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | import { settleApp } from "./helpers/tui.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | let dir: string; | |
| 25 | let app: App; | |
| 26 | let carol: User; | |
| 27 | let bob: User; | |
| 28 | let bot: User; | |
| 29 | ||
| 30 | const identity = (user?: User) => ({ sshUsername: user?.username ?? "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }); | |
| 31 | const crew = () => capacityValue(app.db, "crew.concurrency"); | |
| 32 | ||
| 33 | function boot(user: User) { | |
| 34 | const { app: tui, session } = createForgeApp(app, identity(user), { terminal: headlessTerminal(140, 40), exitHooks: false }); | |
| 35 | tui.start(); | |
| 36 | tui.ctx.push(capacityScreen(session, tui.ctx)); | |
| 37 | const press = async (...keys: string[]) => { | |
| 38 | for (const key of keys) tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 39 | await settleApp(tui); | |
| 40 | }; | |
| 41 | return { press, text: () => tui.screen.toText(), stop: () => tui.stop?.() }; | |
| 42 | } | |
| 43 | ||
| 44 | beforeAll(async () => { | |
| 45 | dir = await mkdtemp(join(tmpdir(), "forge-tui-capacity-")); | |
| 46 | app = createApp({ dataDir: dir, port: 0 }); | |
| 47 | const mk = async (username: string, first = false) => { | |
| 48 | const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first }); | |
| 49 | if (!r.ok) throw new Error(r.error); | |
| 50 | return r.value; | |
| 51 | }; | |
| 52 | carol = await mk("carol", true); | |
| 53 | bob = await mk("bob"); | |
| 54 | const made = await createAgent(app, carol, { username: "capbot", brief: "x" }); | |
| 55 | if (!made.ok) throw new Error(String(made.error)); | |
| 56 | app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(made.value.user.id); | |
| 57 | bot = { ...made.value.user, isAdmin: true }; | |
| 58 | }, 30_000); | |
| 59 | ||
| 60 | afterAll(async () => { | |
| 61 | app.close(); | |
| 62 | await rm(dir, { recursive: true, force: true }); | |
| 63 | }); | |
| 64 | ||
| 65 | describe("tui capacity", () => { | |
| 66 | test("only an admin who is a person is offered the door", () => { | |
| 67 | const door = (u: User) => { | |
| 68 | const { app: tui, session } = createForgeApp(app, identity(u), { terminal: headlessTerminal(80, 24), exitHooks: false }); | |
| 69 | const ids = forgeCommands(session)(tui.ctx).map((c) => c.id); | |
| 70 | tui.stop?.(); | |
| 71 | return ids; | |
| 72 | }; | |
| 73 | expect(door(carol)).toContain("capacity"); | |
| 74 | expect(door(bob)).not.toContain("capacity"); | |
| 75 | expect(door(bot)).not.toContain("capacity"); | |
| 76 | }); | |
| 77 | ||
| 78 | test("lists each guard with its value and bounds", async () => { | |
| 79 | const h = boot(carol); | |
| 80 | await h.press(); | |
| 81 | expect(h.text()).toContain("Crew runs at once"); | |
| 82 | expect(h.text()).toContain("takes 1–16"); | |
| 83 | expect(h.text()).toContain("Merge queue candidates at once"); | |
| 84 | expect(h.text()).not.toContain("read-only"); | |
| 85 | h.stop(); | |
| 86 | }); | |
| 87 | ||
| 88 | test("changes crew concurrency after the plan and a confirm", async () => { | |
| 89 | const h = boot(carol); | |
| 90 | await h.press("e"); | |
| 91 | for (let i = 0; i < 4; i++) await h.press("backspace"); | |
| 92 | await h.press("7", "enter"); | |
| 93 | expect(h.text()).toContain("→ 7"); | |
| 94 | expect(crew()).not.toBe("7"); | |
| 95 | await h.press("enter"); | |
| 96 | expect(crew()).not.toBe("7"); | |
| 97 | await h.press("e"); | |
| 98 | for (let i = 0; i < 4; i++) await h.press("backspace"); | |
| 99 | await h.press("7", "enter"); | |
| 100 | await h.press("left", "enter"); | |
| 101 | expect(crew()).toBe("7"); | |
| 102 | h.stop(); | |
| 103 | }); | |
| 104 | ||
| 105 | test("refuses out of bounds in the setting words, and esc changes nothing", async () => { | |
| 106 | const h = boot(carol); | |
| 107 | const before = crew(); | |
| 108 | await h.press("e"); | |
| 109 | for (let i = 0; i < 4; i++) await h.press("backspace"); | |
| 110 | await h.press("9", "9", "enter"); | |
| 111 | expect(h.text()).toContain("outside what this setting takes"); | |
| 112 | expect(crew()).toBe(before); | |
| 113 | await h.press("esc"); | |
| 114 | expect(crew()).toBe(before); | |
| 115 | h.stop(); | |
| 116 | }); | |
| 117 | ||
| 118 | test("queue.depth, which the page sets, changes after a confirm", async () => { | |
| 119 | const h = boot(carol); | |
| 120 | for (let i = 0; i < CAPACITY_KEYS.indexOf("queue.depth"); i++) await h.press("j"); | |
| 121 | await h.press("e"); | |
| 122 | for (let i = 0; i < 4; i++) await h.press("backspace"); | |
| 123 | await h.press("4", "enter"); | |
| 124 | await h.press("left", "enter"); | |
| 125 | expect(capacityValue(app.db, "queue.depth")).toBe("4"); | |
| 126 | h.stop(); | |
| 127 | }); | |
| 128 | ||
| 129 | test("a change leaves a capacity.changed event, and restoring the default with an empty value works", async () => { | |
| 130 | const seen = () => listEvents(app.db, {}).filter((e) => e.kind === "capacity.changed").length; | |
| 131 | const before = seen(); | |
| 132 | const h = boot(carol); | |
| 133 | await h.press("e"); | |
| 134 | for (let i = 0; i < 4; i++) await h.press("backspace"); | |
| 135 | await h.press("5", "enter"); | |
| 136 | await h.press("left", "enter"); | |
| 137 | expect(crew()).toBe("5"); | |
| 138 | expect(seen()).toBe(before + 1); | |
| 139 | await h.press("e"); | |
| 140 | for (let i = 0; i < 4; i++) await h.press("backspace"); | |
| 141 | await h.press("enter"); | |
| 142 | expect(h.text()).toContain("→ "); | |
| 143 | await h.press("left", "enter"); | |
| 144 | expect(crew()).not.toBe("5"); | |
| 145 | expect(seen()).toBe(before + 2); | |
| 146 | h.stop(); | |
| 147 | }); | |
| 148 | ||
| 149 | test("a typed value is trimmed before the plan", async () => { | |
| 150 | const h = boot(carol); | |
| 151 | await h.press("e"); | |
| 152 | for (let i = 0; i < 4; i++) await h.press("backspace"); | |
| 153 | await h.press(" ", "3", " ", "enter"); | |
| 154 | await h.press("left", "enter"); | |
| 155 | expect(crew()).toBe("3"); | |
| 156 | h.stop(); | |
| 157 | }); | |
| 158 | ||
| 159 | test("signin.edge off for an admin with no credential is refused as a lockout, not as out of range", async () => { | |
| 160 | const kept = app.db.query<{ password_hash: string }, [string]>("SELECT password_hash FROM user WHERE id = ?1").get(carol.id)!.password_hash; | |
| 161 | app.db.query("UPDATE user SET password_hash = '' WHERE id = ?1").run(carol.id); | |
| 162 | const h = boot(carol); | |
| 163 | const before = capacityValue(app.db, "signin.edge"); | |
| 164 | for (let i = 0; i < CAPACITY_KEYS.indexOf("signin.edge"); i++) await h.press("j"); | |
| 165 | await h.press("e"); | |
| 166 | for (let i = 0; i < 4; i++) await h.press("backspace"); | |
| 167 | await h.press("o", "f", "f", "enter"); | |
| 168 | await h.press("left", "enter"); | |
| 169 | expect(capacityValue(app.db, "signin.edge")).toBe(before); | |
| 170 | expect(h.text()).toContain("lock you out"); | |
| 171 | expect(h.text()).not.toContain("outside what"); | |
| 172 | app.db.query("UPDATE user SET password_hash = ?2 WHERE id = ?1").run(carol.id, kept); | |
| 173 | h.stop(); | |
| 174 | }); | |
| 175 | ||
Non-blocking: wrap the body in |
||
| 176 | test("a member or an agent calling the screen directly sees no rows and changes nothing", async () => { | |
| 177 | for (const who of [bob, bot]) { | |
| 178 | const h = boot(who); | |
| 179 | await h.press("e"); | |
| 180 | expect(h.text()).not.toContain("Crew runs at once"); | |
| 181 | expect(changeCapacity(app, who, "crew.concurrency", "9")).toEqual({ ok: false, error: "not_found" }); | |
| 182 | h.stop(); | |
| 183 | } | |
| 184 | }); | |
| 185 | }); |
Hand-copied list of the MCP tool's six keys; import the tool's own list so the two doors cannot drift.