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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/507-what-a-forge-costs-owned-metal-amortised → main 5 commits fork 3 threads · 3 unresolved
17 files
changed +827
−88
docs/PROFIT.md +9
| @@ -51,6 +51,15 @@ ledger already records (`src/core/usage.ts`). | ||
| 51 | 51 | + crew `costUsd` × (1 + margin%) |
| 52 | 52 | - gap = revenue − cost |
| 53 | 53 | |
| 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 | ||
| 54 | 63 | Rounding happens once, at the end, to the cent. Worked example: a $300 box, |
| 55 | 64 | 40 h of cloud at $0.05, 12 GB-months at $0.02 and $57.10 of crew spend |
| 56 | 65 | 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 | |
| 2 | 2 | |
| 3 | 3 | One number per forge, `position`, a whole number from 0 to 100, and beside |
| 4 | 4 | it the margin on crew spend, `marginPct`, a whole number from 0 to 500. The |
| @@ -36,6 +36,51 @@ before. | ||
| 36 | 36 | |
| 37 | 37 | An agent can never move the slider, whatever it was granted. |
| 38 | 38 | |
| 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 | ||
| 39 | 84 | ## Placement (#509) |
| 40 | 85 | |
| 41 | 86 | Where an environment runs is decided by `placeFor` |
src/actions/economics.ts +57 −6
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The forge's economics (`settings/economics.ts`) for a forge admin who is a |
| 3 | 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: | |
| 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: | |
| 5 | 6 | * a crew run that could move the slider would decide its own cost. Everyone |
| 6 | 7 | * else is told `not_found`. Validation is here, once, for the page and the |
| 7 | 8 | * API alike: whole numbers in range, nothing else. |
| @@ -10,13 +11,22 @@ | ||
| 10 | 11 | import type { App } from "../app.ts"; |
| 11 | 12 | import type { User } from "../core/users.ts"; |
| 12 | 13 | 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"; | |
| 13 | 17 | import { |
| 14 | 18 | MARGIN_MAX, |
| 19 | PRICE_MAX_CENTS, | |
| 20 | SLOTS_MAX, | |
| 15 | 21 | parseMarginPct, |
| 16 | 22 | parsePosition, |
| 23 | parseSlots, | |
| 24 | parseUsdCents, | |
| 17 | 25 | policyAt, |
| 18 | 26 | policyWords, |
| 27 | usdOfCents, | |
| 19 | 28 | type Policy, |
| 29 | type Prices, | |
| 20 | 30 | } from "../core/slider.ts"; |
| 21 | 31 | import { NO_CLOUD_BODY, labelInput } from "../core/placement_policy.ts"; |
| 22 | 32 | import { CLOUD_BODY_CONFIGURED } from "../spawn/placement.ts"; |
| @@ -24,8 +34,10 @@ import { | ||
| 24 | 34 | economicsChanges, |
| 25 | 35 | getEconomics, |
| 26 | 36 | getOwnedNodeLabel, |
| 37 | getPrices, | |
| 27 | 38 | setEconomics, |
| 28 | 39 | setOwnedNodeLabel, |
| 40 | setPrices, | |
| 29 | 41 | type Economics, |
| 30 | 42 | type EconomicsChange, |
| 31 | 43 | } from "../settings/economics.ts"; |
| @@ -44,6 +56,9 @@ export interface EconomicsView extends Economics { | ||
| 44 | 56 | readonly words: string; |
| 45 | 57 | readonly placement: PlacementView; |
| 46 | 58 | 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 }; | |
| 47 | 62 | } |
| 48 | 63 | |
| 49 | 64 | function placementView(app: App, policy: Policy): PlacementView { |
| @@ -64,12 +79,22 @@ function admin(actor: User | undefined): Result<User, "not_found"> { | ||
| 64 | 79 | function view(app: App): EconomicsView { |
| 65 | 80 | const now = getEconomics(app.db); |
| 66 | 81 | 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 | ); | |
| 67 | 90 | return { |
| 68 | 91 | ...now, |
| 69 | 92 | policy, |
| 70 | 93 | words: policyWords(policy), |
| 71 | 94 | placement: placementView(app, policy), |
| 72 | 95 | changes: economicsChanges(app.db), |
| 96 | prices, | |
| 97 | month: { ...range, cost }, | |
| 73 | 98 | }; |
| 74 | 99 | } |
| 75 | 100 | |
| @@ -78,23 +103,46 @@ export function readEconomics(app: App, actor: User | undefined): Result<Economi | ||
| 78 | 103 | return who.ok ? ok(view(app)) : who; |
| 79 | 104 | } |
| 80 | 105 | |
| 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"; | |
| 82 | 107 | |
| 83 | 108 | export const ECONOMICS_WORDS: Record<Exclude<EconomicsError, "not_found">, string> = { |
| 84 | 109 | bad_position: "position is a whole number from 0 to 100", |
| 85 | 110 | 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}`, | |
| 86 | 113 | bad_label: "ownedNodeLabel is a node label as key=value, e.g. plat.sh/site=vxrail, or blank for that default", |
| 87 | 114 | }; |
| 88 | 115 | |
| 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 | ||
| 89 | 136 | /** |
| 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. | |
| 93 | 141 | */ |
| 94 | 142 | export function changeEconomics( |
| 95 | 143 | app: App, |
| 96 | 144 | actor: User | undefined, |
| 97 | input: { position?: unknown; marginPct?: unknown; ownedNodeLabel?: unknown }, | |
| 145 | input: { position?: unknown; marginPct?: unknown; ownedNodeLabel?: unknown } & PriceInput, | |
| 98 | 146 | ): Result<EconomicsView, EconomicsError> { |
| 99 | 147 | const who = admin(actor); |
| 100 | 148 | if (!who.ok) return who; |
| @@ -103,10 +151,13 @@ export function changeEconomics( | ||
| 103 | 151 | if (position === undefined) return err("bad_position"); |
| 104 | 152 | const marginPct = input.marginPct === undefined ? before.marginPct : parseMarginPct(input.marginPct); |
| 105 | 153 | if (marginPct === undefined) return err("bad_margin"); |
| 154 | const prices = readPrices(getPrices(app.db), input); | |
| 155 | if (!prices.ok) return prices; | |
| 106 | 156 | // Blank is the default label again; anything not `key=value` refuses the change. |
| 107 | 157 | const label = input.ownedNodeLabel === undefined ? undefined : labelInput(input.ownedNodeLabel); |
| 108 | 158 | if (input.ownedNodeLabel !== undefined && label === undefined) return err("bad_label"); |
| 109 | 159 | setEconomics(app.db, { position, marginPct }, who.value.id); |
| 160 | setPrices(app.db, prices.value, who.value.id); | |
| 110 | 161 | if (label !== undefined) setOwnedNodeLabel(app.db, label, who.value.id); |
| 111 | 162 | return ok(view(app)); |
| 112 | 163 | } |
src/contract/openapi/routes/getApiV1ForgeEconomics.ts +1 −1
| @@ -3,7 +3,7 @@ import type { Route } from "../shared.ts"; | ||
| 3 | 3 | export const route: Route = { |
| 4 | 4 | method: "get", |
| 5 | 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), 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)", | |
| 7 | 7 | auth: true, |
| 8 | 8 | refusals: { |
| 9 | 9 | 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"; | ||
| 3 | 3 | export const route: Route = { |
| 4 | 4 | method: "put", |
| 5 | 5 | 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)", | |
| 7 | 7 | 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: { | |
| 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 | }, | |
| 9 | 17 | refusals: { |
| 10 | 18 | not_found: "the token is not a person who administers the forge (404)", |
| 11 | 19 | not_admin: "the person's role may not administer the forge (403)", |
| 12 | 20 | bad_position: "position is not a whole number from 0 to 100 (422)", |
| 13 | 21 | 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)", | |
| 14 | 24 | bad_label: "ownedNodeLabel is not a node label as key=value (422)", |
| 15 | 25 | }, |
| 16 | 26 | }; |
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 { | ||
| 159 | 159 | : `a quiet pull's preview stops after ${duration(policy.idlePullMs)}`, |
| 160 | 160 | ].join(" · "); |
| 161 | 161 | } |
| 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 @@ | ||
| 1 | 1 | /** |
| 2 | 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 | |
| 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 | |
| 4 | 5 | * forge admin who is a person — the twin of Settings → Economics, with the |
| 5 | 6 | * same validation (`actions/economics.ts`). Anyone else is told 404. |
| 6 | 7 | */ |
| @@ -21,6 +22,14 @@ export function wire(e: EconomicsView) { | ||
| 21 | 22 | words: e.words, |
| 22 | 23 | placement: e.placement, |
| 23 | 24 | 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, | |
| 24 | 33 | }; |
| 25 | 34 | } |
| 26 | 35 | |
| @@ -41,6 +50,10 @@ export function economicsRoutes(router: Router<Handler>): void { | ||
| 41 | 50 | position: input.position, |
| 42 | 51 | marginPct: input.marginPct, |
| 43 | 52 | ownedNodeLabel: input.ownedNodeLabel, |
| 53 | ownedMonthUsd: input.ownedMonthUsd, | |
| 54 | ownedSlots: input.ownedSlots, | |
| 55 | cloudHourUsd: input.cloudHourUsd, | |
| 56 | storageGbMonthUsd: input.storageGbMonthUsd, | |
| 44 | 57 | }); |
| 45 | 58 | if (changed.ok) return json(wire(changed.value)); |
| 46 | 59 | if (changed.error === "not_found") return notFound(); |
src/http/mcp/tools/economics.ts +38 −23
| @@ -1,58 +1,73 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 3 | import { labelInput } from "../../../core/placement_policy.ts"; |
| 4 | 4 | import { wire } from "../../api/economics.ts"; |
| 5 | 5 | import { scopeAllows } from "../../../core/tokens.ts"; |
| 6 | 6 | import { good, bad, type Tool } from "../shared.ts"; |
| 7 | 7 | |
| 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 | ||
| 8 | 12 | export const tool: Tool = { |
| 9 | 13 | 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).", | |
| 12 | 16 | inputSchema: { |
| 13 | 17 | type: "object", |
| 14 | 18 | properties: { |
| 15 | 19 | position: { type: "integer" }, |
| 16 | 20 | marginPct: { type: "integer" }, |
| 17 | 21 | 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 }, | |
| 18 | 26 | dryRun: { type: "boolean" }, |
| 19 | 27 | }, |
| 20 | 28 | }, |
| 21 | 29 | writes: true, |
| 22 | 30 | dryByDefault: true, |
| 23 | readsWithout: ["position", "marginPct", "ownedNodeLabel"], | |
| 31 | readsWithout: [...FIELDS], | |
| 24 | 32 | 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]; | |
| 26 | 35 | const refuse = (error: EconomicsError) => |
| 27 | 36 | error === "not_found" ? bad("not_found", "Not found.") : bad("invalid", ECONOMICS_WORDS[error]); |
| 28 | 37 | const read = readEconomics(ctx.app, user); |
| 29 | 38 | 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)); | |
| 31 | 40 | if (args["dryRun"] !== false) { |
| 32 | const before = read.value; | |
| 33 | const invalid = checkOnly(position, marginPct, ownedNodeLabel); | |
| 41 | const invalid = checkOnly(given); | |
| 34 | 42 | 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." }); | |
| 45 | 57 | } |
| 46 | 58 | 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); | |
| 48 | 60 | return changed.ok ? good(wire(changed.value)) : refuse(changed.error); |
| 49 | 61 | }, |
| 50 | 62 | }; |
| 51 | 63 | |
| 52 | 64 | /** 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"; | |
| 57 | 72 | return undefined; |
| 58 | 73 | } |
src/settings/economics.ts +44 −10
| @@ -16,6 +16,12 @@ import { | ||
| 16 | 16 | POSITION_DEFAULT, |
| 17 | 17 | POSITION_MAX, |
| 18 | 18 | POSITION_MIN, |
| 19 | PRICE_KEYS, | |
| 20 | PRICE_MAX_CENTS, | |
| 21 | PRICES_DEFAULT, | |
| 22 | SLOTS_MAX, | |
| 23 | type PriceKey, | |
| 24 | type Prices, | |
| 19 | 25 | } from "../core/slider.ts"; |
| 20 | 26 | import { OWNED_NODE_LABEL_DEFAULT, parseNodeLabel } from "../core/placement_policy.ts"; |
| 21 | 27 | import { writeSetting } from "./limits.ts"; |
| @@ -25,19 +31,29 @@ export interface Economics { | ||
| 25 | 31 | readonly marginPct: number; |
| 26 | 32 | } |
| 27 | 33 | |
| 28 | export type EconomicsKey = keyof Economics; | |
| 34 | export type EconomicsKey = keyof Economics | PriceKey; | |
| 29 | 35 | |
| 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 | */ | |
| 30 | 40 | const STORE: Record<EconomicsKey, { key: string; min: number; max: number; fallback: number }> = { |
| 31 | 41 | position: { key: "economics.position", min: POSITION_MIN, max: POSITION_MAX, fallback: POSITION_DEFAULT }, |
| 32 | 42 | 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 }, | |
| 33 | 47 | }; |
| 34 | 48 | |
| 49 | const KEYS = Object.keys(STORE) as EconomicsKey[]; | |
| 50 | ||
| 35 | 51 | function stored(db: Database, name: EconomicsKey): number { |
| 36 | 52 | const def = STORE[name]; |
| 37 | 53 | const row = db |
| 38 | 54 | .query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1") |
| 39 | 55 | .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; | |
| 41 | 57 | const n = Number(row.value); |
| 42 | 58 | return n >= def.min && n <= def.max ? n : def.fallback; |
| 43 | 59 | } |
| @@ -61,6 +77,25 @@ export function setEconomics(db: Database, next: Economics, actorId: string): vo | ||
| 61 | 77 | } |
| 62 | 78 | } |
| 63 | 79 | |
| 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 | ||
| 64 | 99 | const LABEL_KEY = "placement.owned_node_label"; |
| 65 | 100 | |
| 66 | 101 | /** |
| @@ -95,26 +130,25 @@ export interface EconomicsChange { | ||
| 95 | 130 | } |
| 96 | 131 | |
| 97 | 132 | 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])), | |
| 100 | 134 | [LABEL_KEY]: "ownedNodeLabel", |
| 101 | 135 | }; |
| 102 | 136 | |
| 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. */ | |
| 104 | 138 | export function economicsChanges(db: Database, limit = 20): EconomicsChange[] { |
| 105 | 139 | const rows = db |
| 106 | 140 | .query< |
| 107 | 141 | { key: string; from_value: string; to_value: string; actor_name: string | null; at: string }, |
| 108 | [string, string, string, number] | |
| 142 | [number, ...string[]] | |
| 109 | 143 | >( |
| 110 | 144 | `SELECT c.key, c.from_value, c.to_value, u.username AS actor_name, c.at |
| 111 | 145 | 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`, | |
| 114 | 148 | ) |
| 115 | .all(STORE.position.key, STORE.marginPct.key, LABEL_KEY, limit); | |
| 149 | .all(limit, ...KEYS.map((k) => STORE[k].key), LABEL_KEY); | |
| 116 | 150 | return rows.map((r) => ({ |
| 117 | key: KEY_OF[r.key] ?? "marginPct", | |
| 151 | key: KEY_OF[r.key] ?? "position", | |
| 118 | 152 | from: r.from_value, |
| 119 | 153 | to: r.to_value, |
| 120 | 154 | actorName: r.actor_name ?? "", |
src/tui/screens/economics.ts +92 −31
| @@ -1,15 +1,27 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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. | |
| 9 | 10 | */ |
| 10 | 11 | |
| 11 | 12 | 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"; | |
| 13 | 25 | import { OWNED_NODE_LABEL_DEFAULT, labelInput } from "../../core/placement_policy.ts"; |
| 14 | 26 | import type { Ctx, Screen } from "../app/app.ts"; |
| 15 | 27 | import { binding } from "../app/keys.ts"; |
| @@ -20,64 +32,112 @@ import { fill, len, split } from "../ui/layout.ts"; | ||
| 20 | 32 | import { ListState, confirmScreen, drawList, promptScreen, type ListRow } from "./common.ts"; |
| 21 | 33 | import type { PaletteEntry } from "./entries.ts"; |
| 22 | 34 | |
| 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 | ||
| 23 | 42 | 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; | |
| 27 | 49 | readonly words: string; |
| 28 | /** What the prompt asks for. */ | |
| 29 | readonly asks: string; | |
| 30 | 50 | readonly unit: string; |
| 31 | 51 | readonly range: string; |
| 32 | 52 | } |
| 33 | 53 | |
| 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 | ||
| 34 | 73 | const LEVERS: readonly Lever[] = [ |
| 35 | 74 | { |
| 36 | key: "position", | |
| 37 | parse: parsePosition, | |
| 75 | ...whole("position"), | |
| 38 | 76 | now: (e) => e.position, |
| 77 | parse: parsePosition, | |
| 39 | 78 | words: ECONOMICS_WORDS.bad_position, |
| 40 | asks: `a whole number, ${POSITION_MIN}–${POSITION_MAX}`, | |
| 41 | 79 | unit: "0 cheapest, 100 most responsive", |
| 42 | 80 | range: `${POSITION_MIN}–${POSITION_MAX}`, |
| 43 | 81 | }, |
| 44 | 82 | { |
| 45 | key: "marginPct", | |
| 46 | parse: parseMarginPct, | |
| 83 | ...whole("marginPct"), | |
| 47 | 84 | now: (e) => e.marginPct, |
| 85 | parse: parseMarginPct, | |
| 48 | 86 | words: ECONOMICS_WORDS.bad_margin, |
| 49 | asks: `a whole number, 0–${MARGIN_MAX}`, | |
| 50 | 87 | unit: "% on crew spend", |
| 51 | 88 | range: `0–${MARGIN_MAX}`, |
| 52 | 89 | }, |
| 53 | 90 | { |
| 54 | key: "ownedNodeLabel", | |
| 55 | parse: labelInput, | |
| 91 | ...whole("ownedNodeLabel"), | |
| 56 | 92 | now: (e) => e.placement.ownedNodeLabel, |
| 93 | parse: labelInput, | |
| 94 | ask: "a node label, key=value", | |
| 57 | 95 | words: ECONOMICS_WORDS.bad_label, |
| 58 | asks: "a node label, key=value", | |
| 59 | 96 | unit: "selects owned metal", |
| 60 | 97 | range: `blank: ${OWNED_NODE_LABEL_DEFAULT}`, |
| 61 | 98 | }, |
| 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"), | |
| 62 | 110 | ]; |
| 63 | 111 | |
| 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 | ||
| 64 | 119 | export function economicsScreen(f: Session, ctx: Ctx): Screen { |
| 65 | 120 | const state = new ListState(); |
| 66 | 121 | let note = ""; |
| 67 | 122 | const read = () => readEconomics(f.app, f.user); |
| 68 | 123 | |
| 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)}.`; | |
| 72 | 131 | ctx.invalidate(); |
| 73 | 132 | }; |
| 74 | 133 | |
| 75 | 134 | const ask = (lever: Lever, now: EconomicsView): void => { |
| 135 | const from = lever.show(lever.now(now)); | |
| 76 | 136 | ctx.push( |
| 77 | 137 | promptScreen(ctx, screen, { |
| 78 | 138 | title: lever.key, |
| 79 | label: lever.asks, | |
| 80 | value: String(lever.now(now)), | |
| 139 | label: `${lever.ask}, ${lever.range}`, | |
| 140 | value: from, | |
| 81 | 141 | hint: lever.unit, |
| 82 | 142 | onSubmit: (raw) => { |
| 83 | 143 | const to = lever.parse(raw); |
| @@ -86,7 +146,7 @@ export function economicsScreen(f: Session, ctx: Ctx): Screen { | ||
| 86 | 146 | ctx.push( |
| 87 | 147 | confirmScreen(ctx, screen, { |
| 88 | 148 | title: "Change the economics", |
| 89 | body: [`${lever.key}: ${lever.now(now)} → ${to}`], | |
| 149 | body: [`${lever.key}: ${from} → ${lever.show(to)}`], | |
| 90 | 150 | actions: [{ label: "Keep", variant: "ghost" }, { label: "Change" }], |
| 91 | 151 | onChoose: (i) => { |
| 92 | 152 | if (i === 1) apply(lever, to); |
| @@ -100,14 +160,14 @@ export function economicsScreen(f: Session, ctx: Ctx): Screen { | ||
| 100 | 160 | }; |
| 101 | 161 | |
| 102 | 162 | 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 })); | |
| 104 | 164 | |
| 105 | 165 | const screen: Screen = { |
| 106 | 166 | title: "Economics", |
| 107 | 167 | keys: [binding(["j", "k"], "move cursor"), binding(["e"], "change")], |
| 108 | 168 | render(area, s) { |
| 109 | 169 | 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)]); | |
| 111 | 171 | const r = read(); |
| 112 | 172 | const text = note !== "" ? note : r.ok ? `At ${r.value.position}: ${r.value.words}.` : "The economics are set by a forge admin."; |
| 113 | 173 | 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 { | ||
| 116 | 176 | s.text(where!.x, where!.y, truncate(placed, where!.w), style(c.mutedFg, c.bg), where!.x + where!.w); |
| 117 | 177 | } |
| 118 | 178 | 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); | |
| 119 | 180 | }, |
| 120 | 181 | key(e) { |
| 121 | 182 | const r = read(); |
| @@ -131,11 +192,11 @@ export function economicsScreen(f: Session, ctx: Ctx): Screen { | ||
| 131 | 192 | return screen; |
| 132 | 193 | } |
| 133 | 194 | |
| 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). */ | |
| 135 | 196 | export const entry: PaletteEntry = { |
| 136 | 197 | id: "economics", |
| 137 | 198 | 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", | |
| 139 | 200 | after: ["limits"], |
| 140 | 201 | offered: (session) => session.user?.isAdmin === true && session.user.kind !== "agent", |
| 141 | 202 | run: (session, ctx) => ctx.push(economicsScreen(session, ctx)), |
src/web/pages/economics.ts +70 −8
| @@ -5,13 +5,15 @@ | ||
| 5 | 5 | * Beside them, where work runs: owned metal first, selected by the owned-node |
| 6 | 6 | * label set here, and whether a cloud body takes bursts — without one the |
| 7 | 7 | * 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. | |
| 10 | 12 | * Anyone else gets the not-found page. |
| 11 | 13 | */ |
| 12 | 14 | |
| 13 | 15 | 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"; | |
| 15 | 17 | import { OWNED_NODE_LABEL_DEFAULT } from "../../core/placement_policy.ts"; |
| 16 | 18 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 17 | 19 | import type { Ctx } from "../../http/context.ts"; |
| @@ -40,6 +42,24 @@ export function economicsPage(ctx: Ctx): Response { | ||
| 40 | 42 | meta: html`${c.actorName === "" ? "someone" : c.actorName} · ${time(c.at)}`, |
| 41 | 43 | }), |
| 42 | 44 | ); |
| 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>`; | |
| 43 | 63 | const body = html`<section class="settings-block"> |
| 44 | 64 | <h2>Profit slider</h2> |
| 45 | 65 | <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 { | ||
| 87 | 107 | <p data-policy>${e.words}.</p> |
| 88 | 108 | <p data-placement>${e.placement.words}.</p> |
| 89 | 109 | </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 |
||
| 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> | |
| 90 | 144 | <section class="settings-block"> |
| 91 | 145 | <h2>Changes</h2> |
| 92 | 146 | ${history.length === 0 |
| @@ -111,18 +165,26 @@ export async function economicsSet(ctx: Ctx): Promise<Response> { | ||
| 111 | 165 | if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") return notFoundPage(ctx); |
| 112 | 166 | const form = await readForm(ctx, "admin"); |
| 113 | 167 | 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); | |
| 114 | 170 | 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"), | |
| 119 | 179 | }); |
| 120 | 180 | if (!changed.ok && changed.error === "not_found") return notFoundPage(ctx); |
| 121 | 181 | return flashTo( |
| 122 | 182 | ctx, |
| 123 | 183 | PATH, |
| 124 | 184 | 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 | } | |
| 126 | 188 | : { tone: "danger", message: `${ECONOMICS_WORDS[changed.error as keyof typeof ECONOMICS_WORDS]}.` }, |
| 127 | 189 | ); |
| 128 | 190 | } |
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"; | ||
| 15 | 15 | import { createToken } from "../src/core/tokens.ts"; |
| 16 | 16 | import { createUser, type User } from "../src/core/users.ts"; |
| 17 | 17 | 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"; | |
| 19 | 19 | |
| 20 | 20 | describe("economics", () => { |
| 21 | 21 | let dataDir: string; |
| @@ -175,6 +175,57 @@ describe("economics", () => { | ||
| 175 | 175 | expect(getEconomics(app.db).position).toBe(0); |
| 176 | 176 | }); |
| 177 | 177 | |
| 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 | ||
| 178 | 229 | test("the owned-node label is a setting: shown with the queue line, set by API and form, validated (#509)", async () => { |
| 179 | 230 | const token = createToken(app.db, alice.id, "t", "admin").token; |
| 180 | 231 | 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"; | ||
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { createApp, type App } from "../src/app.ts"; |
| 7 | 7 | 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"; | |
| 9 | 9 | import { createToken } from "../src/core/tokens.ts"; |
| 10 | 10 | import { createUser } from "../src/core/users.ts"; |
| 11 | 11 | import { serve, type Servers } from "../src/http/server.ts"; |
| @@ -94,6 +94,29 @@ describe("MCP economics", () => { | ||
| 94 | 94 | expect(JSON.parse((await call(admin, {})).text).position).toBe(70); |
| 95 | 95 | }); |
| 96 | 96 | |
| 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 | ||
| 97 | 120 | test("the owned-node label is planned, refused when not key=value, set, and reset by a blank (#509)", async () => { |
| 98 | 121 | const read = JSON.parse((await call(admin, {})).text); |
| 99 | 122 | 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", () => { | ||
| 127 | 127 | ["people", "People", "who is a guest, a member or an admin; promote or demote"], |
| 128 | 128 | ["quotas", "Account quotas", "what one account may own, and who changed it"], |
| 129 | 129 | ["limits", "Limits", "what the forge caps, with default and bounds; change one"], |
| 130 | ["economics", "Economics", "the profit slider's position and the margin; change one"], | |
| 130 | ["economics", "Economics", "the profit slider's position, the margin, the owned-node label and the prices; change one"], | |
| 131 | 131 | ["flows-all", "Flows across the forge", "every repository's flow lines, by kind and stale"], |
| 132 | 132 | ["teams", "Crew teams", "each team's charter, dial and members"], |
| 133 | 133 | ["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"; | ||
| 12 | 12 | import { ECONOMICS_WORDS } from "../src/actions/economics.ts"; |
| 13 | 13 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 14 | 14 | 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"; | |
| 16 | 16 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 17 | 17 | import { economicsScreen } from "../src/tui/screens/economics.ts"; |
| 18 | 18 | import { Session, type TuiIdentity } from "../src/tui/session.ts"; |
| @@ -171,7 +171,7 @@ describe("economics in the terminal", () => { | ||
| 171 | 171 | const h = boot(who); |
| 172 | 172 | await h.ctrl("k"); |
| 173 | 173 | 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"); | |
| 175 | 175 | h.stop(); |
| 176 | 176 | const user = getUserByName(forge.db, who)!; |
| 177 | 177 | const session = new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user }); |
| @@ -183,4 +183,42 @@ describe("economics in the terminal", () => { | ||
| 183 | 183 | } |
| 184 | 184 | expect(getEconomics(forge.db).position).toBe(75); |
| 185 | 185 | }); |
| 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 | }); | |
| 186 | 224 | }); |
The body doc lists only position and marginPct; add ownedMonthUsd, ownedSlots, cloudHourUsd and storageGbMonthUsd so the contract matches the route.