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

Egress enforcement cannot be turned to report by environment alone #10784

Merged opened this

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

Merged

scout/forge:scout/10778-egress-enforcement-cannot-be-turned-to-r → main 4 commits fork 4 threads · 4 unresolved

  • Merged. scout/10778-egress-enforcement-cannot-be-turned-to-r was merged into main as 40ba5ea.

Review

Sign in to review this change.
24 files changed +371 −68
docs/APPS.md +5 −3
@@ -672,8 +672,10 @@ purpose: a repository admin who is no site admin sees the list read-only on the
672672 page, and nothing on the terminal screen (no rows, no `g`/`x`).
673673
674674 A release that turns egress on must not break an app whose writers have
675 not declared what it uses yet, so the operator can start it reported:
676 `FORGE_CELLS_EGRESS=report` lets an undeclared public host through (on 80
675 not declared what it uses yet, so an admin can set it reported:
676 `cells.egress` = `report` on /settings/capacity (or the API's
677 `PUT /api/v1/admin/capacity/cells.egress` with a `reason`; the change log
678 keeps who and why) lets an undeclared public host through (on 80
677679 and 443 only) and
678680 counts it per repository (`<data>/egress/<repo id>.json`, under the
679681 forge's data where no cell sees it), and the app's page tells its writers
@@ -1655,7 +1657,7 @@ a link to the milestone (`docs/PLANNING.md`); its progress never does.
16551657 | `FORGE_CHECK_SHARDS` | the machine's cores | test shards per check (live: Settings → Capacity) |
16561658 | `FORGE_CHECK_CACHE_DAYS` | `30` | days a package may go unlent before the check install cache drops it |
16571659 | `FORGE_CHECK_CACHE_MB` | `2048` | size one repository's check install cache is swept down to |
1658 | `FORGE_CELLS_EGRESS` | `enforce` | `report` lets undeclared egress through on 80/443 (live: Settings → Capacity) |
1660 | `FORGE_CELLS_EGRESS` | ignored | logged once at boot; egress is enforced unless an admin sets `cells.egress` (Settings → Capacity), with a reason |
16591661 | `FORGE_CELL_CGROUP` | `/sys/fs/cgroup` at a namespace root | the delegated cgroup v2 whose children cap each cell's memory; `off` watches only |
16601662 | `FORGE_CREW_CPUS` | `4` | cores one crew run may use (`cpu.max` on its own cell cgroup, where the cpu controller is delegated) |
16611663 | `FORGE_CREW_BUDGET_USD` | `5` | spend per run unless the agent says otherwise |
docs/MCP.md +3 −3
@@ -70,7 +70,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
7070 | `boards` | — | The forge's boards for person admins: slug, title, range, refresh and panels | 425 |
7171 | `branches` | `create`, `delete`, `branches`, `tags` | A repo's branches; `name`, `action` create (from `from`) or delete one, not the default | 407 |
7272 | `builds` | — | A repo's build pins (`refs/builds/`): label, commit, pinned by, note | 231 |
73 | `capacity` | — | Forge capacity and guards, live | 363 |
73 | `capacity` | — | Forge capacity and guards, live | 390 |
7474 | `claim_link` | — | A new claim link for an unclaimed child forge, only with `reveal: true`; the old stops | 289 |
7575 | `close` | — | Close or reopen (`state` open) an issue or pull | 463 |
7676 | `cloud_account` | — | Change a cloud account by `verb` (admin) | 549 |
@@ -79,7 +79,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
7979 | `confirm_wait` | — | Let an issue go ahead though its blocker closed dropped or split | 269 |
8080 | `contribute` | — | For a new agent: what this forge is, its bottleneck, help-wanted issues, how to propose | 210 |
8181 | `converge` | — | Have a fork's crew merge the parent's updates where both changed a file | 200 |
82 | `crew` | — | List a repository's crew, or add or remove an agent | 313 |
82 | `crew` | — | List a repository's crew, or add or remove an agent | 338 |
8383 | `data` | — | Read SQLite files you may see: tables, rows, read-only SQL; secrets masked | 367 |
8484 | `decisions_by` | — | Who decides parked hand-over cards: `me`, `decider`, `decider_tell` | 305 |
8585 | `detach` | — | Detach a fork from its parent | 210 |
@@ -89,7 +89,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
8989 | `egress_grants` | — | Private destinations and grants: list, grant or revoke (site admin) | 289 |
9090 | `email_verify` | — | Send yourself a new verification link | 126 |
9191 | `events` | — | Repository events | 259 |
92 | `explore` | — | The apps, people (and, for an admin, forges) you can see, ranked | 414 |
92 | `explore` | — | The apps, people (and, for an admin, forges) you can see, ranked | 547 |
9393 | `family` | — | The forge that sent this one and, for a site admin, those it sent, with state, version | 205 |
9494 | `features` | — | Apps absorbed as forge features: list, read, absorb or un-absorb, change typed settings | 357 |
9595 | `feed` | — | Your ranked home timeline; `cursor` pages; saved filter applies | 562 |
src/actions/capacity.ts +16 −3
@@ -22,6 +22,8 @@ import {
2222 type CapacityKey,
2323 effectiveCapacity,
2424 followsSpeed,
25 needsReason,
26 REASON_MAX,
2527 } from "../core/capacity.ts";
2628 import type { EgressTotal } from "../apps/egress.ts";
2729 import {
@@ -161,15 +163,23 @@ export function edgeOffDryRun(
161163 });
162164 }
163165
164 /** Set `key` to `value`; `null` or `""` goes back to the boot default. */
166 /** What a move to report, said without a reason, is told. */
167 export const REASON_WORDS = "Say why: setting cells.egress to report is kept with who made it and the reason.";
168
169 /**
170 * Set `key` to `value`; `null` or `""` goes back to the boot default.
171 * `reason` is kept beside who; a move of `cells.egress` to report is refused
172 * without one (#10778). Tightening (enforce, or back to the default) never is.
173 */
165174 export function changeCapacity(
166175 app: App,
167176 actor: User | undefined,
168177 key: string,
169178 value: string | null,
179 reason = "",
170180 ): Result<
171181 { key: CapacityKey; from: string; to: string },
172 "not_found" | "no_such_setting" | "invalid_value" | "no_credential" | "derived"
182 "not_found" | "no_such_setting" | "invalid_value" | "no_credential" | "derived" | "reason_required"
173183 > {
174184 const who = admin(actor);
175185 if (!who.ok) return who;
@@ -180,16 +190,19 @@ export function changeCapacity(
180190 return err("invalid_value");
181191 if (key === "signin.edge" && next === "off" && !signsInWithoutEdge(app.db, who.value.id))
182192 return err("no_credential");
193 const why = reason.replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, REASON_MAX);
outdated

Only a change to report should need a reason. As written, enforce and the reset to default are refused without one, which blocks the safe direction (and the page's Put back). Fix: needsReason(key) && next === "report", then test reset and enforce without a reason.

194 if (needsReason(key) && next === "report" && why === "") return err("reason_required");
183195 const changed = setCapacity(
184196 app.db,
185197 key,
186198 next === "" ? null : next,
187199 who.value.id,
200 why,
188201 );
189202 app.bus.append({
190203 kind: "capacity.changed",
191204 actorId: who.value.id,
192 payload: { key, from: changed.from, to: changed.to },
205 payload: { key, from: changed.from, to: changed.to, ...(why === "" ? {} : { reason: why }) },
193206 });
194207 return ok({ key, ...changed });
195208 }
src/apps/contain.ts +4 −13
@@ -241,8 +241,8 @@ export interface CellSpec {
241241 readonly onRefused?: ((host: string, port: number, isPrivate: boolean) => void) | undefined;
242242 /**
243243 * Refuse undeclared egress, or only report it, for this cell — the
244 * forge's setting when the cell starts (`core/capacity.ts`); the
245 * environment's (`egressMode`) when the caller has none.
244 * forge's setting when the cell starts (`core/capacity.ts`); enforce
245 * when the caller has none. The environment never says (#10778).
246246 */
247247 readonly egressMode?: "enforce" | "report" | undefined;
248248 /**
@@ -274,16 +274,6 @@ export function watchedLimits(limits: Limits, cgroup: { readonly cpuCapped: bool
274274 return cgroup?.cpuCapped === true ? { ...limits, cpus: Number.POSITIVE_INFINITY } : limits;
275275 }
276276
277 /**
278 * Whether undeclared egress is refused (`enforce`, the default) or let
279 * through and reported (`FORGE_CELLS_EGRESS=report`), for a release that
280 * must not break an app before its writers have declared what it uses.
281 * Files, processes and private addresses are held the same in both.
282 */
283 export function egressMode(): "enforce" | "report" {
284 return process.env["FORGE_CELLS_EGRESS"] === "report" ? "report" : "enforce";
285 }
286
287277 export interface Cell {
288278 /** The argv that runs `command` (through `sh -c`) inside the cell. */
289279 readonly argv: (command: string) => string[];
@@ -524,7 +514,8 @@ export function openCell(
524514 // the forge told its parent's door; a check that boots a forge is not.
525515 upstream: process.env["FORGE_PARENT_LOCAL"] === undefined ? undefined : upstreamProxy(),
526516 report:
527 (spec.egressMode ?? egressMode()) === "report"
517 // Enforce unless the caller carries an admin's report (`egressSetting`, #10778).
518 spec.egressMode === "report"
528519 ? (host, port) => spec.onUndeclared?.(host, port)
529520 : undefined,
530521 },
src/apps/egress.ts +1 −1
@@ -33,7 +33,7 @@ export interface EgressPolicy {
3333 */
3434 readonly granted?: readonly string[] | undefined;
3535 /**
36 * Set while egress is only reported (`FORGE_CELLS_EGRESS=report`): an
36 * Set while egress is only reported (`cells.egress` = `report`, an admin's setting): an
3737 * undeclared public host is let through and handed here instead of
3838 * refused. Private addresses are refused either way.
3939 */
src/apps/egress_report.ts +1 −1
@@ -1,6 +1,6 @@
11 /**
22 * What a repository's code reached without declaring it, while egress is
3 * only reported (`FORGE_CELLS_EGRESS=report`, `apps/contain.ts`): one small
3 * only reported (`cells.egress` = `report`, `core/capacity.ts`): one small
44 * file per repository under `<data>/egress/`, read by the app's page so its
55 * writers know what to add to `forge.json` before egress is enforced.
66 */
src/core/capacity.ts +40 −9
@@ -33,7 +33,7 @@ export type CapacityKey =
3333 interface Definition {
3434 /** What the settings page and the API call it. */
3535 readonly label: string;
36 /** The environment variable that is its boot default. */
36 /** The environment variable that is its boot default; "" for a setting only an admin moves. */
3737 readonly env: string;
3838 /** Whole numbers in a range, or one of a few words. */
3939 readonly kind:
@@ -100,10 +100,12 @@ export const CAPACITY: Record<CapacityKey, Definition> = {
100100 },
101101 "cells.egress": {
102102 label: "Egress from cells",
103 env: "FORGE_CELLS_EGRESS",
103 // No environment default: report is a decision an admin makes and the
104 // change log keeps, never what a deployment's environment says (#10778).
105 env: "",
104106 kind: { type: "choice", values: ["enforce", "report"] },
105107 fallback: "enforce",
106 help: "enforce refuses a host an app or check did not declare; report lets it through on 80 and 443 and lists it on the app's page. Takes effect on the next cell started.",
108 help: "enforce refuses a host an app or check did not declare; report lets it through on 80 and 443 and lists it on the app's page. Enforce unless an admin sets report here, with a reason. Takes effect on the next cell started.",
outdated

Principles 1 and 8: this help already says what enforce and report do; the added clause and the page's "The default; only an admin moves it." repeat one another. Say it once: "Enforce unless an admin sets report here, with a reason."

107109 },
108110 "children.modelEgress": {
109111 label: "Model provider a child forge cell may reach",
@@ -178,7 +180,9 @@ export function capacityDefault(key: CapacityKey, cpus?: number): string {
178180
179181 /** The environment's value for `key`, when it holds a valid one. */
180182 function explicitDefault(key: CapacityKey): string | undefined {
181 const env = process.env[CAPACITY[key].env]?.trim() ?? "";
183 const name = CAPACITY[key].env;
184 if (name === "") return undefined;
185 const env = process.env[name]?.trim() ?? "";
182186 return env !== "" && validCapacity(key, env) ? env : undefined;
183187 }
184188
@@ -391,11 +395,32 @@ export function maxChildren(db: Database): number {
391395 return Number(capacityValue(db, "children.max"));
392396 }
393397
394 /** The egress mode a cell starts with now. */
398 /** The egress mode a cell starts with now: enforce unless an admin set report. */
395399 export function egressSetting(db: Database): "enforce" | "report" {
396400 return capacityValue(db, "cells.egress") === "report" ? "report" : "enforce";
397401 }
398402
403 /** The variable that once set the egress mode, and is now only noticed (#10778). */
404 export const EGRESS_ENV = "FORGE_CELLS_EGRESS";
405
406 /**
407 * The one boot line for an environment that still asks for an egress mode:
408 * it is ignored, and this says so once, plainly; null when it asks nothing.
409 */
410 export function egressEnvIgnored(env: Record<string, string | undefined> = process.env): string | null {
411 const asked = env[EGRESS_ENV]?.trim() ?? "";
412 if (asked === "" || asked === "enforce") return null;
413 return `${EGRESS_ENV}=${asked.slice(0, 32)} is ignored: egress is enforced unless an admin sets cells.egress on /settings/capacity, with a reason`;
414 }
415
416 /** Most characters a change's reason keeps. */
417 export const REASON_MAX = 500;
418
419 /** Keys whose loosening must say why: a move of these to report needs a reason. */
420 export function needsReason(key: CapacityKey): boolean {
421 return key === "cells.egress";
422 }
423
399424 /** The model provider hosts a child forge's cell may reach now: none unless an admin chose one. */
400425 export function childModelHosts(db: Database): readonly string[] {
401426 return MODEL_HOSTS[capacityValue(db, "children.modelEgress")] ?? [];
@@ -466,6 +491,8 @@ export interface CapacityChange {
466491 readonly actorId: string;
467492 readonly actorName: string;
468493 readonly at: string;
494 /** Why, as the admin said it; "" for a change made before reasons were kept. */
495 readonly reason: string;
469496 }
470497
471498 /**
@@ -479,6 +506,8 @@ export function setCapacity(
479506 key: CapacityKey,
480507 value: string | null,
481508 actorId: string,
509 /** Why, in the admin's words, already cut to REASON_MAX by the action; kept beside who (#10778). */
510 reason = "",
482511 ): { from: string; to: string } {
483512 const from = capacityValue(db, key);
484513 const at = new Date().toISOString();
@@ -494,9 +523,9 @@ export function setCapacity(
494523 })();
495524 const to = capacityValue(db, key);
496525 db.query(
497 `INSERT INTO forge_setting_change (key, from_value, to_value, actor_id, at)
498 VALUES (?1, ?2, ?3, ?4, ?5)`,
499 ).run(key, from, to, actorId, at);
526 `INSERT INTO forge_setting_change (key, from_value, to_value, actor_id, at, reason)
527 VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
528 ).run(key, from, to, actorId, at, reason);
500529 return { from, to };
501530 }
502531
@@ -511,10 +540,11 @@ export function capacityChanges(db: Database, limit = 20): CapacityChange[] {
511540 actor_id: string;
512541 username: string | null;
513542 at: string;
543 reason: string;
514544 },
515545 [number]
516546 >(
517 `SELECT c.key, c.from_value, c.to_value, c.actor_id, u.username, c.at
547 `SELECT c.key, c.from_value, c.to_value, c.actor_id, u.username, c.at, c.reason
518548 FROM forge_setting_change c LEFT JOIN user u ON u.id = c.actor_id
519549 ORDER BY c.at DESC, c.rowid DESC LIMIT ?1`,
520550 )
@@ -527,5 +557,6 @@ export function capacityChanges(db: Database, limit = 20): CapacityChange[] {
527557 actorId: r.actor_id,
528558 actorName: r.username ?? "",
529559 at: r.at,
560 reason: r.reason,
530561 }));
531562 }
src/core/migration-pins/20261110140000-setting-change-reason.pin added +1
@@ -0,0 +1,1 @@
1 03b5a26202aad083af3b64356cc064faec8f1f275521c22beceef313f3f4612d
src/core/migrations/20261110140000-setting-change-reason.ts added +5
@@ -0,0 +1,5 @@
1 // A capacity change keeps why beside who (#10778): cells.egress moves to
2 // report only with a reason, and the change log shows it. Additive.
3 export default `
4 ALTER TABLE forge_setting_change ADD COLUMN reason TEXT NOT NULL DEFAULT '';
5 `;
src/http/api/admin.ts +6 −3
@@ -13,7 +13,7 @@ import { kpiProbe } from "../../web/studio/kpi_probe.ts";
1313 import { MAX_SECONDS, sampleStacks } from "../../perf/sample.ts";
1414 import { listAccounts, removeAccount, retireAndRemoveAccount, retirementPlan, suspendAccount, unsuspendAccount, verifyAccount } from "../../actions/accounts.ts";
1515 import { ADMIN_WORDS } from "../../actions/admin_words.ts";
16 import { DERIVED_WORDS, changeCapacity, edgeOffDryRun, listCapacity } from "../../actions/capacity.ts";
16 import { DERIVED_WORDS, REASON_WORDS, changeCapacity, edgeOffDryRun, listCapacity } from "../../actions/capacity.ts";
1717 import { changeShield, lastShieldChange, shieldState } from "../../actions/shield.ts";
1818 import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../../actions/invites.ts";
1919 import { changeLimit, listLimits } from "../../actions/limits.ts";
@@ -188,7 +188,8 @@ export function adminRoutes(router: Router<Handler>): void {
188188 return dry.ok ? json({ dry_run: true, ...dry.value }) : notFound();
189189 }
190190 const value = input.value === null ? null : str(input.value);
191 const changed = changeCapacity(ctx.app, ctx.auth.user, ctx.params.key!, value);
191 const reason = typeof input.reason === "string" ? input.reason : "";
192 const changed = changeCapacity(ctx.app, ctx.auth.user, ctx.params.key!, value, reason);
192193 if (changed.ok) return json(changed.value);
193194 if (changed.error === "not_found") return notFound();
194195 return apiError(
@@ -198,7 +199,9 @@ export function adminRoutes(router: Router<Handler>): void {
198199 ? "no such capacity setting"
199200 : changed.error === "derived"
200201 ? DERIVED_WORDS
201 : SETTING_WORDS.outOfRange,
202 : changed.error === "reason_required"
203 ? REASON_WORDS
204 : SETTING_WORDS.outOfRange,
202205 );
203206 });
204207
src/http/mcp/tools/capacity.ts +10 −4
@@ -1,11 +1,11 @@
1 import { DERIVED_WORDS, SETTABLE_CAPACITY, changeCapacity, listCapacity } from "../../../actions/capacity.ts";
1 import { DERIVED_WORDS, REASON_WORDS, 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";
55
66 export const tool: Tool = {
77 name: "capacity",
8 doc: "`{ key?, value?, dryRun? }` (forge admin): the forge's capacity and guards, live (crew and checks concurrency, check shards, cell egress, the new-account guard, the sign-in edge, queue depth, child forges, `egress.npmBytesLastHour` and `npmTopRepoLastHour`, and `selfSwap: { enabled, migrateReq, blocked }`: whether `FORGE_SELF_SWAP=1`, the kernel's `tcp_migrate_req` (`1`, `0`, `unreadable`), and why this forge cannot swap itself, null when it can; only the first six are settable by `key`); with `key` and `value` (`\"\"` restores the default), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token",
8 doc: "`{ key?, value?, reason?, dryRun? }` (forge admin): the forge's capacity and guards, live (crew and checks concurrency, check shards, cell egress, the new-account guard, the sign-in edge, queue depth, child forges, `egress.npmBytesLastHour` and `npmTopRepoLastHour`, and `selfSwap: { enabled, migrateReq, blocked }`: whether `FORGE_SELF_SWAP=1`, the kernel's `tcp_migrate_req` (`1`, `0`, `unreadable`), and why this forge cannot swap itself, null when it can; only the first six are settable by `key`); with `key` and `value` (`\"\"` restores the default) and `reason` (kept with who; required for `cells.egress`), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token",
99 description:
1010 "Forge capacity and guards, live. With `key` and `value` (\"\" restores default), plans.",
1111 inputSchema: {
@@ -16,6 +16,7 @@ export const tool: Tool = {
1616 enum: [...SETTABLE_CAPACITY],
1717 },
1818 value: { type: "string", maxLength: 16 },
19 reason: { type: "string", maxLength: 500 },
1920 dryRun: { type: "boolean" },
2021 },
2122 },
@@ -35,8 +36,13 @@ export const tool: Tool = {
3536 });
3637 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
3738 return bad("forbidden", "changing the forge's capacity needs an admin-scope token");
38 const changed = changeCapacity(ctx.app, user, key, value);
39 if (!changed.ok) return bad(changed.error, changed.error === "derived" ? DERIVED_WORDS : SETTING_WORDS.outOfRange);
39 const reason = typeof args["reason"] === "string" ? args["reason"] : "";
40 const changed = changeCapacity(ctx.app, user, key, value, reason);
41 if (!changed.ok)
42 return bad(
43 changed.error,
44 changed.error === "derived" ? DERIVED_WORDS : changed.error === "reason_required" ? REASON_WORDS : SETTING_WORDS.outOfRange,
45 );
4046 return good({
4147 says: `${key}: ${changed.value.from || "auto"} → ${changed.value.to || "auto"}, from the next pick on.`,
4248 ...changed.value,
src/main.ts +4 −1
@@ -93,7 +93,7 @@ import { ownTagsReader } from "./release/own_tags.ts";
9393 import { rollProbe, tick as keeperTick } from "./keeper/keeper.ts";
9494 import { keeperEnv, keeperPorts } from "./keeper/wire.ts";
9595 import { snapshotPlace } from "./actions/health.ts";
96 import { capacityDefault } from "./core/capacity.ts";
96 import { capacityDefault, egressEnvIgnored } from "./core/capacity.ts";
9797 import { bootCounts, recordBoot, recordCleanExit } from "./kernel/boots.ts";
9898 import { census, censusWords, processRows } from "./kernel/census.ts";
9999 import { reapLeftoverBridges } from "./crew/leftovers.ts";
@@ -397,6 +397,9 @@ const publicUrl = new URL(forgeUrl);
397397 if (appDomain?.ok === false) app.log.error(appDomain.reason);
398398 if (appDomain?.ok === true && appDomain.note !== undefined)
399399 app.log.warn(appDomain.note);
400 // An environment asking for egress report is ignored, and said so once (#10778).
401 const egressIgnored = egressEnvIgnored();
402 if (egressIgnored !== null) app.log.info(egressIgnored);
400403 // One crew run's length: what a run takes, and how long past its idle
401404 // window an app busy with one is left awake.
402405 const crewRunMs = positive(process.env.FORGE_CREW_TIMEOUT_MIN, 45) * 60_000;
src/tui/screens/capacity.ts +23 −8
@@ -2,13 +2,14 @@
22 * The forge's capacity and guards in the terminal, a forge admin's who is a
33 * person: each guard's live value and bounds, and `e` on a settable one asks
44 * 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.
5 * through the same actions as MCP `capacity`; a move of cells.egress to
6 * report asks why first, as the page does. The actions decide who may.
67 */
78
89 import type { SelfSwap } from "../../release/swap.ts";
9 import { DERIVED_WORDS, changeCapacity, listCapacity, type CapacityRow } from "../../actions/capacity.ts";
10 import { DERIVED_WORDS, REASON_WORDS, changeCapacity, listCapacity, type CapacityRow } from "../../actions/capacity.ts";
1011 import { SETTING_WORDS } from "../../actions/setting_words.ts";
11 import { CAPACITY, belowCeiling, validCapacity, type CapacityKey } from "../../core/capacity.ts";
12 import { CAPACITY, belowCeiling, needsReason, validCapacity, type CapacityKey } from "../../core/capacity.ts";
1213 import type { Ctx, Screen } from "../app/app.ts";
1314 import { binding } from "../app/keys.ts";
1415 import type { Session } from "../session.ts";
@@ -29,8 +30,10 @@ export function boundsOf(key: CapacityKey): string {
2930 }
3031
3132 /** What a refused change says, in its own words. */
32 const refused = (error: "not_found" | "no_such_setting" | "invalid_value" | "no_credential" | "derived"): string =>
33 error === "derived"
33 const refused = (error: "not_found" | "no_such_setting" | "invalid_value" | "no_credential" | "derived" | "reason_required"): string =>
34 error === "reason_required"
outdated

Both doors: the terminal refuses and sends the person to the page. The screen must collect the reason itself and call changeCapacity, so a person can do in the terminal what the page does. Add a test in test/tui-capacity.test.ts.

35 ? REASON_WORDS
36 : error === "derived"
3437 ? DERIVED_WORDS
3538 : error === "no_credential"
3639 ? "Set a password or a passkey first: turning the edge off would lock you out"
@@ -72,17 +75,17 @@ export function capacityScreen(f: Session, ctx: Ctx): Screen {
7275 return [...guards, selfSwapRow(listed.value.selfSwap), ...(egress === null ? [] : [{ title: egress, tone: "attn" as const }])];
7376 };
7477
75 const plan = (row: CapacityRow, typed: string): void => {
78 const plan = (row: CapacityRow, typed: string, reason = ""): void => {
7679 const to = typed === "" ? row.default : typed;
7780 later(ctx, () =>
7881 confirmScreen(ctx, screen, {
7982 title: "Capacity",
80 body: [`${row.key}: ${shown(row.value)} → ${shown(to)}`, "change it? y/n"],
83 body: [`${row.key}: ${shown(row.value)} → ${shown(to)}`, ...(reason === "" ? [] : [`why: ${reason}`]), "change it? y/n"],
8184 actions: [{ label: "y" }, { label: "n" }],
8285 selected: 1,
8386 onChoose: (choice) => {
8487 if (choice !== 0) return;
85 const done = changeCapacity(f.app, f.user, row.key, typed);
88 const done = changeCapacity(f.app, f.user, row.key, typed, reason);
8689 if (!done.ok) return ctx.toast(refused(done.error), "danger");
8790 ctx.toast(`${done.value.key}: ${shown(done.value.from)} → ${shown(done.value.to)}, from the next pick on.`, "success");
8891 ctx.invalidate();
@@ -91,6 +94,16 @@ export function capacityScreen(f: Session, ctx: Ctx): Screen {
9194 );
9295 };
9396
97 const askWhy = (row: CapacityRow, value: string): void => {
98 void later(ctx, () =>
99 promptScreen(ctx, screen, {
100 title: row.label,
101 label: `Why set ${row.key} to ${value}? (kept with your name)`,
102 onSubmit: (why) => (why === "" ? REASON_WORDS : (plan(row, value, why), undefined)),
103 }),
104 );
105 };
106
94107 const screen: Screen = {
95108 title: "Capacity",
96109 keys: [binding(["e"], "change a setting"), binding(["j", "k"], "move cursor")],
@@ -113,6 +126,8 @@ export function capacityScreen(f: Session, ctx: Ctx): Screen {
113126 onSubmit: (typed) => {
114127 const value = typed.trim();
115128 if (value !== "" && !validCapacity(row.key, value)) return SETTING_WORDS.outOfRange;
129 // A move to report says why, as the page asks (#10778); tightening never waits.
130 if (needsReason(row.key) && value === "report") return askWhy(row, value), undefined;
116131 return plan(row, value), undefined;
117132 },
118133 }),
src/web/pages/capacity.ts +25 −6
@@ -6,8 +6,8 @@
66 * and each one is listed below the settings: who, when, from what to what.
77 */
88
9 import { DERIVED_WORDS, changeCapacity, edgeOffDryRun, listCapacity, type CapacityRow } from "../../actions/capacity.ts";
10 import { CAPACITY, belowCeiling, type CapacityKey } from "../../core/capacity.ts";
9 import { DERIVED_WORDS, REASON_WORDS, changeCapacity, edgeOffDryRun, listCapacity, type CapacityRow } from "../../actions/capacity.ts";
10 import { CAPACITY, REASON_MAX, belowCeiling, needsReason, type CapacityKey } from "../../core/capacity.ts";
1111 import { EGRESS_REPORTED } from "../../system/egress_line.ts";
1212 import { bytes } from "../../system/format.ts";
1313 import { egressRowsFor } from "../../spawn/process_counters.ts";
@@ -100,11 +100,14 @@ export function capacityPage(ctx: Ctx): Response {
100100 )}`,
101101 meta: html`${s.help} <br />Now <span class="mono">${s.value === "" ? "auto" : s.value}</span>. ${s.source === "setting"
102102 ? html`Set here; the boot default is
103 <span class="mono">${s.default === "" ? "auto" : s.default}</span>
104 (<span class="mono">${s.env}</span>).`
103 <span class="mono">${s.default === "" ? "auto" : s.default}</span>${s.env === ""
104 ? "."
105 : html` (<span class="mono">${s.env}</span>).`}`
105106 : s.source === "derived"
106107 ? html`Derived from Speed (<span class="mono">crew.concurrency</span>); read-only here: change Speed and it moves.`
107 : html`The boot default, from <span class="mono">${s.env}</span>.`}
108 : s.env === ""
109 ? html`The default.`
110 : html`The boot default, from <span class="mono">${s.env}</span>.`}
108111 ${awayLine(s, now)}`,
109112 trail: s.source === "derived"
110113 ? html`<span class="mono" data-derived="${s.key}">${s.value}</span>`
@@ -115,6 +118,20 @@ export function capacityPage(ctx: Ctx): Response {
115118 >
116119 <input type="hidden" name="key" value="${s.key}" />
117120 ${input(s.key, s.value)}
121 ${when(

The reason field is required on the Save form, but the Put back form carries none, so resetting cells.egress now fails with reason_required. Once the action only asks a reason for report this holds; add a page-level test that POSTs without a reason (refused) and a reset (accepted). Also principle 6/3: a bare placeholder="why" input beside the value is cramped; give it its own row.

122 // Inside the row's own form, posted with Save: a move to report says why (#10778).
123 needsReason(s.key),
124 () => html`<input
125 class="input"
126 id="cap-${s.key}-reason"
127 type="text"
128 name="reason"
129 maxlength="${String(REASON_MAX)}"
130 autocomplete="off"
131 placeholder="Why (needed to set report)"
132 aria-label="Why (needed to set report)"
133 />`,
134 )}
118135 ${btn("Save", {
119136 size: "sm",
120137 variant: "primary",
@@ -142,7 +159,7 @@ export function capacityPage(ctx: Ctx): Response {
142159 title: html`<span class="mono">${c.key}</span>
143160 <span class="mono">${c.from === "" ? "auto" : c.from}</span> →
144161 <span class="mono">${c.to === "" ? "auto" : c.to}</span>`,
145 meta: html`${c.actorName === "" ? "someone" : c.actorName} · ${time(c.at)}`,
162 meta: html`${c.actorName === "" ? "someone" : c.actorName} · ${time(c.at)}${c.reason === "" ? "" : html` · ${c.reason}`}`,
146163 }),
147164 );
148165 const reported = settings.some(
@@ -239,6 +256,7 @@ const ERRORS: Record<string, string> = {
239256 no_such_setting: "There is no such setting.",
240257 derived: DERIVED_WORDS,
241258 invalid_value: "That value is outside what this setting takes.",
259 reason_required: REASON_WORDS,
242260 no_credential:
243261 "Add a passkey (or a password) to your own account first: with the edge off, you would be signed out for good.",
244262 };
@@ -268,6 +286,7 @@ export async function capacitySet(ctx: Ctx): Promise<Response> {
268286 ctx.auth.user,
269287 key,
270288 reset ? null : field(form, "value"),
289 field(form, "reason"),
271290 );
272291 if (!changed.ok && changed.error === "not_found") return notFoundPage(ctx);
273292 return flashTo(
src/web/studio/egress.ts +1 −2
@@ -6,7 +6,6 @@
66 */
77
88 import { egressModeSentence, undeclaredHosts } from "../../apps/egress_report.ts";
9 import { egressMode } from "../../apps/contain.ts";
109 import { html, type Html } from "../html.ts";
1110 import { row } from "../ui.ts";
1211
@@ -16,7 +15,7 @@ export function egressNote(
1615 /** What production declares now: a host declared since is not repeated. */
1716 declared: readonly string[],
1817 /** The forge's egress mode now (`core/capacity.ts`). */
19 mode: "enforce" | "report" = egressMode(),
18 mode: "enforce" | "report",
2019 ): Html | null {
2120 // Once egress is enforced the forge refuses these calls; the note is for the
2221 // window before that, when adding them to forge.json still saves the app.
test/board.test.ts +1 −1
@@ -716,7 +716,7 @@ describe("the board's forge strip", () => {
716716 claimRun(app.db, createRun(app.db, { repoId: made.value.id, kind: "agent" }).id);
717717 // Clear the stuck run the first test left behind.
718718 app.db.query("UPDATE run SET state = 'failed' WHERE heartbeat_at = ?1").run("2020-01-01T00:00:00.000Z");
719 expect(changeCapacity(app, admin, "cells.egress", "report").ok).toBe(true);
719 expect(changeCapacity(app, admin, "cells.egress", "report", "a test says why").ok).toBe(true);
720720 try {
721721 const text = await board("root");
722722 expect(text).toContain("All fine");
test/calm-banners.test.ts +1 −1
@@ -63,7 +63,7 @@ test("with egress enforced, System and Capacity say nothing about it, and neithe
6363 });
6464
6565 test("egress in report mode is one line on Capacity and System, not on / or /board", async () => {
66 const changed = await changeCapacity(app, admin.value, "cells.egress", "report");
66 const changed = await changeCapacity(app, admin.value, "cells.egress", "report", "a test says why");
6767 expect(changed.ok).toBe(true);
6868 expect(await get("/settings/capacity")).toContain('id="egress-reported"');
6969 expect(await get("/system")).toContain('id="egress-reported"');
test/capacity-rows.test.ts +1 −1
@@ -63,7 +63,7 @@ test("the egress row reads its true current state, and the reported note says so
6363 const before = await page();
6464 expect(before).not.toContain('id="egress-reported"');
6565 const now = /Now <span class="mono">([^<]+)<\/span>/.exec(egressRow(before))?.[1];
66 expect(changeCapacity(app, alice, "cells.egress", "report").ok).toBe(true);
66 expect(changeCapacity(app, alice, "cells.egress", "report", "a test says why").ok).toBe(true);
6767 const after = await page();
6868 expect(egressRow(after)).toContain('Now <span class="mono">report</span>');
6969 expect(now).not.toBe("report");
test/capacity.test.ts +3 −2
@@ -302,6 +302,7 @@ describe("capacity: only a forge admin reads or changes it", () => {
302302 const made = await mcp(aliceAdmin, {
303303 key: "cells.egress",
304304 value: "report",
305 reason: "a test says why",
305306 dryRun: false,
306307 });
307308 expect(made.error).toBe(false);
@@ -457,7 +458,7 @@ describe("report mode is loud to admins, and to nobody else", () => {
457458 });
458459
459460 test("reported: the Capacity page says it once, as a note; vitals do not", async () => {
460 expect(changeCapacity(app, alice, "cells.egress", "report").ok).toBe(true);
461 expect(changeCapacity(app, alice, "cells.egress", "report", "a test says why").ok).toBe(true);
461462 expect(needs(alice)).toEqual([]);
462463 const shown = await page();
463464 expect(shown).toContain('id="egress-reported"');
@@ -831,7 +832,7 @@ describe("the egress mode is the emergency lever: the next cell starts with what
831832 if (!record.ok) throw new Error(String(record.error));
832833 // A runtime that can hold cells is asked; one that cannot has none to start.
833834 (runtime as unknown as { cells: { ok: true } }).cells = { ok: true };
834 expect(changeCapacity(app, alice, "cells.egress", "report").ok).toBe(
835 expect(changeCapacity(app, alice, "cells.egress", "report", "a test says why").ok).toBe(
835836 true,
836837 );
837838 expect(runtime.cellFor(record.value, "production")?.egressMode).toBe(
test/contain.test.ts +1 −2
@@ -906,10 +906,8 @@ describe.skipIf(noCells)(
906906 hostname: "127.0.0.1",
907907 fetch: () => new Response("inside"),
908908 });
909 process.env["FORGE_CELLS_EGRESS"] = "report";
910909 });
911910 afterAll(async () => {
912 delete process.env["FORGE_CELLS_EGRESS"];
913911 listener.stop(true);
914912 await rm(dir, { recursive: true, force: true });
915913 });
@@ -938,6 +936,7 @@ describe.skipIf(noCells)(
938936 egress: [],
939937 reach: [],
940938 limits: { memoryMiB: 1024, cpus: 2, diskMiB: 1024 },
939 egressMode: "report",
941940 onUndeclared: (host, port) => seen.push(`${host}:${port}`),
942941 },
943942 },
test/egress-default.test.ts added +185
@@ -0,0 +1,185 @@
1 // Egress is enforced unless an admin says otherwise, with a reason (#10778):
2 // an environment asking for report is ignored and said once, never obeyed.
3 import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { serve, type Servers } from "../src/http/server.ts";
9 import { changeCapacity } from "../src/actions/capacity.ts";
10 import {
11 capacityChanges,
12 capacityDefault,
13 egressEnvIgnored,
14 egressSetting,
15 REASON_MAX,
16 setCapacity,
17 } from "../src/core/capacity.ts";
18 import type { User } from "../src/core/users.ts";
19 import { seedAdmin } from "./helpers/admin.ts";
20
21 let dataDir = "";
22 let app: App, servers: Servers, bearer = "", alice: User;
23 const before = process.env["FORGE_CELLS_EGRESS"];
24
25 beforeAll(async () => {
26 dataDir = await mkdtemp(join(tmpdir(), "forge-egress-default-"));
27 app = createApp({ dataDir, port: 0 });
28 servers = await serve(app);
29 const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
30 if (!admin.ok) throw new Error(admin.error);
31 alice = admin.value;
32 const minted = await fetch(`${servers.url}/api/v1/tokens`, {
33 method: "POST",
34 headers: { "content-type": "application/json" },
35 body: JSON.stringify({ adminConfirm: true, username: "alice", password: "correct-horse-battery", name: "cli", scope: "admin" }),
36 });
37 bearer = ((await minted.json()) as { token: string }).token;
38 });
39
40 afterEach(() => {
41 if (before === undefined) delete process.env["FORGE_CELLS_EGRESS"];
42 else process.env["FORGE_CELLS_EGRESS"] = before;
43 setCapacity(app.db, "cells.egress", null, alice.id);
44 });
45
46 afterAll(async () => {
47 servers.stop();
48 app.close();
49 await rm(dataDir, { recursive: true, force: true });
50 });
51
52 test("the default is enforce", () => {
53 delete process.env["FORGE_CELLS_EGRESS"];
54 expect(capacityDefault("cells.egress")).toBe("enforce");
55 expect(egressSetting(app.db)).toBe("enforce");
56 });
57
58 test("an environment asking for report is ignored, and said in one line", () => {
59 process.env["FORGE_CELLS_EGRESS"] = "report";
60 expect(capacityDefault("cells.egress")).toBe("enforce");
61 expect(egressSetting(app.db)).toBe("enforce");
62 const said = egressEnvIgnored();
63 expect(said).toContain("FORGE_CELLS_EGRESS=report is ignored");
64 expect(said).not.toContain("\n");
65 expect(egressEnvIgnored({})).toBeNull();
66 expect(egressEnvIgnored({ FORGE_CELLS_EGRESS: "enforce" })).toBeNull();
67 });
68
69 test("the capacity page shows no report line and no environment for it when only the environment asks", async () => {
70 process.env["FORGE_CELLS_EGRESS"] = "report";
71 const res = await fetch(`${servers.url}/settings/capacity`, { headers: { authorization: `Bearer ${bearer}` } });
72 const body = await res.text();
73 expect(body).not.toContain("Egress is reported, not enforced");
74 expect(body).not.toContain("FORGE_CELLS_EGRESS");
75 });
76
77 test("an admin's report is honoured only with a reason, and kept with who and why", async () => {
78 expect(changeCapacity(app, alice, "cells.egress", "report")).toEqual({ ok: false, error: "reason_required" });
79 expect(changeCapacity(app, alice, "cells.egress", "report", " ")).toEqual({ ok: false, error: "reason_required" });
80 expect(egressSetting(app.db)).toBe("enforce");
81
82 expect(changeCapacity(app, alice, "cells.egress", "report", "migrating the lab, one day").ok).toBe(true);
83 expect(egressSetting(app.db)).toBe("report");
84 const kept = capacityChanges(app.db)[0]!;
85 expect([kept.key, kept.from, kept.to, kept.actorName, kept.reason]).toEqual([
86 "cells.egress",
87 "enforce",
88 "report",
89 "alice",
90 "migrating the lab, one day",
91 ]);
92
93 // The API refuses without a reason and keeps the one it is given.
94 const put = (body: object) =>
95 fetch(`${servers.url}/api/v1/admin/capacity/cells.egress`, {
96 method: "PUT",
97 headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
98 body: JSON.stringify(body),
99 });
100 expect((await put({ value: "report" })).status).toBe(422);
101 expect((await put({ value: "enforce", reason: "lab done" })).status).toBe(200);
102 expect(egressSetting(app.db)).toBe("enforce");
103 expect(capacityChanges(app.db)[0]!.reason).toBe("lab done");
104 expect((await put({ value: "report", reason: "lab again" })).status).toBe(200);
105 expect(egressSetting(app.db)).toBe("report");
106 // Tightening never waits on a reason.
107 expect((await put({ value: "enforce" })).status).toBe(200);
108 expect(egressSetting(app.db)).toBe("enforce");
109
110 // The page lists the reason beside who.
111 const page = await (await fetch(`${servers.url}/settings/capacity`, { headers: { authorization: `Bearer ${bearer}` } })).text();
112 expect(page).toContain("lab done");
113 expect(page).toContain('name="reason"');
114 });
115
116 test("a setting that needs no reason still takes none", () => {
117 expect(changeCapacity(app, alice, "checks.shards", "2").ok).toBe(true);
118 expect(capacityChanges(app.db)[0]!.reason).toBe("");
119 changeCapacity(app, alice, "checks.shards", null);
120 });
121
122 test("tightening needs no reason: enforce and the reset to default both go through bare", () => {
123 expect(changeCapacity(app, alice, "cells.egress", "report", "a window").ok).toBe(true);
124 expect(changeCapacity(app, alice, "cells.egress", "enforce").ok).toBe(true);
125 expect(egressSetting(app.db)).toBe("enforce");
126 expect(changeCapacity(app, alice, "cells.egress", "report", "a window").ok).toBe(true);
127 expect(changeCapacity(app, alice, "cells.egress", null).ok).toBe(true);
128 expect(egressSetting(app.db)).toBe("enforce");
129 });
130
131 test("a reason is cut once, at the action, to REASON_MAX", () => {
132 expect(changeCapacity(app, alice, "cells.egress", "report", "x".repeat(REASON_MAX + 50)).ok).toBe(true);
133 expect(capacityChanges(app.db)[0]!.reason.length).toBe(REASON_MAX);
134 });
135
136 test("the page: report without a reason is refused, with one it holds, and Put back needs none", async () => {
137 const signin = await fetch(`${servers.url}/signin`, {
138 method: "POST",
139 redirect: "manual",
140 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
141 body: new URLSearchParams({ username: "alice", password: "correct-horse-battery" }),
142 });
143 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
144 const post = (body: Record<string, string>) =>
145 fetch(`${servers.url}/settings/capacity`, {
146 method: "POST",
147 redirect: "manual",
148 headers: { cookie, "content-type": "application/x-www-form-urlencoded", origin: servers.url },
149 body: new URLSearchParams(body),
150 });
151 await post({ key: "cells.egress", value: "report" });
152 expect(egressSetting(app.db)).toBe("enforce");
153 await post({ key: "cells.egress", value: "report", reason: "page says why" });
154 expect(egressSetting(app.db)).toBe("report");
155 expect(capacityChanges(app.db)[0]!.reason).toBe("page says why");
156 await post({ key: "cells.egress", reset: "1" });
157 expect(egressSetting(app.db)).toBe("enforce");
158 // The reason is a labelled field inside the setting's own form, posted with Save.
159 const page = await (await fetch(`${servers.url}/settings/capacity`, { headers: { cookie } })).text();
160 const form = [...page.matchAll(/<form\b[\s\S]*?<\/form>/g)]
161 .map((m) => m[0])
162 .find((f) => f.includes('value="cells.egress"'));
163 expect(form).toContain('name="reason"');
164 expect(form).toContain('aria-label="Why (needed to set report)"');
165 });
166
167 test("MCP: report needs the reason argument, and keeps it", async () => {
168 const call = async (args: Record<string, unknown>) => {
169 const res = await fetch(`${servers.url}/mcp`, {
170 method: "POST",
171 headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json", accept: "application/json, text/event-stream" },
172 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "capacity", arguments: args } }),
173 });
174 const body = (await res.json()) as { result: { isError?: boolean; content: { text: string }[] } };
175 return { error: body.result.isError === true, text: body.result.content[0]!.text };
176 };
177 const bare = await call({ key: "cells.egress", value: "report", dryRun: false });
178 expect(bare.error).toBe(true);
179 expect(bare.text).toContain("reason_required");
180 expect(egressSetting(app.db)).toBe("enforce");
181 expect((await call({ key: "cells.egress", value: "report", reason: "mcp says why", dryRun: false })).error).toBe(false);
182 expect(capacityChanges(app.db)[0]!.reason).toBe("mcp says why");
183 expect((await call({ key: "cells.egress", value: "enforce", dryRun: false })).error).toBe(false);
184 expect(egressSetting(app.db)).toBe("enforce");
185 });
test/tui-capacity.test.ts +32 −2
@@ -11,7 +11,7 @@ import { createAgent } from "../src/actions/crew.ts";
1111 import { createApp, type App } from "../src/app.ts";
1212 import { listEvents } from "../src/core/events.ts";
1313 import { changeCapacity } from "../src/actions/capacity.ts";
14 import { CAPACITY_KEYS, capacityDefault, capacityValue } from "../src/core/capacity.ts";
14 import { CAPACITY_KEYS, capacityChanges, capacityDefault, capacityValue } from "../src/core/capacity.ts";
1515 import { createUser, type User } from "../src/core/users.ts";
1616 import { createForgeApp, forgeCommands } from "../src/tui/forge.ts";
1717 import { capacityScreen } from "../src/tui/screens/capacity.ts";
@@ -98,7 +98,7 @@ describe("tui capacity", () => {
9898 };
9999 try {
100100 expect(await egressShown()).toBe(false);
101 expect(changeCapacity(app, carol, "cells.egress", "report").ok).toBe(true);
101 expect(changeCapacity(app, carol, "cells.egress", "report", "a test says why").ok).toBe(true);
102102 expect(await egressShown()).toBe(true);
103103 expect(changeCapacity(app, carol, "cells.egress", "enforce").ok).toBe(true);
104104 expect(await egressShown()).toBe(false);
@@ -107,6 +107,36 @@ describe("tui capacity", () => {
107107 }
108108 });
109109
110 test("cells.egress to report asks why in the terminal, keeps it with who; enforce asks nothing (#10778)", async () => {
111 const egress = () => capacityValue(app.db, "cells.egress");
112 const h = boot(carol, 60);
113 try {
114 for (let i = 0; i < CAPACITY_KEYS.indexOf("cells.egress"); i++) await h.press("j");
115 await h.press("e");
116 for (let i = 0; i < 10; i++) await h.press("backspace");
117 await h.press(..."report", "enter");
118 expect(h.text()).toContain("Why set cells.egress to report?");
119 await h.press("enter");
120 expect(h.text()).toContain("Say why");
121 await h.press(..."lab move", "enter");
122 expect(h.text()).toContain("why: lab move");
123 await h.press("left", "enter");
124 expect(egress()).toBe("report");
125 const kept = capacityChanges(app.db)[0]!;
126 expect([kept.actorName, kept.reason]).toEqual(["carol", "lab move"]);
127
128 await h.press("e");
129 for (let i = 0; i < 10; i++) await h.press("backspace");
130 await h.press(..."enforce", "enter");
131 expect(h.text()).not.toContain("Why set");
132 await h.press("left", "enter");
133 expect(egress()).toBe("enforce");
134 } finally {
135 h.stop();
136 changeCapacity(app, carol, "cells.egress", null);
137 }
138 });
139
110140 test("a crew concurrency below its ceiling is marked, and not at it", async () => {
111141 const ceiling = capacityDefault("crew.concurrency");
112142 try {
test/tui-system.test.ts +1 −1
@@ -285,7 +285,7 @@ describe("tui system: the forge's own state (#7296)", () => {
285285 await seed({});
286286 try {
287287 expect(await systemText(carol)).not.toContain(EGRESS_REPORTED.slice(0, 40));
288 expect(changeCapacity(app, carol, "cells.egress", "report").ok).toBe(true);
288 expect(changeCapacity(app, carol, "cells.egress", "report", "a test says why").ok).toBe(true);
289289 const text = await systemText(carol);
290290 expect(text).toContain("forge 9.8.7 · 0123456 · serving");
291291 // The dialog clips the sentence at its width; its opening words are one row.
test/web.test.ts +1 −1
@@ -455,7 +455,7 @@ describe("web pages end to end", () => {
455455 const { changeCapacity } = await import("../src/actions/capacity.ts");
456456 const { getUserByName } = await import("../src/core/users.ts");
457457 const ada = getUserByName(app.db, "ada")!;
458 expect(changeCapacity(app, ada, "cells.egress", "report").ok).toBe(true);
458 expect(changeCapacity(app, ada, "cells.egress", "report", "a test says why").ok).toBe(true);
459459 try {
460460 const text = await (await fetch(base, { headers: { cookie } })).text();
461461 expect(text).not.toMatch(/class="[^"]*\berror-note/);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs