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

Merged opened this

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

Merged

scout/forge:scout/7275-the-terminal-lists-the-forge-s-capacity → main 5 commits fork 8 threads · 8 unresolved

  • Merged. scout/7275-the-terminal-lists-the-forge-s-capacity was merged into main as 664a56f.

Review

Sign in to review this change.
6 files changed +308 −2
README.md +2
@@ -62,6 +62,8 @@ Home opens with "Since you were here · 2 days": the pulls that landed in public
6262
6363 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.
6464
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
6567 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`.
6668
6769 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";
2626 import type { User } from "../core/users.ts";
2727 import { err, ok, type Result } from "../kernel/result.ts";
2828
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
2939 export interface CapacityRow {
3040 readonly key: CapacityKey;
3141 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";
22 import { SETTING_WORDS } from "../../../actions/setting_words.ts";
33 import { scopeAllows } from "../../../core/tokens.ts";
44 import { good, bad, type Tool } from "../shared.ts";
@@ -13,7 +13,7 @@ export const tool: Tool = {
1313 properties: {
1414 key: {
1515 type: "string",
16 enum: ["crew.concurrency", "checks.concurrency", "checks.shards", "cells.egress", "guard_new_accounts", "signin.edge"],
16 enum: [...SETTABLE_CAPACITY],
1717 },
1818 value: { type: "string", maxLength: 16 },
1919 dryRun: { type: "boolean" },
src/tui/forge.ts +9
@@ -69,6 +69,7 @@ import { quotasScreen } from "./screens/quotas.ts";
6969 import { teamsScreen } from "./screens/teams.ts";
7070 import { domainsScreen } from "./screens/domains.ts";
7171 import { ownerScreen } from "./screens/owner.ts";
72 import { capacityScreen, mayCapacity } from "./screens/capacity.ts";
7273 import { resilienceScreen } from "./screens/resilience.ts";
7374 import { forgeCeiling } from "../flow/ceiling.ts";
7475 import { flowLines, flowScreen } from "./screens/flow.ts";
@@ -386,6 +387,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
386387 },
387388 });
388389 }
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 });
389398 // 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.
390399 if (session.user?.isAdmin === true)
391400 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";

Hand-copied list of the MCP tool's six keys; import the tool's own list so the two doors cannot drift.

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; SETTABLE_CAPACITY stays the MCP tool's enum only.

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 takes <bounds> (and the default when overridden) on every row, not only the MCP tool's six. boundsOf works for every key.

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 (src/web/pages/capacity.ts, a Save form on every row) and PUT /api/v1/admin/capacity/:key let an admin set queue.depth, queue.batch and children.max, and changeCapacity accepts them. This refusal is a door the page does not have, and "the environment sets it" is untrue: the environment only gives the boot default. Let e edit every row, as the page does.

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 value once here before validating and planning, so the plan shows what changeCapacity will actually store.

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 try { … } finally { restore } so a failed expect above does not leave carol without a password for the remaining tests.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs