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

Merged opened this

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

Merged

torque/forge:torque/504-the-profit-slider-s-position-one-number → main 11 commits fork

  • Merged. torque/504-the-profit-slider-s-position-one-number was merged into main as e120a51.

Review

Sign in to review this change.
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`,
103103 - `/maintenance` — `pages/maintenance.ts` · prepare the forge for a power-off and end it · `/resilience`, `/settings/backups` · none seen.
104104 - `/admin/limits` — `pages/admin-limits.ts` · every limit with value, default and reason · `/settings/capacity` (also a numbers-you-can-change page) · none seen.
105105 - `/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.
106107 - `/settings/capacity` — `pages/capacity.ts` · how many runs and checks go at once · `/admin/limits`, `/settings/usage` · none seen.
107108 - `/settings/usage` — `pages/usage.ts` · route patterns by use, unused first · `/settings/telemetry`, `/board/returns` · none seen. Not about cost, despite the name.
108109 - `/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 =
170170 | "asleep"
171171 | "failed";
172172
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;
174177 const SWEEP_EVERY_MS = 60_000;
175178 const BUSY_GRACE_MS = 45 * 60_000;
176179
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";
8080 export { route as getApiV1FleetOps } from "./getApiV1FleetOps.ts";
8181 export { route as getApiV1FleetOpsId } from "./getApiV1FleetOpsId.ts";
8282 export { route as getApiV1Forge } from "./getApiV1Forge.ts";
83 export { route as getApiV1ForgeEconomics } from "./getApiV1ForgeEconomics.ts";
8384 export { route as getApiV1ForgeView } from "./getApiV1ForgeView.ts";
8485 export { route as getApiV1Forges } from "./getApiV1Forges.ts";
8586 export { route as getApiV1ForgesSizes } from "./getApiV1ForgesSizes.ts";
@@ -347,6 +348,7 @@ export { route as putApiV1AdminShield } from "./putApiV1AdminShield.ts";
347348 export { route as putApiV1AdminTeamsSlug } from "./putApiV1AdminTeamsSlug.ts";
348349 export { route as putApiV1BoardsSlug } from "./putApiV1BoardsSlug.ts";
349350 export { route as putApiV1FeaturesSlug } from "./putApiV1FeaturesSlug.ts";
351 export { route as putApiV1ForgeEconomics } from "./putApiV1ForgeEconomics.ts";
350352 export { route as putApiV1Front } from "./putApiV1Front.ts";
351353 export { route as putApiV1ReposOwnerNameAppEnvVar } from "./putApiV1ReposOwnerNameAppEnvVar.ts";
352354 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";
338338 import { egressGrantRoutes } from "./api/egress_grants.ts";
339339 import { forgeRoutes } from "./api/forges.ts";
340340 import { forgeViewRoutes } from "./api/forge_view.ts";
341 import { economicsRoutes } from "./api/economics.ts";
341342 import { releasesRoutes } from "./api/releases.ts";
342343 import { hookRoutes } from "./api/hooks.ts";
343344 import { keeperRoutes } from "./api/keeper.ts";
@@ -3490,6 +3491,7 @@ export function apiRoutes(router: Router<Handler>): void {
34903491
34913492 forgeRoutes(router);
34923493 forgeViewRoutes(router);
3494 economicsRoutes(router);
34933495 releasesRoutes(router);
34943496
34953497 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";
1515 import { teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts";
1616 import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts";
1717 import { flagsPage, flagsSet } from "../web/pages/flags.ts";
18 import { economicsPage, economicsSet } from "../web/pages/economics.ts";
1819 import { trustedAdd, trustedPage, trustedRemove, trustedRevert, trustedStop } from "../web/pages/trusted.ts";
1920 import { usagePage } from "../web/pages/usage.ts";
2021 import { spendPage } from "../web/pages/spend-tab.ts";
@@ -180,6 +181,8 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
180181 router.add("GET", "/settings/resilience", domainsMoved);
181182 router.add("GET", "/settings/capacity", capacityPage);
182183 router.add("POST", "/settings/capacity", capacitySet);
184 router.add("GET", "/settings/economics", economicsPage);
185 router.add("POST", "/settings/economics", economicsSet);
183186 router.add("GET", "/settings/flags", flagsPage);
184187 // Apps absorbed as the forge's own features, and their settings.
185188 router.add("GET", "/settings/features", featuresPage);
src/main.ts +2 −2
@@ -43,7 +43,7 @@ import { runSync } from "./origins/sync.ts";
4343 import { runAheadReads } from "./origins/ahead.ts";
4444 import { runGuardSweep } from "./origins/credential.ts";
4545 import { startForgeSsh } from "./tui/ssh.ts";
46 import { AppsRuntime } from "./apps/runtime.ts";
46 import { AppsRuntime, IDLE_PULL_DAYS } from "./apps/runtime.ts";
4747 import { listApps } from "./core/apps.ts";
4848 import { holdAppHosts, releaseAppHosts } from "./apps/holding.ts";
4949 import { resolveAppDomain } from "./apps/host.ts";
@@ -411,7 +411,7 @@ const apps = appDomain?.ok
411411 busyGraceMs: crewRunMs,
412412 // Days a pull may sit untouched before its preview sleeps; 0 keeps
413413 // 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,
415415 })
416416 : undefined;
417417 // 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";
3636 import { entry as homePins } from "./home_pins.ts";
3737 import { entry as invites } from "./invites.ts";
3838 import { entry as limits } from "./limits.ts";
39 import { entry as economics } from "./economics.ts";
3940 import { entry as loopStalls } from "./loopstalls.ts";
4041 import { entry as maintenance } from "./maintenance.ts";
4142 import { modelCeilingEntry, modelGrantEntry } from "./model.ts";
@@ -103,6 +104,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
103104 people,
104105 quotas,
105106 limits,
107 economics,
106108 flowsAll,
107109 scrapes,
108110 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 =
111111 | "telemetry"
112112 | "access"
113113 | "limits"
114 | "economics"
114115 | "flags"
115116 | "features"
116117 | "cloud";
@@ -156,6 +157,7 @@ const SECTION_ROWS: readonly (readonly [
156157 ["This forge", "Accounts", "/settings/accounts", "users", (c) => c === "accounts", true],
157158 ["This forge", "People", "/settings/people", "users", (c) => c === "people", true],
158159 ["This forge", "Limits", "/admin/limits", "activity", (c) => c === "limits", true],
160 ["This forge", "Economics", "/settings/economics", "activity", (c) => c === "economics", true],
159161 ["This forge", "Flows", "/admin/flows", "activity", (c) => c === "adminflows", true],
160162 ["This forge", "Flags", "/settings/flags", "activity", (c) => c === "flags", true],
161163 ["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", () => {
127127 ["people", "People", "who is a guest, a member or an admin; promote or demote"],
128128 ["quotas", "Account quotas", "what one account may own, and who changed it"],
129129 ["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"],
130131 ["flows-all", "Flows across the forge", "every repository's flow lines, by kind and stale"],
131132 ["teams", "Crew teams", "each team's charter, dial and members"],
132133 ["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
341341 week(70, 6);
342342 const words = crewLineWords(app, NOW)!;
343343 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())!;
344346 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);
346349 expect(admin.split("30% of the week left").length - 1).toBe(1);
347350 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-long-passphrase" });
348351 if (!made.ok) throw new Error(made.error);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs