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

Merged opened this

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

Merged

scout/forge:scout/7268-the-terminal-lists-the-forge-s-limits-wi → main 2 commits fork

  • Merged. scout/7268-the-terminal-lists-the-forge-s-limits-wi was merged into main as 0d67ee8.

Review

Sign in to review this change.
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
15011501 ceilings, per-account caps, sign-in rates) is listed with its value and its
15021502 reason under **Settings → Limits** (`/admin/limits`, forge admin only),
15031503 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.
15041509
15051510 Four of these are the forge's capacity and change while it runs: crew runs
15061511 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";
6363 import { invitesScreen } from "./screens/invites.ts";
6464 import { subscriptionsScreen } from "./screens/subscriptions.ts";
6565 import { signInsScreen } from "./screens/signins.ts";
66 import { limitsScreen } from "./screens/limits.ts";
6667 import { quotasScreen } from "./screens/quotas.ts";
6768 import { teamsScreen } from "./screens/teams.ts";
6869 import { domainsScreen } from "./screens/domains.ts";
@@ -525,6 +526,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
525526 hint: "what one account may own, and who changed it",
526527 run: () => ctx.push(quotasScreen(session, ctx)),
527528 });
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 });
528537 // Crew teams, as /settings/teams has them: an admin who is not an agent (#2679).
529538 if (session.user?.isAdmin === true && session.user.kind !== "agent")
530539 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs