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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7017-the-flags-a-forge-admin-sees-on-settings → main 2 commits fork 4 threads · 4 unresolved
docs/OPERATING.md +1 −1
| @@ -157,7 +157,7 @@ and an accessibility tree, which nothing dependency-free gives. | ||
| 157 | 157 | |
| 158 | 158 | ## Flags |
| 159 | 159 | |
| 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`. | |
| 161 | 161 | |
| 162 | 162 | 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. |
| 163 | 163 |
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 }; | |
| 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 |
||
| 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) { | ||
| 213 | 213 | }, |
| 214 | 214 | decls, |
| 215 | 215 | /** The live value of `name`, if one is set. */ |
| 216 | value(name: keyof D & string): FlagValue | undefined { | |
| 216 | value(name: string): FlagValue | undefined { | |
| 217 | 217 | return values.get(name); |
| 218 | 218 | }, |
| 219 | 219 | /** |
src/tui/screens/flags.ts +9 −10
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | * an admin who is not an agent; anyone else sees nothing and sets nothing. |
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | import { readFlags } from "../../actions/flags_view.ts"; | |
| 12 | 13 | import { |
| 13 | 14 | checkInput, |
| 14 | 15 | FLAGS_ADMIN_ONLY, |
| @@ -16,7 +17,6 @@ import { | ||
| 16 | 17 | mayManageFlags, |
| 17 | 18 | usesLine, |
| 18 | 19 | winnerOffer, |
| 19 | type FlagDecl, | |
| 20 | 20 | type FlagInput, |
| 21 | 21 | type FlagValue, |
| 22 | 22 | } from "../../core/flags.ts"; |
| @@ -87,15 +87,14 @@ export function flagsScreen(f: Session, ctx: Ctx): Screen { | ||
| 87 | 87 | ctx.invalidate(); |
| 88 | 88 | }; |
| 89 | 89 | |
| 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 |
||
| 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 | }; | |
| 99 | 98 | |
| 100 | 99 | const screen: Screen = { |
| 101 | 100 | title: "Flags", |
src/web/pages/flags.ts +8 −10
| @@ -6,7 +6,8 @@ | ||
| 6 | 6 | * Anyone else is told 403, and an agent is never an admin here. |
| 7 | 7 | */ |
| 8 | 8 | |
| 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"; | |
| 10 | 11 | import { makeWinnerDefault } from "../../actions/flag_winner.ts"; |
| 11 | 12 | import type { Ctx } from "../../http/context.ts"; |
| 12 | 13 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| @@ -65,20 +66,18 @@ export function flagsPage(ctx: Ctx): Response { | ||
| 65 | 66 | if (no) return no; |
| 66 | 67 | const registry = timePhase(ctx.request, "registry", flagRegistry); |
| 67 | 68 | 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); | |
| 69 | 72 | // The per-flag reads are interleaved with building each row: sum each. |
| 70 | let valuesMs = 0; | |
| 71 | 73 | let usesMs = 0; |
| 72 | 74 | 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; | |
| 77 | 77 | const t1 = performance.now(); |
| 78 | 78 | const week = registry.uses(ctx.app.db, name, 7); |
| 79 | 79 | const month = registry.uses(ctx.app.db, name, 30); |
| 80 | 80 | const t2 = performance.now(); |
| 81 | valuesMs += t1 - t0; | |
| 82 | 81 | usesMs += t2 - t1; |
| 83 | 82 | const lead = winnerOffer(month, v); |
| 84 | 83 | return row({ |
| @@ -126,9 +125,8 @@ export function flagsPage(ctx: Ctx): Response { | ||
| 126 | 125 | body: html`<div class="page-head"><h1>Flags</h1></div> |
| 127 | 126 | ${settingsLayout(accountSections("flags", { admin: true }), body, "Account settings")}`, |
| 128 | 127 | }); |
| 129 | notePhase(ctx.request, "values", valuesMs); | |
| 130 | 128 | 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)); | |
| 132 | 130 | return response; |
| 133 | 131 | } |
| 134 | 132 |
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 | }); |
_appis never used. Drop it, or use it; as written it is a dead parameter.