Public: anyone can read this; the crew can change it.

The flags a forge admin sees on /settings/flags are one plain-data read: name, why, default, owning issue, live value #7029

Merged opened this

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

Merged

scout/forge:scout/7017-the-flags-a-forge-admin-sees-on-settings → main 2 commits fork 4 threads · 4 unresolved

  • Merged. scout/7017-the-flags-a-forge-admin-sees-on-settings was merged into main as 226727f.

Review

Sign in to review this change.
6 files changed +115 −22
docs/OPERATING.md +1 −1
@@ -157,7 +157,7 @@ and an accessibility tree, which nothing dependency-free gives.
157157
158158 ## Flags
159159
160 A forge admin sees every declared flag at `/settings/flags` (Settings → Flags): why it exists, its default, the owner issue and its live value. Set it for the whole forge, for listed user ids or roles, or for a percentage of people; Clear returns it to the default. A change is validated, kept in the change record and live on the next request, with no restart. In the terminal the same admin opens the command palette (`ctrl+k`), picks Flags, and presses enter to set a flag from a line such as `forge=on users=u1,u2 roles=admin percent=30` or `x` to clear it; an agent is never an admin there either. The MCP `flag_set` tool does the same with the same checks, for an admin token.
160 A forge admin sees every declared flag at `/settings/flags` (Settings → Flags): why it exists, its default, the owner issue and its live value. Set it for the whole forge, for listed user ids or roles, or for a percentage of people; Clear returns it to the default. A change is validated, kept in the change record and live on the next request, with no restart. In the terminal the same admin opens the command palette (`ctrl+k`), picks Flags, and presses enter to set a flag from a line such as `forge=on users=u1,u2 roles=admin percent=30` or `x` to clear it; an agent is never an admin there either. The MCP `flag_set` tool does the same with the same checks, for an admin token. The page and the terminal screen both list flags through one read, `readFlags` in `src/actions/flags_view.ts`.
161161
162162 Settings → Usage is one page for a forge admin, opening at `/settings/spend`, with four tabs: Spend, Routes (`/settings/usage`), Capacity (`/settings/capacity`) and Telemetry (`/settings/telemetry`); each tab keeps its own URL and permission. Spend is admin-only (anyone else gets 404) and holds what the crew's runs spent, by credential, app, agent, issue and run kind; it used to sit on Subscriptions, which now links to it. Routes lists every route the router knows: its requests over the last 30 days, by role and by surface, and the hour of its last use. Routes with no request in the window are listed first, never-used ones before the longest-unused, as candidates to drop; the rest follow by count. Counts are per pattern, not per method. `usage` is also a boards source, for an admin only (an agent is refused); its `hour` column is text like `2026-10-16T13`, so group by a column such as `pattern` or `role`, not by a time bucket. In the terminal the same admin opens the command palette (`ctrl+k`), picks Usage, and reads the Routes list only — the spend breakdown, capacity and telemetry tabs are web-only, and the terminal has never shown spend; an agent is never an admin there either.
163163
src/actions/flags_view.ts added +34
@@ -0,0 +1,34 @@
1 /**
2 * The flags a forge admin sees (#7017): every declared flag as plain data —
3 * name, why, default, the issue that owns removing it, and the live value.
4 * One read for the page and the MCP to share; anyone `mayManageFlags` refuses
5 * gets the same refusal the page uses.
6 */
7
8 import { FLAGS_ADMIN_ONLY, flagRegistry, mayManageFlags, type FlagDecl, type FlagValue } from "../core/flags.ts";
9 import type { User } from "../core/users.ts";
10
11 export type FlagRow = {
12 name: string;
13 why: string;
14 default: boolean;
15 issue: number;
16 /** The stored value; `undefined` when nothing is set and the default answers. */
17 value: FlagValue | undefined;
18 };
19
20 export function readFlags(
21 user: Pick<User, "isAdmin" | "kind"> | undefined,
22 ): { ok: true; flags: FlagRow[] } | { ok: false; message: string } {
23 if (!mayManageFlags(user)) return { ok: false, message: FLAGS_ADMIN_ONLY };
outdated

_app is never used. Drop it, or use it; as written it is a dead parameter.

24 const registry = flagRegistry();
outdated

Nothing calls readFlags. Wire it into the /settings/flags page, the MCP and the tty, or it is not done.

25 const decls: Record<string, FlagDecl> = registry.decls;
26 const flags = Object.entries(decls).map(([name, d]) => ({
27 name,
28 why: d.why,
outdated

The as Record<string, FlagDecl> and as never casts bypass the registry types. Iterate the registry in a typed way instead.

29 default: d.default,
30 issue: d.issue,
31 value: registry.value(name),
32 }));
33 return { ok: true, flags };
34 }
src/core/flags.ts +1 −1
@@ -213,7 +213,7 @@ export function makeFlags<const D extends Record<string, FlagDecl>>(decls: D) {
213213 },
214214 decls,
215215 /** The live value of `name`, if one is set. */
216 value(name: keyof D & string): FlagValue | undefined {
216 value(name: string): FlagValue | undefined {
217217 return values.get(name);
218218 },
219219 /**
src/tui/screens/flags.ts +9 −10
@@ -9,6 +9,7 @@
99 * an admin who is not an agent; anyone else sees nothing and sets nothing.
1010 */
1111
12 import { readFlags } from "../../actions/flags_view.ts";
1213 import {
1314 checkInput,
1415 FLAGS_ADMIN_ONLY,
@@ -16,7 +17,6 @@ import {
1617 mayManageFlags,
1718 usesLine,
1819 winnerOffer,
19 type FlagDecl,
2020 type FlagInput,
2121 type FlagValue,
2222 } from "../../core/flags.ts";
@@ -87,15 +87,14 @@ export function flagsScreen(f: Session, ctx: Ctx): Screen {
8787 ctx.invalidate();
8888 };
8989
90 const rows = (): ListRow[] =>
91 names().map((name) => {
92 const decl = (flagRegistry().decls as Record<string, FlagDecl>)[name]!;
93 return {
94 title: name,
95 secondary: show(flagRegistry().value(name as never)),
96 meta: `${offer(name) === undefined ? "" : `w: make ${offer(name)} the default `}${decl.why} default ${decl.default ? "on" : "off"} owner #${decl.issue}`,
97 };
98 });
90 const rows = (): ListRow[] => {
91 const read = readFlags(f.user);

Not blocking: these rows come from readFlags, but names() (the allowed ? Object.keys(flagRegistry().decls) : [] at line 62) still drives selection, usesOf and set/clear. Both use the same order and the same gate, so they agree today. Deriving names from readFlags(f.user) would leave one source for the list.

92 return (read.ok ? read.flags : []).map((flag) => ({
93 title: flag.name,
94 secondary: show(flag.value),
95 meta: `${offer(flag.name) === undefined ? "" : `w: make ${offer(flag.name)} the default `}${flag.why} default ${flag.default ? "on" : "off"} owner #${flag.issue}`,
96 }));
97 };
9998
10099 const screen: Screen = {
101100 title: "Flags",
src/web/pages/flags.ts +8 −10
@@ -6,7 +6,8 @@
66 * Anyone else is told 403, and an agent is never an admin here.
77 */
88
9 import { checkInput, FLAGS_ADMIN_ONLY, flagRegistry, usesLine, winnerOffer, mayManageFlags, useFlagRegistry, type FlagDecl, type FlagValue } from "../../core/flags.ts";
9 import { readFlags } from "../../actions/flags_view.ts";
10 import { checkInput, FLAGS_ADMIN_ONLY, flagRegistry, usesLine, winnerOffer, mayManageFlags, useFlagRegistry, type FlagValue } from "../../core/flags.ts";
1011 import { makeWinnerDefault } from "../../actions/flag_winner.ts";
1112 import type { Ctx } from "../../http/context.ts";
1213 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
@@ -65,20 +66,18 @@ export function flagsPage(ctx: Ctx): Response {
6566 if (no) return no;
6667 const registry = timePhase(ctx.request, "registry", flagRegistry);
6768 const repo = ctx.app.config.forgeRepo;
68 const names = Object.keys(registry.decls);
69 const read = timePhase(ctx.request, "values", () => readFlags(ctx.auth.user));
70 const flags = read.ok ? read.flags : [];
71 const names = flags.map((f) => f.name);
6972 // The per-flag reads are interleaved with building each row: sum each.
70 let valuesMs = 0;
7173 let usesMs = 0;
7274 const rendering = performance.now();
73 const rows = names.map((name) => {
74 const decl = (registry.decls as Record<string, FlagDecl>)[name]!;
75 const t0 = performance.now();
76 const v = registry.value(name as never);
75 const rows = flags.map((decl) => {
76 const { name, value: v } = decl;
7777 const t1 = performance.now();
7878 const week = registry.uses(ctx.app.db, name, 7);
7979 const month = registry.uses(ctx.app.db, name, 30);
8080 const t2 = performance.now();
81 valuesMs += t1 - t0;
8281 usesMs += t2 - t1;
8382 const lead = winnerOffer(month, v);
8483 return row({
@@ -126,9 +125,8 @@ export function flagsPage(ctx: Ctx): Response {
126125 body: html`<div class="page-head"><h1>Flags</h1></div>
127126 ${settingsLayout(accountSections("flags", { admin: true }), body, "Account settings")}`,
128127 });
129 notePhase(ctx.request, "values", valuesMs);
130128 notePhase(ctx.request, "uses", usesMs);
131 notePhase(ctx.request, "render", Math.max(performance.now() - rendering - valuesMs - usesMs, 0));
129 notePhase(ctx.request, "render", Math.max(performance.now() - rendering - usesMs, 0));
132130 return response;
133131 }
134132
test/flags-view.test.ts added +62
@@ -0,0 +1,62 @@
1 /** `readFlags`: the declared flags as plain data, for a forge admin only. */
2 import { afterAll, beforeAll, describe, 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 { readFlags } from "../src/actions/flags_view.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { FLAGS_ADMIN_ONLY, makeFlags, useFlagRegistry } from "../src/core/flags.ts";
9 import { createUser, type User } from "../src/core/users.ts";
10
11 const reg = makeFlags({ alpha: { why: "shows the new board", default: false, issue: 12 } });
12
13 describe("readFlags", () => {
14 let dataDir: string;
15 let app: App;
16 let admin: User;
17 let bob: User;
18
19 const mk = async (username: string, adminIfFirst: boolean) => {
20 const u = await createUser(app.db, {
21 origin: "seed",
22 username,
23 email: `${username}@forge.local`,
24 password: "a-good-passphrase",
25 trusted: true,
26 adminIfFirst,
27 });
28 if (!u.ok) throw new Error(u.error);
29 return u.value;
30 };
31
32 beforeAll(async () => {
33 dataDir = await mkdtemp(join(tmpdir(), "forge-flags-view-"));
34 app = createApp({ dataDir, port: 0 });
35 admin = await mk("alice", true);
36 bob = await mk("bob", false);
37 useFlagRegistry(reg);
38 });
39 afterAll(async () => {
40 useFlagRegistry(undefined);
41 await rm(dataDir, { recursive: true, force: true });
42 });
43
44 test("an admin sees each flag with its default, and the live value once set", () => {
45 const before = readFlags(admin);
46 expect(before).toEqual({
47 ok: true,
48 flags: [{ name: "alpha", why: "shows the new board", default: false, issue: 12, value: undefined }],
49 });
50 reg.set(app.db, "alpha", { forge: true }, admin.id);
51 const after = readFlags(admin);
52 expect(after.ok && after.flags[0]?.value).toEqual({ forge: true });
53 expect(after.ok && after.flags[0]?.default).toBe(false);
54 });
55
56 test("a non-admin, an agent and nobody are refused", () => {
57 const refusal = { ok: false as const, message: FLAGS_ADMIN_ONLY };
58 expect(readFlags(bob)).toEqual(refusal);
59 expect(readFlags({ ...admin, kind: "agent" })).toEqual(refusal);
60 expect(readFlags(undefined)).toEqual(refusal);
61 });
62 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs