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

What a forge costs: owned metal amortised, cloud per hour, storage per GB-month, and crew spend — one cost model #10185

Merged opened this

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

Merged

torque/forge:torque/507-what-a-forge-costs-owned-metal-amortised → main 5 commits fork 3 threads · 3 unresolved

  • Merged. torque/507-what-a-forge-costs-owned-metal-amortised was merged into main as 4f13d10.

Review

Sign in to review this change.
17 files changed +827 −88
docs/PROFIT.md +9
@@ -51,6 +51,15 @@ ledger already records (`src/core/usage.ts`).
5151 + crew `costUsd` × (1 + margin%)
5252 - gap = revenue − cost
5353
54 `src/core/cost.ts` (`costOf`) is the cost half. Owned metal is spread over
55 each UTC calendar month, so a whole month (28 or 31 days) costs exactly one
56 box, and a range pays each month's elapsed share. Storage turns GB-hours into
57 GB-months at 730 h (8760 h / 12, the cloud-billing convention). Prices are
58 kept as whole cents and set in Settings → Economics or through
59 `PUT /api/v1/forge/economics` (`ownedMonthUsd`, `ownedSlots`,
60 `cloudHourUsd`, `storageGbMonthUsd`). Each line and the total are rounded once,
61 at the end, to the cent; the total is the unrounded sum, rounded.
62
5463 Rounding happens once, at the end, to the cent. Worked example: a $300 box,
5564 40 h of cloud at $0.05, 12 GB-months at $0.02 and $57.10 of crew spend
5665 cost $359.34.
docs/features/economics.md +46 −1
@@ -1,4 +1,4 @@
1 # Economics: the profit slider's position
1 # Economics: the profit slider, its prices and what the month costs
22
33 One number per forge, `position`, a whole number from 0 to 100, and beside
44 it the margin on crew spend, `marginPct`, a whole number from 0 to 500. The
@@ -36,6 +36,51 @@ before.
3636
3737 An agent can never move the slider, whatever it was granted.
3838
39 ## Prices and what the month costs (#507)
40
41 The operator sets four prices beside the slider. They are stored as whole
42 cents, and every change is kept in the same history as the position.
43
44 | field | unit | range |
45 | ------------------- | ----------------------------------------- | ------------------ |
46 | `ownedMonthUsd` | US dollars per calendar month, owned box | 0–1,000,000.00 |
47 | `ownedSlots` | app-slots the owned box holds (a count) | 0–10,000 |
48 | `cloudHourUsd` | US dollars per awake-hour on cloud | 0–1,000,000.00 |
49 | `storageGbMonthUsd` | US dollars per GB-month kept (decimal GB) | 0–1,000,000.00 |
50
51 Prices take at most two decimals and cannot be negative. Anything else is
52 refused as `bad_price` (or `bad_slots`) and nothing changes.
53
54 - **Settings → Economics**: the Prices form, then **This month so far**,
55 with the owned, cloud, storage, crew and total lines in US dollars for the
56 UTC calendar month up to now. When the box and its slots are both set,
57 the page also shows the box's price per app-slot.
58 - **Terminal**: the four prices are rows beside the position and the
59 margin. `e` asks for the value in dollars, shows from → to, and changes
60 it after a confirm. The bottom line shows this month's cost.
61 - **API**: `GET` adds `prices` (dollars) and `month: { from, to, cost }`;
62 `PUT` takes any of the four fields, e.g. `{"ownedMonthUsd": 300,
63 "cloudHourUsd": 0.05}`.
64 - **MCP**: `economics` takes the same four fields, planned first as for the
65 position.
66
67 The cost is `costOf` in `src/core/cost.ts`, a pure function. Each line is
68 in US dollars:
69
70 - **owned** = box $/month × the share of each UTC calendar month the range
71 covers. It is a fixed cost: a whole month, of 28 or 31 days, is exactly
72 one box, whatever ran.
73 - **cloud** = cloud awake-seconds ÷ 3600 × $/awake-hour.
74 - **storage** = GB-hours ÷ 730 × $/GB-month. A month is 730 h (8760 h ÷
75 12), the usual cloud-billing month.
76 - **crew** = the usage ledger's `costUsd` (list price).
77 - **total** = the four lines summed.
78
79 Everything is summed in unrounded cents. Each line and the total are
80 rounded once, at the end, to the cent; the total is the unrounded sum,
81 rounded. Worked example: a $300 box + 40 h of cloud at $0.05 + 12
82 GB-months at $0.02 + $57.10 of crew = $359.34.
83
3984 ## Placement (#509)
4085
4186 Where an environment runs is decided by `placeFor`
src/actions/economics.ts +57 −6
@@ -1,7 +1,8 @@
11 /**
22 * The forge's economics (`settings/economics.ts`) for a forge admin who is a
33 * 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:
4 * the position means (`core/slider.ts`), the operator's prices and what this
5 * month has cost so far at them (`core/cost.ts`), and the last changes. Never an agent:
56 * a crew run that could move the slider would decide its own cost. Everyone
67 * else is told `not_found`. Validation is here, once, for the page and the
78 * API alike: whole numbers in range, nothing else.
@@ -10,13 +11,22 @@
1011 import type { App } from "../app.ts";
1112 import type { User } from "../core/users.ts";
1213 import { err, ok, type Result } from "../kernel/result.ts";
14 import { costOf, monthSoFar, type Cost, type Range } from "../core/cost.ts";
15 import { usageBetween } from "../core/meter.ts";
16 import { usageTotals } from "../core/usage.ts";
1317 import {
1418 MARGIN_MAX,
19 PRICE_MAX_CENTS,
20 SLOTS_MAX,
1521 parseMarginPct,
1622 parsePosition,
23 parseSlots,
24 parseUsdCents,
1725 policyAt,
1826 policyWords,
27 usdOfCents,
1928 type Policy,
29 type Prices,
2030 } from "../core/slider.ts";
2131 import { NO_CLOUD_BODY, labelInput } from "../core/placement_policy.ts";
2232 import { CLOUD_BODY_CONFIGURED } from "../spawn/placement.ts";
@@ -24,8 +34,10 @@ import {
2434 economicsChanges,
2535 getEconomics,
2636 getOwnedNodeLabel,
37 getPrices,
2738 setEconomics,
2839 setOwnedNodeLabel,
40 setPrices,
2941 type Economics,
3042 type EconomicsChange,
3143 } from "../settings/economics.ts";
@@ -44,6 +56,9 @@ export interface EconomicsView extends Economics {
4456 readonly words: string;
4557 readonly placement: PlacementView;
4658 readonly changes: EconomicsChange[];
59 readonly prices: Prices;
60 /** The UTC calendar month so far, and what it cost at `prices`. */
61 readonly month: Range & { readonly cost: Cost };
4762 }
4863
4964 function placementView(app: App, policy: Policy): PlacementView {
@@ -64,12 +79,22 @@ function admin(actor: User | undefined): Result<User, "not_found"> {
6479 function view(app: App): EconomicsView {
6580 const now = getEconomics(app.db);
6681 const policy = policyAt(now.position);
82 const prices = getPrices(app.db);
83 const range = monthSoFar(new Date().toISOString());
84 const cost = costOf(
85 usageBetween(app.db, range.from, range.to, range.to),
86 usageTotals(app.db, "kind", { since: range.from, until: range.to }),
87 prices,
88 range,
89 );
6790 return {
6891 ...now,
6992 policy,
7093 words: policyWords(policy),
7194 placement: placementView(app, policy),
7295 changes: economicsChanges(app.db),
96 prices,
97 month: { ...range, cost },
7398 };
7499 }
75100
@@ -78,23 +103,46 @@ export function readEconomics(app: App, actor: User | undefined): Result<Economi
78103 return who.ok ? ok(view(app)) : who;
79104 }
80105
81 export type EconomicsError = "not_found" | "bad_position" | "bad_margin" | "bad_label";
106 export type EconomicsError = "not_found" | "bad_position" | "bad_margin" | "bad_price" | "bad_slots" | "bad_label";
82107
83108 export const ECONOMICS_WORDS: Record<Exclude<EconomicsError, "not_found">, string> = {
84109 bad_position: "position is a whole number from 0 to 100",
85110 bad_margin: `marginPct is a whole number from 0 to ${MARGIN_MAX}`,
111 bad_price: `a price is US dollars from 0 to ${usdOfCents(PRICE_MAX_CENTS)} with at most two decimals`,
112 bad_slots: `ownedSlots is a whole number from 0 to ${SLOTS_MAX}`,
86113 bad_label: "ownedNodeLabel is a node label as key=value, e.g. plat.sh/site=vxrail, or blank for that default",
87114 };
88115
116 /** The prices on the wire and in the form: dollars, not cents. */
117 export interface PriceInput {
118 ownedMonthUsd?: unknown;
119 ownedSlots?: unknown;
120 cloudHourUsd?: unknown;
121 storageGbMonthUsd?: unknown;
122 }
123
124 function readPrices(before: Prices, input: PriceInput): Result<Prices, "bad_price" | "bad_slots"> {
125 const usd = (v: unknown, kept: number) => (v === undefined ? kept : parseUsdCents(v));
126 const ownedMonthCents = usd(input.ownedMonthUsd, before.ownedMonthCents);
127 const cloudHourCents = usd(input.cloudHourUsd, before.cloudHourCents);
128 const storageGbMonthCents = usd(input.storageGbMonthUsd, before.storageGbMonthCents);
129 if (ownedMonthCents === undefined || cloudHourCents === undefined || storageGbMonthCents === undefined)
130 return err("bad_price");
131 const ownedSlots = input.ownedSlots === undefined ? before.ownedSlots : parseSlots(input.ownedSlots);
132 if (ownedSlots === undefined) return err("bad_slots");
133 return ok({ ownedMonthCents, ownedSlots, cloudHourCents, storageGbMonthCents });
134 }
135
89136 /**
90 * Set either number, the owned-node label, or any of them; one left
91 * `undefined` keeps what stands. A value given but not valid — a number not
92 * whole and in range, a label not `key=value` — refuses the whole change.
137 * Set any of the numbers or the owned-node label; one left `undefined`
138 * keeps what stands. A value given but not valid (a whole number in range;
139 * a price of at most two decimals, non-negative; a label not `key=value`)
140 * refuses the whole change.
93141 */
94142 export function changeEconomics(
95143 app: App,
96144 actor: User | undefined,
97 input: { position?: unknown; marginPct?: unknown; ownedNodeLabel?: unknown },
145 input: { position?: unknown; marginPct?: unknown; ownedNodeLabel?: unknown } & PriceInput,
98146 ): Result<EconomicsView, EconomicsError> {
99147 const who = admin(actor);
100148 if (!who.ok) return who;
@@ -103,10 +151,13 @@ export function changeEconomics(
103151 if (position === undefined) return err("bad_position");
104152 const marginPct = input.marginPct === undefined ? before.marginPct : parseMarginPct(input.marginPct);
105153 if (marginPct === undefined) return err("bad_margin");
154 const prices = readPrices(getPrices(app.db), input);
155 if (!prices.ok) return prices;
106156 // Blank is the default label again; anything not `key=value` refuses the change.
107157 const label = input.ownedNodeLabel === undefined ? undefined : labelInput(input.ownedNodeLabel);
108158 if (input.ownedNodeLabel !== undefined && label === undefined) return err("bad_label");
109159 setEconomics(app.db, { position, marginPct }, who.value.id);
160 setPrices(app.db, prices.value, who.value.id);
110161 if (label !== undefined) setOwnedNodeLabel(app.db, label, who.value.id);
111162 return ok(view(app));
112163 }
src/contract/openapi/routes/getApiV1ForgeEconomics.ts +1 −1
@@ -3,7 +3,7 @@ import type { Route } from "../shared.ts";
33 export const route: Route = {
44 method: "get",
55 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), placement (whether a cloud body takes bursts, the owned-node label, in words) and the last changes (a person admin's token)",
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), placement (whether a cloud body takes bursts, the owned-node label, in words), the operator's prices in US dollars, this UTC month's cost so far ({ owned, cloud, storage, crew, total } in USD) and the last changes (a person admin's token)",
77 auth: true,
88 refusals: {
99 not_found: "the token is not a person who administers the forge (404)",
src/contract/openapi/routes/putApiV1ForgeEconomics.ts +12 −2
@@ -3,14 +3,24 @@ import type { Route } from "../shared.ts";
33 export const route: Route = {
44 method: "put",
55 path: "/api/v1/forge/economics",
6 summary: "Set the profit slider's position, the margin on crew spend and/or the label that selects owned nodes; a field left out keeps its value, each change is kept (a person admin's token, admin scope)",
6 summary: "Set the profit slider's position, the margin on crew spend, the label that selects owned nodes and/or the operator's prices (ownedMonthUsd, ownedSlots, cloudHourUsd, storageGbMonthUsd: US dollars, at most two decimals, non-negative); a field left out keeps its value, each change is kept (a person admin's token, admin scope)",
77 auth: true,
8 body: { position: "whole number 0-100", marginPct: "whole number 0-500", ownedNodeLabel: "node label key=value, e.g. plat.sh/site=vxrail" },
8 body: {
outdated

The body doc lists only position and marginPct; add ownedMonthUsd, ownedSlots, cloudHourUsd and storageGbMonthUsd so the contract matches the route.

9 position: "whole number 0-100",
10 marginPct: "whole number 0-500",
11 ownedNodeLabel: "node label key=value, e.g. plat.sh/site=vxrail",
12 ownedMonthUsd: "US dollars per month for the owned box, 0-1000000, at most two decimals",
13 ownedSlots: "whole number of app-slots the owned box holds, 0-10000",
14 cloudHourUsd: "US dollars per awake-hour on cloud, 0-1000000, at most two decimals",
15 storageGbMonthUsd: "US dollars per GB-month kept, 0-1000000, at most two decimals",
16 },
917 refusals: {
1018 not_found: "the token is not a person who administers the forge (404)",
1119 not_admin: "the person's role may not administer the forge (403)",
1220 bad_position: "position is not a whole number from 0 to 100 (422)",
1321 bad_margin: "marginPct is not a whole number from 0 to 500 (422)",
22 bad_price: "a price is not US dollars from 0 to 1000000.00 with at most two decimals (422)",
23 bad_slots: "ownedSlots is not a whole number from 0 to 10000 (422)",
1424 bad_label: "ownedNodeLabel is not a node label as key=value (422)",
1525 },
1626 };
src/core/cost.ts added +142
@@ -0,0 +1,142 @@
1 /**
2 * What a forge costs over a range, in US dollars (docs/PROFIT.md, "Units
3 * and formulas"): metered use (`core/meter.ts`) and crew spend
4 * (`core/usage.ts`) priced by the operator's prices (`core/slider.ts`).
5 *
6 * owned = Σ over UTC calendar months m touching [from, to):
7 * box $/month × |m ∩ [from, to)| / |m|
8 * — a fixed cost spread evenly over each month whatever runs,
9 * which is why the slider fills owned metal first. A whole
10 * calendar month is exactly the box; 31 days of March and 28 of
11 * February each cost one box.
12 * cloud = Σ cloud awake-seconds / 3600 × cloud $/awake-hour
13 * storage = Σ GB-hours / 730 × storage $/GB-month
14 * — 730 h is the cloud-billing month (8760 h a year / 12), the
15 * convention by which providers turn GB-hours into GB-months.
16 * crew = Σ the usage ledger's `costUsd` (provider list price)
17 * total = owned + cloud + storage + crew
18 *
19 * Everything is summed in cents unrounded; each line and the total are
20 * rounded once, at the end, to the cent (half away from zero). A line can
21 * then differ from the sum of the others by at most a cent per line; the
22 * total is always the unrounded sum, rounded.
23 *
24 * Worked example (test/cost.test.ts): a $300 box for a whole month, 40 h of
25 * cloud at $0.05, 12 GB-months at $0.02 and $57.10 of crew:
26 * 300 + 2.00 + 0.24 + 57.10 = $359.34.
27 *
28 * Pure: plain data in, plain data out.
29 */
30
31 import { defect } from "../kernel/error.ts";
32 import type { Prices } from "./slider.ts";
33
34 /** Hours in a billing month: 365 × 24 / 12. */
35 export const HOURS_PER_GB_MONTH = 730;
36
37 /** What `costOf` reads of each app's meter (`AppUsage` fits). */
38 export interface MeterUse {
39 /** Seconds awake in the range, by where they ran. */
40 readonly awakeByPlacement: { readonly owned: number; readonly cloud: number };
41 /** (data + repository bytes) × hours / 1e9. */
42 readonly gbHours: number;
43 }
44
45 /** What `costOf` reads of the usage ledger (`UsageTotals` fits). */
46 export interface CrewUse {
47 /** US dollars, list price. */
48 readonly costUsd: number;
49 }
50
51 /** A half-open range of instants, ISO 8601. */
52 export interface Range {
53 readonly from: string;
54 readonly to: string;
55 }
56
57 /** Each line in US dollars, rounded to the cent. */
58 export interface Cost {
59 /** Owned box, amortised over the range's share of each calendar month. */
60 readonly owned: number;
61 /** Cloud awake-hours × $/awake-hour. */
62 readonly cloud: number;
63 /** GB-months × $/GB-month. */
64 readonly storage: number;
65 /** Crew spend from the usage ledger. */
66 readonly crew: number;
67 /** The four lines' unrounded sum, rounded. */
68 readonly total: number;
69 }
70
71 function instant(at: string): number {
72 const ms = Date.parse(at);
73 if (!Number.isFinite(ms)) defect("cost.bad_instant", `not an instant: ${at}`);
74 return ms;
75 }
76
77 /** A count or amount that is not a finite non-negative number counts as 0. */
78 const amount = (n: number): number => (Number.isFinite(n) && n > 0 ? n : 0);
79
80 /** Cents to dollars, rounded once to the cent. */
81 const dollars = (cents: number): number => Math.round(cents) / 100;
82
83 /**
84 * The share of owned months in [from, to): Σ |m ∩ range| / |m| over the UTC
85 * calendar months m it touches. A whole month is 1; an empty range is 0.
86 */
87 export function ownedMonths(range: Range): number {
88 const lo = instant(range.from);
89 const hi = instant(range.to);
90 let share = 0;
91 const d = new Date(lo);
92 let y = d.getUTCFullYear();
93 let m = d.getUTCMonth();
94 for (;;) {
95 const start = Date.UTC(y, m, 1);
96 const end = Date.UTC(y, m + 1, 1);
97 if (start >= hi) break;
98 const overlap = Math.min(hi, end) - Math.max(lo, start);
99 if (overlap > 0) share += overlap / (end - start);
100 m += 1;
101 if (m === 12) {
102 m = 0;
103 y += 1;
104 }
105 }
106 return share;
107 }
108
109 export function costOf(
110 meter: readonly MeterUse[],
111 usage: readonly CrewUse[],
112 prices: Prices,
113 range: Range,
114 ): Cost {
115 const ownedCents = prices.ownedMonthCents * ownedMonths(range);
116 let cloudSeconds = 0;
117 let gbHours = 0;
118 for (const app of meter) {
119 cloudSeconds += amount(app.awakeByPlacement.cloud);
120 gbHours += amount(app.gbHours);
121 }
122 const cloudCents = (cloudSeconds / 3600) * prices.cloudHourCents;
123 const storageCents = (gbHours / HOURS_PER_GB_MONTH) * prices.storageGbMonthCents;
124 let crewCents = 0;
125 for (const line of usage) crewCents += amount(line.costUsd) * 100;
126 return {
127 owned: dollars(ownedCents),
128 cloud: dollars(cloudCents),
129 storage: dollars(storageCents),
130 crew: dollars(crewCents),
131 total: dollars(ownedCents + cloudCents + storageCents + crewCents),
132 };
133 }
134
135 /** The UTC calendar month holding `now`, from its first instant to `now`. */
136 export function monthSoFar(now: string): Range {
137 const d = new Date(instant(now));
138 return {
139 from: new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)).toISOString(),
140 to: d.toISOString(),
141 };
142 }
src/core/slider.ts +66
@@ -159,3 +159,69 @@ export function policyWords(policy: Policy): string {
159159 : `a quiet pull's preview stops after ${duration(policy.idlePullMs)}`,
160160 ].join(" · ");
161161 }
162
163 // ---- prices (#507): what the operator pays, beside the position ----
164
165 /**
166 * What the operator pays, as whole US cents so that two decimals stay exact
167 * (docs/PROFIT.md, "Units and formulas"). Units are in the names:
168 * - `ownedMonthCents`: the owned box, US cents per calendar month, a fixed
169 * cost whatever runs on it;
170 * - `ownedSlots`: how many apps the box holds awake at once (app-slots);
171 * - `cloudHourCents`: US cents per awake-hour on a cloud;
172 * - `storageGbMonthCents`: US cents per GB-month (decimal GB) kept.
173 */
174 export interface Prices {
175 readonly ownedMonthCents: number;
176 readonly ownedSlots: number;
177 readonly cloudHourCents: number;
178 readonly storageGbMonthCents: number;
179 }
180
181 export type PriceKey = keyof Prices;
182 export const PRICE_KEYS: readonly PriceKey[] = [
183 "ownedMonthCents",
184 "ownedSlots",
185 "cloudHourCents",
186 "storageGbMonthCents",
187 ];
188
189 /** Nothing priced until the operator says: an unpriced forge costs $0. */
190 export const PRICES_DEFAULT: Prices = {
191 ownedMonthCents: 0,
192 ownedSlots: 0,
193 cloudHourCents: 0,
194 storageGbMonthCents: 0,
195 };
196
197 /** $1,000,000.00: far above any one box, low enough to stay a safe integer. */
198 export const PRICE_MAX_CENTS = 100_000_000;
199 export const SLOTS_MAX = 10_000;
200
201 /**
202 * A price in dollars with at most two decimals, non-negative, up to
203 * `PRICE_MAX_CENTS`, as whole cents: "300" → 30000, "0.05" → 5, 0.5 → 50.
204 * Undefined for signs, exponents, a third decimal, blanks, booleans, null.
205 */
206 export function parseUsdCents(input: unknown): number | undefined {
207 let cents: number;
208 if (typeof input === "number") {
209 if (!Number.isFinite(input) || input < 0) return undefined;
210 cents = Math.round(input * 100);
211 // 0.05 * 100 is 5.000000000000001: within a millionth of a cent is two decimals.
212 if (Math.abs(input * 100 - cents) > 1e-6) return undefined;
213 } else if (typeof input === "string") {
214 const m = /^(\d{1,7})(?:\.(\d{1,2}))?$/.exec(input.trim());
215 if (m === null) return undefined;
216 cents = Number(m[1]) * 100 + Number((m[2] ?? "").padEnd(2, "0"));
217 } else return undefined;
218 return cents <= PRICE_MAX_CENTS ? cents : undefined;
219 }
220
221 export const parseSlots = (input: unknown): number | undefined =>
222 wholeIn(input, 0, SLOTS_MAX);
223
224 /** Whole cents as dollars with two decimals and no sign: 30000 → "300.00". */
225 export function usdOfCents(cents: number): string {
226 return `${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, "0")}`;
227 }
src/http/api/economics.ts +14 −1
@@ -1,6 +1,7 @@
11 /**
22 * 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
3 * on crew spend, the policy the position means (`core/slider.ts`), the
4 * operator's prices and this month's cost so far (`core/cost.ts`), for a
45 * forge admin who is a person — the twin of Settings → Economics, with the
56 * same validation (`actions/economics.ts`). Anyone else is told 404.
67 */
@@ -21,6 +22,14 @@ export function wire(e: EconomicsView) {
2122 words: e.words,
2223 placement: e.placement,
2324 changes: e.changes,
25 // Dollars with two decimals; slots a count (docs/PROFIT.md).
26 prices: {
27 ownedMonthUsd: e.prices.ownedMonthCents / 100,
28 ownedSlots: e.prices.ownedSlots,
29 cloudHourUsd: e.prices.cloudHourCents / 100,
30 storageGbMonthUsd: e.prices.storageGbMonthCents / 100,
31 },
32 month: e.month,
2433 };
2534 }
2635
@@ -41,6 +50,10 @@ export function economicsRoutes(router: Router<Handler>): void {
4150 position: input.position,
4251 marginPct: input.marginPct,
4352 ownedNodeLabel: input.ownedNodeLabel,
53 ownedMonthUsd: input.ownedMonthUsd,
54 ownedSlots: input.ownedSlots,
55 cloudHourUsd: input.cloudHourUsd,
56 storageGbMonthUsd: input.storageGbMonthUsd,
4457 });
4558 if (changed.ok) return json(wire(changed.value));
4659 if (changed.error === "not_found") return notFound();
src/http/mcp/tools/economics.ts +38 −23
@@ -1,58 +1,73 @@
11 import { ECONOMICS_WORDS, changeEconomics, readEconomics, type EconomicsError } from "../../../actions/economics.ts";
2 import { parseMarginPct, parsePosition } from "../../../core/slider.ts";
2 import { parseMarginPct, parsePosition, parseSlots, parseUsdCents } from "../../../core/slider.ts";
33 import { labelInput } from "../../../core/placement_policy.ts";
44 import { wire } from "../../api/economics.ts";
55 import { scopeAllows } from "../../../core/tokens.ts";
66 import { good, bad, type Tool } from "../shared.ts";
77
8 /** Every field the tool can set, as `PUT /api/v1/forge/economics` takes them. */
9 const FIELDS = ["position", "marginPct", "ownedNodeLabel", "ownedMonthUsd", "ownedSlots", "cloudHourUsd", "storageGbMonthUsd"] as const;
10 type Field = (typeof FIELDS)[number];
11
812 export const tool: Tool = {
913 name: "economics",
10 doc: "`{ position?, marginPct?, ownedNodeLabel?, dryRun? }`: with nothing, the profit slider's `position` (0 to 100), `marginPct` (0 to 500), the `policy` and its `words`, and `placement` (whether a cloud body takes bursts, the `ownedNodeLabel` that selects owned nodes), as `GET /api/v1/forge/economics` answers; with either number or a label (`key=value`, blank for the default `plat.sh/site=vxrail`), set it (an admin token; a forge admin who is a person, never an agent), planned first (`dryRun` is true unless `false`); anyone else is told `not_found`",
11 description: "Read the profit slider and its policy, or set it (admin).",
14 doc: "`{ position?, marginPct?, ownedNodeLabel?, ownedMonthUsd?, ownedSlots?, cloudHourUsd?, storageGbMonthUsd?, dryRun? }`: with nothing, the profit slider's `position` (0 to 100), `marginPct` (0 to 500), the `policy` and its `words`, `placement` (whether a cloud body takes bursts, the `ownedNodeLabel` that selects owned nodes), the operator's `prices` (US dollars) and this UTC `month`'s cost, as `GET /api/v1/forge/economics` answers; with any field, set it (prices in US dollars with at most two decimals, non-negative; `ownedSlots` a whole number of app-slots; a label as `key=value`, blank for the default `plat.sh/site=vxrail`) with an admin token, a forge admin who is a person, never an agent, planned first (`dryRun` is true unless `false`); anyone else is told `not_found`",
15 description: "Read the profit slider, its policy, placement, the prices and the month's cost, or set them (admin).",
1216 inputSchema: {
1317 type: "object",
1418 properties: {
1519 position: { type: "integer" },
1620 marginPct: { type: "integer" },
1721 ownedNodeLabel: { type: "string" },
22 ownedMonthUsd: { type: "number", minimum: 0 },
23 ownedSlots: { type: "integer", minimum: 0 },
24 cloudHourUsd: { type: "number", minimum: 0 },
25 storageGbMonthUsd: { type: "number", minimum: 0 },
1826 dryRun: { type: "boolean" },
1927 },
2028 },
2129 writes: true,
2230 dryByDefault: true,
23 readsWithout: ["position", "marginPct", "ownedNodeLabel"],
31 readsWithout: [...FIELDS],
2432 run(ctx, user, args) {
25 const { position, marginPct, ownedNodeLabel } = args;
33 const given: Partial<Record<Field, unknown>> = {};
34 for (const f of FIELDS) if (args[f] !== undefined) given[f] = args[f];
2635 const refuse = (error: EconomicsError) =>
2736 error === "not_found" ? bad("not_found", "Not found.") : bad("invalid", ECONOMICS_WORDS[error]);
2837 const read = readEconomics(ctx.app, user);
2938 if (!read.ok) return refuse(read.error);
30 if (position === undefined && marginPct === undefined && ownedNodeLabel === undefined) return good(wire(read.value));
39 if (Object.keys(given).length === 0) return good(wire(read.value));
3140 if (args["dryRun"] !== false) {
32 const before = read.value;
33 const invalid = checkOnly(position, marginPct, ownedNodeLabel);
41 const invalid = checkOnly(given);
3442 if (invalid) return refuse(invalid);
35 const to = {
36 position: position === undefined ? before.position : position,
37 marginPct: marginPct === undefined ? before.marginPct : marginPct,
38 ownedNodeLabel: ownedNodeLabel === undefined ? before.placement.ownedNodeLabel : labelInput(ownedNodeLabel),
39 };
40 return good({
41 from: { position: before.position, marginPct: before.marginPct, ownedNodeLabel: before.placement.ownedNodeLabel },
42 to,
43 says: "This is the plan; nothing changed. Send dryRun: false to set it.",
44 });
43 const before = wire(read.value);
44 const now = (f: Field): unknown =>
45 f === "position" || f === "marginPct"
46 ? before[f]
47 : f === "ownedNodeLabel"
48 ? before.placement.ownedNodeLabel
49 : before.prices[f];
50 const from: Partial<Record<Field, unknown>> = {};
51 const to: Partial<Record<Field, unknown>> = {};
52 for (const f of FIELDS) {
53 from[f] = now(f);
54 to[f] = given[f] === undefined ? now(f) : f === "ownedNodeLabel" ? labelInput(given[f]) : Number(given[f]);
55 }
56 return good({ from, to, says: "This is the plan; nothing changed. Send dryRun: false to set it." });
4557 }
4658 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) return bad("forbidden", "setting the slider needs an admin token");
47 const changed = changeEconomics(ctx.app, user, { position, marginPct, ownedNodeLabel });
59 const changed = changeEconomics(ctx.app, user, given);
4860 return changed.ok ? good(wire(changed.value)) : refuse(changed.error);
4961 },
5062 };
5163
5264 /** The write's own validation, without the write. */
53 function checkOnly(position: unknown, marginPct: unknown, label: unknown): "bad_position" | "bad_margin" | "bad_label" | undefined {
54 if (position !== undefined && parsePosition(position) === undefined) return "bad_position";
55 if (marginPct !== undefined && parseMarginPct(marginPct) === undefined) return "bad_margin";
56 if (label !== undefined && labelInput(label) === undefined) return "bad_label";
65 function checkOnly(given: Partial<Record<Field, unknown>>): Exclude<EconomicsError, "not_found"> | undefined {
66 if (given.position !== undefined && parsePosition(given.position) === undefined) return "bad_position";
67 if (given.marginPct !== undefined && parseMarginPct(given.marginPct) === undefined) return "bad_margin";
68 if (given.ownedNodeLabel !== undefined && labelInput(given.ownedNodeLabel) === undefined) return "bad_label";
69 for (const f of ["ownedMonthUsd", "cloudHourUsd", "storageGbMonthUsd"] as const)
70 if (given[f] !== undefined && parseUsdCents(given[f]) === undefined) return "bad_price";
71 if (given.ownedSlots !== undefined && parseSlots(given.ownedSlots) === undefined) return "bad_slots";
5772 return undefined;
5873 }
src/settings/economics.ts +44 −10
@@ -16,6 +16,12 @@ import {
1616 POSITION_DEFAULT,
1717 POSITION_MAX,
1818 POSITION_MIN,
19 PRICE_KEYS,
20 PRICE_MAX_CENTS,
21 PRICES_DEFAULT,
22 SLOTS_MAX,
23 type PriceKey,
24 type Prices,
1925 } from "../core/slider.ts";
2026 import { OWNED_NODE_LABEL_DEFAULT, parseNodeLabel } from "../core/placement_policy.ts";
2127 import { writeSetting } from "./limits.ts";
@@ -25,19 +31,29 @@ export interface Economics {
2531 readonly marginPct: number;
2632 }
2733
28 export type EconomicsKey = keyof Economics;
34 export type EconomicsKey = keyof Economics | PriceKey;
2935
36 /**
37 * Prices are kept as whole cents (and slots as a count), so a stored value
38 * is always a whole number — no float ever goes through the database.
39 */
3040 const STORE: Record<EconomicsKey, { key: string; min: number; max: number; fallback: number }> = {
3141 position: { key: "economics.position", min: POSITION_MIN, max: POSITION_MAX, fallback: POSITION_DEFAULT },
3242 marginPct: { key: "economics.margin_pct", min: 0, max: MARGIN_MAX, fallback: MARGIN_DEFAULT },
43 ownedMonthCents: { key: "economics.owned_month_cents", min: 0, max: PRICE_MAX_CENTS, fallback: PRICES_DEFAULT.ownedMonthCents },
44 ownedSlots: { key: "economics.owned_slots", min: 0, max: SLOTS_MAX, fallback: PRICES_DEFAULT.ownedSlots },
45 cloudHourCents: { key: "economics.cloud_hour_cents", min: 0, max: PRICE_MAX_CENTS, fallback: PRICES_DEFAULT.cloudHourCents },
46 storageGbMonthCents: { key: "economics.storage_gb_month_cents", min: 0, max: PRICE_MAX_CENTS, fallback: PRICES_DEFAULT.storageGbMonthCents },
3347 };
3448
49 const KEYS = Object.keys(STORE) as EconomicsKey[];
50
3551 function stored(db: Database, name: EconomicsKey): number {
3652 const def = STORE[name];
3753 const row = db
3854 .query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1")
3955 .get(def.key);
40 if (row === null || !/^\d{1,4}$/.test(row.value)) return def.fallback;
56 if (row === null || !/^\d{1,9}$/.test(row.value)) return def.fallback;
4157 const n = Number(row.value);
4258 return n >= def.min && n <= def.max ? n : def.fallback;
4359 }
@@ -61,6 +77,25 @@ export function setEconomics(db: Database, next: Economics, actorId: string): vo
6177 }
6278 }
6379
80 /** The operator's prices as they stand now: four point reads. */
81 export function getPrices(db: Database): Prices {
82 return {
83 ownedMonthCents: stored(db, "ownedMonthCents"),
84 ownedSlots: stored(db, "ownedSlots"),
85 cloudHourCents: stored(db, "cloudHourCents"),
86 storageGbMonthCents: stored(db, "storageGbMonthCents"),
87 };
88 }
89
90 /** Write the prices that differ, each change kept; validated by the caller. */
91 export function setPrices(db: Database, next: Prices, actorId: string): void {
92 const before = getPrices(db);
93 for (const name of PRICE_KEYS) {
94 if (next[name] === before[name]) continue;
95 writeSetting(db, STORE[name].key, String(next[name]), String(before[name]), String(next[name]), actorId);
96 }
97 }
98
6499 const LABEL_KEY = "placement.owned_node_label";
65100
66101 /**
@@ -95,26 +130,25 @@ export interface EconomicsChange {
95130 }
96131
97132 const KEY_OF: Record<string, EconomicsChange["key"]> = {
98 [STORE.position.key]: "position",
99 [STORE.marginPct.key]: "marginPct",
133 ...Object.fromEntries(KEYS.map((k) => [STORE[k].key, k])),
100134 [LABEL_KEY]: "ownedNodeLabel",
101135 };
102136
103 /** The most recent changes to either number or the owned-node label, newest first. */
137 /** The most recent changes to any of the numbers or the owned-node label, newest first. */
104138 export function economicsChanges(db: Database, limit = 20): EconomicsChange[] {
105139 const rows = db
106140 .query<
107141 { key: string; from_value: string; to_value: string; actor_name: string | null; at: string },
108 [string, string, string, number]
142 [number, ...string[]]
109143 >(
110144 `SELECT c.key, c.from_value, c.to_value, u.username AS actor_name, c.at
111145 FROM forge_setting_change c LEFT JOIN user u ON u.id = c.actor_id
112 WHERE c.key IN (?1, ?2, ?3)
113 ORDER BY c.at DESC, c.rowid DESC LIMIT ?4`,
146 WHERE c.key IN (${[...KEYS.map((_, i) => `?${i + 2}`), `?${KEYS.length + 2}`].join(", ")})
147 ORDER BY c.at DESC, c.rowid DESC LIMIT ?1`,
114148 )
115 .all(STORE.position.key, STORE.marginPct.key, LABEL_KEY, limit);
149 .all(limit, ...KEYS.map((k) => STORE[k].key), LABEL_KEY);
116150 return rows.map((r) => ({
117 key: KEY_OF[r.key] ?? "marginPct",
151 key: KEY_OF[r.key] ?? "position",
118152 from: r.from_value,
119153 to: r.to_value,
120154 actorName: r.actor_name ?? "",
src/tui/screens/economics.ts +92 −31
@@ -1,15 +1,27 @@
11 /**
22 * The profit slider in the terminal, a forge admin's: the position, the
3 * margin on crew spend and the label that selects owned nodes, with the
4 * policy the position means as the head line and where bursts go under it,
5 * as /settings/economics and /api/v1/forge/economics have them. `e` asks for
6 * the value, shows the plan, and changes it only after a confirm,
7 * through `changeEconomics`. The gate is the action's: an admin who is not
8 * an agent; anyone else sees nothing and changes nothing.
3 * margin on crew spend, the label that selects owned nodes and the
4 * operator's four prices, with the policy the position means as the head
5 * line, where bursts go under it, and this month's cost (`core/cost.ts`) as
6 * the foot, as /settings/economics and /api/v1/forge/economics have them.
7 * `e` asks for the value, shows the plan, and changes it only after a
8 * confirm, through `changeEconomics`. The gate is the action's: an admin who
9 * is not an agent; anyone else sees nothing and changes nothing.
910 */
1011
1112 import { ECONOMICS_WORDS, changeEconomics, readEconomics, type EconomicsView } from "../../actions/economics.ts";
12 import { MARGIN_MAX, POSITION_MAX, POSITION_MIN, parseMarginPct, parsePosition } from "../../core/slider.ts";
13 import {
14 MARGIN_MAX,
15 POSITION_MAX,
16 POSITION_MIN,
17 PRICE_MAX_CENTS,
18 SLOTS_MAX,
19 parseMarginPct,
20 parsePosition,
21 parseSlots,
22 parseUsdCents,
23 usdOfCents,
24 } from "../../core/slider.ts";
1325 import { OWNED_NODE_LABEL_DEFAULT, labelInput } from "../../core/placement_policy.ts";
1426 import type { Ctx, Screen } from "../app/app.ts";
1527 import { binding } from "../app/keys.ts";
@@ -20,64 +32,112 @@ import { fill, len, split } from "../ui/layout.ts";
2032 import { ListState, confirmScreen, drawList, promptScreen, type ListRow } from "./common.ts";
2133 import type { PaletteEntry } from "./entries.ts";
2234
35 /**
36 * One value the screen can change. `parse` reads what was typed into the
37 * stored unit (a whole number, whole cents for a price, a label); `send`
38 * turns it into what `changeEconomics` takes (dollars for a price).
39 */
40 type Value = number | string;
41
2342 interface Lever {
24 readonly key: "position" | "marginPct" | "ownedNodeLabel";
25 readonly parse: (raw: string) => number | string | undefined;
26 readonly now: (e: EconomicsView) => number | string;
43 readonly key: string;
44 readonly now: (e: EconomicsView) => Value;
45 readonly parse: (raw: string) => Value | undefined;
46 readonly show: (v: Value) => string;
47 readonly send: (v: Value) => Record<string, Value>;
48 readonly ask: string;
2749 readonly words: string;
28 /** What the prompt asks for. */
29 readonly asks: string;
3050 readonly unit: string;
3151 readonly range: string;
3252 }
3353
54 const whole = (key: string) => ({
55 key,
56 show: (v: Value) => String(v),
57 send: (v: Value) => ({ [key]: v }),
58 ask: "a whole number",
59 });
60
61 const price = (key: string, now: (e: EconomicsView) => number, unit: string): Lever => ({
62 key,
63 now,
64 parse: parseUsdCents,
65 show: (cents) => usdOfCents(Number(cents)),
66 send: (cents) => ({ [key]: Number(cents) / 100 }),
67 ask: "US dollars, at most two decimals",
68 words: ECONOMICS_WORDS.bad_price,
69 unit,
70 range: `0–${usdOfCents(PRICE_MAX_CENTS)}`,
71 });
72
3473 const LEVERS: readonly Lever[] = [
3574 {
36 key: "position",
37 parse: parsePosition,
75 ...whole("position"),
3876 now: (e) => e.position,
77 parse: parsePosition,
3978 words: ECONOMICS_WORDS.bad_position,
40 asks: `a whole number, ${POSITION_MIN}–${POSITION_MAX}`,
4179 unit: "0 cheapest, 100 most responsive",
4280 range: `${POSITION_MIN}–${POSITION_MAX}`,
4381 },
4482 {
45 key: "marginPct",
46 parse: parseMarginPct,
83 ...whole("marginPct"),
4784 now: (e) => e.marginPct,
85 parse: parseMarginPct,
4886 words: ECONOMICS_WORDS.bad_margin,
49 asks: `a whole number, 0–${MARGIN_MAX}`,
5087 unit: "% on crew spend",
5188 range: `0–${MARGIN_MAX}`,
5289 },
5390 {
54 key: "ownedNodeLabel",
55 parse: labelInput,
91 ...whole("ownedNodeLabel"),
5692 now: (e) => e.placement.ownedNodeLabel,
93 parse: labelInput,
94 ask: "a node label, key=value",
5795 words: ECONOMICS_WORDS.bad_label,
58 asks: "a node label, key=value",
5996 unit: "selects owned metal",
6097 range: `blank: ${OWNED_NODE_LABEL_DEFAULT}`,
6198 },
99 price("ownedMonthUsd", (e) => e.prices.ownedMonthCents, "$/month for the owned box"),
100 {
101 ...whole("ownedSlots"),
102 now: (e) => e.prices.ownedSlots,
103 parse: parseSlots,
104 words: ECONOMICS_WORDS.bad_slots,
105 unit: "app-slots the owned box holds",
106 range: `0–${SLOTS_MAX}`,
107 },
108 price("cloudHourUsd", (e) => e.prices.cloudHourCents, "$/awake-hour on cloud"),
109 price("storageGbMonthUsd", (e) => e.prices.storageGbMonthCents, "$/GB-month kept"),
62110 ];
63111
112 /** "This month so far: owned $60.00 · cloud $0.00 · … · total $61.50". */
113 function monthWords(e: EconomicsView): string {
114 const c = e.month.cost;
115 const usd = (n: number) => `$${n.toFixed(2)}`;
116 return `This month so far: owned ${usd(c.owned)} · cloud ${usd(c.cloud)} · storage ${usd(c.storage)} · crew ${usd(c.crew)} · total ${usd(c.total)}`;
117 }
118
64119 export function economicsScreen(f: Session, ctx: Ctx): Screen {
65120 const state = new ListState();
66121 let note = "";
67122 const read = () => readEconomics(f.app, f.user);
68123
69 const apply = (lever: Lever, to: number | string): void => {
70 const made = changeEconomics(f.app, f.user, { [lever.key]: to });
71 note = made.ok ? `${lever.key} is ${to}: ${made.value.words}.` : "You cannot change the economics.";
124 const apply = (lever: Lever, to: Value): void => {
125 const made = changeEconomics(f.app, f.user, lever.send(to));
126 note = !made.ok
127 ? "You cannot change the economics."
128 : lever.key === "position"
129 ? `position is ${to}: ${made.value.words}.`
130 : `${lever.key} is ${lever.show(to)}.`;
72131 ctx.invalidate();
73132 };
74133
75134 const ask = (lever: Lever, now: EconomicsView): void => {
135 const from = lever.show(lever.now(now));
76136 ctx.push(
77137 promptScreen(ctx, screen, {
78138 title: lever.key,
79 label: lever.asks,
80 value: String(lever.now(now)),
139 label: `${lever.ask}, ${lever.range}`,
140 value: from,
81141 hint: lever.unit,
82142 onSubmit: (raw) => {
83143 const to = lever.parse(raw);
@@ -86,7 +146,7 @@ export function economicsScreen(f: Session, ctx: Ctx): Screen {
86146 ctx.push(
87147 confirmScreen(ctx, screen, {
88148 title: "Change the economics",
89 body: [`${lever.key}: ${lever.now(now)} → ${to}`],
149 body: [`${lever.key}: ${from} → ${lever.show(to)}`],
90150 actions: [{ label: "Keep", variant: "ghost" }, { label: "Change" }],
91151 onChoose: (i) => {
92152 if (i === 1) apply(lever, to);
@@ -100,14 +160,14 @@ export function economicsScreen(f: Session, ctx: Ctx): Screen {
100160 };
101161
102162 const rows = (e: EconomicsView): ListRow[] =>
103 LEVERS.map((l) => ({ title: l.key, secondary: `${l.now(e)} · ${l.unit}`, meta: l.range }));
163 LEVERS.map((l) => ({ title: l.key, secondary: `${l.show(l.now(e))} · ${l.unit}`, meta: l.range }));
104164
105165 const screen: Screen = {
106166 title: "Economics",
107167 keys: [binding(["j", "k"], "move cursor"), binding(["e"], "change")],
108168 render(area, s) {
109169 const c = ctx.theme.c;
110 const [head, where, list] = split(area, "v", [len(1), len(1), fill(1)]);
170 const [head, where, list, foot] = split(area, "v", [len(1), len(1), fill(1), len(1)]);
111171 const r = read();
112172 const text = note !== "" ? note : r.ok ? `At ${r.value.position}: ${r.value.words}.` : "The economics are set by a forge admin.";
113173 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
@@ -116,6 +176,7 @@ export function economicsScreen(f: Session, ctx: Ctx): Screen {
116176 s.text(where!.x, where!.y, truncate(placed, where!.w), style(c.mutedFg, c.bg), where!.x + where!.w);
117177 }
118178 drawList(s, list!, c, state, r.ok ? rows(r.value) : []);
179 if (r.ok) s.text(foot!.x, foot!.y, truncate(monthWords(r.value), foot!.w), style(c.fg, c.bg), foot!.x + foot!.w);
119180 },
120181 key(e) {
121182 const r = read();
@@ -131,11 +192,11 @@ export function economicsScreen(f: Session, ctx: Ctx): Screen {
131192 return screen;
132193 }
133194
134 /** The profit slider, as /settings/economics has it: an admin who is not an agent (#504). */
195 /** The profit slider and the prices, as /settings/economics has them: an admin who is not an agent (#504, #507). */
135196 export const entry: PaletteEntry = {
136197 id: "economics",
137198 title: "Economics",
138 hint: "the profit slider's position and the margin; change one",
199 hint: "the profit slider's position, the margin, the owned-node label and the prices; change one",
139200 after: ["limits"],
140201 offered: (session) => session.user?.isAdmin === true && session.user.kind !== "agent",
141202 run: (session, ctx) => ctx.push(economicsScreen(session, ctx)),
src/web/pages/economics.ts +70 −8
@@ -5,13 +5,15 @@
55 * Beside them, where work runs: owned metal first, selected by the owned-node
66 * label set here, and whether a cloud body takes bursts — without one the
77 * page says "no cloud body: bursts queue" (#509).
8 * Server-rendered; the form is a same-origin POST (`readForm`). 50 is today's
9 * behaviour.
8 * The operator's prices, each with its unit, and what this UTC month has
9 * cost so far at them, line by line (`core/cost.ts`).
10 * Server-rendered; the forms are same-origin POSTs (`readForm`); a field a
11 * form does not carry keeps its value. 50 is today's behaviour.
1012 * Anyone else gets the not-found page.
1113 */
1214
1315 import { ECONOMICS_WORDS, changeEconomics, readEconomics } from "../../actions/economics.ts";
14 import { MARGIN_MAX, POSITION_MAX, POSITION_MIN } from "../../core/slider.ts";
16 import { MARGIN_MAX, POSITION_MAX, POSITION_MIN, PRICE_MAX_CENTS, SLOTS_MAX, usdOfCents } from "../../core/slider.ts";
1517 import { OWNED_NODE_LABEL_DEFAULT } from "../../core/placement_policy.ts";
1618 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1719 import type { Ctx } from "../../http/context.ts";
@@ -40,6 +42,24 @@ export function economicsPage(ctx: Ctx): Response {
4042 meta: html`${c.actorName === "" ? "someone" : c.actorName} · ${time(c.at)}`,
4143 }),
4244 );
45 const usd = (n: number) => `$${n.toFixed(2)}`;
46 const priceField = (name: string, label: string, cents: number) =>
47 html`<label class="small"
48 >${label}
49 <input
50 class="input mono"
51 type="number"
52 name="${name}"
53 min="0"
54 max="${usdOfCents(PRICE_MAX_CENTS)}"
55 step="0.01"
56 inputmode="decimal"
57 value="${usdOfCents(cents)}"
58 /></label>`;
59 const p = e.prices;
60 const perSlot = p.ownedSlots > 0 && p.ownedMonthCents > 0 ? p.ownedMonthCents / 100 / p.ownedSlots : undefined;
61 const c = e.month.cost;
62 const line = (label: string, n: number) => html`<div><dt>${label}</dt><dd class="mono">${usd(n)}</dd></div>`;
4363 const body = html`<section class="settings-block">
4464 <h2>Profit slider</h2>
4565 <p class="muted small">One number for the whole forge, from 0 (cheapest) to 100 (most responsive).</p>
@@ -87,6 +107,40 @@ export function economicsPage(ctx: Ctx): Response {
87107 <p data-policy>${e.words}.</p>
88108 <p data-placement>${e.placement.words}.</p>
89109 </section>
110 <section class="settings-block">
111 <h2>Prices</h2>
112 <p class="muted small">What the forge costs you. The owned box costs the same each month whatever runs on it.</p>
113 <form class="row-fields" method="post" action="${PATH}">
114 ${priceField("ownedMonthUsd", "Owned box ($/month)", p.ownedMonthCents)}
115 <label class="small"
116 >Owned capacity (app-slots)
117 <input
outdated

Principle 1 (nothing explains itself twice): this line restates the four values the form directly above shows, so it is the same fact twice. Keep only the derived per-app-slot figure, as a dl row, and drop the rest.

118 class="input mono"
119 type="number"
120 name="ownedSlots"
121 min="0"
122 max="${String(SLOTS_MAX)}"
123 step="1"
124 value="${String(p.ownedSlots)}"
125 /></label>
126 ${priceField("cloudHourUsd", "Cloud ($/awake-hour)", p.cloudHourCents)}
127 ${priceField("storageGbMonthUsd", "Storage ($/GB-month)", p.storageGbMonthCents)}
128 ${btn("Save", { size: "sm", variant: "primary", label: "Save prices" })}
129 </form>
130 ${perSlot === undefined
outdated

Principle 1 and 8 (plain words): a paragraph of formulas that the doc comment, docs/PROFIT.md and the section lead all say again. Cut it to one short sentence, or move the formulas to docs/features/economics.md.

131 ? null
132 : html`<dl class="stats" data-prices>
133 <div><dt>Per app-slot ($/month)</dt><dd class="mono">${usd(perSlot)}</dd></div>
134 </dl>`}
135 </section>
136 <section class="settings-block">
137 <h2>This month so far</h2>
138 <p class="muted small">From ${time(e.month.from)} to ${time(e.month.to)}, in US dollars.</p>
139 <dl class="stats" data-cost>
140 ${line("Owned metal", c.owned)} ${line("Cloud", c.cloud)} ${line("Storage", c.storage)}
141 ${line("Crew", c.crew)} ${line("Total", c.total)}
142 </dl>
143 </section>
90144 <section class="settings-block">
91145 <h2>Changes</h2>
92146 ${history.length === 0
@@ -111,18 +165,26 @@ export async function economicsSet(ctx: Ctx): Promise<Response> {
111165 if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") return notFoundPage(ctx);
112166 const form = await readForm(ctx, "admin");
113167 if (form instanceof Response) return form;
168 // A field the posted form does not carry is left undefined: kept as it stands.
169 const given = (name: string) => (form.has(name) ? field(form, name) : undefined);
114170 const changed = changeEconomics(ctx.app, ctx.auth.user, {
115 position: field(form, "position"),
116 marginPct: field(form, "marginPct"),
117 // A form without the field keeps the label; the field left blank is the default again.
118 ownedNodeLabel: form.get("ownedNodeLabel") === null ? undefined : field(form, "ownedNodeLabel"),
171 position: given("position"),
172 marginPct: given("marginPct"),
173 // The field left blank is the default label again.
174 ownedNodeLabel: given("ownedNodeLabel"),
175 ownedMonthUsd: given("ownedMonthUsd"),
176 ownedSlots: given("ownedSlots"),
177 cloudHourUsd: given("cloudHourUsd"),
178 storageGbMonthUsd: given("storageGbMonthUsd"),
119179 });
120180 if (!changed.ok && changed.error === "not_found") return notFoundPage(ctx);
121181 return flashTo(
122182 ctx,
123183 PATH,
124184 changed.ok
125 ? { message: `Saved: position ${changed.value.position}, margin ${changed.value.marginPct}%.` }
185 ? {
186 message: `Saved: position ${changed.value.position}, margin ${changed.value.marginPct}%, owned box $${usdOfCents(changed.value.prices.ownedMonthCents)}/month.`,
187 }
126188 : { tone: "danger", message: `${ECONOMICS_WORDS[changed.error as keyof typeof ECONOMICS_WORDS]}.` },
127189 );
128190 }
test/cost.test.ts added +119
@@ -0,0 +1,119 @@
1 /**
2 * The cost model (#507, docs/PROFIT.md): each case is computed by hand in
3 * its comment and must match to the cent.
4 */
5
6 import { describe, expect, test } from "bun:test";
7 import { costOf, monthSoFar, ownedMonths } from "../src/core/cost.ts";
8 import { PRICES_DEFAULT, parseSlots, parseUsdCents, usdOfCents, type Prices } from "../src/core/slider.ts";
9
10 const prices: Prices = {
11 ownedMonthCents: 30_000, // $300.00 a month
12 ownedSlots: 40,
13 cloudHourCents: 5, // $0.05 an awake-hour
14 storageGbMonthCents: 2, // $0.02 a GB-month
15 };
16 const november = { from: "2026-11-01T00:00:00.000Z", to: "2026-12-01T00:00:00.000Z" };
17
18 describe("costOf", () => {
19 test("the worked month: $300 + 40 h × $0.05 + 12 GB-months × $0.02 + $57.10 = $359.34", () => {
20 const meter = [
21 // 30 h of cloud and 7 GB-months (7 × 730 GB-hours) in one app…
22 { awakeByPlacement: { owned: 500_000, cloud: 30 * 3600 }, gbHours: 7 * 730 },
23 // …10 h and 5 GB-months in the other; owned hours cost nothing extra.
24 { awakeByPlacement: { owned: 9_999, cloud: 10 * 3600 }, gbHours: 5 * 730 },
25 ];
26 const crew = [{ costUsd: 50 }, { costUsd: 7.1 }];
27 expect(costOf(meter, crew, prices, november)).toEqual({
28 owned: 300,
29 cloud: 2,
30 storage: 0.24,
31 crew: 57.1,
32 total: 359.34,
33 });
34 });
35
36 test("an empty month costs exactly the owned box", () => {
37 expect(costOf([], [], prices, november)).toEqual({ owned: 300, cloud: 0, storage: 0, crew: 0, total: 300 });
38 // A 28-day February and a 31-day March each cost one box.
39 const feb = { from: "2027-02-01T00:00:00.000Z", to: "2027-03-01T00:00:00.000Z" };
40 const mar = { from: "2027-03-01T00:00:00.000Z", to: "2027-04-01T00:00:00.000Z" };
41 expect(costOf([], [], prices, feb).total).toBe(300);
42 expect(costOf([], [], prices, mar).total).toBe(300);
43 });
44
45 test("owned metal is amortised by the share of each calendar month", () => {
46 // 6 of November's 30 days: 300 × 6/30 = $60.00.
47 const week = { from: "2026-11-01T00:00:00.000Z", to: "2026-11-07T00:00:00.000Z" };
48 expect(costOf([], [], prices, week).owned).toBe(60);
49 // 15 Nov → 15 Dec: 15/30 of November + 14/31 of December
50 // = 150 + 135.4838… = $285.48.
51 const across = { from: "2026-11-16T00:00:00.000Z", to: "2026-12-15T00:00:00.000Z" };
52 expect(ownedMonths(across)).toBeCloseTo(15 / 30 + 14 / 31, 12);
53 expect(costOf([], [], prices, across).owned).toBe(285.48);
54 // Across a year end: all of December and January is two boxes.
55 expect(ownedMonths({ from: "2026-12-01T00:00:00.000Z", to: "2027-02-01T00:00:00.000Z" })).toBe(2);
56 // An empty or backwards range is no time at all.
57 expect(costOf([], [], prices, { from: november.to, to: november.from }).owned).toBe(0);
58 });
59
60 test("rounding happens once, at the end", () => {
61 // 1 s of cloud at $0.05/h is 0.00139 ¢ — three of them on separate apps
62 // still round to $0.00; the total is the unrounded sum, rounded.
63 const tiny = { awakeByPlacement: { owned: 0, cloud: 1 }, gbHours: 0 };
64 const c = costOf([tiny, tiny, tiny], [{ costUsd: 0.004 }, { costUsd: 0.004 }], PRICES_DEFAULT, november);
65 // Crew: 0.4 ¢ + 0.4 ¢ = 0.8 ¢ → $0.01, not 0 + 0.
66 expect(c.crew).toBe(0.01);
67 expect(c.total).toBe(0.01);
68 });
69
70 test("an unpriced forge costs only its crew", () => {
71 const meter = [{ awakeByPlacement: { owned: 3600, cloud: 3600 }, gbHours: 1e6 }];
72 expect(costOf(meter, [{ costUsd: 1.5 }], PRICES_DEFAULT, november)).toEqual({
73 owned: 0,
74 cloud: 0,
75 storage: 0,
76 crew: 1.5,
77 total: 1.5,
78 });
79 });
80
81 test("a negative or non-finite reading counts as nothing", () => {
82 const meter = [{ awakeByPlacement: { owned: 0, cloud: -3600 }, gbHours: Number.NaN }];
83 expect(costOf(meter, [{ costUsd: -5 }], prices, november).total).toBe(300);
84 });
85
86 test("monthSoFar starts at the UTC month's first instant", () => {
87 expect(monthSoFar("2026-11-09T13:00:00.000Z")).toEqual({
88 from: "2026-11-01T00:00:00.000Z",
89 to: "2026-11-09T13:00:00.000Z",
90 });
91 });
92 });
93
94 describe("prices at the boundary", () => {
95 test("dollars with at most two decimals, non-negative, as whole cents", () => {
96 expect(parseUsdCents("300")).toBe(30_000);
97 expect(parseUsdCents("0.05")).toBe(5);
98 expect(parseUsdCents("0.5")).toBe(50);
99 expect(parseUsdCents(" 12.34 ")).toBe(1234);
100 expect(parseUsdCents(0.05)).toBe(5);
101 expect(parseUsdCents(57.1)).toBe(5710);
102 expect(parseUsdCents(0)).toBe(0);
103 expect(parseUsdCents("1000000.00")).toBe(100_000_000);
104 for (const bad of ["-1", "0.001", "1e3", "", "abc", "1,5", "1000000.01", -0.01, 0.001, Number.NaN, Infinity, null, true, undefined])
105 expect(parseUsdCents(bad)).toBeUndefined();
106 });
107
108 test("slots are whole and non-negative", () => {
109 expect(parseSlots("40")).toBe(40);
110 expect(parseSlots("0")).toBe(0);
111 for (const bad of ["-1", "1.5", "10001", ""]) expect(parseSlots(bad)).toBeUndefined();
112 });
113
114 test("cents print as dollars with two decimals", () => {
115 expect(usdOfCents(30_000)).toBe("300.00");
116 expect(usdOfCents(5)).toBe("0.05");
117 expect(usdOfCents(1234)).toBe("12.34");
118 });
119 });
test/economics.test.ts +52 −1
@@ -15,7 +15,7 @@ import { createAgent } from "../src/actions/crew.ts";
1515 import { createToken } from "../src/core/tokens.ts";
1616 import { createUser, type User } from "../src/core/users.ts";
1717 import { serve, type Servers } from "../src/http/server.ts";
18 import { economicsChanges, getEconomics, getOwnedNodeLabel } from "../src/settings/economics.ts";
18 import { economicsChanges, getEconomics, getOwnedNodeLabel, getPrices } from "../src/settings/economics.ts";
1919
2020 describe("economics", () => {
2121 let dataDir: string;
@@ -175,6 +175,57 @@ describe("economics", () => {
175175 expect(getEconomics(app.db).position).toBe(0);
176176 });
177177
178 test("prices: the form and the API set them in dollars, refuse a third decimal or a sign, and the page costs the month", async () => {
179 const cookie = await login("alice");
180 expect(getPrices(app.db)).toEqual({ ownedMonthCents: 0, ownedSlots: 0, cloudHourCents: 0, storageGbMonthCents: 0 });
181 const prices = { ownedMonthUsd: "300", ownedSlots: "40", cloudHourUsd: "0.05", storageGbMonthUsd: "0.02" };
182 expect((await post(cookie, prices, "https://evil.example")).status).toBe(403);
183 expect(getPrices(app.db).ownedMonthCents).toBe(0);
184
185 // The prices form carries no position: it keeps what stands.
186 const before = getEconomics(app.db);
187 expect((await post(cookie, prices)).status).toBe(303);
188 expect(getPrices(app.db)).toEqual({ ownedMonthCents: 30_000, ownedSlots: 40, cloudHourCents: 5, storageGbMonthCents: 2 });
189 expect(getEconomics(app.db)).toEqual(before);
190 expect(economicsChanges(app.db).filter((c) => c.key === "ownedMonthCents").map((c) => [c.from, c.to])).toEqual([["0", "30000"]]);
191
192 for (const bad of [{ ownedMonthUsd: "-1" }, { cloudHourUsd: "0.001" }, { storageGbMonthUsd: "abc" }, { ownedSlots: "1.5" }]) {
193 await post(cookie, { ...prices, ...bad, ownedMonthUsd: bad.ownedMonthUsd ?? "999" });
194 expect(getPrices(app.db).ownedMonthCents).toBe(30_000);
195 }
196
197 // No awake time, storage or crew yet this month: the cost is the box
198 // amortised over the month so far, at most the whole box.
199 const page = await (await fetch(`${servers.url}/settings/economics`, { headers: { cookie } })).text();
200 expect(page).toContain('name="ownedMonthUsd"');
201 expect(page).toContain('value="300.00"');
202 expect(page).toContain("$/awake-hour");
203 expect(page).toContain("$/GB-month");
204 expect(page).toContain("Per app-slot ($/month)");
205 expect(page).toContain("$7.50");
206 // Said once: the form holds the prices; the formulas live in docs/features/economics.md.
207 expect(page).not.toContain("/awake-hour on cloud");
208 expect(page).not.toContain("GB-hours ÷");
209
210 const token = createToken(app.db, alice.id, "t2", "admin").token;
211 const got = (await (await api(token, "GET")).json()) as {
212 prices: Record<string, number>;
213 month: { from: string; to: string; cost: { owned: number; cloud: number; total: number } };
214 };
215 expect(got.prices).toEqual({ ownedMonthUsd: 300, ownedSlots: 40, cloudHourUsd: 0.05, storageGbMonthUsd: 0.02 });
216 expect(got.month.cost.cloud).toBe(0);
217 expect(got.month.cost.owned).toBeGreaterThan(0);
218 expect(got.month.cost.owned).toBeLessThanOrEqual(300);
219 expect(got.month.cost.total).toBe(got.month.cost.owned);
220
221 const bad = await api(token, "PUT", { cloudHourUsd: 0.055 });
222 expect(bad.status).toBe(422);
223 expect(((await bad.json()) as { error: string }).error).toBe("bad_price");
224 expect(((await (await api(token, "PUT", { ownedSlots: -1 })).json()) as { error: string }).error).toBe("bad_slots");
225 expect((await api(token, "PUT", { cloudHourUsd: 0.1 })).status).toBe(200);
226 expect(getPrices(app.db).cloudHourCents).toBe(10);
227 });
228
178229 test("the owned-node label is a setting: shown with the queue line, set by API and form, validated (#509)", async () => {
179230 const token = createToken(app.db, alice.id, "t", "admin").token;
180231 const got = (await (await api(token, "GET")).json()) as { placement: Record<string, unknown> };
test/mcp-economics.test.ts +24 −1
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 import { createApp, type App } from "../src/app.ts";
77 import { createAgent } from "../src/actions/crew.ts";
8 import { getEconomics, getOwnedNodeLabel } from "../src/settings/economics.ts";
8 import { getEconomics, getOwnedNodeLabel, getPrices } from "../src/settings/economics.ts";
99 import { createToken } from "../src/core/tokens.ts";
1010 import { createUser } from "../src/core/users.ts";
1111 import { serve, type Servers } from "../src/http/server.ts";
@@ -94,6 +94,29 @@ describe("MCP economics", () => {
9494 expect(JSON.parse((await call(admin, {})).text).position).toBe(70);
9595 });
9696
97 test("prices are planned, refused past two decimals, then set in dollars; the read carries the month's cost", async () => {
98 const plan = await call(admin, { ownedMonthUsd: 300, cloudHourUsd: 0.05 });
99 expect(plan.error).toBe(false);
100 const p = JSON.parse(plan.text);
101 expect(p.from.ownedMonthUsd).toBe(0);
102 expect(p.to).toMatchObject({ ownedMonthUsd: 300, cloudHourUsd: 0.05, position: 70 });
103 expect(getPrices(app.db).ownedMonthCents).toBe(0);
104
105 const badPlan = await call(admin, { cloudHourUsd: 0.055 });
106 expect(badPlan.error).toBe(true);
107 expect(badPlan.text).toContain("at most two decimals");
108 const badSlots = await call(admin, { ownedSlots: -1, dryRun: false });
109 expect(badSlots.error).toBe(true);
110
111 const set = await call(admin, { ownedMonthUsd: 300, ownedSlots: 40, cloudHourUsd: 0.05, storageGbMonthUsd: 0.02, dryRun: false });
112 expect(set.error).toBe(false);
113 expect(getPrices(app.db)).toEqual({ ownedMonthCents: 30_000, ownedSlots: 40, cloudHourCents: 5, storageGbMonthCents: 2 });
114 const read = JSON.parse((await call(admin, {})).text);
115 expect(read.prices).toEqual({ ownedMonthUsd: 300, ownedSlots: 40, cloudHourUsd: 0.05, storageGbMonthUsd: 0.02 });
116 expect(read.month.cost.owned).toBeGreaterThan(0);
117 expect(read.month.cost.owned).toBeLessThanOrEqual(300);
118 });
119
97120 test("the owned-node label is planned, refused when not key=value, set, and reset by a blank (#509)", async () => {
98121 const read = JSON.parse((await call(admin, {})).text);
99122 expect(read.placement).toEqual({ cloudBody: false, ownedNodeLabel: "plat.sh/site=vxrail", words: "no cloud body: bursts queue" });
test/palette-roster.test.ts +1 −1
@@ -127,7 +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"],
130 ["economics", "Economics", "the profit slider's position, the margin, the owned-node label and the prices; change one"],
131131 ["flows-all", "Flows across the forge", "every repository's flow lines, by kind and stale"],
132132 ["teams", "Crew teams", "each team's charter, dial and members"],
133133 ["agents", "Crew agents", "each agent's model, budget and brief"],
test/tui-economics.test.ts +40 −2
@@ -12,7 +12,7 @@ import { join } from "node:path";
1212 import { ECONOMICS_WORDS } from "../src/actions/economics.ts";
1313 import { createApp, type App as Forge } from "../src/app.ts";
1414 import { createUser, getUserByName } from "../src/core/users.ts";
15 import { getEconomics, getOwnedNodeLabel } from "../src/settings/economics.ts";
15 import { getEconomics, getOwnedNodeLabel, getPrices } from "../src/settings/economics.ts";
1616 import { createForgeApp } from "../src/tui/forge.ts";
1717 import { economicsScreen } from "../src/tui/screens/economics.ts";
1818 import { Session, type TuiIdentity } from "../src/tui/session.ts";
@@ -171,7 +171,7 @@ describe("economics in the terminal", () => {
171171 const h = boot(who);
172172 await h.ctrl("k");
173173 await h.type("Economics");
174 expect(h.text()).not.toContain("the profit slider's position and the margin");
174 expect(h.text()).not.toContain("the profit slider's position, the margin, the owned-node label and the prices");
175175 h.stop();
176176 const user = getUserByName(forge.db, who)!;
177177 const session = new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user });
@@ -183,4 +183,42 @@ describe("economics in the terminal", () => {
183183 }
184184 expect(getEconomics(forge.db).position).toBe(75);
185185 });
186
187 test("the prices are levers in dollars, a third decimal is refused, and the foot costs the month", async () => {
188 const h = boot("alice");
189 await open(h);
190 expect(h.text()).toContain("ownedMonthUsd");
191 expect(h.text()).toContain("$/awake-hour on cloud");
192 expect(h.text()).toContain("This month so far: owned $0.00 · cloud $0.00 · storage $0.00 · crew $0.00 · total $0.00");
193
194 const wipe = async () => {
195 for (let i = 0; i < 12; i++) await h.press("backspace");
196 };
197 await h.press("j", "j", "j", "e"); // ownedMonthUsd
198 await wipe();
199 await h.type("0.001");
200 await h.press("enter");
201 expect(h.text()).toContain(ECONOMICS_WORDS.bad_price.slice(0, 40)); // the dialog truncates the rest
202 await h.press("esc");
203
204 await h.press("e");
205 await wipe();
206 await h.type("300");
207 await h.press("enter");
208 expect(h.text()).toContain("ownedMonthUsd: 0.00 → 300.00");
209 expect(getPrices(forge.db).ownedMonthCents).toBe(0);
210 await h.press("right", "enter");
211 expect(getPrices(forge.db).ownedMonthCents).toBe(30_000);
212 expect(h.text()).toContain("ownedMonthUsd is 300.00.");
213 expect(h.text()).not.toContain("owned $0.00");
214
215 await h.press("j", "j", "e"); // cloudHourUsd
216 await wipe();
217 await h.type("0.05");
218 await h.press("enter");
219 expect(h.text()).toContain("cloudHourUsd: 0.00 → 0.05");
220 await h.press("right", "enter");
221 expect(getPrices(forge.db).cloudHourCents).toBe(5);
222 h.stop();
223 });
186224 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs