Public: anyone can read this; the crew can change it.
Every limit the owner can hit is listed on one Limits page with its value and its reason, read from settings, not constants #1582
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1476-every-limit-the-owner-can-hit-is-listed → main 5 commits fork 2 threads · 2 unresolved
24 files
changed +885
−46
README.md +4
| @@ -702,3 +702,7 @@ Invariants are proven by faults, not by hope. No dependencies were added. | ||
| 702 | 702 | `forge.json`'s `check` may declare `fast` beside `run` (the full gate; `full` names it explicitly). `bun scripts/test-shards.ts --tier fast --diff <base>..<head>` runs the tests that touch the diff (changed, named for a changed source file, or importing one within two hops), every test the times table records under 1 s, and any named by `--learned a,b`. `src/checks/tiers.ts` decides; it adds no dependency. |
| 703 | 703 | |
| 704 | 704 | In the forge itself: a pull's check runs `fast` when the manifest has one, in a clone holding the fork point and the head as `FORGE_CHECK_BASE` and `HEAD` (`origin/main` is not a ref there), with `FORGE_CHECK_LEARNED` naming tests earlier land attempts taught the pull (`src/checks/history.ts`, `learned.ts`). Landing (`landIfGreen`) runs `full` once on the merge with its base before merging (`src/checks/landing.ts`); red comments on the pull with the tests named, is remembered per head and base, and the files ride the pull's next fast run. The pull page's checks line shows the tier words. |
| 705 | ||
| 706 | ## Limits | |
| 707 | ||
| 708 | Every limit the owner can hit is declared in `src/settings/limits.ts` with its default, bounds and reason, and changed live by a forge admin at `/admin/limits`, `PUT /api/v1/admin/limits/:key` or the `limits` MCP tool (never by an agent). Each change is kept with who made it. |
docs/APPS.md +5
| @@ -1081,6 +1081,11 @@ the data directory `FORGE_APP_DATA`, and the defaults become | ||
| 1081 | 1081 | `FORGE_SSH_PORT=0`, `FORGE_CREW_CONCURRENCY=1` and |
| 1082 | 1082 | `FORGE_APP_DOMAIN=--<its own host>`. |
| 1083 | 1083 | |
| 1084 | Every limit an owner can hit (crew runs and checks at once, budget | |
| 1085 | ceilings, per-account caps, sign-in rates) is listed with its value and its | |
| 1086 | reason under **Settings → Limits** (`/admin/limits`, forge admin only), | |
| 1087 | declared in `settings/limits.ts` and read on every use. | |
| 1088 | ||
| 1084 | 1089 | Four of these are the forge's capacity and change while it runs: crew runs |
| 1085 | 1090 | at once, checks at once, shards per check, and the cells' egress mode. A |
| 1086 | 1091 | forge admin sets them under **Settings → Capacity**, through |
scripts/gate.ts +1
| @@ -90,6 +90,7 @@ export const GATE: readonly string[] = [ | ||
| 90 | 90 | "test/decide.test.ts", |
| 91 | 91 | "test/ledger.test.ts", |
| 92 | 92 | "test/capacity.test.ts", |
| 93 | "test/limits.test.ts", | |
| 93 | 94 | "test/guard-new-accounts.test.ts", |
| 94 | 95 | "test/checks.test.ts", |
| 95 | 96 | "test/dependencies.test.ts", |
src/actions/app_members.ts +3 −5
| @@ -21,9 +21,7 @@ import { canWrite, type Repo } from "../core/repos.ts"; | ||
| 21 | 21 | import { getUserByName, isActive, type User } from "../core/users.ts"; |
| 22 | 22 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 23 | 23 | import { requireReader } from "./access.ts"; |
| 24 | ||
| 25 | /** The most names one app may hold. */ | |
| 26 | export const MAX_APP_MEMBERS = 100; | |
| 24 | import { limitValue } from "../settings/limits.ts"; | |
| 27 | 25 | |
| 28 | 26 | export type AppMembersError = |
| 29 | 27 | | { readonly kind: "not_found" } |
| @@ -84,10 +82,10 @@ export function setAppMembersThrough( | ||
| 84 | 82 | const names = [ |
| 85 | 83 | ...new Set(input.members.map((n) => n.trim()).filter((n) => n !== "")), |
| 86 | 84 | ]; |
| 87 | if (names.length > MAX_APP_MEMBERS) | |
| 85 | if (names.length > limitValue(app.db, "app.members_max")) | |
| 88 | 86 | return err({ |
| 89 | 87 | kind: "invalid", |
| 90 | message: `name at most ${MAX_APP_MEMBERS} people`, | |
| 88 | message: `name at most ${limitValue(app.db, "app.members_max")} people`, | |
| 91 | 89 | }); |
| 92 | 90 | const people: User[] = []; |
| 93 | 91 | for (const name of names) { |
src/actions/crew.ts +5 −4
| @@ -24,7 +24,6 @@ import { | ||
| 24 | 24 | type AgentPatch, |
| 25 | 25 | type CrewRole, |
| 26 | 26 | BRIEF_MAX, |
| 27 | BUDGET_MAX_USD, | |
| 28 | 27 | MODEL_MAX, |
| 29 | 28 | } from "../core/agents.ts"; |
| 30 | 29 | import { canWrite, type Repo } from "../core/repos.ts"; |
| @@ -36,6 +35,7 @@ import { | ||
| 36 | 35 | } from "../core/users.ts"; |
| 37 | 36 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 38 | 37 | import { requireReader, requireRepoAdmin, requireUser } from "./access.ts"; |
| 38 | import { limitValue } from "../settings/limits.ts"; | |
| 39 | 39 | |
| 40 | 40 | export interface CreateAgentInput { |
| 41 | 41 | username: string; |
| @@ -63,6 +63,7 @@ function requireForgeAdmin( | ||
| 63 | 63 | } |
| 64 | 64 | |
| 65 | 65 | function checkPatch( |
| 66 | app: App, | |
| 66 | 67 | patch: AgentPatch, |
| 67 | 68 | ): Result<AgentPatch, "invalid_model" | "brief_too_long" | "invalid_budget"> { |
| 68 | 69 | if (patch.model !== undefined) { |
| @@ -75,7 +76,7 @@ function checkPatch( | ||
| 75 | 76 | patch.budgetUsd !== undefined && |
| 76 | 77 | (!Number.isFinite(patch.budgetUsd) || |
| 77 | 78 | patch.budgetUsd < 0 || |
| 78 | patch.budgetUsd > BUDGET_MAX_USD) | |
| 79 | patch.budgetUsd > limitValue(app.db, "agent.budget_max_usd")) | |
| 79 | 80 | ) |
| 80 | 81 | return err("invalid_budget"); |
| 81 | 82 | return ok(patch); |
| @@ -89,7 +90,7 @@ export async function createAgent( | ||
| 89 | 90 | ): Promise<Result<Agent, CreateAgentError>> { |
| 90 | 91 | const admin = requireForgeAdmin(actor); |
| 91 | 92 | if (!admin.ok) return admin; |
| 92 | const patch = checkPatch({ | |
| 93 | const patch = checkPatch(app, { | |
| 93 | 94 | ...(input.model === undefined ? {} : { model: input.model }), |
| 94 | 95 | ...(input.brief === undefined ? {} : { brief: input.brief }), |
| 95 | 96 | ...(input.budgetUsd === undefined ? {} : { budgetUsd: input.budgetUsd }), |
| @@ -128,7 +129,7 @@ export function updateAgent( | ||
| 128 | 129 | if (!admin.ok) return admin; |
| 129 | 130 | const agent = getAgentByName(app.db, username); |
| 130 | 131 | if (!agent) return err("not_found"); |
| 131 | const checked = checkPatch(patch); | |
| 132 | const checked = checkPatch(app, patch); | |
| 132 | 133 | if (!checked.ok) return checked; |
| 133 | 134 | const updated = patchAgent(app.db, agent.user.id, checked.value); |
| 134 | 135 | if (!updated) return err("not_found"); |
src/actions/front.ts +5 −7
| @@ -46,13 +46,11 @@ import { canRead, getRepo, getReposByIds, type Repo } from "../core/repos.ts"; | ||
| 46 | 46 | import { listActiveRuns } from "../core/runs.ts"; |
| 47 | 47 | import { getUsersByIds, isActive, type User } from "../core/users.ts"; |
| 48 | 48 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 49 | import { limitValue } from "../settings/limits.ts"; | |
| 49 | 50 | |
| 50 | 51 | /** How many public apps are read, and how many the shelf shows. */ |
| 51 | 52 | const READ = 200; |
| 52 | 53 | export const SHELF = 12; |
| 53 | /** The most a site admin may pin, and hide. */ | |
| 54 | export const MAX_PINNED = 12; | |
| 55 | export const MAX_HIDDEN = 200; | |
| 56 | 54 | /** How long a ranking is kept before it is worked out again. */ |
| 57 | 55 | const SCORES_MS = 60_000; |
| 58 | 56 | |
| @@ -355,10 +353,10 @@ export function setFront( | ||
| 355 | 353 | use: weights["use"] as number, |
| 356 | 354 | fresh: weights["fresh"] as number, |
| 357 | 355 | }; |
| 358 | if (input.pinned.length > MAX_PINNED) | |
| 359 | return err({ kind: "invalid", message: `pin at most ${MAX_PINNED}` }); | |
| 360 | if (input.hidden.length > MAX_HIDDEN) | |
| 361 | return err({ kind: "invalid", message: `hide at most ${MAX_HIDDEN}` }); | |
| 356 | if (input.pinned.length > limitValue(app.db, "front.pinned_max")) | |
| 357 | return err({ kind: "invalid", message: `pin at most ${limitValue(app.db, "front.pinned_max")}` }); | |
| 358 | if (input.hidden.length > limitValue(app.db, "front.hidden_max")) | |
| 359 | return err({ kind: "invalid", message: `hide at most ${limitValue(app.db, "front.hidden_max")}` }); | |
| 362 | 360 | const resolve = ( |
| 363 | 361 | names: readonly string[], |
| 364 | 362 | ): Result<Repo[], SetFrontError> => { |
src/actions/limits.ts added +76
| @@ -0,0 +1,76 @@ | ||
| 1 | /** | |
| 2 | * The forge's limits (`settings/limits.ts`), for a forge admin: listed with | |
| 3 | * their value and reason, and changed. Never by an agent, whatever it was | |
| 4 | * granted — a crew run that could raise its own ceilings would be the crew | |
| 5 | * deciding how much it may spend. Everyone else is told `not_found`. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { App } from "../app.ts"; | |
| 9 | import type { User } from "../core/users.ts"; | |
| 10 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 11 | import { | |
| 12 | LIMITS, | |
| 13 | LIMIT_KEYS, | |
| 14 | isLimitKey, | |
| 15 | limitChanges, | |
| 16 | limitDefault, | |
| 17 | limitIsSet, | |
| 18 | limitValue, | |
| 19 | setLimit, | |
| 20 | type LimitChange, | |
| 21 | type LimitDef, | |
| 22 | type LimitKey, | |
| 23 | } from "../settings/limits.ts"; | |
| 24 | ||
| 25 | export interface LimitRow extends LimitDef { | |
| 26 | readonly key: LimitKey; | |
| 27 | readonly value: number; | |
| 28 | readonly isSet: boolean; | |
| 29 | readonly defaultValue: number; | |
| 30 | } | |
| 31 | ||
| 32 | function admin(actor: User | undefined): Result<User, "not_found"> { | |
| 33 | return actor?.isAdmin === true && actor.kind !== "agent" | |
| 34 | ? ok(actor) | |
| 35 | : err("not_found"); | |
| 36 | } | |
| 37 | ||
| 38 | export function listLimits( | |
| 39 | app: App, | |
| 40 | actor: User | undefined, | |
| 41 | ): Result<{ limits: LimitRow[]; changes: LimitChange[] }, "not_found"> { | |
| 42 | const who = admin(actor); | |
| 43 | if (!who.ok) return who; | |
| 44 | return ok({ | |
| 45 | limits: LIMIT_KEYS.map((key) => ({ | |
| 46 | ...LIMITS[key], | |
| 47 | key, | |
| 48 | value: limitValue(app.db, key), | |
| 49 | isSet: limitIsSet(app.db, key), | |
| 50 | defaultValue: limitDefault(app.db, key), | |
| 51 | })), | |
| 52 | changes: limitChanges(app.db), | |
| 53 | }); | |
| 54 | } | |
| 55 | ||
| 56 | export type ChangeLimitError = | |
| 57 | | { readonly kind: "not_found" } | |
| 58 | | { readonly kind: "no_such_limit" } | |
| 59 | | { readonly kind: "out_of_bounds"; readonly message: string }; | |
| 60 | ||
| 61 | /** Set `key` to `value`; `null` goes back to the default. */ | |
| 62 | export function changeLimit( | |
| 63 | app: App, | |
| 64 | actor: User | undefined, | |
| 65 | key: string, | |
| 66 | value: string | null, | |
| 67 | ): Result<{ key: LimitKey; from: number; to: number }, ChangeLimitError> { | |
| 68 | const who = admin(actor); | |
| 69 | if (!who.ok) return err({ kind: "not_found" }); | |
| 70 | if (!isLimitKey(key)) return err({ kind: "no_such_limit" }); | |
| 71 | const trimmed = value?.trim() ?? null; | |
| 72 | const n = trimmed === null ? null : /^\d{1,12}$/.test(trimmed) ? Number(trimmed) : Number.NaN; | |
| 73 | const set = setLimit(app.db, key, n, who.value.id); | |
| 74 | if (!set.ok) return err(set.error); | |
| 75 | return ok({ key, ...set.value }); | |
| 76 | } |
src/core/agents.ts +2 −2
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import type { Database } from "bun:sqlite"; |
| 11 | import { limitValue } from "../settings/limits.ts"; | |
| 11 | 12 | import { getUserById, type User } from "./users.ts"; |
| 12 | 13 | |
| 13 | 14 | export interface Agent { |
| @@ -33,7 +34,6 @@ interface AgentRow { | ||
| 33 | 34 | |
| 34 | 35 | export const MODEL_MAX = 80; |
| 35 | 36 | export const BRIEF_MAX = 4_000; |
| 36 | export const BUDGET_MAX_USD = 100; | |
| 37 | 37 | |
| 38 | 38 | function toAgent(db: Database, row: AgentRow): Agent | undefined { |
| 39 | 39 | const user = getUserById(db, row.user_id); |
| @@ -128,7 +128,7 @@ export function updateAgent( | ||
| 128 | 128 | sets.push(`brief = ?${params.length}`); |
| 129 | 129 | } |
| 130 | 130 | if (patch.budgetUsd !== undefined) { |
| 131 | params.push(Math.min(Math.max(patch.budgetUsd, 0), BUDGET_MAX_USD)); | |
| 131 | params.push(Math.min(Math.max(patch.budgetUsd, 0), limitValue(db, "agent.budget_max_usd"))); | |
| 132 | 132 | sets.push(`budget_usd = ?${params.length}`); |
| 133 | 133 | } |
| 134 | 134 | if (sets.length > 0) { |
src/core/ratelimit.ts +6 −1
| @@ -96,12 +96,17 @@ export class RateLimiter { | ||
| 96 | 96 | private readonly windowMs: number; |
| 97 | 97 | |
| 98 | 98 | constructor( |
| 99 | private readonly limits: RateLimits = limitsFromEnv(), | |
| 99 | private limits: RateLimits = limitsFromEnv(), | |
| 100 | 100 | private readonly now: () => number = Date.now, |
| 101 | 101 | ) { |
| 102 | 102 | this.windowMs = limits.windowMs ?? WINDOW_MS; |
| 103 | 103 | } |
| 104 | 104 | |
| 105 | /** Take new numbers, honoured by the next `take`; counted hits are kept. */ | |
| 106 | setLimits(limits: RateLimits): void { | |
| 107 | this.limits = limits; | |
| 108 | } | |
| 109 | ||
| 105 | 110 | get enabled(): boolean { |
| 106 | 111 | return this.limits.enabled; |
| 107 | 112 | } |
src/core/subscriptions.ts +3 −3
| @@ -28,6 +28,7 @@ import type { Database } from "bun:sqlite"; | ||
| 28 | 28 | import { id } from "../kernel/id.ts"; |
| 29 | 29 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 30 | 30 | import { open, seal } from "../kernel/secret.ts"; |
| 31 | import { limitValue } from "../settings/limits.ts"; | |
| 31 | 32 | |
| 32 | 33 | export type SubscriptionKind = "oauth" | "api_key"; |
| 33 | 34 | |
| @@ -95,7 +96,6 @@ const COMMITTED = `(s.spent_usd + (SELECT COALESCE(SUM( | ||
| 95 | 96 | MAX(0, COALESCE(r.budget_reserved_usd, 0) - r.cost_usd)), 0) |
| 96 | 97 | FROM run r WHERE r.subscription_id = s.id |
| 97 | 98 | AND r.state IN ('queued', 'running') AND r.id <> ?4))`; |
| 98 | export const BUDGET_MAX_USD = 100_000; | |
| 99 | 99 | |
| 100 | 100 | /** An account's whole-life budget and what its runs have spent, in dollars. */ |
| 101 | 101 | export interface Budget { |
| @@ -124,7 +124,7 @@ export function budgetsOf(db: Database): Map<string, Budget> { | ||
| 124 | 124 | * whoever made this forge is never changed from inside it. |
| 125 | 125 | */ |
| 126 | 126 | export function setBudget(db: Database, subscriptionId: string, usd: number | null): boolean { |
| 127 | if (usd !== null && !(Number.isFinite(usd) && usd > 0 && usd <= BUDGET_MAX_USD)) return false; | |
| 127 | if (usd !== null && !(Number.isFinite(usd) && usd > 0 && usd <= limitValue(db, "subscription.budget_max_usd"))) return false; | |
| 128 | 128 | const value = usd === null ? null : Math.round(usd * 100) / 100; |
| 129 | 129 | // A locked budget may only come down: never up, never cleared. |
| 130 | 130 | return ( |
| @@ -143,7 +143,7 @@ export function setBudget(db: Database, subscriptionId: string, usd: number | nu | ||
| 143 | 143 | * a child's starter key, so the spawner's money ends where they said. |
| 144 | 144 | */ |
| 145 | 145 | export function lockBudget(db: Database, subscriptionId: string, usd: number): boolean { |
| 146 | if (!(Number.isFinite(usd) && usd > 0 && usd <= BUDGET_MAX_USD)) return false; | |
| 146 | if (!(Number.isFinite(usd) && usd > 0 && usd <= limitValue(db, "subscription.budget_max_usd"))) return false; | |
| 147 | 147 | return ( |
| 148 | 148 | db |
| 149 | 149 | .query( |
src/http/api.ts +31
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | import { askCheck } from "../checks/again.ts"; |
| 12 | 12 | import { mayView, mayViewId, sealedScope } from "../core/sealed.ts"; |
| 13 | 13 | import { changeCapacity, listCapacity } from "../actions/capacity.ts"; |
| 14 | import { changeLimit, listLimits } from "../actions/limits.ts"; | |
| 14 | 15 | import { |
| 15 | 16 | accountSignIns, |
| 16 | 17 | endAccountSession, |
| @@ -1270,6 +1271,36 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1270 | 1271 | ); |
| 1271 | 1272 | }); |
| 1272 | 1273 | |
| 1274 | // The forge's limits (`settings/limits.ts`): the same door as capacity. | |
| 1275 | router.add("GET", "/api/v1/admin/limits", (ctx) => { | |
| 1276 | const denied = accountAdmin(ctx, "ro"); | |
| 1277 | if (denied) return denied; | |
| 1278 | const listed = listLimits(ctx.app, ctx.auth.user); | |
| 1279 | return listed.ok ? json(listed.value) : notFound(); | |
| 1280 | }); | |
| 1281 | ||
| 1282 | router.add("PUT", "/api/v1/admin/limits/:key", async (ctx) => { | |
| 1283 | const denied = accountAdmin(ctx, "admin"); | |
| 1284 | if (denied) return denied; | |
| 1285 | const input = await body(ctx.request); | |
| 1286 | const value = | |
| 1287 | input.value === null | |
| 1288 | ? null | |
| 1289 | : typeof input.value === "number" && Number.isFinite(input.value) | |
| 1290 | ? String(input.value) | |
| 1291 | : str(input.value); | |
| 1292 | const changed = changeLimit(ctx.app, ctx.auth.user, ctx.params.key!, value); | |
| 1293 | if (changed.ok) return json(changed.value); | |
| 1294 | if (changed.error.kind === "not_found") return notFound(); | |
| 1295 | return apiError( | |
| 1296 | changed.error.kind, | |
| 1297 | 422, | |
| 1298 | changed.error.kind === "no_such_limit" | |
| 1299 | ? "no such limit" | |
| 1300 | : changed.error.message, | |
| 1301 | ); | |
| 1302 | }); | |
| 1303 | ||
| 1273 | 1304 | router.add("GET", "/api/v1/admin/accounts", (ctx) => { |
| 1274 | 1305 | const denied = accountAdmin(ctx, "ro"); |
| 1275 | 1306 | if (denied) return denied; |
src/http/mcp.ts +41 −3
| @@ -19,6 +19,8 @@ | ||
| 19 | 19 | |
| 20 | 20 | import { mayView, mayViewId, sealedScope } from "../core/sealed.ts"; |
| 21 | 21 | import { changeCapacity, listCapacity } from "../actions/capacity.ts"; |
| 22 | import { changeLimit, listLimits } from "../actions/limits.ts"; | |
| 23 | import { LIMIT_KEYS } from "../settings/limits.ts"; | |
| 22 | 24 | import { relationOf, takeUpdates } from "../actions/forks.ts"; |
| 23 | 25 | import { watchMilestone } from "../actions/watches.ts"; |
| 24 | 26 | import { |
| @@ -1051,7 +1053,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1051 | 1053 | type: "object", |
| 1052 | 1054 | properties: { |
| 1053 | 1055 | repo: REPO_ARG, |
| 1054 | members: { type: "array", items: { type: "string" }, maxItems: 100 }, | |
| 1056 | members: { type: "array", items: { type: "string" } }, | |
| 1055 | 1057 | dryRun: { type: "boolean" }, |
| 1056 | 1058 | }, |
| 1057 | 1059 | required: ["repo"], |
| @@ -1178,8 +1180,8 @@ const TOOLS: readonly Tool[] = [ | ||
| 1178 | 1180 | inputSchema: { |
| 1179 | 1181 | type: "object", |
| 1180 | 1182 | properties: { |
| 1181 | pinned: { type: "array", items: { type: "string" }, maxItems: 12 }, | |
| 1182 | hidden: { type: "array", items: { type: "string" }, maxItems: 200 }, | |
| 1183 | pinned: { type: "array", items: { type: "string" } }, | |
| 1184 | hidden: { type: "array", items: { type: "string" } }, | |
| 1183 | 1185 | weights: { |
| 1184 | 1186 | type: "object", |
| 1185 | 1187 | description: "What counts toward an app's place, each 0 to 10: forks, ships, use, fresh. One left out keeps what is set.", |
| @@ -1790,6 +1792,42 @@ const TOOLS: readonly Tool[] = [ | ||
| 1790 | 1792 | }); |
| 1791 | 1793 | }, |
| 1792 | 1794 | }, |
| 1795 | { | |
| 1796 | name: "limits", | |
| 1797 | description: | |
| 1798 | "Every limit the forge's owner can hit, with its value, bounds and the reason it is there (people per app, pins, hidden repositories, budget ceilings, daily shares, sign-in attempts, crew and check concurrency). Without `key`, each limit and the recent changes. With `key` and `value` (\"\" goes back to the default), the change it would make; with dryRun false, made, by a forge admin with an admin-scope token, never an agent. Honoured by the next use, never by a restart. The same as /admin/limits and /api/v1/admin/limits.", | |
| 1799 | inputSchema: { | |
| 1800 | type: "object", | |
| 1801 | properties: { | |
| 1802 | key: { type: "string", enum: [...LIMIT_KEYS] }, | |
| 1803 | value: { type: "string", maxLength: 16 }, | |
| 1804 | dryRun: { type: "boolean", description: "Plan only (the default). Send false to change it." }, | |
| 1805 | }, | |
| 1806 | }, | |
| 1807 | writes: true, | |
| 1808 | dryByDefault: true, | |
| 1809 | run(ctx, user, args) { | |
| 1810 | const listed = listLimits(ctx.app, user); | |
| 1811 | if (!listed.ok) return bad("not_found", "no such tool here"); | |
| 1812 | const key = typeof args["key"] === "string" ? args["key"] : undefined; | |
| 1813 | if (key === undefined) return good(listed.value); | |
| 1814 | const value = typeof args["value"] === "string" ? args["value"] : ""; | |
| 1815 | const now = listed.value.limits.find((l) => l.key === key); | |
| 1816 | if (now === undefined) return bad("no_such_limit", `no limit ${key}`); | |
| 1817 | if (args["dryRun"] !== false) | |
| 1818 | return good({ | |
| 1819 | says: `Would set ${key} from ${now.value} to ${value || `its default, ${now.defaultValue}`}. Send dryRun: false to change it.`, | |
| 1820 | }); | |
| 1821 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) | |
| 1822 | return bad("forbidden", "changing the forge's limits needs an admin-scope token"); | |
| 1823 | const changed = changeLimit(ctx.app, user, key, value === "" ? null : value); | |
| 1824 | if (!changed.ok) return bad(changed.error.kind, changed.error.kind === "out_of_bounds" ? changed.error.message : "no such limit"); | |
| 1825 | return good({ | |
| 1826 | says: `${key}: ${changed.value.from} → ${changed.value.to}, from the next use on.`, | |
| 1827 | ...changed.value, | |
| 1828 | }); | |
| 1829 | }, | |
| 1830 | }, | |
| 1793 | 1831 | ]; |
| 1794 | 1832 | |
| 1795 | 1833 | /** |
src/http/server.ts +3
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | import { TOOLBAR_NAME, toolbarResponse } from "../apps/toolbar.ts"; |
| 9 | 9 | import { unsealReleased } from "../actions/sealed.ts"; |
| 10 | 10 | import { capacityPage, capacitySet } from "../web/pages/capacity.ts"; |
| 11 | import { limitsPage, limitsSet } from "../web/pages/admin-limits.ts"; | |
| 11 | 12 | import { |
| 12 | 13 | closeStreamsOf, |
| 13 | 14 | closeStreamsOfSession, |
| @@ -666,6 +667,8 @@ export function buildRouter(): Router<Handler> { | ||
| 666 | 667 | router.add("POST", "/settings/cloud/remove", cloudRemove); |
| 667 | 668 | router.add("GET", "/settings/capacity", capacityPage); |
| 668 | 669 | router.add("POST", "/settings/capacity", capacitySet); |
| 670 | router.add("GET", "/admin/limits", limitsPage); | |
| 671 | router.add("POST", "/admin/limits", limitsSet); | |
| 669 | 672 | router.add("GET", "/settings/backups", backupsPage); |
| 670 | 673 | router.add("POST", "/settings/backups/now", backupsNow); |
| 671 | 674 | router.add("POST", "/settings/backups/destination", backupsDestination); |
src/settings/limits.ts added +295
| @@ -0,0 +1,295 @@ | ||
| 1 | /** | |
| 2 | * Every limit a forge's owner can hit, in one place: its name, default, | |
| 3 | * bounds and the one sentence that says why it is there. The code that | |
| 4 | * enforces a limit asks `limitValue` on every use, so a change on | |
| 5 | * `/admin/limits` governs the next request and never needs a restart. | |
| 6 | * | |
| 7 | * Values live in `forge_setting` (like capacity, `core/capacity.ts`); the | |
| 8 | * two crew/check concurrency limits *are* capacity settings, so they are | |
| 9 | * read and written there and listed here with the bounds capacity holds — | |
| 10 | * one stored value, one place that says what it may be. Every change is | |
| 11 | * kept in `forge_setting_change`: who, when, from what to what. | |
| 12 | * Pure data; authorization is in `actions/limits.ts`. | |
| 13 | */ | |
| 14 | ||
| 15 | import type { Database } from "bun:sqlite"; | |
| 16 | import { | |
| 17 | CAPACITY, | |
| 18 | bootDefault, | |
| 19 | capacityValue, | |
| 20 | setCapacity, | |
| 21 | type CapacityKey, | |
| 22 | } from "../core/capacity.ts"; | |
| 23 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 24 | ||
| 25 | export type LimitKey = | |
| 26 | | "crew.concurrency" | |
| 27 | | "checks.concurrency" | |
| 28 | | "agent.budget_max_usd" | |
| 29 | | "subscription.budget_max_usd" | |
| 30 | | "app.members_max" | |
| 31 | | "front.pinned_max" | |
| 32 | | "front.hidden_max" | |
| 33 | | "share.daily_max" | |
| 34 | | "signin.per_account" | |
| 35 | | "signin.per_forge"; | |
| 36 | ||
| 37 | export interface LimitDef { | |
| 38 | readonly label: string; | |
| 39 | readonly unit: string; | |
| 40 | readonly default: number; | |
| 41 | readonly min: number; | |
| 42 | readonly max: number; | |
| 43 | /** One sentence: why the limit is there. */ | |
| 44 | readonly reason: string; | |
| 45 | } | |
| 46 | ||
| 47 | function capacityLimit(key: CapacityKey, unit: string, reason: string): LimitDef { | |
| 48 | const kind = CAPACITY[key].kind; | |
| 49 | if (kind.type !== "count") throw new Error(`${key} is not a count`); | |
| 50 | return { | |
| 51 | label: CAPACITY[key].label, | |
| 52 | unit, | |
| 53 | default: Number(CAPACITY[key].fallback), | |
| 54 | min: kind.min, | |
| 55 | max: kind.max, | |
| 56 | reason, | |
| 57 | }; | |
| 58 | } | |
| 59 | ||
| 60 | export const LIMITS: Record<LimitKey, LimitDef> = { | |
| 61 | "crew.concurrency": capacityLimit( | |
Note: crew and check concurrency can now be edited on both Settings → Capacity and Settings → Limits. They store a single value, so nothing drifts, but the direction says a setting is one line where it belongs. Later, either list them on Limits read-only with a link to Capacity, or fold Capacity into Limits. Also, every other settings page lives under |
||
| 62 | "crew.concurrency", | |
| 63 | "runs", | |
| 64 | "The crew shares one machine; more runs at once slow every run and every page.", | |
| 65 | ), | |
| 66 | "checks.concurrency": capacityLimit( | |
| 67 | "checks.concurrency", | |
| 68 | "checks", | |
| 69 | "A check runs a whole test suite; more at once starve the web process of CPU.", | |
| 70 | ), | |
| 71 | "agent.budget_max_usd": { | |
| 72 | label: "Crew member budget ceiling", | |
| 73 | unit: "dollars", | |
| 74 | default: 100, | |
| 75 | min: 1, | |
| 76 | max: 1_000_000, | |
| 77 | reason: "A crew member's per-run budget is spent unattended, so what one run may cost has a top.", | |
| 78 | }, | |
| 79 | "subscription.budget_max_usd": { | |
| 80 | label: "Pool account budget ceiling", | |
| 81 | unit: "dollars", | |
| 82 | default: 100_000, | |
| 83 | min: 1, | |
| 84 | max: 100_000_000, | |
| 85 | reason: "A sealed account's whole-life budget is a promise about real money, so a typo cannot make it unbounded.", | |
| 86 | }, | |
| 87 | "app.members_max": { | |
| 88 | label: "People named on one app", | |
| 89 | unit: "people", | |
| 90 | default: 100, | |
| 91 | min: 1, | |
| 92 | max: 10_000, | |
| 93 | reason: "The member list is read on every request to the app, so it stays short enough to scan.", | |
| 94 | }, | |
| 95 | "front.pinned_max": { | |
| 96 | label: "Pinned repositories on the front page", | |
| 97 | unit: "repositories", | |
| 98 | default: 12, | |
| 99 | min: 1, | |
| 100 | max: 100, | |
| 101 | reason: "Pins sit above the fold; past a dozen they stop being picks.", | |
| 102 | }, | |
| 103 | "front.hidden_max": { | |
| 104 | label: "Hidden repositories on the front page", | |
| 105 | unit: "repositories", | |
| 106 | default: 200, | |
| 107 | min: 1, | |
| 108 | max: 10_000, | |
| 109 | reason: "The hidden list is checked while the front page is ranked, so it is kept bounded.", | |
| 110 | }, | |
| 111 | "share.daily_max": { | |
| 112 | label: "Shares delivered to one person a day", | |
| 113 | unit: "shares", | |
| 114 | default: 5, | |
| 115 | min: 1, | |
| 116 | max: 1_000, | |
| 117 | reason: "A person's feed is theirs: shares past the day's allowance wait instead of crowding it out.", | |
| 118 | }, | |
| 119 | "signin.per_account": { | |
| 120 | label: "Sign-in attempts per account a minute", | |
| 121 | unit: "attempts", | |
| 122 | default: 20, | |
| 123 | min: 1, | |
| 124 | max: 1_000, | |
| 125 | reason: "Every attempt costs a password hash, and a guesser gets only so many tries at one account.", | |
| 126 | }, | |
| 127 | "signin.per_forge": { | |
| 128 | label: "Sign-in attempts per forge a minute", | |
| 129 | unit: "attempts", | |
| 130 | default: 200, | |
| 131 | min: 1, | |
| 132 | max: 100_000, | |
| 133 | reason: "Spread across many accounts, guessing still burns CPU on hashes, so the forge as a whole has a ceiling.", | |
| 134 | }, | |
| 135 | }; | |
| 136 | ||
| 137 | export const LIMIT_KEYS = Object.keys(LIMITS) as LimitKey[]; | |
| 138 | ||
| 139 | export function isLimitKey(key: string): key is LimitKey { | |
| 140 | return Object.hasOwn(LIMITS, key); | |
| 141 | } | |
| 142 | ||
| 143 | /** Which limits are capacity settings and so stored there. */ | |
| 144 | function capacityKey(key: LimitKey): CapacityKey | undefined { | |
| 145 | return key === "crew.concurrency" || key === "checks.concurrency" | |
| 146 | ? key | |
| 147 | : undefined; | |
| 148 | } | |
| 149 | ||
| 150 | function storeKey(key: LimitKey): string { | |
| 151 | return `limit.${key}`; | |
| 152 | } | |
| 153 | ||
| 154 | /** Whether `n` is a whole number inside `key`'s bounds. */ | |
| 155 | export function validLimit(key: LimitKey, n: number): boolean { | |
| 156 | const def = LIMITS[key]; | |
| 157 | return Number.isInteger(n) && n >= def.min && n <= def.max; | |
| 158 | } | |
| 159 | ||
| 160 | /** The sentence that names a limit's bounds, for a refusal. */ | |
| 161 | export function limitBounds(key: LimitKey): string { | |
| 162 | const def = LIMITS[key]; | |
| 163 | return `${def.label} is a whole number from ${def.min} to ${def.max}`; | |
| 164 | } | |
| 165 | ||
| 166 | function stored(db: Database, key: LimitKey): number | undefined { | |
| 167 | const row = db | |
| 168 | .query<{ value: string }, [string]>( | |
| 169 | "SELECT value FROM forge_setting WHERE key = ?1", | |
| 170 | ) | |
| 171 | .get(storeKey(key)); | |
| 172 | if (row === null || !/^\d+$/.test(row.value)) return undefined; | |
| 173 | const n = Number(row.value); | |
| 174 | return validLimit(key, n) ? n : undefined; | |
| 175 | } | |
| 176 | ||
| 177 | /** The value in force when nobody has set one. */ | |
| 178 | export function limitDefault(db: Database, key: LimitKey): number { | |
| 179 | const cap = capacityKey(key); | |
| 180 | if (cap !== undefined) { | |
| 181 | const n = Number(bootDefault(db, cap)); | |
| 182 | return validLimit(key, n) ? n : LIMITS[key].default; | |
| 183 | } | |
| 184 | return LIMITS[key].default; | |
| 185 | } | |
| 186 | ||
| 187 | /** What `key` is now. Read on every use, never once at boot. */ | |
| 188 | export function limitValue(db: Database, key: LimitKey): number { | |
| 189 | const cap = capacityKey(key); | |
| 190 | if (cap !== undefined) { | |
| 191 | const n = Number(capacityValue(db, cap)); | |
| 192 | return validLimit(key, n) ? n : limitDefault(db, key); | |
| 193 | } | |
| 194 | return stored(db, key) ?? LIMITS[key].default; | |
| 195 | } | |
| 196 | ||
| 197 | /** Whether an admin has set `key` (rather than it being the default). */ | |
| 198 | export function limitIsSet(db: Database, key: LimitKey): boolean { | |
| 199 | const cap = capacityKey(key); | |
| 200 | if (cap !== undefined) | |
| 201 | return ( | |
| 202 | db | |
| 203 | .query("SELECT 1 FROM forge_setting WHERE key = ?1") | |
| 204 | .get(cap) !== null | |
| 205 | ); | |
| 206 | return stored(db, key) !== undefined; | |
| 207 | } | |
| 208 | ||
| 209 | export type LimitError = { | |
| 210 | readonly kind: "out_of_bounds"; | |
| 211 | readonly message: string; | |
| 212 | }; | |
| 213 | ||
| 214 | /** | |
| 215 | * Set `key` to `value` (`null` goes back to the default) and keep the | |
| 216 | * change. A value outside the bounds is refused with the bounds named. | |
| 217 | */ | |
| 218 | export function setLimit( | |
| 219 | db: Database, | |
| 220 | key: LimitKey, | |
| 221 | value: number | null, | |
| 222 | actorId: string, | |
| 223 | ): Result<{ from: number; to: number }, LimitError> { | |
| 224 | if (value !== null && !validLimit(key, value)) | |
| 225 | return err({ kind: "out_of_bounds", message: limitBounds(key) }); | |
| 226 | const from = limitValue(db, key); | |
| 227 | const cap = capacityKey(key); | |
| 228 | if (cap !== undefined) { | |
| 229 | setCapacity(db, cap, value === null ? null : String(value), actorId); | |
| 230 | return ok({ from, to: limitValue(db, key) }); | |
| 231 | } | |
| 232 | const at = new Date().toISOString(); | |
| 233 | db.transaction(() => { | |
| 234 | if (value === null) | |
| 235 | db.query("DELETE FROM forge_setting WHERE key = ?1").run(storeKey(key)); | |
| 236 | else | |
| 237 | db.query( | |
| 238 | `INSERT INTO forge_setting (key, value, updated_by, updated_at) VALUES (?1, ?2, ?3, ?4) | |
| 239 | ON CONFLICT (key) DO UPDATE SET value = excluded.value, | |
| 240 | updated_by = excluded.updated_by, updated_at = excluded.updated_at`, | |
| 241 | ).run(storeKey(key), String(value), actorId, at); | |
| 242 | db.query( | |
| 243 | `INSERT INTO forge_setting_change (key, from_value, to_value, actor_id, at) | |
| 244 | VALUES (?1, ?2, ?3, ?4, ?5)`, | |
| 245 | ).run(storeKey(key), String(from), String(limitValue(db, key)), actorId, at); | |
| 246 | })(); | |
| 247 | return ok({ from, to: limitValue(db, key) }); | |
| 248 | } | |
| 249 | ||
| 250 | export interface LimitChange { | |
| 251 | readonly key: LimitKey; | |
| 252 | readonly from: string; | |
| 253 | readonly to: string; | |
| 254 | readonly actorName: string; | |
| 255 | readonly at: string; | |
| 256 | } | |
| 257 | ||
| 258 | /** The most recent changes to any listed limit, newest first. */ | |
| 259 | export function limitChanges(db: Database, limit = 20): LimitChange[] { | |
| 260 | const marks = LIMIT_KEYS.map((_, i) => `?${i + 1}`).join(", "); | |
| 261 | const rows = db | |
| 262 | .query< | |
| 263 | { | |
| 264 | key: string; | |
| 265 | from_value: string; | |
| 266 | to_value: string; | |
| 267 | username: string | null; | |
| 268 | at: string; | |
| 269 | }, | |
| 270 | string[] | |
| 271 | >( | |
| 272 | `SELECT c.key, c.from_value, c.to_value, u.username, c.at | |
| 273 | FROM forge_setting_change c LEFT JOIN user u ON u.id = c.actor_id | |
| 274 | WHERE c.key IN (${marks}) | |
| 275 | ORDER BY c.at DESC, c.rowid DESC | |
| 276 | LIMIT ${Math.trunc(limit)}`, | |
| 277 | ) | |
| 278 | .all(...LIMIT_KEYS.map(storedChangeKey)); | |
| 279 | return rows.map((r) => ({ | |
| 280 | key: fromChangeKey(r.key), | |
| 281 | from: r.from_value, | |
| 282 | to: r.to_value, | |
| 283 | actorName: r.username ?? "", | |
| 284 | at: r.at, | |
| 285 | })); | |
| 286 | } | |
| 287 | ||
| 288 | /** The key a change is kept under: capacity's own, or `limit.` + the key. */ | |
| 289 | function storedChangeKey(key: LimitKey): string { | |
| 290 | return capacityKey(key) ?? storeKey(key); | |
| 291 | } | |
| 292 | ||
| 293 | function fromChangeKey(key: string): LimitKey { | |
| 294 | return (key.startsWith("limit.") ? key.slice(6) : key) as LimitKey; | |
| 295 | } |
src/web/pages/admin-limits.ts added +149
| @@ -0,0 +1,149 @@ | ||
| 1 | /** | |
| 2 | * Limits, for a forge admin: every limit the owner can hit, with its value, | |
| 3 | * its default and the reason it is there (`settings/limits.ts`). A change | |
| 4 | * governs the next request that meets the limit; each one is listed below: | |
| 5 | * who, when, from what to what. Anyone else gets the not-found page. | |
| 6 | */ | |
| 7 | ||
| 8 | import { changeLimit, listLimits } from "../../actions/limits.ts"; | |
| 9 | import type { Ctx } from "../../http/context.ts"; | |
| 10 | import { notFoundPage, settingsLayout } from "../chrome.ts"; | |
| 11 | import { field, readForm } from "../form.ts"; | |
| 12 | import { html, when } from "../html.ts"; | |
| 13 | import { flashTo, page } from "../layout.ts"; | |
| 14 | import { btn, card, icon, row, time } from "../ui.ts"; | |
| 15 | import { accountSections } from "./origins.ts"; | |
| 16 | ||
| 17 | // GET /admin/limits | |
| 18 | export function limitsPage(ctx: Ctx): Response { | |
| 19 | const listed = listLimits(ctx.app, ctx.auth.user); | |
| 20 | if (!listed.ok) return notFoundPage(ctx); | |
| 21 | const { limits, changes } = listed.value; | |
| 22 | const rows = limits.map((l) => | |
| 23 | row({ | |
| 24 | lead: icon("activity"), | |
| 25 | title: html`${l.label} <span class="muted small mono">${l.key}</span>`, | |
| 26 | meta: html`${l.reason} <br />${l.isSet | |
| 27 | ? html`Set here; the default is | |
| 28 | <span class="mono">${String(l.defaultValue)}</span>.` | |
| 29 | : html`The default.`} | |
| 30 | Whole number, ${l.unit}, from | |
| 31 | <span class="mono">${String(l.min)}</span> to | |
| 32 | <span class="mono">${String(l.max)}</span>.`, | |
| 33 | trail: html`<form class="row-actions" method="post" action="/admin/limits"> | |
| 34 | <input type="hidden" name="key" value="${l.key}" /> | |
| 35 | <input | |
| 36 | class="input mono" | |
| 37 | type="number" | |
| 38 | name="value" | |
| 39 | min="${String(l.min)}" | |
| 40 | max="${String(l.max)}" | |
| 41 | step="1" | |
| 42 | value="${String(l.value)}" | |
| 43 | aria-label="${l.label}" | |
| 44 | /> | |
| 45 | ${btn("Save", { | |
| 46 | size: "sm", | |
| 47 | variant: "primary", | |
| 48 | label: `Save ${l.label}`, | |
| 49 | })} | |
| 50 | ${when( | |
| 51 | l.isSet, | |
| 52 | () => | |
| 53 | html`${btn("Default", { | |
| 54 | size: "sm", | |
| 55 | name: "reset", | |
| 56 | value: "1", | |
| 57 | label: `Back to the default for ${l.label}`, | |
| 58 | })}`, | |
| 59 | )} | |
| 60 | </form>`, | |
| 61 | }), | |
| 62 | ); | |
| 63 | const history = changes.map((c) => | |
| 64 | row({ | |
| 65 | lead: icon("history"), | |
| 66 | title: html`<span class="mono">${c.key}</span> | |
| 67 | <span class="mono">${c.from}</span> → | |
| 68 | <span class="mono">${c.to}</span>`, | |
| 69 | meta: html`${c.actorName === "" ? "someone" : c.actorName} · ${time(c.at)}`, | |
| 70 | }), | |
| 71 | ); | |
| 72 | const body = html`${card( | |
| 73 | html`<div class="card-body"> | |
| 74 | <p class="muted small"> | |
| 75 | Every limit you can hit, and why it is there. A change governs | |
| 76 | the next request that meets the limit; nothing needs a restart. | |
| 77 | </p> | |
| 78 | </div> | |
| 79 | <ul class="list"> | |
| 80 | ${rows} | |
| 81 | </ul>`, | |
| 82 | { | |
| 83 | head: html`${icon("activity")} | |
| 84 | <h2>Limits</h2>`, | |
| 85 | pad: false, | |
| 86 | }, | |
| 87 | )} | |
| 88 | ${card( | |
| 89 | history.length === 0 | |
| 90 | ? html`<div class="card-body"> | |
| 91 | <p class="muted small">No change yet.</p> | |
| 92 | </div>` | |
| 93 | : html`<ul class="list"> | |
| 94 | ${history} | |
| 95 | </ul>`, | |
| 96 | { | |
| 97 | head: html`${icon("history")} | |
| 98 | <h2>Changes</h2>`, | |
| 99 | pad: false, | |
| 100 | }, | |
| 101 | )}`; | |
| 102 | return page({ | |
| 103 | ctx, | |
| 104 | title: "Limits", | |
| 105 | body: html`<div class="page-head"><h1>Limits</h1></div> | |
| 106 | ${settingsLayout( | |
| 107 | accountSections("limits", { admin: true }), | |
| 108 | body, | |
| 109 | "Account settings", | |
| 110 | )}`, | |
| 111 | }); | |
| 112 | } | |
| 113 | ||
| 114 | // POST /admin/limits | |
| 115 | export async function limitsSet(ctx: Ctx): Promise<Response> { | |
| 116 | // Checked before the body is read: a non-admin learns nothing of the door. | |
| 117 | if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") | |
| 118 | return notFoundPage(ctx); | |
| 119 | const form = await readForm(ctx, "admin"); | |
| 120 | if (form instanceof Response) return form; | |
| 121 | const key = field(form, "key"); | |
| 122 | const reset = field(form, "reset") === "1"; | |
| 123 | const changed = changeLimit( | |
| 124 | ctx.app, | |
| 125 | ctx.auth.user, | |
| 126 | key, | |
| 127 | reset ? null : field(form, "value"), | |
| 128 | ); | |
| 129 | if (!changed.ok && changed.error.kind === "not_found") | |
| 130 | return notFoundPage(ctx); | |
| 131 | return flashTo( | |
| 132 | ctx, | |
| 133 | "/admin/limits", | |
| 134 | changed.ok | |
| 135 | ? { | |
| 136 | message: | |
| 137 | changed.value.from === changed.value.to | |
| 138 | ? `${key} is already ${changed.value.to}.` | |
| 139 | : `${key}: ${changed.value.from} → ${changed.value.to}.`, | |
| 140 | } | |
| 141 | : { | |
| 142 | tone: "danger", | |
| 143 | message: | |
| 144 | changed.error.kind === "out_of_bounds" | |
| 145 | ? `${changed.error.message}.` | |
| 146 | : "There is no such limit.", | |
| 147 | }, | |
| 148 | ); | |
| 149 | } |
src/web/pages/crew.ts +6 −6
| @@ -15,7 +15,6 @@ import { | ||
| 15 | 15 | } from "../../actions/crew.ts"; |
| 16 | 16 | import { |
| 17 | 17 | BRIEF_MAX, |
| 18 | BUDGET_MAX_USD, | |
| 19 | 18 | MODEL_MAX, |
| 20 | 19 | type Agent, |
| 21 | 20 | } from "../../core/agents.ts"; |
| @@ -42,6 +41,7 @@ import { | ||
| 42 | 41 | } from "../ui.ts"; |
| 43 | 42 | import { accountSections } from "./origins.ts"; |
| 44 | 43 | import { markOf } from "../../core/marks.ts"; |
| 44 | import { limitValue } from "../../settings/limits.ts"; | |
| 45 | 45 | |
| 46 | 46 | /** The admin, or the response that turns everyone else away. */ |
| 47 | 47 | function requireAdmin(ctx: Ctx): Response | undefined { |
| @@ -115,14 +115,14 @@ interface AgentValues { | ||
| 115 | 115 | budgetUsd: string; |
| 116 | 116 | } |
| 117 | 117 | |
| 118 | const AGENT_ERRORS: Record<string, string> = { | |
| 118 | const agentErrors = (ctx: Ctx): Record<string, string> => ({ | |
| 119 | 119 | invalid_username: "Usernames are letters, digits, dots, dashes.", |
| 120 | 120 | username_taken: "That username is taken.", |
| 121 | 121 | email_taken: "That username is taken.", |
| 122 | 122 | invalid_model: `A model is up to ${MODEL_MAX} characters with no spaces.`, |
| 123 | 123 | brief_too_long: `A brief is at most ${BRIEF_MAX} characters.`, |
| 124 | invalid_budget: `A budget is a number of dollars between 0 and ${BUDGET_MAX_USD}.`, | |
| 125 | }; | |
| 124 | invalid_budget: `A budget is a number of dollars between 0 and ${limitValue(ctx.app.db, "agent.budget_max_usd")}.`, | |
| 125 | }); | |
| 126 | 126 | |
| 127 | 127 | function agentFields(values: AgentValues, error?: string): Html { |
| 128 | 128 | return html`${selectField({ |
| @@ -448,7 +448,7 @@ export async function agentCreate(ctx: Ctx): Promise<Response> { | ||
| 448 | 448 | crewView( |
| 449 | 449 | ctx, |
| 450 | 450 | values, |
| 451 | AGENT_ERRORS[made.error] ?? "That agent could not be created.", | |
| 451 | agentErrors(ctx)[made.error] ?? "That agent could not be created.", | |
| 452 | 452 | ), |
| 453 | 453 | 422, |
| 454 | 454 | ); |
| @@ -540,7 +540,7 @@ export async function agentUpdate(ctx: Ctx): Promise<Response> { | ||
| 540 | 540 | agentView( |
| 541 | 541 | agent, |
| 542 | 542 | values, |
| 543 | AGENT_ERRORS[updated.error] ?? "Those changes could not be saved.", | |
| 543 | agentErrors(ctx)[updated.error] ?? "Those changes could not be saved.", | |
| 544 | 544 | ), |
| 545 | 545 | 422, |
| 546 | 546 | ); |
src/web/pages/origins.ts +7
| @@ -80,6 +80,7 @@ export type AccountSection = | ||
| 80 | 80 | | "backups" |
| 81 | 81 | | "secrets" |
| 82 | 82 | | "capacity" |
| 83 | | "limits" | |
| 83 | 84 | | "cloud"; |
| 84 | 85 | |
| 85 | 86 | /** |
| @@ -131,6 +132,12 @@ export function accountSections( | ||
| 131 | 132 | icon: "activity" as const, |
| 132 | 133 | current: current === "capacity", |
| 133 | 134 | }, |
| 135 | { | |
| 136 | label: "Limits", | |
| 137 | href: "/admin/limits", | |
| 138 | icon: "activity" as const, | |
| 139 | current: current === "limits", | |
| 140 | }, | |
| 134 | 141 | { |
| 135 | 142 | label: "Backups", |
| 136 | 143 | href: "/settings/backups", |
src/web/pages/site.ts +8 −7
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * typed still in it. A dead end that loses input is a bug, not a message. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { limitValue } from "../../settings/limits.ts"; | |
| 11 | 12 | import { mayView } from "../../core/sealed.ts"; |
| 12 | 13 | import { signOutHere } from "../../actions/signins.ts"; |
| 13 | 14 | import { clientAddress } from "../../http/peer.ts"; |
| @@ -463,19 +464,19 @@ export function signupPage(ctx: Ctx): Response { | ||
| 463 | 464 | * limiter per forge, so a test's forge never throttles another's. |
| 464 | 465 | */ |
| 465 | 466 | const signinLimits = new WeakMap<Ctx["app"], RateLimiter>(); |
| 466 | const SIGNIN_PER_ACCOUNT = 20; | |
| 467 | const SIGNIN_PER_FORGE = 200; | |
| 468 | 467 | |
| 469 | 468 | function signinAllowed(ctx: Ctx, username: string): number | undefined { |
| 470 | 469 | let limiter = signinLimits.get(ctx.app); |
| 471 | 470 | if (limiter === undefined) { |
| 472 | limiter = new RateLimiter({ | |
| 473 | perMinute: SIGNIN_PER_ACCOUNT, | |
| 474 | adminPerMinute: SIGNIN_PER_FORGE, | |
| 475 | enabled: true, | |
| 476 | }); | |
| 471 | limiter = new RateLimiter({ perMinute: 0, adminPerMinute: 0, enabled: true }); | |
| 477 | 472 | signinLimits.set(ctx.app, limiter); |
| 478 | 473 | } |
| 474 | // Read on every attempt, so a change on /admin/limits governs the next one. | |
| 475 | limiter.setLimits({ | |
| 476 | perMinute: limitValue(ctx.app.db, "signin.per_account"), | |
| 477 | adminPerMinute: limitValue(ctx.app.db, "signin.per_forge"), | |
| 478 | enabled: true, | |
| 479 | }); | |
| 479 | 480 | const whole = limiter.take("forge", "admin"); |
| 480 | 481 | if (!whole.allowed) return whole.retryAfter; |
| 481 | 482 | const one = limiter.take(`account:${username.toLowerCase()}`, "ro"); |
src/web/pages/subscriptions.ts +3 −3
| @@ -9,7 +9,6 @@ import { listAgents, type Agent } from "../../core/agents.ts"; | ||
| 9 | 9 | import { |
| 10 | 10 | addSubscription, |
| 11 | 11 | API_BASE_MAX, |
| 12 | BUDGET_MAX_USD, | |
| 13 | 12 | budgetsOf, |
| 14 | 13 | deleteSubscription, |
| 15 | 14 | EVERYONE, |
| @@ -59,6 +58,7 @@ import { readSpend } from "../../actions/spend.ts"; | ||
| 59 | 58 | import { liveRegionsJs } from "../client.ts"; |
| 60 | 59 | import { spendView } from "./spend.ts"; |
| 61 | 60 | import { markOf } from "../../core/marks.ts"; |
| 61 | import { limitValue } from "../../settings/limits.ts"; | |
| 62 | 62 | |
| 63 | 63 | const POOL = "/settings/subscriptions"; |
| 64 | 64 | |
| @@ -250,7 +250,7 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 250 | 250 | name="budget" |
| 251 | 251 | type="number" |
| 252 | 252 | min="1" |
| 253 | max="${budget?.locked ? budget.budgetUsd : BUDGET_MAX_USD}" | |
| 253 | max="${budget?.locked ? budget.budgetUsd : limitValue(ctx.app.db, "subscription.budget_max_usd")}" | |
| 254 | 254 | step="1" |
| 255 | 255 | placeholder="none" |
| 256 | 256 | value="${budget?.budgetUsd ?? ""}" |
| @@ -485,7 +485,7 @@ export async function subscriptionBudget(ctx: Ctx): Promise<Response> { | ||
| 485 | 485 | POOL, |
| 486 | 486 | saved |
| 487 | 487 | ? { message: usd === null ? "No budget: this account spends until you stop it." : `Budget set: ${dollars(usd)} in all.` } |
| 488 | : { tone: "danger", message: `A budget is dollars from 1 to ${BUDGET_MAX_USD}, or empty for none.` }, | |
| 488 | : { tone: "danger", message: `A budget is dollars from 1 to ${limitValue(ctx.app.db, "subscription.budget_max_usd")}, or empty for none.` }, | |
| 489 | 489 | ); |
| 490 | 490 | } |
| 491 | 491 |
test/limits.test.ts added +193
| @@ -0,0 +1,193 @@ | ||
| 1 | /** | |
| 2 | * Every limit is a setting with its reason beside it (#1476): a forge | |
| 3 | * admin lists and changes them on /admin/limits, a value out of bounds is | |
| 4 | * refused with its bound named, each change is kept, and a changed limit | |
| 5 | * changes what it governs on the very next use. Anyone else gets a 404. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { createAgent } from "../src/actions/crew.ts"; | |
| 14 | import { setFront } from "../src/actions/front.ts"; | |
| 15 | import { capacityCount } from "../src/core/capacity.ts"; | |
| 16 | import { createToken } from "../src/core/tokens.ts"; | |
| 17 | import { createUser, type User } from "../src/core/users.ts"; | |
| 18 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 19 | import { | |
| 20 | LIMITS, | |
| 21 | LIMIT_KEYS, | |
| 22 | limitChanges, | |
| 23 | limitValue, | |
| 24 | } from "../src/settings/limits.ts"; | |
| 25 | ||
| 26 | describe("limits", () => { | |
| 27 | let dataDir: string; | |
| 28 | let app: App; | |
| 29 | let servers: Servers; | |
| 30 | let alice: User; | |
| 31 | let bob: User; | |
| 32 | ||
| 33 | const login = async (username: string, password = "a-good-passphrase") => { | |
| 34 | const res = await fetch(`${servers.url}/signin`, { | |
| 35 | method: "POST", | |
| 36 | redirect: "manual", | |
| 37 | headers: { | |
| 38 | "content-type": "application/x-www-form-urlencoded", | |
| 39 | origin: servers.url, | |
| 40 | }, | |
| 41 | body: new URLSearchParams({ username, password }), | |
| 42 | }); | |
| 43 | return { | |
| 44 | status: res.status, | |
| 45 | cookie: (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "", | |
| 46 | text: await res.text(), | |
| 47 | }; | |
| 48 | }; | |
| 49 | const post = (cookie: string, body: Record<string, string>, origin = servers.url) => | |
| 50 | fetch(`${servers.url}/admin/limits`, { | |
| 51 | method: "POST", | |
| 52 | redirect: "manual", | |
| 53 | headers: { | |
| 54 | cookie, | |
| 55 | "content-type": "application/x-www-form-urlencoded", | |
| 56 | origin, | |
| 57 | }, | |
| 58 | body: new URLSearchParams(body), | |
| 59 | }); | |
| 60 | ||
| 61 | beforeAll(async () => { | |
| 62 | dataDir = await mkdtemp(join(tmpdir(), "forge-limits-")); | |
| 63 | app = createApp({ dataDir, port: 0 }); | |
| 64 | servers = await serve(app); | |
| 65 | for (const [username, first] of [["alice", true], ["bob", false]] as const) { | |
| 66 | const made = await createUser(app.db, { | |
| 67 | trusted: true, | |
| 68 | username, | |
| 69 | email: `${username}@forge.local`, | |
| 70 | password: "a-good-passphrase", | |
| 71 | adminIfFirst: first, | |
| 72 | }); | |
| 73 | if (!made.ok) throw new Error(made.error); | |
| 74 | if (username === "alice") alice = made.value; | |
| 75 | else bob = made.value; | |
| 76 | } | |
| 77 | }); | |
| 78 | afterAll(async () => { | |
| 79 | await servers.stop(); | |
| 80 | await rm(dataDir, { recursive: true, force: true }); | |
| 81 | }); | |
| 82 | ||
| 83 | test("a non-admin, signed in or not, gets a 404 and changes nothing", async () => { | |
| 84 | const bob = (await login("bob")).cookie; | |
| 85 | for (const cookie of [bob, ""]) { | |
| 86 | const got = await fetch(`${servers.url}/admin/limits`, { headers: { cookie } }); | |
| 87 | expect(got.status).toBe(404); | |
| 88 | expect((await post(cookie, { key: "front.pinned_max", value: "50" })).status).toBe(404); | |
| 89 | } | |
| 90 | expect(limitValue(app.db, "front.pinned_max")).toBe(LIMITS["front.pinned_max"].default); | |
| 91 | expect(limitChanges(app.db)).toEqual([]); | |
| 92 | }); | |
| 93 | ||
| 94 | test("the page lists every limit with its value and its reason", async () => { | |
| 95 | const { cookie } = await login("alice"); | |
| 96 | const res = await fetch(`${servers.url}/admin/limits`, { headers: { cookie } }); | |
| 97 | expect(res.status).toBe(200); | |
| 98 | const text = await res.text(); | |
| 99 | for (const key of LIMIT_KEYS) { | |
| 100 | expect(text).toContain(key); | |
| 101 | expect(text).toContain(LIMITS[key].reason.replace(/'/g, "'")); | |
| 102 | } | |
| 103 | }); | |
| 104 | ||
| 105 | test("a value out of bounds is refused with its bound named; a cross-origin post is refused", async () => { | |
| 106 | const { cookie } = await login("alice"); | |
| 107 | const def = LIMITS["front.pinned_max"]; | |
| 108 | for (const value of [String(def.max + 1), "0", "-3", "1.5", "lots", ""]) { | |
| 109 | const res = await post(cookie, { key: "front.pinned_max", value }); | |
| 110 | expect(res.status).toBe(303); | |
| 111 | const flashed = await fetch(`${servers.url}${res.headers.get("location")}`, { | |
| 112 | headers: { cookie: `${cookie}; ${(res.headers.get("set-cookie") ?? "").split(";")[0]}` }, | |
| 113 | }); | |
| 114 | expect(await flashed.text()).toContain(`from ${def.min} to ${def.max}`); | |
| 115 | } | |
| 116 | expect(limitValue(app.db, "front.pinned_max")).toBe(def.default); | |
| 117 | expect((await post(cookie, { key: "front.pinned_max", value: "20" }, "https://evil.example")).status).toBe(403); | |
| 118 | expect(limitValue(app.db, "front.pinned_max")).toBe(def.default); | |
| 119 | expect(limitChanges(app.db)).toEqual([]); | |
| 120 | }); | |
| 121 | ||
| 122 | test("changing a limit changes what it governs: pins, a crew member's budget, sign-ins, check concurrency", async () => { | |
| 123 | const { cookie } = await login("alice"); | |
| 124 | const names = Array.from({ length: 13 }, (_, i) => `repo${i}`); | |
| 125 | ||
| 126 | // Pins: thirteen is over twelve until the limit is raised. | |
| 127 | const before = setFront(app, alice, { pinned: names, hidden: [] }, true); | |
| 128 | expect(!before.ok && before.error.kind === "invalid" && before.error.message).toBe("pin at most 12"); | |
| 129 | expect((await post(cookie, { key: "front.pinned_max", value: "13" })).status).toBe(303); | |
| 130 | const after = setFront(app, alice, { pinned: names, hidden: [] }, true); | |
| 131 | // Thirteen pins now pass the limit; only the repositories not existing refuses. | |
| 132 | expect(!after.ok && after.error.kind === "invalid" && after.error.message).not.toMatch(/^pin at most/); | |
| 133 | expect(after.ok).toBe(false); | |
| 134 | ||
| 135 | // A crew member's budget: 150 dollars is over the ceiling of 100 until raised. | |
| 136 | const mk = (username: string, budgetUsd: number) => | |
| 137 | createAgent(app, alice, { username, budgetUsd }); | |
| 138 | expect(await mk("robot-a", 150)).toEqual({ ok: false, error: "invalid_budget" }); | |
| 139 | await post(cookie, { key: "agent.budget_max_usd", value: "200" }); | |
| 140 | expect((await mk("robot-b", 150)).ok).toBe(true); | |
| 141 | ||
| 142 | // Sign-ins: with two attempts allowed a minute, the third is turned away. | |
| 143 | await post(cookie, { key: "signin.per_account", value: "2" }); | |
| 144 | const bad = [await login("carol", "x"), await login("carol", "x"), await login("carol", "x")]; | |
| 145 | expect(bad[0]!.text).not.toContain("Too many attempts"); | |
| 146 | expect(bad[2]!.text).toContain("Too many attempts"); | |
| 147 | await post(cookie, { key: "signin.per_account", reset: "1" }); | |
| 148 | expect(limitValue(app.db, "signin.per_account")).toBe(LIMITS["signin.per_account"].default); | |
| 149 | ||
| 150 | // Check concurrency is the capacity setting, listed here: one value. | |
| 151 | await post(cookie, { key: "checks.concurrency", value: "4" }); | |
| 152 | expect(limitValue(app.db, "checks.concurrency")).toBe(4); | |
| 153 | expect(capacityCount(app.db, "checks.concurrency", 1)).toBe(4); | |
| 154 | }); | |
| 155 | ||
| 156 | test("every change is kept with who made it and when", () => { | |
| 157 | const kept = limitChanges(app.db); | |
| 158 | expect(kept.map((c) => [c.key, c.from, c.to, c.actorName])).toEqual([ | |
| 159 | ["checks.concurrency", "1", "4", "alice"], | |
| 160 | ["signin.per_account", "2", "20", "alice"], | |
| 161 | ["signin.per_account", "20", "2", "alice"], | |
| 162 | ["agent.budget_max_usd", "100", "200", "alice"], | |
| 163 | ["front.pinned_max", "12", "13", "alice"], | |
| 164 | ]); | |
| 165 | expect(kept.every((c) => Date.parse(c.at) > 0)).toBe(true); | |
| 166 | }); | |
| 167 | ||
| 168 | test("the API lists and changes limits for an admin only, and refuses out-of-bounds values", async () => { | |
| 169 | const adminToken = createToken(app.db, alice.id, "t", "admin").token; | |
| 170 | const bobToken = createToken(app.db, bob.id, "t", "admin").token; | |
| 171 | const api = (token: string, method: string, path: string, body?: unknown) => | |
| 172 | fetch(`${servers.url}/api/v1${path}`, { | |
| 173 | method, | |
| 174 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 175 | ...(body === undefined ? {} : { body: JSON.stringify(body) }), | |
| 176 | }); | |
| 177 | const listed = await api(adminToken, "GET", "/admin/limits"); | |
| 178 | expect(listed.status).toBe(200); | |
| 179 | expect(((await listed.json()) as { limits: unknown[] }).limits).toHaveLength(LIMIT_KEYS.length); | |
| 180 | expect((await api(bobToken, "GET", "/admin/limits")).status).toBe(404); | |
| 181 | expect((await api(bobToken, "PUT", "/admin/limits/share.daily_max", { value: "9" })).status).toBe(404); | |
| 182 | expect((await api(adminToken, "PUT", "/admin/limits/share.daily_max", { value: "0" })).status).toBe(422); | |
| 183 | expect((await api(adminToken, "PUT", "/admin/limits/nothing.here", { value: "3" })).status).toBe(422); | |
| 184 | expect(limitValue(app.db, "share.daily_max")).toBe(LIMITS["share.daily_max"].default); | |
| 185 | const put = await api(adminToken, "PUT", "/admin/limits/share.daily_max", { value: "9" }); | |
| 186 | expect(put.status).toBe(200); | |
| 187 | expect(limitValue(app.db, "share.daily_max")).toBe(9); | |
| 188 | expect((await api(adminToken, "PUT", "/admin/limits/share.daily_max", { value: 11 })).status).toBe(200); | |
| 189 | expect(limitValue(app.db, "share.daily_max")).toBe(11); | |
| 190 | expect((await api(adminToken, "PUT", "/admin/limits/share.daily_max", { value: null })).status).toBe(200); | |
| 191 | expect(limitValue(app.db, "share.daily_max")).toBe(LIMITS["share.daily_max"].default); | |
| 192 | }); | |
| 193 | }); |
test/mcp.test.ts +32
| @@ -18,6 +18,7 @@ import { createRun, listRuns } from "../src/core/runs.ts"; | ||
| 18 | 18 | import { createToken } from "../src/core/tokens.ts"; |
| 19 | 19 | import { createUser, type User } from "../src/core/users.ts"; |
| 20 | 20 | import { serve, type Servers } from "../src/http/server.ts"; |
| 21 | import { limitChanges, limitValue } from "../src/settings/limits.ts"; | |
| 21 | 22 | |
| 22 | 23 | describe("the forge over MCP", () => { |
| 23 | 24 | let dataDir: string; |
| @@ -197,6 +198,7 @@ describe("the forge over MCP", () => { | ||
| 197 | 198 | "hold", |
| 198 | 199 | "plan", |
| 199 | 200 | "capacity", |
| 201 | "limits", | |
| 200 | 202 | "report", |
| 201 | 203 | ]); |
| 202 | 204 | const byName = new Map<string, any>( |
| @@ -549,6 +551,36 @@ describe("the forge over MCP", () => { | ||
| 549 | 551 | }); |
| 550 | 552 | expect(res.status).toBe(401); |
| 551 | 553 | }); |
| 554 | test("limits: admins only, dry by default, an admin-scope token to change", async () => { | |
| 555 | const key = "front.pinned_max"; | |
| 556 | const admin = createToken(app.db, alice.id, "admin", "admin").token; | |
| 557 | // A non-admin and an agent (even one marked admin) are told nothing. | |
| 558 | expect((await tool(bobRw, "limits")).error).toContain("no such tool"); | |
| 559 | const made = await createAgent(app, alice, { username: "limitbot", brief: "x" }); | |
| 560 | if (!made.ok) throw new Error(String(made.error)); | |
| 561 | app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(made.value.user.id); | |
| 562 | const botToken = createToken(app.db, made.value.user.id, "bot", "admin").token; | |
| 563 | expect((await tool(botToken, "limits")).error).toContain("no such tool"); | |
| 564 | ||
| 565 | const before = limitValue(app.db, key); | |
| 566 | const listed = await tool(ro, "limits"); | |
| 567 | expect(listed.value.limits.map((l: any) => l.key)).toContain(key); | |
| 568 | ||
| 569 | // The default is a dry run: nothing changes, even with an admin token. | |
| 570 | const dry = await tool(admin, "limits", { key, value: "13" }); | |
| 571 | expect(dry.value.says).toContain("Would set"); | |
| 572 | expect(limitValue(app.db, key)).toBe(before); | |
| 573 | ||
| 574 | // Writing needs the admin scope. | |
| 575 | const refused = await tool(ro, "limits", { key, value: "13", dryRun: false }); | |
| 576 | expect(refused.error).toContain("forbidden"); | |
| 577 | expect(limitValue(app.db, key)).toBe(before); | |
| 578 | ||
| 579 | const done = await tool(admin, "limits", { key, value: "13", dryRun: false }); | |
| 580 | expect(done.value).toMatchObject({ key, from: before, to: 13 }); | |
| 581 | expect(limitValue(app.db, key)).toBe(13); | |
| 582 | expect(limitChanges(app.db)[0]).toMatchObject({ key }); | |
| 583 | }); | |
| 552 | 584 | |
| 553 | 585 | test("ui:// card resources are listed, read, and tied to their tools", async () => { |
| 554 | 586 | const list = await call(rw, "resources/list"); |
new above
offline
Low: the dry run reports
Would set front.pinned_max from 12 to lots(or to0, or above the max) and only refuses the value oncedryRun:falseis sent. The plan should give the same answer the write would. RunvalidLimit/limitBounds(or the same parsechangeLimitdoes) before you buildsays, and returnout_of_boundsfrom the dry run.capacityhas the same gap at :1781, so this can be a follow-up for both.