Public: anyone can read this; the crew can change it.
The profit slider's position: one number per forge, and the pure function from position to policy #10123
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/504-the-profit-slider-s-position-one-number → main 11 commits fork
22 files
changed +1143
−4
docs/design/audit.md +1
| @@ -103,6 +103,7 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 103 | 103 | - `/maintenance` — `pages/maintenance.ts` · prepare the forge for a power-off and end it · `/resilience`, `/settings/backups` · none seen. |
| 104 | 104 | - `/admin/limits` — `pages/admin-limits.ts` · every limit with value, default and reason · `/settings/capacity` (also a numbers-you-can-change page) · none seen. |
| 105 | 105 | - `/admin/flows` — `pages/flows-all.ts` · every repository's flow lines with state and last moved, by kind and stale · `/:owner/:name/flows` · none seen. |
| 106 | - `/settings/economics` — `pages/economics.ts` · the profit slider's position and the margin, with the policy in words · `/admin/limits`, `/settings/capacity` · none seen. | |
| 106 | 107 | - `/settings/capacity` — `pages/capacity.ts` · how many runs and checks go at once · `/admin/limits`, `/settings/usage` · none seen. |
| 107 | 108 | - `/settings/usage` — `pages/usage.ts` · route patterns by use, unused first · `/settings/telemetry`, `/board/returns` · none seen. Not about cost, despite the name. |
| 108 | 109 | - `/settings/telemetry` — `pages/telemetry.ts` · which agents may read telemetry · `/settings/crew` (agent grants) · none seen. |
docs/features/economics.md added +32
| @@ -0,0 +1,32 @@ | ||
| 1 | # Economics: the profit slider's position | |
| 2 | ||
| 3 | One number per forge, `position`, a whole number from 0 to 100, and beside | |
| 4 | it the margin on crew spend, `marginPct`, a whole number from 0 to 500. The | |
| 5 | design is `docs/PROFIT.md`; this is the first step (#504): the numbers are | |
| 6 | stored and the policy they mean is shown, but nothing acts on them yet. | |
| 7 | ||
| 8 | - **Settings → Economics** (`/settings/economics`, a forge admin who is a | |
| 9 | person): a range input for the position, a number input for the margin, | |
| 10 | and the policy in words, e.g. at 50 "production sleeps after 24 h idle · | |
| 11 | previews after 30 min · cloud takes work above 85% of owned capacity · a | |
| 12 | quiet pull's preview stops after 3 days". Every change is listed below. | |
| 13 | - **Terminal** (the forge's ssh terminal, palette → **Economics**, the same | |
| 14 | admin who is a person): the policy in words as the head line, the | |
| 15 | position and the margin as rows; `e` asks for a whole number, shows | |
| 16 | from → to, and changes it only after a confirm. | |
| 17 | - **API**: `GET /api/v1/forge/economics` (read scope) answers | |
| 18 | `{ position, marginPct, policy, words, changes }`; `PUT` with | |
| 19 | `{"position": 70}` and/or `{"marginPct": 15}` (admin scope) sets them. A | |
| 20 | field left out keeps its value; anything but a whole number in range is a | |
| 21 | 422 (`bad_position`, `bad_margin`) and changes nothing. | |
| 22 | ||
| 23 | `policy` is `policyAt(position)` (`src/core/slider.ts`): milliseconds for | |
| 24 | `sleepAfterMs.production`, `sleepAfterMs.preview` and `idlePullMs` (`null` | |
| 25 | is never), and `spillAtOwnedUse`, the share of owned capacity (0–1) above | |
| 26 | which cloud takes work. It is linear between the rows at 0, 50 and 100; | |
| 27 | above 50 "never" counts as a 7-day cap until 100 itself. 50 is the | |
| 28 | runtime's constants (`SLEEP_AFTER_MS`, `IDLE_PULL_DAYS` in | |
| 29 | `src/apps/runtime.ts`), so a forge that never moves the slider behaves as | |
| 30 | before. | |
| 31 | ||
| 32 | An agent can never move the slider, whatever it was granted. |
src/actions/economics.ts added +75
| @@ -0,0 +1,75 @@ | ||
| 1 | /** | |
| 2 | * The forge's economics (`settings/economics.ts`) for a forge admin who is a | |
| 3 | * person: the profit slider's position, the margin on crew spend, the policy | |
| 4 | * the position means (`core/slider.ts`) and the last changes. Never an agent: | |
| 5 | * a crew run that could move the slider would decide its own cost. Everyone | |
| 6 | * else is told `not_found`. Validation is here, once, for the page and the | |
| 7 | * API alike: whole numbers in range, nothing else. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { App } from "../app.ts"; | |
| 11 | import type { User } from "../core/users.ts"; | |
| 12 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 13 | import { | |
| 14 | MARGIN_MAX, | |
| 15 | parseMarginPct, | |
| 16 | parsePosition, | |
| 17 | policyAt, | |
| 18 | policyWords, | |
| 19 | type Policy, | |
| 20 | } from "../core/slider.ts"; | |
| 21 | import { | |
| 22 | economicsChanges, | |
| 23 | getEconomics, | |
| 24 | setEconomics, | |
| 25 | type Economics, | |
| 26 | type EconomicsChange, | |
| 27 | } from "../settings/economics.ts"; | |
| 28 | ||
| 29 | export interface EconomicsView extends Economics { | |
| 30 | readonly policy: Policy; | |
| 31 | readonly words: string; | |
| 32 | readonly changes: EconomicsChange[]; | |
| 33 | } | |
| 34 | ||
| 35 | function admin(actor: User | undefined): Result<User, "not_found"> { | |
| 36 | return actor?.isAdmin === true && actor.kind !== "agent" ? ok(actor) : err("not_found"); | |
| 37 | } | |
| 38 | ||
| 39 | function view(app: App): EconomicsView { | |
| 40 | const now = getEconomics(app.db); | |
| 41 | const policy = policyAt(now.position); | |
| 42 | return { ...now, policy, words: policyWords(policy), changes: economicsChanges(app.db) }; | |
| 43 | } | |
| 44 | ||
| 45 | export function readEconomics(app: App, actor: User | undefined): Result<EconomicsView, "not_found"> { | |
| 46 | const who = admin(actor); | |
| 47 | return who.ok ? ok(view(app)) : who; | |
| 48 | } | |
| 49 | ||
| 50 | export type EconomicsError = "not_found" | "bad_position" | "bad_margin"; | |
| 51 | ||
| 52 | export const ECONOMICS_WORDS: Record<Exclude<EconomicsError, "not_found">, string> = { | |
| 53 | bad_position: "position is a whole number from 0 to 100", | |
| 54 | bad_margin: `marginPct is a whole number from 0 to ${MARGIN_MAX}`, | |
| 55 | }; | |
| 56 | ||
| 57 | /** | |
| 58 | * Set either number or both; one left `undefined` keeps what stands. A value | |
| 59 | * given but not a whole number in range refuses the whole change. | |
| 60 | */ | |
| 61 | export function changeEconomics( | |
| 62 | app: App, | |
| 63 | actor: User | undefined, | |
| 64 | input: { position?: unknown; marginPct?: unknown }, | |
| 65 | ): Result<EconomicsView, EconomicsError> { | |
| 66 | const who = admin(actor); | |
| 67 | if (!who.ok) return who; | |
| 68 | const before = getEconomics(app.db); | |
| 69 | const position = input.position === undefined ? before.position : parsePosition(input.position); | |
| 70 | if (position === undefined) return err("bad_position"); | |
| 71 | const marginPct = input.marginPct === undefined ? before.marginPct : parseMarginPct(input.marginPct); | |
| 72 | if (marginPct === undefined) return err("bad_margin"); | |
| 73 | setEconomics(app.db, { position, marginPct }, who.value.id); | |
| 74 | return ok(view(app)); | |
| 75 | } |
src/apps/runtime.ts +4 −1
| @@ -170,7 +170,10 @@ export type EnvState = | ||
| 170 | 170 | | "asleep" |
| 171 | 171 | | "failed"; |
| 172 | 172 | |
| 173 | const SLEEP_AFTER_MS = { production: 24 * 3_600_000, preview: 30 * 60_000 }; | |
| 173 | /** Idle windows before an environment sleeps: the profit slider's 50 (`core/slider.ts`). */ | |
| 174 | export const SLEEP_AFTER_MS = { production: 24 * 3_600_000, preview: 30 * 60_000 }; | |
| 175 | /** Days a quiet pull keeps its preview by default: the slider's 50 too. */ | |
| 176 | export const IDLE_PULL_DAYS = 3; | |
| 174 | 177 | const SWEEP_EVERY_MS = 60_000; |
| 175 | 178 | const BUSY_GRACE_MS = 45 * 60_000; |
| 176 | 179 |
src/contract/openapi/routes/getApiV1ForgeEconomics.ts added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: "/api/v1/forge/economics", | |
| 6 | summary: "The profit slider: position (0 cheapest to 100 most responsive), marginPct, the policy the position means (sleep windows in ms, idle-pull window, cloud spill share; null is never) and the last changes (a person admin's token)", | |
| 7 | auth: true, | |
| 8 | refusals: { | |
| 9 | not_found: "the token is not a person who administers the forge (404)", | |
| 10 | not_admin: "the person's role may not administer the forge (403)", | |
| 11 | }, | |
| 12 | }; |
src/contract/openapi/routes/index.ts +2
| @@ -80,6 +80,7 @@ export { route as getApiV1FleetGraph } from "./getApiV1FleetGraph.ts"; | ||
| 80 | 80 | export { route as getApiV1FleetOps } from "./getApiV1FleetOps.ts"; |
| 81 | 81 | export { route as getApiV1FleetOpsId } from "./getApiV1FleetOpsId.ts"; |
| 82 | 82 | export { route as getApiV1Forge } from "./getApiV1Forge.ts"; |
| 83 | export { route as getApiV1ForgeEconomics } from "./getApiV1ForgeEconomics.ts"; | |
| 83 | 84 | export { route as getApiV1ForgeView } from "./getApiV1ForgeView.ts"; |
| 84 | 85 | export { route as getApiV1Forges } from "./getApiV1Forges.ts"; |
| 85 | 86 | export { route as getApiV1ForgesSizes } from "./getApiV1ForgesSizes.ts"; |
| @@ -347,6 +348,7 @@ export { route as putApiV1AdminShield } from "./putApiV1AdminShield.ts"; | ||
| 347 | 348 | export { route as putApiV1AdminTeamsSlug } from "./putApiV1AdminTeamsSlug.ts"; |
| 348 | 349 | export { route as putApiV1BoardsSlug } from "./putApiV1BoardsSlug.ts"; |
| 349 | 350 | export { route as putApiV1FeaturesSlug } from "./putApiV1FeaturesSlug.ts"; |
| 351 | export { route as putApiV1ForgeEconomics } from "./putApiV1ForgeEconomics.ts"; | |
| 350 | 352 | export { route as putApiV1Front } from "./putApiV1Front.ts"; |
| 351 | 353 | export { route as putApiV1ReposOwnerNameAppEnvVar } from "./putApiV1ReposOwnerNameAppEnvVar.ts"; |
| 352 | 354 | export { route as putApiV1ReposOwnerNameAppMembers } from "./putApiV1ReposOwnerNameAppMembers.ts"; |
src/contract/openapi/routes/putApiV1ForgeEconomics.ts added +15
| @@ -0,0 +1,15 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "put", | |
| 5 | path: "/api/v1/forge/economics", | |
| 6 | summary: "Set the profit slider's position and/or the margin on crew spend; a field left out keeps its value, each change is kept (a person admin's token, admin scope)", | |
| 7 | auth: true, | |
| 8 | body: { position: "whole number 0-100", marginPct: "whole number 0-500" }, | |
| 9 | refusals: { | |
| 10 | not_found: "the token is not a person who administers the forge (404)", | |
| 11 | not_admin: "the person's role may not administer the forge (403)", | |
| 12 | bad_position: "position is not a whole number from 0 to 100 (422)", | |
| 13 | bad_margin: "marginPct is not a whole number from 0 to 500 (422)", | |
| 14 | }, | |
| 15 | }; |
src/core/slider.ts added +161
| @@ -0,0 +1,161 @@ | ||
| 1 | /** | |
| 2 | * The profit slider's policy: one forge-wide `position` (an integer 0–100) | |
| 3 | * and the pure function from it to the windows and threshold it means | |
| 4 | * (docs/PROFIT.md). 0 is cheapest — sleep early, cloud only when owned | |
| 5 | * metal is full; 100 is most responsive — stay warm, spill to cloud early. | |
| 6 | * 50 is today's behaviour exactly, so landing this changes nothing. | |
| 7 | * | |
| 8 | * Plain data in, plain data out: nothing here reads the database or acts. | |
| 9 | */ | |
| 10 | ||
| 11 | const MIN = 60_000; | |
| 12 | const HOUR = 60 * MIN; | |
| 13 | const DAY = 24 * HOUR; | |
| 14 | ||
| 15 | export const POSITION_MIN = 0; | |
| 16 | export const POSITION_MAX = 100; | |
| 17 | export const POSITION_DEFAULT = 50; | |
| 18 | export const MARGIN_MAX = 500; | |
| 19 | export const MARGIN_DEFAULT = 0; | |
| 20 | ||
| 21 | /** | |
| 22 | * "Never" stands in for infinity in the interpolation: above 50 a window | |
| 23 | * that is "never" at 100 climbs toward this cap, and only 100 itself is | |
| 24 | * `null` (docs/PROFIT.md, "The control"). | |
| 25 | */ | |
| 26 | export const NEVER_CAP_MS = 7 * DAY; | |
| 27 | ||
| 28 | export interface Policy { | |
| 29 | /** Idle milliseconds before an environment sleeps; `null` is never. */ | |
| 30 | readonly sleepAfterMs: { | |
| 31 | readonly production: number | null; | |
| 32 | readonly preview: number | null; | |
| 33 | }; | |
| 34 | /** Idle milliseconds before a quiet pull's preview stops; `null` is never. */ | |
| 35 | readonly idlePullMs: number | null; | |
| 36 | /** Share of owned capacity in use (0–1) above which cloud takes work. */ | |
| 37 | readonly spillAtOwnedUse: number; | |
| 38 | } | |
| 39 | ||
| 40 | interface Anchor { | |
| 41 | readonly production: number | null; | |
| 42 | readonly preview: number | null; | |
| 43 | readonly idlePull: number | null; | |
| 44 | /** Whole percent, so the arithmetic stays exact until the last step. */ | |
| 45 | readonly spillPct: number; | |
| 46 | } | |
| 47 | ||
| 48 | /** The three rows of docs/PROFIT.md; 50 is `SLEEP_AFTER_MS` and `IDLE_PULL_DAYS`. */ | |
| 49 | const ANCHORS: readonly [Anchor, Anchor, Anchor] = [ | |
| 50 | { production: 10 * MIN, preview: 5 * MIN, idlePull: 1 * DAY, spillPct: 100 }, | |
| 51 | { production: 24 * HOUR, preview: 30 * MIN, idlePull: 3 * DAY, spillPct: 85 }, | |
| 52 | { production: null, preview: 24 * HOUR, idlePull: null, spillPct: 60 }, | |
| 53 | ]; | |
| 54 | ||
| 55 | /** a + (b − a)·t, with "never" read as the cap; whole milliseconds. */ | |
| 56 | function lerpMs(a: number | null, b: number | null, t: number): number | null { | |
| 57 | if (t === 1 && b === null) return null; | |
| 58 | if (t === 0 && a === null) return null; | |
| 59 | const from = a ?? NEVER_CAP_MS; | |
| 60 | const to = b ?? NEVER_CAP_MS; | |
| 61 | return Math.round(from + (to - from) * t); | |
| 62 | } | |
| 63 | ||
| 64 | /** | |
| 65 | * The policy a position means: linear between the anchor at or below it and | |
| 66 | * the one above. A position outside 0–100 or not whole is a programmer's | |
| 67 | * mistake — the boundary (`parsePosition`) refuses it first — so it is | |
| 68 | * clamped and rounded rather than trusted. | |
| 69 | */ | |
| 70 | export function policyAt(position: number): Policy { | |
| 71 | const p = Number.isFinite(position) | |
| 72 | ? Math.min(POSITION_MAX, Math.max(POSITION_MIN, Math.round(position))) | |
| 73 | : POSITION_DEFAULT; | |
| 74 | const upper = p > 50; | |
| 75 | const lo = upper ? ANCHORS[1] : ANCHORS[0]; | |
| 76 | const hi = upper ? ANCHORS[2] : ANCHORS[1]; | |
| 77 | const t = (upper ? p - 50 : p) / 50; | |
| 78 | // Percent to one decimal (the interpolation steps by 0.3 % or 0.5 %), then | |
| 79 | // a share: 72.5 % is 0.725. | |
| 80 | const pct = lo.spillPct + (hi.spillPct - lo.spillPct) * t; | |
| 81 | return { | |
| 82 | sleepAfterMs: { | |
| 83 | production: lerpMs(lo.production, hi.production, t), | |
| 84 | preview: lerpMs(lo.preview, hi.preview, t), | |
| 85 | }, | |
| 86 | idlePullMs: lerpMs(lo.idlePull, hi.idlePull, t), | |
| 87 | spillAtOwnedUse: Math.round(pct * 10) / 1000, | |
| 88 | }; | |
| 89 | } | |
| 90 | ||
| 91 | /** | |
| 92 | * A whole number in [min, max] from a form field or a JSON value; undefined | |
| 93 | * for anything else — fractions, signs, exponents, blanks, booleans, null. | |
| 94 | */ | |
| 95 | function wholeIn(input: unknown, min: number, max: number): number | undefined { | |
| 96 | let n: number; | |
| 97 | if (typeof input === "number") n = input; | |
| 98 | else if (typeof input === "string" && /^\d{1,4}$/.test(input.trim())) | |
| 99 | n = Number(input.trim()); | |
| 100 | else return undefined; | |
| 101 | return Number.isInteger(n) && n >= min && n <= max ? n : undefined; | |
| 102 | } | |
| 103 | ||
| 104 | export const parsePosition = (input: unknown): number | undefined => | |
| 105 | wholeIn(input, POSITION_MIN, POSITION_MAX); | |
| 106 | ||
| 107 | export const parseMarginPct = (input: unknown): number | undefined => | |
| 108 | wholeIn(input, 0, MARGIN_MAX); | |
| 109 | ||
| 110 | /** "24 h", "12 h 5 min", "17 min 30 s", "4 days": the two largest units. */ | |
| 111 | export function duration(ms: number): string { | |
| 112 | const s = Math.round(ms / 1000); | |
| 113 | const parts: [number, string][] = | |
| 114 | s >= 48 * 3600 | |
| 115 | ? [ | |
| 116 | [Math.floor(s / 86_400), "days"], | |
| 117 | [Math.floor((s % 86_400) / 3600), "h"], | |
| 118 | ] | |
| 119 | : s >= 3600 | |
| 120 | ? [ | |
| 121 | [Math.floor(s / 3600), "h"], | |
| 122 | [Math.floor((s % 3600) / 60), "min"], | |
| 123 | ] | |
| 124 | : s >= 60 | |
| 125 | ? [ | |
| 126 | [Math.floor(s / 60), "min"], | |
| 127 | [s % 60, "s"], | |
| 128 | ] | |
| 129 | : [[s, "s"]]; | |
| 130 | return parts | |
| 131 | .filter(([n], i) => i === 0 || n > 0) | |
| 132 | .map(([n, unit]) => `${n} ${unit}`) | |
| 133 | .join(" "); | |
| 134 | } | |
| 135 | ||
| 136 | function percent(share: number): string { | |
| 137 | return `${Math.round(share * 1000) / 10}%`; | |
| 138 | } | |
| 139 | ||
| 140 | /** | |
| 141 | * The policy in words, one clause per lever: | |
| 142 | * "production sleeps after 24 h idle · previews after 30 min · cloud takes | |
| 143 | * work above 85% of owned capacity · a quiet pull's preview stops after 3 days". | |
| 144 | */ | |
| 145 | export function policyWords(policy: Policy): string { | |
| 146 | const { production, preview } = policy.sleepAfterMs; | |
| 147 | return [ | |
| 148 | production === null | |
| 149 | ? "production never sleeps" | |
| 150 | : `production sleeps after ${duration(production)} idle`, | |
| 151 | preview === null | |
| 152 | ? "previews never sleep" | |
| 153 | : `previews after ${duration(preview)}`, | |
| 154 | policy.spillAtOwnedUse >= 1 | |
| 155 | ? "cloud takes work only when owned capacity is full" | |
| 156 | : `cloud takes work above ${percent(policy.spillAtOwnedUse)} of owned capacity`, | |
| 157 | policy.idlePullMs === null | |
| 158 | ? "a quiet pull's preview never stops" | |
| 159 | : `a quiet pull's preview stops after ${duration(policy.idlePullMs)}`, | |
| 160 | ].join(" · "); | |
| 161 | } |
src/http/api.ts +2
| @@ -338,6 +338,7 @@ import { passportRoutes } from "./passport_edge.ts"; | ||
| 338 | 338 | import { egressGrantRoutes } from "./api/egress_grants.ts"; |
| 339 | 339 | import { forgeRoutes } from "./api/forges.ts"; |
| 340 | 340 | import { forgeViewRoutes } from "./api/forge_view.ts"; |
| 341 | import { economicsRoutes } from "./api/economics.ts"; | |
| 341 | 342 | import { releasesRoutes } from "./api/releases.ts"; |
| 342 | 343 | import { hookRoutes } from "./api/hooks.ts"; |
| 343 | 344 | import { keeperRoutes } from "./api/keeper.ts"; |
| @@ -3490,6 +3491,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3490 | 3491 | |
| 3491 | 3492 | forgeRoutes(router); |
| 3492 | 3493 | forgeViewRoutes(router); |
| 3494 | economicsRoutes(router); | |
| 3493 | 3495 | releasesRoutes(router); |
| 3494 | 3496 | |
| 3495 | 3497 | router.add("GET", "/api/v1/apps", (ctx) => { |
src/http/api/economics.ts added +47
| @@ -0,0 +1,47 @@ | ||
| 1 | /** | |
| 2 | * GET/PUT /api/v1/forge/economics: the profit slider's position, the margin | |
| 3 | * on crew spend, and the policy the position means (`core/slider.ts`), for a | |
| 4 | * forge admin who is a person — the twin of Settings → Economics, with the | |
| 5 | * same validation (`actions/economics.ts`). Anyone else is told 404. | |
| 6 | */ | |
| 7 | ||
| 8 | import { ECONOMICS_WORDS, changeEconomics, readEconomics, type EconomicsView } from "../../actions/economics.ts"; | |
| 9 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 10 | import type { Handler } from "../context.ts"; | |
| 11 | import { apiError, body, json, notFound } from "../respond.ts"; | |
| 12 | import type { Router } from "../router.ts"; | |
| 13 | import { accountAdmin } from "./admin.ts"; | |
| 14 | ||
| 15 | /** The wire shape: milliseconds and shares as numbers, `null` for never. */ | |
| 16 | function wire(e: EconomicsView) { | |
| 17 | return { | |
| 18 | position: e.position, | |
| 19 | marginPct: e.marginPct, | |
| 20 | policy: e.policy, | |
| 21 | words: e.words, | |
| 22 | changes: e.changes, | |
| 23 | }; | |
| 24 | } | |
| 25 | ||
| 26 | export function economicsRoutes(router: Router<Handler>): void { | |
| 27 | router.add("GET", "/api/v1/forge/economics", (ctx) => { | |
| 28 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 29 | const denied = accountAdmin(ctx, "ro"); | |
| 30 | if (denied) return denied; | |
| 31 | const read = readEconomics(ctx.app, ctx.auth.user); | |
| 32 | return read.ok ? json(wire(read.value), 200, { "cache-control": "private, no-cache" }) : notFound(); | |
| 33 | }); | |
| 34 | ||
| 35 | router.add("PUT", "/api/v1/forge/economics", async (ctx) => { | |
| 36 | const denied = accountAdmin(ctx, "admin"); | |
| 37 | if (denied) return denied; | |
| 38 | const input = await body(ctx.request); | |
| 39 | const changed = changeEconomics(ctx.app, ctx.auth.user, { | |
| 40 | position: input.position, | |
| 41 | marginPct: input.marginPct, | |
| 42 | }); | |
| 43 | if (changed.ok) return json(wire(changed.value)); | |
| 44 | if (changed.error === "not_found") return notFound(); | |
| 45 | return apiError(changed.error, 422, ECONOMICS_WORDS[changed.error]); | |
| 46 | }); | |
| 47 | } |
src/http/settings_routes.ts +3
| @@ -15,6 +15,7 @@ import { capacityPage, capacitySet } from "../web/pages/capacity.ts"; | ||
| 15 | 15 | import { teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts"; |
| 16 | 16 | import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts"; |
| 17 | 17 | import { flagsPage, flagsSet } from "../web/pages/flags.ts"; |
| 18 | import { economicsPage, economicsSet } from "../web/pages/economics.ts"; | |
| 18 | 19 | import { trustedAdd, trustedPage, trustedRemove, trustedRevert, trustedStop } from "../web/pages/trusted.ts"; |
| 19 | 20 | import { usagePage } from "../web/pages/usage.ts"; |
| 20 | 21 | import { spendPage } from "../web/pages/spend-tab.ts"; |
| @@ -180,6 +181,8 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 180 | 181 | router.add("GET", "/settings/resilience", domainsMoved); |
| 181 | 182 | router.add("GET", "/settings/capacity", capacityPage); |
| 182 | 183 | router.add("POST", "/settings/capacity", capacitySet); |
| 184 | router.add("GET", "/settings/economics", economicsPage); | |
| 185 | router.add("POST", "/settings/economics", economicsSet); | |
| 183 | 186 | router.add("GET", "/settings/flags", flagsPage); |
| 184 | 187 | // Apps absorbed as the forge's own features, and their settings. |
| 185 | 188 | router.add("GET", "/settings/features", featuresPage); |
src/main.ts +2 −2
| @@ -43,7 +43,7 @@ import { runSync } from "./origins/sync.ts"; | ||
| 43 | 43 | import { runAheadReads } from "./origins/ahead.ts"; |
| 44 | 44 | import { runGuardSweep } from "./origins/credential.ts"; |
| 45 | 45 | import { startForgeSsh } from "./tui/ssh.ts"; |
| 46 | import { AppsRuntime } from "./apps/runtime.ts"; | |
| 46 | import { AppsRuntime, IDLE_PULL_DAYS } from "./apps/runtime.ts"; | |
| 47 | 47 | import { listApps } from "./core/apps.ts"; |
| 48 | 48 | import { holdAppHosts, releaseAppHosts } from "./apps/holding.ts"; |
| 49 | 49 | import { resolveAppDomain } from "./apps/host.ts"; |
| @@ -411,7 +411,7 @@ const apps = appDomain?.ok | ||
| 411 | 411 | busyGraceMs: crewRunMs, |
| 412 | 412 | // Days a pull may sit untouched before its preview sleeps; 0 keeps |
| 413 | 413 | // every preview warm. |
| 414 | idlePullMs: nonNegative(process.env.FORGE_PREVIEW_IDLE_DAYS, 3) * 86_400_000, | |
| 414 | idlePullMs: nonNegative(process.env.FORGE_PREVIEW_IDLE_DAYS, IDLE_PULL_DAYS) * 86_400_000, | |
| 415 | 415 | }) |
| 416 | 416 | : undefined; |
| 417 | 417 | // Which other forge serves this data's apps, read before this one starts its own. |
src/settings/economics.ts added +91
| @@ -0,0 +1,91 @@ | ||
| 1 | /** | |
| 2 | * The forge's economics as stored: the profit slider's `position` (0–100, | |
| 3 | * default 50) and the margin on crew spend (`marginPct`, 0–500, default 0), | |
| 4 | * docs/PROFIT.md. Each is one `forge_setting` row, written through | |
| 5 | * `writeSetting` so every change is kept in `forge_setting_change` — who, | |
| 6 | * when, from what to what — like every limit. A stored value that is not a | |
| 7 | * whole number in range (hand-edited, or from a later build) reads as the | |
| 8 | * default, never as itself. Pure data; authorization is in | |
| 9 | * `actions/economics.ts`. | |
| 10 | */ | |
| 11 | ||
| 12 | import type { Database } from "bun:sqlite"; | |
| 13 | import { | |
| 14 | MARGIN_DEFAULT, | |
| 15 | MARGIN_MAX, | |
| 16 | POSITION_DEFAULT, | |
| 17 | POSITION_MAX, | |
| 18 | POSITION_MIN, | |
| 19 | } from "../core/slider.ts"; | |
| 20 | import { writeSetting } from "./limits.ts"; | |
| 21 | ||
| 22 | export interface Economics { | |
| 23 | readonly position: number; | |
| 24 | readonly marginPct: number; | |
| 25 | } | |
| 26 | ||
| 27 | export type EconomicsKey = keyof Economics; | |
| 28 | ||
| 29 | const STORE: Record<EconomicsKey, { key: string; min: number; max: number; fallback: number }> = { | |
| 30 | position: { key: "economics.position", min: POSITION_MIN, max: POSITION_MAX, fallback: POSITION_DEFAULT }, | |
| 31 | marginPct: { key: "economics.margin_pct", min: 0, max: MARGIN_MAX, fallback: MARGIN_DEFAULT }, | |
| 32 | }; | |
| 33 | ||
| 34 | function stored(db: Database, name: EconomicsKey): number { | |
| 35 | const def = STORE[name]; | |
| 36 | const row = db | |
| 37 | .query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1") | |
| 38 | .get(def.key); | |
| 39 | if (row === null || !/^\d{1,4}$/.test(row.value)) return def.fallback; | |
| 40 | const n = Number(row.value); | |
| 41 | return n >= def.min && n <= def.max ? n : def.fallback; | |
| 42 | } | |
| 43 | ||
| 44 | /** Both numbers as they stand now: two point reads, on every use. */ | |
| 45 | export function getEconomics(db: Database): Economics { | |
| 46 | return { position: stored(db, "position"), marginPct: stored(db, "marginPct") }; | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * Write the numbers that differ from what stands, each with its change | |
| 51 | * kept. The caller has validated them (`parsePosition`, `parseMarginPct`); | |
| 52 | * an unchanged number writes nothing, so saving the form twice keeps one | |
| 53 | * change, not two. | |
| 54 | */ | |
| 55 | export function setEconomics(db: Database, next: Economics, actorId: string): void { | |
| 56 | const before = getEconomics(db); | |
| 57 | for (const name of ["position", "marginPct"] as const) { | |
| 58 | if (next[name] === before[name]) continue; | |
| 59 | writeSetting(db, STORE[name].key, String(next[name]), String(before[name]), String(next[name]), actorId); | |
| 60 | } | |
| 61 | } | |
| 62 | ||
| 63 | export interface EconomicsChange { | |
| 64 | readonly key: EconomicsKey; | |
| 65 | readonly from: string; | |
| 66 | readonly to: string; | |
| 67 | readonly actorName: string; | |
| 68 | readonly at: string; | |
| 69 | } | |
| 70 | ||
| 71 | /** The most recent changes to either number, newest first. */ | |
| 72 | export function economicsChanges(db: Database, limit = 20): EconomicsChange[] { | |
| 73 | const rows = db | |
| 74 | .query< | |
| 75 | { key: string; from_value: string; to_value: string; actor_name: string | null; at: string }, | |
| 76 | [string, string, number] | |
| 77 | >( | |
| 78 | `SELECT c.key, c.from_value, c.to_value, u.username AS actor_name, c.at | |
| 79 | FROM forge_setting_change c LEFT JOIN user u ON u.id = c.actor_id | |
| 80 | WHERE c.key IN (?1, ?2) | |
| 81 | ORDER BY c.at DESC, c.rowid DESC LIMIT ?3`, | |
| 82 | ) | |
| 83 | .all(STORE.position.key, STORE.marginPct.key, limit); | |
| 84 | return rows.map((r) => ({ | |
| 85 | key: r.key === STORE.position.key ? "position" : "marginPct", | |
| 86 | from: r.from_value, | |
| 87 | to: r.to_value, | |
| 88 | actorName: r.actor_name ?? "", | |
| 89 | at: r.at, | |
| 90 | })); | |
| 91 | } |
src/tui/screens/economics.ts added +120
| @@ -0,0 +1,120 @@ | ||
| 1 | /** | |
| 2 | * The profit slider in the terminal, a forge admin's: the position and the | |
| 3 | * margin on crew spend, with the policy the position means as the head line, | |
| 4 | * as /settings/economics and /api/v1/forge/economics have them. `e` asks for | |
| 5 | * a whole number, shows the plan, and changes it only after a confirm, | |
| 6 | * through `changeEconomics`. The gate is the action's: an admin who is not | |
| 7 | * an agent; anyone else sees nothing and changes nothing. | |
| 8 | */ | |
| 9 | ||
| 10 | import { ECONOMICS_WORDS, changeEconomics, readEconomics, type EconomicsView } from "../../actions/economics.ts"; | |
| 11 | import { MARGIN_MAX, POSITION_MAX, POSITION_MIN, parseMarginPct, parsePosition } from "../../core/slider.ts"; | |
| 12 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 13 | import { binding } from "../app/keys.ts"; | |
| 14 | import { style } from "../term/style.ts"; | |
| 15 | import { truncate } from "../term/width.ts"; | |
| 16 | import type { Session } from "../session.ts"; | |
| 17 | import { fill, len, split } from "../ui/layout.ts"; | |
| 18 | import { ListState, confirmScreen, drawList, promptScreen, type ListRow } from "./common.ts"; | |
| 19 | import type { PaletteEntry } from "./entries.ts"; | |
| 20 | ||
| 21 | interface Lever { | |
| 22 | readonly key: "position" | "marginPct"; | |
| 23 | readonly parse: (raw: string) => number | undefined; | |
| 24 | readonly words: string; | |
| 25 | readonly unit: string; | |
| 26 | readonly range: string; | |
| 27 | } | |
| 28 | ||
| 29 | const LEVERS: readonly Lever[] = [ | |
| 30 | { | |
| 31 | key: "position", | |
| 32 | parse: parsePosition, | |
| 33 | words: ECONOMICS_WORDS.bad_position, | |
| 34 | unit: "0 cheapest, 100 most responsive", | |
| 35 | range: `${POSITION_MIN}–${POSITION_MAX}`, | |
| 36 | }, | |
| 37 | { | |
| 38 | key: "marginPct", | |
| 39 | parse: parseMarginPct, | |
| 40 | words: ECONOMICS_WORDS.bad_margin, | |
| 41 | unit: "% on crew spend", | |
| 42 | range: `0–${MARGIN_MAX}`, | |
| 43 | }, | |
| 44 | ]; | |
| 45 | ||
| 46 | export function economicsScreen(f: Session, ctx: Ctx): Screen { | |
| 47 | const state = new ListState(); | |
| 48 | let note = ""; | |
| 49 | const read = () => readEconomics(f.app, f.user); | |
| 50 | ||
| 51 | const apply = (lever: Lever, to: number): void => { | |
| 52 | const made = changeEconomics(f.app, f.user, { [lever.key]: to }); | |
| 53 | note = made.ok ? `${lever.key} is ${to}: ${made.value.words}.` : "You cannot change the economics."; | |
| 54 | ctx.invalidate(); | |
| 55 | }; | |
| 56 | ||
| 57 | const ask = (lever: Lever, now: EconomicsView): void => { | |
| 58 | ctx.push( | |
| 59 | promptScreen(ctx, screen, { | |
| 60 | title: lever.key, | |
| 61 | label: `a whole number, ${lever.range}`, | |
| 62 | value: String(now[lever.key]), | |
| 63 | hint: lever.unit, | |
| 64 | onSubmit: (raw) => { | |
| 65 | const to = lever.parse(raw); | |
| 66 | if (to === undefined) return lever.words; | |
| 67 | return Promise.resolve().then(() => { | |
| 68 | ctx.push( | |
| 69 | confirmScreen(ctx, screen, { | |
| 70 | title: "Change the economics", | |
| 71 | body: [`${lever.key}: ${now[lever.key]} → ${to}`], | |
| 72 | actions: [{ label: "Keep", variant: "ghost" }, { label: "Change" }], | |
| 73 | onChoose: (i) => { | |
| 74 | if (i === 1) apply(lever, to); | |
| 75 | }, | |
| 76 | }), | |
| 77 | ); | |
| 78 | }); | |
| 79 | }, | |
| 80 | }), | |
| 81 | ); | |
| 82 | }; | |
| 83 | ||
| 84 | const rows = (e: EconomicsView): ListRow[] => | |
| 85 | LEVERS.map((l) => ({ title: l.key, secondary: `${e[l.key]} · ${l.unit}`, meta: l.range })); | |
| 86 | ||
| 87 | const screen: Screen = { | |
| 88 | title: "Economics", | |
| 89 | keys: [binding(["j", "k"], "move cursor"), binding(["e"], "change")], | |
| 90 | render(area, s) { | |
| 91 | const c = ctx.theme.c; | |
| 92 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 93 | const r = read(); | |
| 94 | const text = note !== "" ? note : r.ok ? `At ${r.value.position}: ${r.value.words}.` : "The economics are set by a forge admin."; | |
| 95 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 96 | drawList(s, list!, c, state, r.ok ? rows(r.value) : []); | |
| 97 | }, | |
| 98 | key(e) { | |
| 99 | const r = read(); | |
| 100 | if (!r.ok) return false; | |
| 101 | if (state.key(e, LEVERS.length)) return true; | |
| 102 | if (e.ctrl || e.alt || e.key !== "e") return false; | |
| 103 | const lever = LEVERS[state.index]; | |
| 104 | if (lever === undefined) return false; | |
| 105 | ask(lever, r.value); | |
| 106 | return true; | |
| 107 | }, | |
| 108 | }; | |
| 109 | return screen; | |
| 110 | } | |
| 111 | ||
| 112 | /** The profit slider, as /settings/economics has it: an admin who is not an agent (#504). */ | |
| 113 | export const entry: PaletteEntry = { | |
| 114 | id: "economics", | |
| 115 | title: "Economics", | |
| 116 | hint: "the profit slider's position and the margin; change one", | |
| 117 | after: ["limits"], | |
| 118 | offered: (session) => session.user?.isAdmin === true && session.user.kind !== "agent", | |
| 119 | run: (session, ctx) => ctx.push(economicsScreen(session, ctx)), | |
| 120 | }; |
src/tui/screens/entries.ts +2
| @@ -36,6 +36,7 @@ import { entry as forges } from "./forges.ts"; | ||
| 36 | 36 | import { entry as homePins } from "./home_pins.ts"; |
| 37 | 37 | import { entry as invites } from "./invites.ts"; |
| 38 | 38 | import { entry as limits } from "./limits.ts"; |
| 39 | import { entry as economics } from "./economics.ts"; | |
| 39 | 40 | import { entry as loopStalls } from "./loopstalls.ts"; |
| 40 | 41 | import { entry as maintenance } from "./maintenance.ts"; |
| 41 | 42 | import { modelCeilingEntry, modelGrantEntry } from "./model.ts"; |
| @@ -103,6 +104,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 103 | 104 | people, |
| 104 | 105 | quotas, |
| 105 | 106 | limits, |
| 107 | economics, | |
| 106 | 108 | flowsAll, |
| 107 | 109 | scrapes, |
| 108 | 110 | trafficScreenEntry, |
src/web/pages/economics.ts added +107
| @@ -0,0 +1,107 @@ | ||
| 1 | /** | |
| 2 | * Settings → Economics, for a forge admin who is a person: the profit | |
| 3 | * slider's position (0 cheapest, 100 most responsive) with the policy it | |
| 4 | * means in words beside it, and the margin on crew spend (docs/PROFIT.md). | |
| 5 | * Server-rendered; the form is a same-origin POST (`readForm`). Nothing acts | |
| 6 | * on the numbers yet; 50 is today's behaviour. | |
| 7 | * Anyone else gets the not-found page. | |
| 8 | */ | |
| 9 | ||
| 10 | import { ECONOMICS_WORDS, changeEconomics, readEconomics } from "../../actions/economics.ts"; | |
| 11 | import { MARGIN_MAX, POSITION_MAX, POSITION_MIN } from "../../core/slider.ts"; | |
| 12 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; | |
| 13 | import type { Ctx } from "../../http/context.ts"; | |
| 14 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; | |
| 15 | import { field, readForm } from "../form.ts"; | |
| 16 | import { html } from "../html.ts"; | |
| 17 | import { flashTo, page } from "../layout.ts"; | |
| 18 | import { btn, icon, row, time } from "../ui.ts"; | |
| 19 | import { accountSections } from "./origins.ts"; | |
| 20 | ||
| 21 | const PATH = "/settings/economics"; | |
| 22 | ||
| 23 | // GET /settings/economics | |
| 24 | export function economicsPage(ctx: Ctx): Response { | |
| 25 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 26 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 27 | const read = timePhase(ctx.request, "economics", () => readEconomics(ctx.app, ctx.auth.user)); | |
| 28 | if (!read.ok) return notFoundPage(ctx); | |
| 29 | const rendering = performance.now(); | |
| 30 | const e = read.value; | |
| 31 | const history = e.changes.map((c) => | |
| 32 | row({ | |
| 33 | lead: icon("history"), | |
| 34 | title: html`<span class="mono">${c.key}</span> <span class="mono">${c.from}</span> → | |
| 35 | <span class="mono">${c.to}</span>`, | |
| 36 | meta: html`${c.actorName === "" ? "someone" : c.actorName} · ${time(c.at)}`, | |
| 37 | }), | |
| 38 | ); | |
| 39 | const body = html`<section class="settings-block"> | |
| 40 | <h2>Profit slider</h2> | |
| 41 | <p class="muted small">One number for the whole forge, from 0 (cheapest) to 100 (most responsive).</p> | |
| 42 | <form class="row-fields" method="post" action="${PATH}"> | |
| 43 | <label class="small" | |
| 44 | >Position | |
| 45 | <input | |
| 46 | class="input" | |
| 47 | type="range" | |
| 48 | name="position" | |
| 49 | min="${String(POSITION_MIN)}" | |
| 50 | max="${String(POSITION_MAX)}" | |
| 51 | step="1" | |
| 52 | value="${String(e.position)}" | |
| 53 | aria-label="Profit slider position: 0 cheapest, 100 most responsive" | |
| 54 | /></label> | |
| 55 | <span class="mono">${String(e.position)}</span> | |
| 56 | <label class="small" | |
| 57 | >Margin on crew spend (%) | |
| 58 | <input | |
| 59 | class="input mono" | |
| 60 | type="number" | |
| 61 | name="marginPct" | |
| 62 | min="0" | |
| 63 | max="${String(MARGIN_MAX)}" | |
| 64 | step="1" | |
| 65 | value="${String(e.marginPct)}" | |
| 66 | /></label> | |
| 67 | ${btn("Save", { size: "sm", variant: "primary", label: "Save economics" })} | |
| 68 | </form> | |
| 69 | <p data-policy>${e.words}.</p> | |
| 70 | </section> | |
| 71 | <section class="settings-block"> | |
| 72 | <h2>Changes</h2> | |
| 73 | ${history.length === 0 | |
| 74 | ? html`<p class="muted">No change yet.</p>` | |
| 75 | : html`<ul class="list setting-list"> | |
| 76 | ${history} | |
| 77 | </ul>`} | |
| 78 | </section>`; | |
| 79 | const response = page({ | |
| 80 | ctx, | |
| 81 | title: "Economics", | |
| 82 | body: html`<div class="page-head"><h1>Economics</h1></div> | |
| 83 | ${settingsLayout(accountSections("economics", { admin: true }), body, "Account settings")}`, | |
| 84 | }); | |
| 85 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 86 | return response; | |
| 87 | } | |
| 88 | ||
| 89 | // POST /settings/economics | |
| 90 | export async function economicsSet(ctx: Ctx): Promise<Response> { | |
| 91 | // Checked before the body is read: a non-admin learns nothing of the door. | |
| 92 | if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") return notFoundPage(ctx); | |
| 93 | const form = await readForm(ctx, "admin"); | |
| 94 | if (form instanceof Response) return form; | |
| 95 | const changed = changeEconomics(ctx.app, ctx.auth.user, { | |
| 96 | position: field(form, "position"), | |
| 97 | marginPct: field(form, "marginPct"), | |
| 98 | }); | |
| 99 | if (!changed.ok && changed.error === "not_found") return notFoundPage(ctx); | |
| 100 | return flashTo( | |
| 101 | ctx, | |
| 102 | PATH, | |
| 103 | changed.ok | |
| 104 | ? { message: `Saved: position ${changed.value.position}, margin ${changed.value.marginPct}%.` } | |
| 105 | : { tone: "danger", message: `${ECONOMICS_WORDS[changed.error as keyof typeof ECONOMICS_WORDS]}.` }, | |
| 106 | ); | |
| 107 | } |
src/web/pages/origins.ts +2
| @@ -111,6 +111,7 @@ export type AccountSection = | ||
| 111 | 111 | | "telemetry" |
| 112 | 112 | | "access" |
| 113 | 113 | | "limits" |
| 114 | | "economics" | |
| 114 | 115 | | "flags" |
| 115 | 116 | | "features" |
| 116 | 117 | | "cloud"; |
| @@ -156,6 +157,7 @@ const SECTION_ROWS: readonly (readonly [ | ||
| 156 | 157 | ["This forge", "Accounts", "/settings/accounts", "users", (c) => c === "accounts", true], |
| 157 | 158 | ["This forge", "People", "/settings/people", "users", (c) => c === "people", true], |
| 158 | 159 | ["This forge", "Limits", "/admin/limits", "activity", (c) => c === "limits", true], |
| 160 | ["This forge", "Economics", "/settings/economics", "activity", (c) => c === "economics", true], | |
| 159 | 161 | ["This forge", "Flows", "/admin/flows", "activity", (c) => c === "adminflows", true], |
| 160 | 162 | ["This forge", "Flags", "/settings/flags", "activity", (c) => c === "flags", true], |
| 161 | 163 | ["This forge", "Features", "/settings/features", "rocket", (c) => c === "features", true], |
test/economics.test.ts added +182
| @@ -0,0 +1,182 @@ | ||
| 1 | /** | |
| 2 | * Settings → Economics and /api/v1/forge/economics (#504): a forge admin who | |
| 3 | * is a person reads and sets the profit slider's position and the margin; | |
| 4 | * the page shows the policy in words; both doors refuse what is not a whole | |
| 5 | * number in range and keep every change; a cross-site form, a non-admin and | |
| 6 | * an agent change nothing. | |
| 7 | */ | |
| 8 | ||
| 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 10 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { createAgent } from "../src/actions/crew.ts"; | |
| 15 | import { createToken } from "../src/core/tokens.ts"; | |
| 16 | import { createUser, type User } from "../src/core/users.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | import { economicsChanges, getEconomics } from "../src/settings/economics.ts"; | |
| 19 | ||
| 20 | describe("economics", () => { | |
| 21 | let dataDir: string; | |
| 22 | let app: App; | |
| 23 | let servers: Servers; | |
| 24 | let alice: User; | |
| 25 | let bob: User; | |
| 26 | ||
| 27 | const login = async (username: string) => { | |
| 28 | const res = await fetch(`${servers.url}/signin`, { | |
| 29 | method: "POST", | |
| 30 | redirect: "manual", | |
| 31 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 32 | body: new URLSearchParams({ username, password: "a-good-passphrase" }), | |
| 33 | }); | |
| 34 | return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 35 | }; | |
| 36 | const post = (cookie: string, body: Record<string, string>, origin = servers.url) => | |
| 37 | fetch(`${servers.url}/settings/economics`, { | |
| 38 | method: "POST", | |
| 39 | redirect: "manual", | |
| 40 | headers: { cookie, "content-type": "application/x-www-form-urlencoded", origin }, | |
| 41 | body: new URLSearchParams(body), | |
| 42 | }); | |
| 43 | const api = (token: string, method: string, body?: unknown) => | |
| 44 | fetch(`${servers.url}/api/v1/forge/economics`, { | |
| 45 | method, | |
| 46 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 47 | ...(body === undefined ? {} : { body: JSON.stringify(body) }), | |
| 48 | }); | |
| 49 | ||
| 50 | beforeAll(async () => { | |
| 51 | dataDir = await mkdtemp(join(tmpdir(), "forge-economics-")); | |
| 52 | app = createApp({ dataDir, port: 0 }); | |
| 53 | servers = await serve(app); | |
| 54 | for (const [username, first] of [["alice", true], ["bob", false]] as const) { | |
| 55 | const made = await createUser(app.db, { | |
| 56 | origin: "seed", | |
| 57 | trusted: true, | |
| 58 | username, | |
| 59 | email: `${username}@forge.local`, | |
| 60 | password: "a-good-passphrase", | |
| 61 | adminIfFirst: first, | |
| 62 | }); | |
| 63 | if (!made.ok) throw new Error(made.error); | |
| 64 | if (username === "alice") alice = made.value; | |
| 65 | else bob = made.value; | |
| 66 | } | |
| 67 | }); | |
| 68 | afterAll(async () => { | |
| 69 | await servers.stop(); | |
| 70 | await rm(dataDir, { recursive: true, force: true }); | |
| 71 | }); | |
| 72 | ||
| 73 | test("a fresh forge stands at 50 with no margin, and the page says what 50 means", async () => { | |
| 74 | expect(getEconomics(app.db)).toEqual({ position: 50, marginPct: 0 }); | |
| 75 | const res = await fetch(`${servers.url}/settings/economics`, { headers: { cookie: await login("alice") } }); | |
| 76 | expect(res.status).toBe(200); | |
| 77 | const text = await res.text(); | |
| 78 | expect(text).toContain('type="range"'); | |
| 79 | expect(text).toContain('name="position"'); | |
| 80 | expect(text).toContain("production sleeps after 24 h idle · previews after 30 min · cloud takes work above 85% of owned capacity"); | |
| 81 | }); | |
| 82 | ||
| 83 | test("a non-admin, signed in or not, is refused and changes nothing", async () => { | |
| 84 | const bobCookie = await login("bob"); | |
| 85 | for (const cookie of [bobCookie, ""]) { | |
| 86 | const got = await fetch(`${servers.url}/settings/economics`, { headers: { cookie }, redirect: "manual" }); | |
| 87 | expect(got.status).not.toBe(200); | |
| 88 | expect((await post(cookie, { position: "90", marginPct: "10" })).status).not.toBe(303); | |
| 89 | } | |
| 90 | const bobToken = createToken(app.db, bob.id, "t", "admin").token; | |
| 91 | expect((await api(bobToken, "GET")).status).toBe(404); | |
| 92 | expect((await api(bobToken, "PUT", { position: 90 })).status).toBe(404); | |
| 93 | expect(getEconomics(app.db)).toEqual({ position: 50, marginPct: 0 }); | |
| 94 | }); | |
| 95 | ||
| 96 | test("an agent, even one granted admin, cannot move the slider", async () => { | |
| 97 | const made = await createAgent(app, alice, { username: "crew-a", budgetUsd: 1 }); | |
| 98 | if (!made.ok) throw new Error(String(made.error)); | |
| 99 | const agentId = made.value.user.id; | |
| 100 | app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(agentId); | |
| 101 | const token = createToken(app.db, agentId, "t", "admin").token; | |
| 102 | expect((await api(token, "PUT", { position: 100 })).status).toBe(404); | |
| 103 | expect(getEconomics(app.db).position).toBe(50); | |
| 104 | }); | |
| 105 | ||
| 106 | test("the form sets both numbers, same-origin only, and keeps each change", async () => { | |
| 107 | const cookie = await login("alice"); | |
| 108 | const crossSite = await post(cookie, { position: "75", marginPct: "20" }, "https://evil.example"); | |
| 109 | expect(crossSite.status).toBe(403); | |
| 110 | expect(getEconomics(app.db)).toEqual({ position: 50, marginPct: 0 }); | |
| 111 | ||
| 112 | const saved = await post(cookie, { position: "75", marginPct: "20" }); | |
| 113 | expect(saved.status).toBe(303); | |
| 114 | expect(getEconomics(app.db)).toEqual({ position: 75, marginPct: 20 }); | |
| 115 | const page = await (await fetch(`${servers.url}/settings/economics`, { headers: { cookie } })).text(); | |
| 116 | expect(page).toContain("production sleeps after 4 days idle · previews after 12 h 15 min · cloud takes work above 72.5% of owned capacity"); | |
| 117 | ||
| 118 | // Saving the same numbers again keeps no second change. | |
| 119 | await post(cookie, { position: "75", marginPct: "20" }); | |
| 120 | const changes = economicsChanges(app.db); | |
| 121 | expect(changes.map((c) => [c.key, c.from, c.to, c.actorName]).sort()).toEqual([ | |
| 122 | ["marginPct", "0", "20", "alice"], | |
| 123 | ["position", "50", "75", "alice"], | |
| 124 | ]); | |
| 125 | }); | |
| 126 | ||
| 127 | test("the form refuses a fraction, a word or a number out of range", async () => { | |
| 128 | const cookie = await login("alice"); | |
| 129 | for (const body of [{ position: "50.5" }, { position: "abc" }, { position: "101" }, { position: "-1" }, { position: "10", marginPct: "501" }]) { | |
| 130 | const res = await post(cookie, { marginPct: "20", ...body }); | |
| 131 | expect(res.status).toBe(303); | |
| 132 | expect(getEconomics(app.db)).toEqual({ position: 75, marginPct: 20 }); | |
| 133 | } | |
| 134 | }); | |
| 135 | ||
| 136 | test("the API reads and sets with the same validation", async () => { | |
| 137 | const token = createToken(app.db, alice.id, "t", "admin").token; | |
| 138 | const read = await api(token, "GET"); | |
| 139 | expect(read.status).toBe(200); | |
| 140 | const got = (await read.json()) as Record<string, unknown>; | |
| 141 | expect(got.position).toBe(75); | |
| 142 | expect(got.marginPct).toBe(20); | |
| 143 | expect(got.policy).toEqual({ | |
| 144 | sleepAfterMs: { production: 345_600_000, preview: 44_100_000 }, | |
| 145 | idlePullMs: 432_000_000, | |
| 146 | spillAtOwnedUse: 0.725, | |
| 147 | }); | |
| 148 | ||
| 149 | for (const [body, code] of [ | |
| 150 | [{ position: 100.5 }, "bad_position"], | |
| 151 | [{ position: "100" }, null], | |
| 152 | [{ position: 101 }, "bad_position"], | |
| 153 | [{ position: null }, "bad_position"], | |
| 154 | [{ marginPct: -5 }, "bad_margin"], | |
| 155 | ] as const) { | |
| 156 | const res = await api(token, "PUT", body); | |
| 157 | if (code === null) { | |
| 158 | expect(res.status).toBe(200); | |
| 159 | continue; | |
| 160 | } | |
| 161 | expect(res.status).toBe(422); | |
| 162 | expect(((await res.json()) as { error: string }).error).toBe(code); | |
| 163 | } | |
| 164 | expect(getEconomics(app.db)).toEqual({ position: 100, marginPct: 20 }); | |
| 165 | ||
| 166 | const set = await api(token, "PUT", { position: 0 }); | |
| 167 | expect(set.status).toBe(200); | |
| 168 | const after = (await set.json()) as { position: number; marginPct: number; policy: { spillAtOwnedUse: number } }; | |
| 169 | expect(after.position).toBe(0); | |
| 170 | expect(after.marginPct).toBe(20); | |
| 171 | expect(after.policy.spillAtOwnedUse).toBe(1); | |
| 172 | ||
| 173 | const ro = createToken(app.db, alice.id, "r", "ro").token; | |
| 174 | expect((await api(ro, "PUT", { position: 50 })).status).toBe(403); | |
| 175 | expect(getEconomics(app.db).position).toBe(0); | |
| 176 | }); | |
| 177 | ||
| 178 | test("a hand-edited stored value reads as the default, never as itself", () => { | |
| 179 | app.db.query("UPDATE forge_setting SET value = '250' WHERE key = 'economics.position'").run(); | |
| 180 | expect(getEconomics(app.db).position).toBe(50); | |
| 181 | }); | |
| 182 | }); |
test/palette-roster.test.ts +1
| @@ -127,6 +127,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 127 | 127 | ["people", "People", "who is a guest, a member or an admin; promote or demote"], |
| 128 | 128 | ["quotas", "Account quotas", "what one account may own, and who changed it"], |
| 129 | 129 | ["limits", "Limits", "what the forge caps, with default and bounds; change one"], |
| 130 | ["economics", "Economics", "the profit slider's position and the margin; change one"], | |
| 130 | 131 | ["flows-all", "Flows across the forge", "every repository's flow lines, by kind and stale"], |
| 131 | 132 | ["teams", "Crew teams", "each team's charter, dial and members"], |
| 132 | 133 | ["agents", "Crew agents", "each agent's model, budget and brief"], |
test/slider.test.ts added +123
| @@ -0,0 +1,123 @@ | ||
| 1 | /** | |
| 2 | * The profit slider's policy (#504, docs/PROFIT.md): the three anchor rows | |
| 3 | * match hand-computed values, 25 and 75 interpolate, 50 is the runtime's | |
| 4 | * constants exactly, and the boundary refuses anything but a whole number | |
| 5 | * in range. | |
| 6 | */ | |
| 7 | ||
| 8 | import { describe, expect, test } from "bun:test"; | |
| 9 | import { IDLE_PULL_DAYS, SLEEP_AFTER_MS } from "../src/apps/runtime.ts"; | |
| 10 | import { duration, parseMarginPct, parsePosition, policyAt, policyWords } from "../src/core/slider.ts"; | |
| 11 | ||
| 12 | const MIN = 60_000; | |
| 13 | const HOUR = 3_600_000; | |
| 14 | const DAY = 86_400_000; | |
| 15 | ||
| 16 | describe("policyAt", () => { | |
| 17 | test("0 is the cheapest row: 10 min, 5 min, 1 day, cloud only when full", () => { | |
| 18 | expect(policyAt(0)).toEqual({ | |
| 19 | sleepAfterMs: { production: 600_000, preview: 300_000 }, | |
| 20 | idlePullMs: 86_400_000, | |
| 21 | spillAtOwnedUse: 1, | |
| 22 | }); | |
| 23 | }); | |
| 24 | ||
| 25 | test("50 is today's row: 24 h, 30 min, 3 days, 85%", () => { | |
| 26 | expect(policyAt(50)).toEqual({ | |
| 27 | sleepAfterMs: { production: 86_400_000, preview: 1_800_000 }, | |
| 28 | idlePullMs: 259_200_000, | |
| 29 | spillAtOwnedUse: 0.85, | |
| 30 | }); | |
| 31 | }); | |
| 32 | ||
| 33 | test("100 is the most responsive row: never, 24 h, never, 60%", () => { | |
| 34 | expect(policyAt(100)).toEqual({ | |
| 35 | sleepAfterMs: { production: null, preview: 86_400_000 }, | |
| 36 | idlePullMs: null, | |
| 37 | spillAtOwnedUse: 0.6, | |
| 38 | }); | |
| 39 | }); | |
| 40 | ||
| 41 | test("50 equals the runtime's constants, so landing the slider changes nothing", () => { | |
| 42 | const p = policyAt(50); | |
| 43 | expect(p.sleepAfterMs).toEqual(SLEEP_AFTER_MS); | |
| 44 | expect(p.idlePullMs).toBe(IDLE_PULL_DAYS * DAY); | |
| 45 | }); | |
| 46 | ||
| 47 | test("25 is halfway between 0 and 50", () => { | |
| 48 | // (10 min + 24 h)/2 = 43 500 000 ms; (5 + 30)/2 min = 1 050 000 ms; | |
| 49 | // (1 + 3)/2 days = 172 800 000 ms; (100 + 85)/2 % = 92.5 %. | |
| 50 | expect(policyAt(25)).toEqual({ | |
| 51 | sleepAfterMs: { production: 43_500_000, preview: 1_050_000 }, | |
| 52 | idlePullMs: 172_800_000, | |
| 53 | spillAtOwnedUse: 0.925, | |
| 54 | }); | |
| 55 | }); | |
| 56 | ||
| 57 | test("75 is halfway between 50 and 100, never read as the 7-day cap", () => { | |
| 58 | // (24 h + 7 d)/2 = 4 d = 345 600 000 ms; (30 min + 24 h)/2 = 12 h 15 min | |
| 59 | // = 44 100 000 ms; (3 + 7)/2 days = 432 000 000 ms; (85 + 60)/2 % = 72.5 %. | |
| 60 | expect(policyAt(75)).toEqual({ | |
| 61 | sleepAfterMs: { production: 4 * DAY, preview: 12 * HOUR + 15 * MIN }, | |
| 62 | idlePullMs: 5 * DAY, | |
| 63 | spillAtOwnedUse: 0.725, | |
| 64 | }); | |
| 65 | }); | |
| 66 | ||
| 67 | test("windows never shrink as the position rises", () => { | |
| 68 | let last = policyAt(0); | |
| 69 | for (let p = 1; p <= 100; p++) { | |
| 70 | const now = policyAt(p); | |
| 71 | const cap = (n: number | null) => n ?? Infinity; | |
| 72 | expect(cap(now.sleepAfterMs.production)).toBeGreaterThanOrEqual(cap(last.sleepAfterMs.production)); | |
| 73 | expect(cap(now.sleepAfterMs.preview)).toBeGreaterThanOrEqual(cap(last.sleepAfterMs.preview)); | |
| 74 | expect(cap(now.idlePullMs)).toBeGreaterThanOrEqual(cap(last.idlePullMs)); | |
| 75 | expect(now.spillAtOwnedUse).toBeLessThanOrEqual(last.spillAtOwnedUse); | |
| 76 | last = now; | |
| 77 | } | |
| 78 | }); | |
| 79 | }); | |
| 80 | ||
| 81 | describe("the boundary", () => { | |
| 82 | test("whole numbers 0 to 100 are positions, as numbers or form text", () => { | |
| 83 | expect(parsePosition(0)).toBe(0); | |
| 84 | expect(parsePosition(100)).toBe(100); | |
| 85 | expect(parsePosition("50")).toBe(50); | |
| 86 | expect(parsePosition(" 7 ")).toBe(7); | |
| 87 | }); | |
| 88 | ||
| 89 | test("out-of-range, fractional and non-numeric positions are refused", () => { | |
| 90 | for (const bad of [-1, 101, 50.5, "50.5", "-1", "101", "1e2", "0x10", "abc", "", " ", null, undefined, true, NaN, Infinity, [50], {}]) { | |
| 91 | expect(parsePosition(bad)).toBeUndefined(); | |
| 92 | } | |
| 93 | }); | |
| 94 | ||
| 95 | test("the margin is a whole percent from 0 to 500", () => { | |
| 96 | expect(parseMarginPct(0)).toBe(0); | |
| 97 | expect(parseMarginPct("500")).toBe(500); | |
| 98 | for (const bad of [-1, 501, 2.5, "2.5", "x", null]) expect(parseMarginPct(bad)).toBeUndefined(); | |
| 99 | }); | |
| 100 | }); | |
| 101 | ||
| 102 | describe("the policy in words", () => { | |
| 103 | test("50 reads as the issue's sentence", () => { | |
| 104 | expect(policyWords(policyAt(50))).toBe( | |
| 105 | "production sleeps after 24 h idle · previews after 30 min · cloud takes work above 85% of owned capacity · a quiet pull's preview stops after 3 days", | |
| 106 | ); | |
| 107 | }); | |
| 108 | ||
| 109 | test("0 and 100 name the ends", () => { | |
| 110 | expect(policyWords(policyAt(0))).toBe( | |
| 111 | "production sleeps after 10 min idle · previews after 5 min · cloud takes work only when owned capacity is full · a quiet pull's preview stops after 24 h", | |
| 112 | ); | |
| 113 | expect(policyWords(policyAt(100))).toBe( | |
| 114 | "production never sleeps · previews after 24 h · cloud takes work above 60% of owned capacity · a quiet pull's preview never stops", | |
| 115 | ); | |
| 116 | }); | |
| 117 | ||
| 118 | test("durations keep the two largest units", () => { | |
| 119 | expect(duration(12 * HOUR + 15 * MIN)).toBe("12 h 15 min"); | |
| 120 | expect(duration(17 * MIN + 30_000)).toBe("17 min 30 s"); | |
| 121 | expect(duration(4 * DAY)).toBe("4 days"); | |
| 122 | }); | |
| 123 | }); |
test/tui-economics.test.ts added +155
| @@ -0,0 +1,155 @@ | ||
| 1 | /** | |
| 2 | * The profit slider in the terminal (#504): an admin who is not an agent | |
| 3 | * reads the position, the margin and the policy in words, and changes one | |
| 4 | * after a confirm; a fraction or a number out of range is refused in the | |
| 5 | * API's words; anyone else has no command and, driving the screen | |
| 6 | * directly, can neither read nor write. | |
| 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 { ECONOMICS_WORDS } from "../src/actions/economics.ts"; | |
| 13 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 14 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 15 | import { getEconomics } from "../src/settings/economics.ts"; | |
| 16 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 17 | import { economicsScreen } from "../src/tui/screens/economics.ts"; | |
| 18 | import { Session, type TuiIdentity } from "../src/tui/session.ts"; | |
| 19 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | import { settleApp } from "./helpers/tui.ts"; | |
| 22 | ||
| 23 | quietLogs(); | |
| 24 | ||
| 25 | describe("economics in the terminal", () => { | |
| 26 | let dataDir: string; | |
| 27 | let forge: Forge; | |
| 28 | ||
| 29 | function boot(username: string) { | |
| 30 | const terminal = headlessTerminal(180, 32); | |
| 31 | const user = getUserByName(forge.db, username)!; | |
| 32 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 33 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 34 | app.start(); | |
| 35 | const key = (k: string) => app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 36 | return { | |
| 37 | text: () => app.screen.toText(), | |
| 38 | press: async (...keys: string[]) => { | |
| 39 | keys.forEach(key); | |
| 40 | await settleApp(app); | |
| 41 | }, | |
| 42 | ctrl: async (k: string) => { | |
| 43 | app.dispatch({ type: "key", key: k, ctrl: true, alt: false, shift: false }); | |
| 44 | await settleApp(app); | |
| 45 | }, | |
| 46 | type: async (t: string) => { | |
| 47 | for (const ch of t) key(ch); | |
| 48 | await settleApp(app); | |
| 49 | }, | |
| 50 | stop: () => app.stop(), | |
| 51 | }; | |
| 52 | } | |
| 53 | const open = async (h: ReturnType<typeof boot>) => { | |
| 54 | await h.ctrl("k"); | |
| 55 | await h.type("Economics"); | |
| 56 | await h.press("enter"); | |
| 57 | }; | |
| 58 | const clear = async (h: ReturnType<typeof boot>) => { | |
| 59 | for (let i = 0; i < 4; i++) await h.press("backspace"); | |
| 60 | }; | |
| 61 | ||
| 62 | beforeAll(async () => { | |
| 63 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-economics-")); | |
| 64 | forge = createApp({ dataDir, port: 0 }); | |
| 65 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false], ["carol", false]] as const) { | |
| 66 | const made = await createUser(forge.db, { | |
| 67 | origin: "seed", | |
| 68 | trusted: true, | |
| 69 | username, | |
| 70 | email: `${username}@forge.local`, | |
| 71 | password: "a-good-passphrase", | |
| 72 | adminIfFirst, | |
| 73 | }); | |
| 74 | if (!made.ok) throw new Error(made.error); | |
| 75 | } | |
| 76 | forge.db.query("UPDATE user SET is_admin = 1, is_agent = 1 WHERE username = 'carol'").run(); | |
| 77 | }); | |
| 78 | afterAll(async () => { | |
| 79 | forge.close(); | |
| 80 | await rm(dataDir, { recursive: true, force: true }); | |
| 81 | }); | |
| 82 | ||
| 83 | test("shows the position, the margin and what 50 means", async () => { | |
| 84 | const h = boot("alice"); | |
| 85 | await open(h); | |
| 86 | const text = h.text(); | |
| 87 | expect(text).toContain("At 50: production sleeps after 24 h idle · previews after 30 min"); | |
| 88 | expect(text).toContain("position"); | |
| 89 | expect(text).toContain("marginPct"); | |
| 90 | expect(text).toContain("0–500"); | |
| 91 | h.stop(); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a change shows the plan and applies only after the confirm", async () => { | |
| 95 | const h = boot("alice"); | |
| 96 | await open(h); | |
| 97 | await h.press("e"); | |
| 98 | await clear(h); | |
| 99 | await h.type("75"); | |
| 100 | await h.press("enter"); | |
| 101 | expect(h.text()).toContain("position: 50 → 75"); | |
| 102 | expect(getEconomics(forge.db).position).toBe(50); | |
| 103 | await h.press("right", "enter"); | |
| 104 | expect(getEconomics(forge.db)).toEqual({ position: 75, marginPct: 0 }); | |
| 105 | expect(h.text()).toContain("position is 75: production sleeps after 4 days idle"); | |
| 106 | ||
| 107 | await h.press("j", "e"); | |
| 108 | await clear(h); | |
| 109 | await h.type("20"); | |
| 110 | await h.press("enter"); | |
| 111 | expect(h.text()).toContain("marginPct: 0 → 20"); | |
| 112 | await h.press("right", "enter"); | |
| 113 | expect(getEconomics(forge.db)).toEqual({ position: 75, marginPct: 20 }); | |
| 114 | h.stop(); | |
| 115 | }); | |
| 116 | ||
| 117 | test("a fraction or out of range is refused in the API's words; Keep changes nothing", async () => { | |
| 118 | const h = boot("alice"); | |
| 119 | await open(h); | |
| 120 | for (const bad of ["50.5", "101"]) { | |
| 121 | await h.press("e"); | |
| 122 | await clear(h); | |
| 123 | await h.type(bad); | |
| 124 | await h.press("enter"); | |
| 125 | expect(h.text()).toContain(ECONOMICS_WORDS.bad_position); | |
| 126 | await h.press("esc"); | |
| 127 | } | |
| 128 | await h.press("e"); | |
| 129 | await clear(h); | |
| 130 | await h.type("10"); | |
| 131 | await h.press("enter"); | |
| 132 | expect(h.text()).toContain("position: 75 → 10"); | |
| 133 | await h.press("enter"); // the first button is Keep | |
| 134 | expect(getEconomics(forge.db).position).toBe(75); | |
| 135 | h.stop(); | |
| 136 | }); | |
| 137 | ||
| 138 | test("a non-admin and an admin agent have no command, see nothing and change nothing", async () => { | |
| 139 | for (const who of ["bob", "carol"]) { | |
| 140 | const h = boot(who); | |
| 141 | await h.ctrl("k"); | |
| 142 | await h.type("Economics"); | |
| 143 | expect(h.text()).not.toContain("the profit slider's position and the margin"); | |
| 144 | h.stop(); | |
| 145 | const user = getUserByName(forge.db, who)!; | |
| 146 | const session = new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user }); | |
| 147 | const pushed: unknown[] = []; | |
| 148 | const ctx = { push: (x: unknown) => pushed.push(x), invalidate() {} } as never; | |
| 149 | const screen = economicsScreen(session, ctx); | |
| 150 | screen.key?.({ type: "key", key: "e", ctrl: false, alt: false, shift: false } as never); | |
| 151 | expect(pushed).toEqual([]); | |
| 152 | } | |
| 153 | expect(getEconomics(forge.db).position).toBe(75); | |
| 154 | }); | |
| 155 | }); |
test/week-wiring.test.ts +4 −1
| @@ -341,8 +341,11 @@ test("the terminal's home carries the crew line for the admin, the same words as | ||
| 341 | 341 | week(70, 6); |
| 342 | 342 | const words = crewLineWords(app, NOW)!; |
| 343 | 343 | expect(words).toContain("30% of the week left, runs out"); |
| 344 | // The terminal reads the clock when it draws, not NOW: the run-out minute may tick over in between. | |
| 345 | const before = crewLineWords(app, new Date())!; | |
| 344 | 346 | const admin = await terminalFor(alice); |
| 345 | expect(admin).toContain(words); | |
| 347 | const after = crewLineWords(app, new Date())!; | |
| 348 | expect(admin.includes(before) || admin.includes(after)).toBe(true); | |
| 346 | 349 | expect(admin.split("30% of the week left").length - 1).toBe(1); |
| 347 | 350 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-long-passphrase" }); |
| 348 | 351 | if (!made.ok) throw new Error(made.error); |