Public: anyone can read this; the crew can change it.
The terminal lists the forge's limits with value, default and bounds, and a forge admin changes one after a plan, as MCP `limits` does #7467
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7268-the-terminal-lists-the-forge-s-limits-wi → main 2 commits fork
4 files
changed +262
−0
docs/APPS.md +5
| @@ -1501,6 +1501,11 @@ Every limit an owner can hit (crew runs and checks at once, budget | ||
| 1501 | 1501 | ceilings, per-account caps, sign-in rates) is listed with its value and its |
| 1502 | 1502 | reason under **Settings → Limits** (`/admin/limits`, forge admin only), |
| 1503 | 1503 | declared in `settings/limits.ts` and read on every use. |
| 1504 | The same list is MCP `limits` and the terminal's **Limits** command (Ctrl+K, | |
| 1505 | then Limits; a forge admin who is a person, never an agent): each row shows | |
| 1506 | the key, value, default and bounds. `e` asks for a value (empty restores the | |
| 1507 | default), shows the plan as from → to, and changes it only after a confirm; a | |
| 1508 | value out of bounds is refused in the same words as MCP. | |
| 1504 | 1509 | |
| 1505 | 1510 | Four of these are the forge's capacity and change while it runs: crew runs |
| 1506 | 1511 | at once, checks at once, shards per check, and the cells' egress mode. A |
src/tui/forge.ts +9
| @@ -63,6 +63,7 @@ import { proposalTokensPrompt } from "./screens/proposal_tokens.ts"; | ||
| 63 | 63 | import { invitesScreen } from "./screens/invites.ts"; |
| 64 | 64 | import { subscriptionsScreen } from "./screens/subscriptions.ts"; |
| 65 | 65 | import { signInsScreen } from "./screens/signins.ts"; |
| 66 | import { limitsScreen } from "./screens/limits.ts"; | |
| 66 | 67 | import { quotasScreen } from "./screens/quotas.ts"; |
| 67 | 68 | import { teamsScreen } from "./screens/teams.ts"; |
| 68 | 69 | import { domainsScreen } from "./screens/domains.ts"; |
| @@ -525,6 +526,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 525 | 526 | hint: "what one account may own, and who changed it", |
| 526 | 527 | run: () => ctx.push(quotasScreen(session, ctx)), |
| 527 | 528 | }); |
| 529 | // The forge's limits, as MCP `limits` has them: an admin who is not an agent (#7268). | |
| 530 | if (session.user?.isAdmin === true && session.user.kind !== "agent") | |
| 531 | commands.push({ | |
| 532 | id: "limits", | |
| 533 | title: "Limits", | |
| 534 | hint: "what the forge caps, with default and bounds; change one", | |
| 535 | run: () => ctx.push(limitsScreen(session, ctx)), | |
| 536 | }); | |
| 528 | 537 | // Crew teams, as /settings/teams has them: an admin who is not an agent (#2679). |
| 529 | 538 | if (session.user?.isAdmin === true && session.user.kind !== "agent") |
| 530 | 539 | commands.push({ |
src/tui/screens/limits.ts added +101
| @@ -0,0 +1,101 @@ | ||
| 1 | /** | |
| 2 | * The forge's limits in the terminal, a forge admin's: every limit with its | |
| 3 | * value, default and bounds, as MCP `limits` and /admin/limits list them. | |
| 4 | * `e` asks for a value (empty restores the default), shows the plan, and | |
| 5 | * changes it only after a confirm, through `changeLimit`. A value out of bounds | |
| 6 | * is refused with the words MCP uses (`limitBounds`). The gate is the action's: | |
| 7 | * an admin who is not an agent; anyone else sees nothing and changes nothing. | |
| 8 | */ | |
| 9 | ||
| 10 | import { changeLimit, listLimits, type LimitRow } from "../../actions/limits.ts"; | |
| 11 | import { limitBounds, validLimit } from "../../settings/limits.ts"; | |
| 12 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 13 | import { binding } from "../app/keys.ts"; | |
| 14 | import { style } from "../term/style.ts"; | |
| 15 | import { truncate } from "../term/width.ts"; | |
| 16 | import type { Session } from "../session.ts"; | |
| 17 | import { fill, len, split } from "../ui/layout.ts"; | |
| 18 | import { ListState, confirmScreen, drawList, promptScreen, type ListRow } from "./common.ts"; | |
| 19 | ||
| 20 | export function limitsScreen(f: Session, ctx: Ctx): Screen { | |
| 21 | const state = new ListState(); | |
| 22 | let note = ""; | |
| 23 | const read = () => listLimits(f.app, f.user); | |
| 24 | const current = (): LimitRow[] => { | |
| 25 | const r = read(); | |
| 26 | return r.ok ? r.value.limits : []; | |
| 27 | }; | |
| 28 | ||
| 29 | const apply = (row: LimitRow, raw: string): void => { | |
| 30 | const made = changeLimit(f.app, f.user, row.key, raw === "" ? null : raw); | |
| 31 | note = made.ok | |
| 32 | ? `${row.key}: ${made.value.from} → ${made.value.to}, from the next use on.` | |
| 33 | : made.error.kind === "out_of_bounds" | |
| 34 | ? made.error.message | |
| 35 | : "You cannot change limits."; | |
| 36 | ctx.invalidate(); | |
| 37 | }; | |
| 38 | ||
| 39 | const ask = (row: LimitRow): void => { | |
| 40 | ctx.push( | |
| 41 | promptScreen(ctx, screen, { | |
| 42 | title: row.key, | |
| 43 | label: `a whole number, empty for the default ${row.defaultValue}`, | |
| 44 | value: row.isSet ? String(row.value) : "", | |
| 45 | hint: limitBounds(row.key), | |
| 46 | onSubmit: (raw) => { | |
| 47 | if (raw !== "" && !(/^\d{1,12}$/.test(raw) && validLimit(row.key, Number(raw)))) | |
| 48 | return limitBounds(row.key); | |
| 49 | const to = raw === "" ? row.defaultValue : Number(raw); | |
| 50 | return Promise.resolve().then(() => { | |
| 51 | ctx.push( | |
| 52 | confirmScreen(ctx, screen, { | |
| 53 | title: "Change this limit", | |
| 54 | body: [`${row.key}: ${row.value} → ${to}${raw === "" ? " (the default)" : ""}`], | |
| 55 | actions: [{ label: "Keep", variant: "ghost" }, { label: "Change" }], | |
| 56 | onChoose: (i) => { | |
| 57 | if (i === 1) apply(row, raw); | |
| 58 | }, | |
| 59 | }), | |
| 60 | ); | |
| 61 | }); | |
| 62 | }, | |
| 63 | }), | |
| 64 | ); | |
| 65 | }; | |
| 66 | ||
| 67 | const rows = (limits: readonly LimitRow[]): ListRow[] => | |
| 68 | limits.map((l) => ({ | |
| 69 | title: l.key, | |
| 70 | secondary: `${l.value} ${l.unit}${l.isSet ? "" : " (default)"}`, | |
| 71 | meta: `default ${l.defaultValue} · ${l.min}–${l.max}`, | |
| 72 | })); | |
| 73 | ||
| 74 | const screen: Screen = { | |
| 75 | title: "Limits", | |
| 76 | keys: [binding(["j", "k"], "move cursor"), binding(["e"], "change")], | |
| 77 | render(area, s) { | |
| 78 | const c = ctx.theme.c; | |
| 79 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 80 | const r = read(); | |
| 81 | const text = | |
| 82 | note !== "" | |
| 83 | ? note | |
| 84 | : r.ok | |
| 85 | ? "What the forge's owner can hit. e changes one." | |
| 86 | : "Limits are set by a forge admin."; | |
| 87 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 88 | drawList(s, list!, c, state, rows(r.ok ? r.value.limits : [])); | |
| 89 | }, | |
| 90 | key(e) { | |
| 91 | const all = current(); | |
| 92 | if (state.key(e, all.length)) return true; | |
| 93 | if (e.ctrl || e.alt || e.key !== "e") return false; | |
| 94 | const row = all[state.index]; | |
| 95 | if (row === undefined) return false; | |
| 96 | ask(row); | |
| 97 | return true; | |
| 98 | }, | |
| 99 | }; | |
| 100 | return screen; | |
| 101 | } |
test/tui-limits.test.ts added +147
| @@ -0,0 +1,147 @@ | ||
| 1 | /** | |
| 2 | * The forge's limits in the terminal (#7268): an admin who is not an agent | |
| 3 | * lists the limits, changes one after a confirm, restores a default and is | |
| 4 | * refused out of bounds; anyone else has no command and, driving the screen | |
| 5 | * directly, can neither read nor write. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 12 | import { LIMITS, LIMIT_KEYS, limitBounds, limitDefault, limitValue } from "../src/settings/limits.ts"; | |
| 13 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 14 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 15 | import { limitsScreen } from "../src/tui/screens/limits.ts"; | |
| 16 | import { Session, type TuiIdentity } from "../src/tui/session.ts"; | |
| 17 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | import { settleApp } from "./helpers/tui.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | describe("limits in the terminal", () => { | |
| 24 | let dataDir: string; | |
| 25 | let forge: Forge; | |
| 26 | ||
| 27 | function boot(username: string) { | |
| 28 | const terminal = headlessTerminal(140, 32); | |
| 29 | const user = getUserByName(forge.db, username)!; | |
| 30 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 31 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 32 | app.start(); | |
| 33 | const key = (k: string) => | |
| 34 | app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 35 | return { | |
| 36 | text: () => app.screen.toText(), | |
| 37 | press: async (...keys: string[]) => { | |
| 38 | keys.forEach(key); | |
| 39 | await settleApp(app); | |
| 40 | }, | |
| 41 | ctrl: async (k: string) => { | |
| 42 | app.dispatch({ type: "key", key: k, ctrl: true, alt: false, shift: false }); | |
| 43 | await settleApp(app); | |
| 44 | }, | |
| 45 | type: async (t: string) => { | |
| 46 | for (const ch of t) key(ch); | |
| 47 | await settleApp(app); | |
| 48 | }, | |
| 49 | stop: () => app.stop(), | |
| 50 | }; | |
| 51 | } | |
| 52 | ||
| 53 | beforeAll(async () => { | |
| 54 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-limits-")); | |
| 55 | forge = createApp({ dataDir, port: 0 }); | |
| 56 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false], ["carol", false]] as const) { | |
| 57 | const made = await createUser(forge.db, { | |
| 58 | origin: "seed", | |
| 59 | trusted: true, | |
| 60 | username, | |
| 61 | email: `${username}@forge.local`, | |
| 62 | password: "a-good-passphrase", | |
| 63 | adminIfFirst, | |
| 64 | }); | |
| 65 | if (!made.ok) throw new Error(made.error); | |
| 66 | } | |
| 67 | forge.db.query("UPDATE user SET is_admin = 1, is_agent = 1 WHERE username = 'carol'").run(); | |
| 68 | }); | |
| 69 | afterAll(async () => { | |
| 70 | forge.close(); | |
| 71 | await rm(dataDir, { recursive: true, force: true }); | |
| 72 | }); | |
| 73 | ||
| 74 | const KEY = LIMIT_KEYS[0]!; | |
| 75 | const def = LIMITS[KEY]; | |
| 76 | const dflt = () => limitDefault(forge.db, KEY); | |
| 77 | const to = 1; | |
| 78 | const open = async (h: ReturnType<typeof boot>) => { | |
| 79 | await h.ctrl("k"); | |
| 80 | await h.type("Limits"); | |
| 81 | await h.press("enter"); | |
| 82 | }; | |
| 83 | ||
| 84 | test("lists key, value, default and bounds", async () => { | |
| 85 | const h = boot("alice"); | |
| 86 | await open(h); | |
| 87 | expect(h.text()).toContain(KEY); | |
| 88 | expect(h.text()).toContain(`default ${dflt()}`); | |
| 89 | expect(h.text()).toContain(`${def.min}–${def.max}`); | |
| 90 | h.stop(); | |
| 91 | }); | |
| 92 | ||
| 93 | test("a change shows the plan and applies only after the confirm; empty restores", async () => { | |
| 94 | const h = boot("alice"); | |
| 95 | await open(h); | |
| 96 | await h.press("e"); | |
| 97 | await h.type(String(to)); | |
| 98 | await h.press("enter"); | |
| 99 | expect(h.text()).toContain(`${KEY}: ${dflt()} → ${to}`); | |
| 100 | expect(limitValue(forge.db, KEY)).toBe(dflt()); | |
| 101 | await h.press("right", "enter"); | |
| 102 | expect(limitValue(forge.db, KEY)).toBe(to); | |
| 103 | expect(h.text()).toContain(`${KEY}: ${dflt()} → ${to}, from the next use on.`); | |
| 104 | ||
| 105 | await h.press("e"); | |
| 106 | for (let i = 0; i < 12; i++) await h.press("backspace"); | |
| 107 | await h.press("enter"); | |
| 108 | expect(h.text()).toContain("(the default)"); | |
| 109 | await h.press("right", "enter"); | |
| 110 | expect(limitValue(forge.db, KEY)).toBe(dflt()); | |
| 111 | h.stop(); | |
| 112 | }); | |
| 113 | ||
| 114 | test("out of bounds is refused in MCP's words, and esc and Keep change nothing", async () => { | |
| 115 | const h = boot("alice"); | |
| 116 | await open(h); | |
| 117 | await h.press("e"); | |
| 118 | await h.type(String(def.max + 1)); | |
| 119 | await h.press("enter"); | |
| 120 | expect(h.text()).toContain(limitBounds(KEY)); | |
| 121 | await h.press("esc"); | |
| 122 | expect(limitValue(forge.db, KEY)).toBe(dflt()); | |
| 123 | ||
| 124 | await h.press("e"); | |
| 125 | await h.type(String(to)); | |
| 126 | await h.press("enter", "enter"); // the first button is Keep | |
| 127 | expect(limitValue(forge.db, KEY)).toBe(dflt()); | |
| 128 | h.stop(); | |
| 129 | }); | |
| 130 | ||
| 131 | test("a non-admin and an admin agent have no command and change nothing", async () => { | |
| 132 | for (const who of ["bob", "carol"]) { | |
| 133 | const h = boot(who); | |
| 134 | await h.ctrl("k"); | |
| 135 | await h.type("Limits"); | |
| 136 | expect(h.text()).not.toContain("what the forge caps"); | |
| 137 | h.stop(); | |
| 138 | const user = getUserByName(forge.db, who)!; | |
| 139 | const session = new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user }); | |
| 140 | const pushed: unknown[] = []; | |
| 141 | const ctx = { push: (x: unknown) => pushed.push(x), invalidate() {} } as never; | |
| 142 | const screen = limitsScreen(session, ctx); | |
| 143 | screen.key?.({ type: "key", key: "e", ctrl: false, alt: false, shift: false } as never); | |
| 144 | expect(pushed).toEqual([]); | |
| 145 | } | |
| 146 | }); | |
| 147 | }); |
new above
offline