Public: anyone can read this; the crew can change it.
Placement policy: owned capacity first, burst spills to cloud above the slider's threshold #10193
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/509-placement-policy-owned-capacity-first-bu → main 7 commits fork 2 threads · 2 unresolved
24 files
changed +663
−71
docs/features/economics.md +37
| @@ -35,3 +35,40 @@ before. | ||
| 35 | 35 | Anyone else is told `not_found`, as the API does. |
| 36 | 36 | |
| 37 | 37 | An agent can never move the slider, whatever it was granted. |
| 38 | ||
| 39 | ## Placement (#509) | |
| 40 | ||
| 41 | Where an environment runs is decided by `placeFor` | |
| 42 | (`src/core/placement_policy.ts`), a pure function of how full owned metal | |
| 43 | is, the slider's `spillAtOwnedUse`, whether the environment is production | |
| 44 | or `always_on`, the app's `placement: metal|cloud` preference and whether a | |
| 45 | cloud body is configured: | |
| 46 | ||
| 47 | - owned metal first; a preview spills to cloud once owned use reaches the | |
| 48 | threshold, production and `always_on` apps only when owned is full; | |
| 49 | - `placement: cloud` is honoured; `placement: metal` is honoured unless | |
| 50 | owned is full; | |
| 51 | - with no cloud body configured (`CLOUD_BODY_CONFIGURED` in | |
| 52 | `src/spawn/placement.ts`, false until a cloud body exists — spike #339) | |
| 53 | every answer is `owned` and its reason says "no cloud body: bursts queue". | |
| 54 | ||
| 55 | The runtime asks as each meter interval opens: the answer is the interval's | |
| 56 | `placement` (so cost knows which price applies) and a `place` row in the | |
| 57 | decision ledger (`recordPlacement`, `src/core/decisions.ts`) with the | |
| 58 | inputs, the one-sentence reason and the body target. `place` rows are | |
| 59 | counted, not listed among the crew's decisions on the board; the API's | |
| 60 | decisions list returns them with `kind=place`. | |
| 61 | ||
| 62 | The label that selects owned nodes, `ownedNodeLabel` (default | |
| 63 | `plat.sh/site=vxrail`), is a setting, at every door: a field on Settings → | |
| 64 | Economics, the third row of the terminal's Economics screen (`e`, then a | |
| 65 | confirm), `PUT /api/v1/forge/economics` and the MCP `economics` tool | |
| 66 | (planned first, like the numbers) take `ownedNodeLabel: "key=value"`. | |
| 67 | Blank is the default again; anything else is refused (`bad_label`). `GET` | |
| 68 | and the tool answer `placement: { cloudBody, ownedNodeLabel, words }`; an | |
| 69 | owned decision passes the label to the body as its `nodeLabel`. | |
| 70 | ||
| 71 | Where a burst would wait is said, never silently: the site admin's strip on | |
| 72 | the board reads "All fine · Placement: no cloud body: bursts queue" (or | |
| 73 | carries that line among its notes), as do Settings → Economics and the | |
| 74 | terminal screen's second line. |
src/actions/economics.ts +42 −5
| @@ -18,20 +18,45 @@ import { | ||
| 18 | 18 | policyWords, |
| 19 | 19 | type Policy, |
| 20 | 20 | } from "../core/slider.ts"; |
| 21 | import { NO_CLOUD_BODY, labelInput } from "../core/placement_policy.ts"; | |
| 22 | import { CLOUD_BODY_CONFIGURED } from "../spawn/placement.ts"; | |
| 21 | 23 | import { |
| 22 | 24 | economicsChanges, |
| 23 | 25 | getEconomics, |
| 26 | getOwnedNodeLabel, | |
| 24 | 27 | setEconomics, |
| 28 | setOwnedNodeLabel, | |
| 25 | 29 | type Economics, |
| 26 | 30 | type EconomicsChange, |
| 27 | 31 | } from "../settings/economics.ts"; |
| 28 | 32 | |
| 33 | /** Where work runs (`core/placement_policy.ts`): owned first, bursts to a cloud body if one is configured. */ | |
| 34 | export interface PlacementView { | |
| 35 | readonly cloudBody: boolean; | |
| 36 | /** The label that selects owned nodes, passed to the body on owned metal. */ | |
| 37 | readonly ownedNodeLabel: string; | |
| 38 | /** "no cloud body: bursts queue", or where bursts go. */ | |
| 39 | readonly words: string; | |
| 40 | } | |
| 41 | ||
| 29 | 42 | export interface EconomicsView extends Economics { |
| 30 | 43 | readonly policy: Policy; |
| 31 | 44 | readonly words: string; |
| 45 | readonly placement: PlacementView; | |
| 32 | 46 | readonly changes: EconomicsChange[]; |
| 33 | 47 | } |
| 34 | 48 | |
| 49 | function placementView(app: App, policy: Policy): PlacementView { | |
| 50 | const cloudBody = CLOUD_BODY_CONFIGURED as boolean; | |
| 51 | return { | |
| 52 | cloudBody, | |
| 53 | ownedNodeLabel: getOwnedNodeLabel(app.db), | |
| 54 | words: cloudBody | |
| 55 | ? `previews spill to cloud at ${Math.round(policy.spillAtOwnedUse * 1000) / 10}% of owned capacity, production when owned is full` | |
| 56 | : NO_CLOUD_BODY, | |
| 57 | }; | |
| 58 | } | |
| 59 | ||
| 35 | 60 | function admin(actor: User | undefined): Result<User, "not_found"> { |
| 36 | 61 | return actor?.isAdmin === true && actor.kind !== "agent" ? ok(actor) : err("not_found"); |
| 37 | 62 | } |
| @@ -39,7 +64,13 @@ function admin(actor: User | undefined): Result<User, "not_found"> { | ||
| 39 | 64 | function view(app: App): EconomicsView { |
| 40 | 65 | const now = getEconomics(app.db); |
| 41 | 66 | const policy = policyAt(now.position); |
| 42 | return { ...now, policy, words: policyWords(policy), changes: economicsChanges(app.db) }; | |
| 67 | return { | |
| 68 | ...now, | |
| 69 | policy, | |
| 70 | words: policyWords(policy), | |
| 71 | placement: placementView(app, policy), | |
| 72 | changes: economicsChanges(app.db), | |
| 73 | }; | |
| 43 | 74 | } |
| 44 | 75 | |
| 45 | 76 | export function readEconomics(app: App, actor: User | undefined): Result<EconomicsView, "not_found"> { |
| @@ -47,21 +78,23 @@ export function readEconomics(app: App, actor: User | undefined): Result<Economi | ||
| 47 | 78 | return who.ok ? ok(view(app)) : who; |
| 48 | 79 | } |
| 49 | 80 | |
| 50 | export type EconomicsError = "not_found" | "bad_position" | "bad_margin"; | |
| 81 | export type EconomicsError = "not_found" | "bad_position" | "bad_margin" | "bad_label"; | |
| 51 | 82 | |
| 52 | 83 | export const ECONOMICS_WORDS: Record<Exclude<EconomicsError, "not_found">, string> = { |
| 53 | 84 | bad_position: "position is a whole number from 0 to 100", |
| 54 | 85 | bad_margin: `marginPct is a whole number from 0 to ${MARGIN_MAX}`, |
| 86 | bad_label: "ownedNodeLabel is a node label as key=value, e.g. plat.sh/site=vxrail, or blank for that default", | |
| 55 | 87 | }; |
| 56 | 88 | |
| 57 | 89 | /** |
| 58 | * Set either number or both; one left `undefined` keeps what stands. A value | |
| 59 | * given but not a whole number in range refuses the whole change. | |
| 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. | |
| 60 | 93 | */ |
| 61 | 94 | export function changeEconomics( |
| 62 | 95 | app: App, |
| 63 | 96 | actor: User | undefined, |
| 64 | input: { position?: unknown; marginPct?: unknown }, | |
| 97 | input: { position?: unknown; marginPct?: unknown; ownedNodeLabel?: unknown }, | |
| 65 | 98 | ): Result<EconomicsView, EconomicsError> { |
| 66 | 99 | const who = admin(actor); |
| 67 | 100 | if (!who.ok) return who; |
| @@ -70,6 +103,10 @@ export function changeEconomics( | ||
| 70 | 103 | if (position === undefined) return err("bad_position"); |
| 71 | 104 | const marginPct = input.marginPct === undefined ? before.marginPct : parseMarginPct(input.marginPct); |
| 72 | 105 | if (marginPct === undefined) return err("bad_margin"); |
| 106 | // Blank is the default label again; anything not `key=value` refuses the change. | |
| 107 | const label = input.ownedNodeLabel === undefined ? undefined : labelInput(input.ownedNodeLabel); | |
| 108 | if (input.ownedNodeLabel !== undefined && label === undefined) return err("bad_label"); | |
| 73 | 109 | setEconomics(app.db, { position, marginPct }, who.value.id); |
| 110 | if (label !== undefined) setOwnedNodeLabel(app.db, label, who.value.id); | |
| 74 | 111 | return ok(view(app)); |
| 75 | 112 | } |
src/apps/runtime.ts +49 −11
| @@ -41,6 +41,9 @@ import { pullMayRun } from "../core/pull_trust.ts"; | ||
| 41 | 41 | import { guestWaits } from "../origins/guest.ts"; |
| 42 | 42 | import { canEnterApp, getRepoById, type Repo } from "../core/repos.ts"; |
| 43 | 43 | import { runningLimitSentence } from "../settings/quotas.ts"; |
| 44 | import { placeFor } from "../core/placement_policy.ts"; | |
| 45 | import { recordPlacement } from "../core/decisions.ts"; | |
| 46 | import { bodyTarget, CLOUD_BODY_CONFIGURED } from "../spawn/placement.ts"; | |
| 44 | 47 | import type { User } from "../core/users.ts"; |
| 45 | 48 | import { |
| 46 | 49 | cancelRun, |
| @@ -73,7 +76,6 @@ import { | ||
| 73 | 76 | openAwake, |
| 74 | 77 | recordStorage, |
| 75 | 78 | SAMPLE_SPAN_MS, |
| 76 | type Placement, | |
| 77 | 79 | } from "../core/meter.ts"; |
| 78 | 80 | import { dirBytes } from "./bytes.ts"; |
| 79 | 81 | import { BUSY_WORDS, busyReason, type BusyReason } from "./busy.ts"; |
| @@ -102,7 +104,7 @@ import { identityKey } from "./proxy.ts"; | ||
| 102 | 104 | import { noteReached } from "./egress_report.ts"; |
| 103 | 105 | import { egressSetting } from "../core/capacity.ts"; |
| 104 | 106 | import { policyAt } from "../core/slider.ts"; |
| 105 | import { getEconomics } from "../settings/economics.ts"; | |
| 107 | import { getEconomics, getOwnedNodeLabel } from "../settings/economics.ts"; | |
| 106 | 108 | import { asleepWords, sleepWindow, type WindowSource } from "./sleep_window.ts"; |
| 107 | 109 | import { noteProtection } from "../kernel/protection.ts"; |
| 108 | 110 | import { cellsAvailable, resolveLimits, type CellSpec } from "./contain.ts"; |
| @@ -162,8 +164,17 @@ export interface RuntimeOptions { | ||
| 162 | 164 | * its own, so proving `app.example.com` needs no network in a test. |
| 163 | 165 | */ |
| 164 | 166 | readonly dns?: DomainResolver | undefined; |
| 165 | /** Where this forge's processes run, as the meter records it. @default "owned" */ | |
| 166 | readonly placement?: Placement | undefined; | |
| 167 | /** | |
| 168 | * Whether a cloud body can take a burst (`spawn/placement.ts`). Without | |
| 169 | * one every environment is placed on owned metal and a burst queues. | |
| 170 | * @default CLOUD_BODY_CONFIGURED (none yet) | |
| 171 | */ | |
| 172 | readonly cloudBody?: boolean | undefined; | |
| 173 | /** | |
| 174 | * How many environments owned metal holds, read at each placement. | |
| 175 | * @default unbounded — no limit is known, so owned is never full | |
| 176 | */ | |
| 177 | readonly ownedCapacity?: (() => number) | undefined; | |
| 167 | 178 | /** How often storage is sampled for the meter. @default one hour */ |
| 168 | 179 | readonly storageEveryMs?: number | undefined; |
| 169 | 180 | /** |
| @@ -2169,16 +2180,43 @@ export class AppsRuntime { | ||
| 2169 | 2180 | } |
| 2170 | 2181 | } |
| 2171 | 2182 | |
| 2172 | /** The environment began serving: its awake interval opens, if not open. */ | |
| 2183 | /** | |
| 2184 | * The environment began serving: its awake interval opens, if not open, | |
| 2185 | * placed by the policy (`core/placement_policy.ts`) and with that decision | |
| 2186 | * kept in the ledger, so cost knows which price applies and why. | |
| 2187 | */ | |
| 2173 | 2188 | private awake(state: Environment, at: string): void { |
| 2174 | this.metered(() => | |
| 2175 | openAwake(this.app.db, { | |
| 2189 | this.metered(() => { | |
| 2190 | const db = this.app.db; | |
| 2191 | const service = state.live?.manifest.service ?? null; | |
| 2192 | const seen = { | |
| 2193 | ownedUsed: this.ownedUsed(state), | |
| 2194 | ownedCapacity: this.options.ownedCapacity?.() ?? Infinity, | |
| 2195 | spillAtOwnedUse: policyAt(getEconomics(db).position).spillAtOwnedUse, | |
| 2196 | alwaysOn: state.env === "production" || service?.alwaysOn === true, | |
| 2197 | preference: service?.placement ?? null, | |
| 2198 | cloudBody: this.options.cloudBody ?? CLOUD_BODY_CONFIGURED, | |
| 2199 | }; | |
| 2200 | const placed = placeFor(seen); | |
| 2201 | const opened = openAwake(db, { repoId: state.app.repoId, env: state.env, placement: placed.where, at }); | |
| 2202 | if (!opened) return; | |
| 2203 | recordPlacement(db, { | |
| 2176 | 2204 | repoId: state.app.repoId, |
| 2177 | 2205 | env: state.env, |
| 2178 | placement: this.options.placement ?? "owned", | |
| 2179 | at, | |
| 2180 | }), | |
| 2181 | ); | |
| 2206 | where: placed.where, | |
| 2207 | reason: placed.reason, | |
| 2208 | // JSON has no Infinity: an unbounded capacity is kept as null. | |
| 2209 | seen: { ...seen, ownedCapacity: Number.isFinite(seen.ownedCapacity) ? seen.ownedCapacity : null }, | |
| 2210 | target: bodyTarget(placed.where, getOwnedNodeLabel(db)), | |
| 2211 | }); | |
| 2212 | }); | |
| 2213 | } | |
| 2214 | ||
| 2215 | /** Environments up on this forge now, `except` aside: what owned metal holds. */ | |
| 2216 | private ownedUsed(except: Environment): number { | |
| 2217 | let n = 0; | |
| 2218 | for (const other of this.envs.values()) if (other !== except && other.live !== undefined) n++; | |
| 2219 | return n; | |
| 2182 | 2220 | } |
| 2183 | 2221 | |
| 2184 | 2222 | /** The environment stopped serving: its awake interval closes. */ |
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) 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) 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 +3 −2
| @@ -3,13 +3,14 @@ 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 and/or the margin on crew spend; a field left out keeps its value, each change is kept (a person admin's token, admin scope)", | |
| 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)", | |
| 7 | 7 | auth: true, |
| 8 | body: { position: "whole number 0-100", marginPct: "whole number 0-500" }, | |
| 8 | body: { position: "whole number 0-100", marginPct: "whole number 0-500", ownedNodeLabel: "node label key=value, e.g. plat.sh/site=vxrail" }, | |
| 9 | 9 | refusals: { |
| 10 | 10 | not_found: "the token is not a person who administers the forge (404)", |
| 11 | 11 | not_admin: "the person's role may not administer the forge (403)", |
| 12 | 12 | bad_position: "position is not a whole number from 0 to 100 (422)", |
| 13 | 13 | bad_margin: "marginPct is not a whole number from 0 to 500 (422)", |
| 14 | bad_label: "ownedNodeLabel is not a node label as key=value (422)", | |
| 14 | 15 | }, |
| 15 | 16 | }; |
src/core/decisions.ts +39 −4
| @@ -20,6 +20,12 @@ import { id as newId } from "../kernel/id.ts"; | ||
| 20 | 20 | * |
| 21 | 21 | * `hand` is a person's tap on "Let the decider" on a hand-over card |
| 22 | 22 | * (`crew/handover.ts`): its state is the curated context the decider saw. |
| 23 | * | |
| 24 | * `place` is where an environment's awake interval runs, owned metal or | |
| 25 | * cloud (`core/placement_policy.ts`), recorded by the runtime as the meter | |
| 26 | * interval opens (`recordPlacement`): its answer is the placement, its state | |
| 27 | * the inputs, the reason and the body target. Like a guard it is counted, | |
| 28 | * not listed among the crew's decisions. | |
| 23 | 29 | */ |
| 24 | 30 | export type DecisionKind = |
| 25 | 31 | | "next" |
| @@ -27,7 +33,8 @@ export type DecisionKind = | ||
| 27 | 33 | | "retry" |
| 28 | 34 | | "lean" |
| 29 | 35 | | "guard" |
| 30 | | "hand"; | |
| 36 | | "hand" | |
| 37 | | "place"; | |
| 31 | 38 | |
| 32 | 39 | export interface Decision { |
| 33 | 40 | readonly id: string; |
| @@ -119,6 +126,33 @@ export function recordDecision( | ||
| 119 | 126 | return getDecision(db, id)!; |
| 120 | 127 | } |
| 121 | 128 | |
| 129 | /** | |
| 130 | * The placement policy's answer for one environment, by the rule: the | |
| 131 | * inputs it saw, its reason and where the body goes, so cost can tell later | |
| 132 | * which price applied and a person can tell why. | |
| 133 | */ | |
| 134 | export function recordPlacement( | |
| 135 | db: Database, | |
| 136 | input: { | |
| 137 | readonly repoId: string; | |
| 138 | readonly env: string; | |
| 139 | readonly where: "owned" | "cloud"; | |
| 140 | readonly reason: string; | |
| 141 | readonly seen: unknown; | |
| 142 | readonly target: unknown; | |
| 143 | }, | |
| 144 | ): Decision { | |
| 145 | return recordDecision(db, { | |
| 146 | kind: "place", | |
| 147 | repoId: input.repoId, | |
| 148 | state: { env: input.env, seen: input.seen, reason: input.reason, target: input.target }, | |
| 149 | answer: input.where, | |
| 150 | p: 1, | |
| 151 | by: "rule", | |
| 152 | ms: 0, | |
| 153 | }); | |
| 154 | } | |
| 155 | ||
| 122 | 156 | /** A person's like or dislike of one pull among its variant siblings; the newest replaces their earlier one. */ |
| 123 | 157 | export function recordVariantVote( |
| 124 | 158 | db: Database, |
| @@ -351,11 +385,12 @@ export function listDecisionsForRepos( | ||
| 351 | 385 | if (unique.length === 0) return []; |
| 352 | 386 | return db |
| 353 | 387 | .query<Row, (string | number)[]>( |
| 354 | // A guard's skips and caps are counted, not shown one by one: they | |
| 355 | // would crowd out the decisions a person reads (`crew/guard.ts`). | |
| 388 | // A guard's skips and caps, and where each environment was placed, | |
| 389 | // are counted, not shown one by one: they would crowd out the | |
| 390 | // decisions a person reads (`crew/guard.ts`). | |
| 356 | 391 | `SELECT * FROM decision |
| 357 | 392 | WHERE repo_id IN (${unique.map((_, n) => `?${n + 1}`).join(",")}) |
| 358 | AND kind != 'guard' | |
| 393 | AND kind NOT IN ('guard', 'place') | |
| 359 | 394 | ORDER BY created_at DESC, id DESC LIMIT ?${unique.length + 1}`, |
| 360 | 395 | ) |
| 361 | 396 | .all(...unique, Math.min(500, Math.max(1, limit))) |
src/core/meter.ts +4 −4
| @@ -46,17 +46,17 @@ function instant(at: string): number { | ||
| 46 | 46 | /** |
| 47 | 47 | * An environment began serving. Idempotent: while an interval is open for |
| 48 | 48 | * the environment (a redeploy swapping one process for the next) nothing |
| 49 | * new is opened — the app never stopped being awake. | |
| 49 | * new is opened — the app never stopped being awake. True when one opened. | |
| 50 | 50 | */ |
| 51 | 51 | export function openAwake( |
| 52 | 52 | db: Database, |
| 53 | 53 | input: { repoId: string; env: string; placement: Placement; at: string }, |
| 54 | ): void { | |
| 54 | ): boolean { | |
| 55 | 55 | const at = iso(instant(input.at)); |
| 56 | db.query( | |
| 56 | return db.query( | |
| 57 | 57 | `INSERT OR IGNORE INTO meter (repo_id, env, placement, started_at, beat_at) |
| 58 | 58 | VALUES (?1, ?2, ?3, ?4, ?4)`, |
| 59 | ).run(input.repoId, input.env, input.placement, at); | |
| 59 | ).run(input.repoId, input.env, input.placement, at).changes > 0; | |
| 60 | 60 | } |
| 61 | 61 | |
| 62 | 62 | /** An environment stopped serving: close its open interval, if any. */ |
src/core/placement_policy.ts added +113
| @@ -0,0 +1,113 @@ | ||
| 1 | /** | |
| 2 | * The placement policy (#509, docs/PROFIT.md): where an environment should | |
| 3 | * run — on owned metal or in a cloud — from how full owned metal is and the | |
| 4 | * profit slider's spill threshold (`policyAt(position).spillAtOwnedUse`). | |
| 5 | * Owned metal is paid for either way, so it runs full; cloud takes bursts. | |
| 6 | * | |
| 7 | * - With no cloud body configured every answer is `owned`, and the reason | |
| 8 | * says a burst queues — never silently. | |
| 9 | * - Production and an `always_on` app stay owned until owned is full. | |
| 10 | * - A preview spills first: once owned use reaches the threshold. | |
| 11 | * - An app's `placement: metal|cloud` preference is honoured, unless it | |
| 12 | * asks for metal and owned is full. | |
| 13 | * | |
| 14 | * Plain data in, plain data out: nothing here reads the database or acts. | |
| 15 | * Where the body actually runs is the spawn seam's (`spawn/placement.ts`). | |
| 16 | */ | |
| 17 | ||
| 18 | import type { Placement } from "./meter.ts"; | |
| 19 | ||
| 20 | export type { Placement }; | |
| 21 | ||
| 22 | /** What an app's manifest asks for (`app.placement`, `apps/service.ts`). */ | |
| 23 | export type PlacementPreference = "metal" | "cloud" | null; | |
| 24 | ||
| 25 | export interface PlacementInput { | |
| 26 | /** Environments owned metal holds now, this one not counted. */ | |
| 27 | readonly ownedUsed: number; | |
| 28 | /** Environments owned metal can hold; `Infinity` when no limit is known. */ | |
| 29 | readonly ownedCapacity: number; | |
| 30 | /** Share of owned capacity (0–1) at or above which a preview spills. */ | |
| 31 | readonly spillAtOwnedUse: number; | |
| 32 | /** Production, or an `always_on` app: stays owned until owned is full. */ | |
| 33 | readonly alwaysOn: boolean; | |
| 34 | readonly preference: PlacementPreference; | |
| 35 | /** Whether a cloud body is configured to take a burst. */ | |
| 36 | readonly cloudBody: boolean; | |
| 37 | } | |
| 38 | ||
| 39 | export interface PlacementDecision { | |
| 40 | readonly where: Placement; | |
| 41 | /** One sentence a person reads on the ledger and the board. */ | |
| 42 | readonly reason: string; | |
| 43 | } | |
| 44 | ||
| 45 | /** The words the board shows when there is nowhere to spill to. */ | |
| 46 | export const NO_CLOUD_BODY = "no cloud body: bursts queue"; | |
| 47 | ||
| 48 | function percent(share: number): string { | |
| 49 | return `${Math.round(share * 1000) / 10}%`; | |
| 50 | } | |
| 51 | ||
| 52 | export function placeFor(input: PlacementInput): PlacementDecision { | |
| 53 | const capacity = Number.isFinite(input.ownedCapacity) ? Math.max(0, input.ownedCapacity) : Infinity; | |
| 54 | const used = Math.max(0, input.ownedUsed); | |
| 55 | const full = used >= capacity; | |
| 56 | const share = capacity === Infinity ? 0 : capacity === 0 ? 1 : used / capacity; | |
| 57 | const use = capacity === Infinity ? "owned metal has room" : `owned metal is at ${percent(share)}`; | |
| 58 | ||
| 59 | if (!input.cloudBody) { | |
| 60 | return { | |
| 61 | where: "owned", | |
| 62 | reason: full ? `Owned metal is full and there is ${NO_CLOUD_BODY}.` : `Runs on owned metal; ${NO_CLOUD_BODY}.`, | |
| 63 | }; | |
| 64 | } | |
| 65 | if (input.preference === "cloud") return { where: "cloud", reason: "The app asks for cloud." }; | |
| 66 | if (full) return { where: "cloud", reason: "Owned metal is full, so this spills to cloud." }; | |
| 67 | if (input.preference === "metal") return { where: "owned", reason: `The app asks for metal and ${use}.` }; | |
| 68 | if (input.alwaysOn) return { where: "owned", reason: `Production stays on owned metal until it is full; ${use}.` }; | |
| 69 | if (share >= input.spillAtOwnedUse) { | |
| 70 | return { | |
| 71 | where: "cloud", | |
| 72 | reason: `A preview spills first: ${use}, at or above the ${percent(input.spillAtOwnedUse)} threshold.`, | |
| 73 | }; | |
| 74 | } | |
| 75 | return { where: "owned", reason: `${use[0]!.toUpperCase()}${use.slice(1)}, below the ${percent(input.spillAtOwnedUse)} threshold.` }; | |
| 76 | } | |
| 77 | ||
| 78 | /** The owned-node label a forge boots with until an admin sets another. */ | |
| 79 | export const OWNED_NODE_LABEL_DEFAULT = "plat.sh/site=vxrail"; | |
| 80 | ||
| 81 | const LABEL_NAME = /^[A-Za-z0-9](?:[-A-Za-z0-9_.]{0,61}[A-Za-z0-9])?$/; | |
| 82 | const LABEL_PREFIX = /^(?=.{1,253}$)[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?)*$/; | |
| 83 | ||
| 84 | /** | |
| 85 | * A Kubernetes node label as `key=value` (`plat.sh/site=vxrail`), trimmed; | |
| 86 | * undefined for anything else. The key may carry a DNS prefix; the value may | |
| 87 | * be empty, as a label's may. | |
| 88 | */ | |
| 89 | export function parseNodeLabel(input: unknown): string | undefined { | |
| 90 | if (typeof input !== "string") return undefined; | |
| 91 | const label = input.trim(); | |
| 92 | if (label.length > 400) return undefined; | |
| 93 | const eq = label.indexOf("="); | |
| 94 | if (eq < 0) return undefined; | |
| 95 | const key = label.slice(0, eq); | |
| 96 | const value = label.slice(eq + 1); | |
| 97 | const slash = key.indexOf("/"); | |
| 98 | const prefix = slash < 0 ? undefined : key.slice(0, slash); | |
| 99 | const name = slash < 0 ? key : key.slice(slash + 1); | |
| 100 | if (prefix !== undefined && !LABEL_PREFIX.test(prefix)) return undefined; | |
| 101 | if (!LABEL_NAME.test(name)) return undefined; | |
| 102 | if (value !== "" && !LABEL_NAME.test(value)) return undefined; | |
| 103 | return label; | |
| 104 | } | |
| 105 | ||
| 106 | /** | |
| 107 | * What a person typed for the owned-node label, at any door: blank is the | |
| 108 | * default again, a label is itself, anything else is undefined (refused). | |
| 109 | */ | |
| 110 | export function labelInput(input: unknown): string | undefined { | |
| 111 | if (typeof input === "string" && input.trim() === "") return OWNED_NODE_LABEL_DEFAULT; | |
| 112 | return parseNodeLabel(input); | |
| 113 | } |
src/http/api.ts +2 −1
| @@ -2420,7 +2420,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2420 | 2420 | kind === "next" || |
| 2421 | 2421 | kind === "land" || |
| 2422 | 2422 | kind === "retry" || |
| 2423 | kind === "guard" | |
| 2423 | kind === "guard" || | |
| 2424 | kind === "place" | |
| 2424 | 2425 | ? kind |
| 2425 | 2426 | : undefined, |
| 2426 | 2427 | limit, |
src/http/api/economics.ts +2
| @@ -19,6 +19,7 @@ export function wire(e: EconomicsView) { | ||
| 19 | 19 | marginPct: e.marginPct, |
| 20 | 20 | policy: e.policy, |
| 21 | 21 | words: e.words, |
| 22 | placement: e.placement, | |
| 22 | 23 | changes: e.changes, |
| 23 | 24 | }; |
| 24 | 25 | } |
| @@ -39,6 +40,7 @@ export function economicsRoutes(router: Router<Handler>): void { | ||
| 39 | 40 | const changed = changeEconomics(ctx.app, ctx.auth.user, { |
| 40 | 41 | position: input.position, |
| 41 | 42 | marginPct: input.marginPct, |
| 43 | ownedNodeLabel: input.ownedNodeLabel, | |
| 42 | 44 | }); |
| 43 | 45 | if (changed.ok) return json(wire(changed.value)); |
| 44 | 46 | if (changed.error === "not_found") return notFound(); |
src/http/mcp/tools/economics.ts +14 −10
| @@ -1,54 +1,58 @@ | ||
| 1 | import { ECONOMICS_WORDS, changeEconomics, readEconomics } from "../../../actions/economics.ts"; | |
| 1 | import { ECONOMICS_WORDS, changeEconomics, readEconomics, type EconomicsError } from "../../../actions/economics.ts"; | |
| 2 | 2 | import { parseMarginPct, parsePosition } from "../../../core/slider.ts"; |
| 3 | import { labelInput } from "../../../core/placement_policy.ts"; | |
| 3 | 4 | import { wire } from "../../api/economics.ts"; |
| 4 | 5 | import { scopeAllows } from "../../../core/tokens.ts"; |
| 5 | 6 | import { good, bad, type Tool } from "../shared.ts"; |
| 6 | 7 | |
| 7 | 8 | export const tool: Tool = { |
| 8 | 9 | name: "economics", |
| 9 | doc: "`{ position?, marginPct?, dryRun? }`: with nothing, the profit slider's `position` (0 to 100), `marginPct` (0 to 500), the `policy` and its `words`, as `GET /api/v1/forge/economics` answers; with either number, 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`", | |
| 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`", | |
| 10 | 11 | description: "Read the profit slider and its policy, or set it (admin).", |
| 11 | 12 | inputSchema: { |
| 12 | 13 | type: "object", |
| 13 | 14 | properties: { |
| 14 | 15 | position: { type: "integer" }, |
| 15 | 16 | marginPct: { type: "integer" }, |
| 17 | ownedNodeLabel: { type: "string" }, | |
| 16 | 18 | dryRun: { type: "boolean" }, |
| 17 | 19 | }, |
| 18 | 20 | }, |
| 19 | 21 | writes: true, |
| 20 | 22 | dryByDefault: true, |
| 21 | readsWithout: ["position", "marginPct"], | |
| 23 | readsWithout: ["position", "marginPct", "ownedNodeLabel"], | |
| 22 | 24 | run(ctx, user, args) { |
| 23 | const { position, marginPct } = args; | |
| 24 | const refuse = (error: "not_found" | "bad_position" | "bad_margin") => | |
| 25 | const { position, marginPct, ownedNodeLabel } = args; | |
| 26 | const refuse = (error: EconomicsError) => | |
| 25 | 27 | error === "not_found" ? bad("not_found", "Not found.") : bad("invalid", ECONOMICS_WORDS[error]); |
| 26 | 28 | const read = readEconomics(ctx.app, user); |
| 27 | 29 | if (!read.ok) return refuse(read.error); |
| 28 | if (position === undefined && marginPct === undefined) return good(wire(read.value)); | |
| 30 | if (position === undefined && marginPct === undefined && ownedNodeLabel === undefined) return good(wire(read.value)); | |
| 29 | 31 | if (args["dryRun"] !== false) { |
| 30 | 32 | const before = read.value; |
| 31 | const invalid = checkOnly(position, marginPct); | |
| 33 | const invalid = checkOnly(position, marginPct, ownedNodeLabel); | |
| 32 | 34 | if (invalid) return refuse(invalid); |
| 33 | 35 | const to = { |
| 34 | 36 | position: position === undefined ? before.position : position, |
| 35 | 37 | marginPct: marginPct === undefined ? before.marginPct : marginPct, |
| 38 | ownedNodeLabel: ownedNodeLabel === undefined ? before.placement.ownedNodeLabel : labelInput(ownedNodeLabel), | |
| 36 | 39 | }; |
| 37 | 40 | return good({ |
| 38 | from: { position: before.position, marginPct: before.marginPct }, | |
| 41 | from: { position: before.position, marginPct: before.marginPct, ownedNodeLabel: before.placement.ownedNodeLabel }, | |
| 39 | 42 | to, |
| 40 | 43 | says: "This is the plan; nothing changed. Send dryRun: false to set it.", |
| 41 | 44 | }); |
| 42 | 45 | } |
| 43 | 46 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) return bad("forbidden", "setting the slider needs an admin token"); |
| 44 | const changed = changeEconomics(ctx.app, user, { position, marginPct }); | |
| 47 | const changed = changeEconomics(ctx.app, user, { position, marginPct, ownedNodeLabel }); | |
| 45 | 48 | return changed.ok ? good(wire(changed.value)) : refuse(changed.error); |
| 46 | 49 | }, |
| 47 | 50 | }; |
| 48 | 51 | |
| 49 | 52 | /** The write's own validation, without the write. */ |
| 50 | function checkOnly(position: unknown, marginPct: unknown): "bad_position" | "bad_margin" | undefined { | |
| 53 | function checkOnly(position: unknown, marginPct: unknown, label: unknown): "bad_position" | "bad_margin" | "bad_label" | undefined { | |
| 51 | 54 | if (position !== undefined && parsePosition(position) === undefined) return "bad_position"; |
| 52 | 55 | if (marginPct !== undefined && parseMarginPct(marginPct) === undefined) return "bad_margin"; |
| 56 | if (label !== undefined && labelInput(label) === undefined) return "bad_label"; | |
| 53 | 57 | return undefined; |
| 54 | 58 | } |
src/settings/economics.ts +39 −7
| @@ -17,6 +17,7 @@ import { | ||
| 17 | 17 | POSITION_MAX, |
| 18 | 18 | POSITION_MIN, |
| 19 | 19 | } from "../core/slider.ts"; |
| 20 | import { OWNED_NODE_LABEL_DEFAULT, parseNodeLabel } from "../core/placement_policy.ts"; | |
| 20 | 21 | import { writeSetting } from "./limits.ts"; |
| 21 | 22 | |
| 22 | 23 | export interface Economics { |
| @@ -60,29 +61,60 @@ export function setEconomics(db: Database, next: Economics, actorId: string): vo | ||
| 60 | 61 | } |
| 61 | 62 | } |
| 62 | 63 | |
| 64 | const LABEL_KEY = "placement.owned_node_label"; | |
| 65 | ||
| 66 | /** | |
| 67 | * The label that selects owned nodes (#509): placement passes it to the body | |
| 68 | * that runs on owned metal (`spawn/placement.ts`). A setting, not a | |
| 69 | * constant; a stored label that does not parse reads as the default. | |
| 70 | */ | |
| 71 | export function getOwnedNodeLabel(db: Database): string { | |
| 72 | const row = db | |
| 73 | .query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1") | |
| 74 | .get(LABEL_KEY); | |
| 75 | return (row === null ? undefined : parseNodeLabel(row.value)) ?? OWNED_NODE_LABEL_DEFAULT; | |
| 76 | } | |
| 77 | ||
| 78 | /** | |
| 79 | * Write the label if it differs, its change kept. The caller has parsed it | |
| 80 | * (`labelInput`). The default is stored as no row, so a forge set back to it | |
| 81 | * follows the default if that ever moves. | |
| 82 | */ | |
| 83 | export function setOwnedNodeLabel(db: Database, label: string, actorId: string): void { | |
| 84 | const before = getOwnedNodeLabel(db); | |
| 85 | if (label === before) return; | |
| 86 | writeSetting(db, LABEL_KEY, label === OWNED_NODE_LABEL_DEFAULT ? null : label, before, label, actorId); | |
| 87 | } | |
| 88 | ||
| 63 | 89 | export interface EconomicsChange { |
| 64 | readonly key: EconomicsKey; | |
| 90 | readonly key: EconomicsKey | "ownedNodeLabel"; | |
| 65 | 91 | readonly from: string; |
| 66 | 92 | readonly to: string; |
| 67 | 93 | readonly actorName: string; |
| 68 | 94 | readonly at: string; |
| 69 | 95 | } |
| 70 | 96 | |
| 71 | /** The most recent changes to either number, newest first. */ | |
| 97 | const KEY_OF: Record<string, EconomicsChange["key"]> = { | |
| 98 | [STORE.position.key]: "position", | |
| 99 | [STORE.marginPct.key]: "marginPct", | |
| 100 | [LABEL_KEY]: "ownedNodeLabel", | |
| 101 | }; | |
| 102 | ||
| 103 | /** The most recent changes to either number or the owned-node label, newest first. */ | |
| 72 | 104 | export function economicsChanges(db: Database, limit = 20): EconomicsChange[] { |
| 73 | 105 | const rows = db |
| 74 | 106 | .query< |
| 75 | 107 | { key: string; from_value: string; to_value: string; actor_name: string | null; at: string }, |
| 76 | [string, string, number] | |
| 108 | [string, string, string, number] | |
| 77 | 109 | >( |
| 78 | 110 | `SELECT c.key, c.from_value, c.to_value, u.username AS actor_name, c.at |
| 79 | 111 | FROM forge_setting_change c LEFT JOIN user u ON u.id = c.actor_id |
| 80 | WHERE c.key IN (?1, ?2) | |
| 81 | ORDER BY c.at DESC, c.rowid DESC LIMIT ?3`, | |
| 112 | WHERE c.key IN (?1, ?2, ?3) | |
| 113 | ORDER BY c.at DESC, c.rowid DESC LIMIT ?4`, | |
| 82 | 114 | ) |
| 83 | .all(STORE.position.key, STORE.marginPct.key, limit); | |
| 115 | .all(STORE.position.key, STORE.marginPct.key, LABEL_KEY, limit); | |
| 84 | 116 | return rows.map((r) => ({ |
| 85 | key: r.key === STORE.position.key ? "position" : "marginPct", | |
| 117 | key: KEY_OF[r.key] ?? "marginPct", | |
| 86 | 118 | from: r.from_value, |
| 87 | 119 | to: r.to_value, |
| 88 | 120 | actorName: r.actor_name ?? "", |
src/spawn/child-apps.ts +2 −1
| @@ -51,7 +51,8 @@ export async function startChildApps(parent: App, child: App, name: string): Pro | ||
| 51 | 51 | sleepAfterMs: own.options.sleepAfterMs, |
| 52 | 52 | sweepEveryMs: own.options.sweepEveryMs, |
| 53 | 53 | dns: own.options.dns, |
| 54 | placement: own.options.placement, | |
| 54 | cloudBody: own.options.cloudBody, | |
| 55 | ownedCapacity: own.options.ownedCapacity, | |
| 55 | 56 | }); |
| 56 | 57 | // Claimed before the first await: a second request cannot start it twice. |
| 57 | 58 | const parentChecks = checksFor(parent); |
src/spawn/placement.ts +21
| @@ -12,6 +12,27 @@ | ||
| 12 | 12 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 13 | 13 | import type { SpawnPlan } from "./plan.ts"; |
| 14 | 14 | |
| 15 | /** | |
| 16 | * Whether a body that runs on a cloud is configured behind this seam. None | |
| 17 | * is yet (spike #339), so the placement policy (`core/placement_policy.ts`) | |
| 18 | * answers `owned` every time and a burst queues — the board says so. | |
| 19 | */ | |
| 20 | export const CLOUD_BODY_CONFIGURED = false; | |
| 21 | ||
| 22 | /** | |
| 23 | * What the policy's choice hands the body that carries it out: owned metal | |
| 24 | * names the node label that selects owned nodes (a setting, | |
| 25 | * `getOwnedNodeLabel`, `plat.sh/site=vxrail` by default), which a body that | |
| 26 | * schedules onto nodes uses as its selector; cloud names nothing more. | |
| 27 | */ | |
| 28 | export type BodyTarget = | |
| 29 | | { readonly where: "owned"; readonly nodeLabel: string } | |
| 30 | | { readonly where: "cloud" }; | |
| 31 | ||
| 32 | export function bodyTarget(where: "owned" | "cloud", ownedNodeLabel: string): BodyTarget { | |
| 33 | return where === "owned" ? { where, nodeLabel: ownedNodeLabel } : { where }; | |
| 34 | } | |
| 35 | ||
| 15 | 36 | /** Where a child runs: inside the parent's process, or as a process in its own cell. */ |
| 16 | 37 | export type PlacementKind = "hosted" | "cell"; |
| 17 | 38 |
src/tui/screens/economics.ts +33 −11
| @@ -1,14 +1,16 @@ | ||
| 1 | 1 | /** |
| 2 | * The profit slider in the terminal, a forge admin's: the position and the | |
| 3 | * margin on crew spend, with the policy the position means as the head line, | |
| 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, | |
| 4 | 5 | * as /settings/economics and /api/v1/forge/economics have them. `e` asks for |
| 5 | * a whole number, shows the plan, and changes it only after a confirm, | |
| 6 | * the value, shows the plan, and changes it only after a confirm, | |
| 6 | 7 | * through `changeEconomics`. The gate is the action's: an admin who is not |
| 7 | 8 | * an agent; anyone else sees nothing and changes nothing. |
| 8 | 9 | */ |
| 9 | 10 | |
| 10 | 11 | import { ECONOMICS_WORDS, changeEconomics, readEconomics, type EconomicsView } from "../../actions/economics.ts"; |
| 11 | 12 | import { MARGIN_MAX, POSITION_MAX, POSITION_MIN, parseMarginPct, parsePosition } from "../../core/slider.ts"; |
| 13 | import { OWNED_NODE_LABEL_DEFAULT, labelInput } from "../../core/placement_policy.ts"; | |
| 12 | 14 | import type { Ctx, Screen } from "../app/app.ts"; |
| 13 | 15 | import { binding } from "../app/keys.ts"; |
| 14 | 16 | import { style } from "../term/style.ts"; |
| @@ -19,9 +21,12 @@ import { ListState, confirmScreen, drawList, promptScreen, type ListRow } from " | ||
| 19 | 21 | import type { PaletteEntry } from "./entries.ts"; |
| 20 | 22 | |
| 21 | 23 | interface Lever { |
| 22 | readonly key: "position" | "marginPct"; | |
| 23 | readonly parse: (raw: string) => number | undefined; | |
| 24 | readonly key: "position" | "marginPct" | "ownedNodeLabel"; | |
| 25 | readonly parse: (raw: string) => number | string | undefined; | |
| 26 | readonly now: (e: EconomicsView) => number | string; | |
| 24 | 27 | readonly words: string; |
| 28 | /** What the prompt asks for. */ | |
| 29 | readonly asks: string; | |
| 25 | 30 | readonly unit: string; |
| 26 | 31 | readonly range: string; |
| 27 | 32 | } |
| @@ -30,17 +35,30 @@ const LEVERS: readonly Lever[] = [ | ||
| 30 | 35 | { |
| 31 | 36 | key: "position", |
| 32 | 37 | parse: parsePosition, |
| 38 | now: (e) => e.position, | |
| 33 | 39 | words: ECONOMICS_WORDS.bad_position, |
| 40 | asks: `a whole number, ${POSITION_MIN}–${POSITION_MAX}`, | |
| 34 | 41 | unit: "0 cheapest, 100 most responsive", |
| 35 | 42 | range: `${POSITION_MIN}–${POSITION_MAX}`, |
| 36 | 43 | }, |
| 37 | 44 | { |
| 38 | 45 | key: "marginPct", |
| 39 | 46 | parse: parseMarginPct, |
| 47 | now: (e) => e.marginPct, | |
| 40 | 48 | words: ECONOMICS_WORDS.bad_margin, |
| 49 | asks: `a whole number, 0–${MARGIN_MAX}`, | |
| 41 | 50 | unit: "% on crew spend", |
| 42 | 51 | range: `0–${MARGIN_MAX}`, |
| 43 | 52 | }, |
| 53 | { | |
| 54 | key: "ownedNodeLabel", | |
| 55 | parse: labelInput, | |
| 56 | now: (e) => e.placement.ownedNodeLabel, | |
| 57 | words: ECONOMICS_WORDS.bad_label, | |
| 58 | asks: "a node label, key=value", | |
| 59 | unit: "selects owned metal", | |
| 60 | range: `blank: ${OWNED_NODE_LABEL_DEFAULT}`, | |
| 61 | }, | |
| 44 | 62 | ]; |
| 45 | 63 | |
| 46 | 64 | export function economicsScreen(f: Session, ctx: Ctx): Screen { |
| @@ -48,7 +66,7 @@ export function economicsScreen(f: Session, ctx: Ctx): Screen { | ||
| 48 | 66 | let note = ""; |
| 49 | 67 | const read = () => readEconomics(f.app, f.user); |
| 50 | 68 | |
| 51 | const apply = (lever: Lever, to: number): void => { | |
| 69 | const apply = (lever: Lever, to: number | string): void => { | |
| 52 | 70 | const made = changeEconomics(f.app, f.user, { [lever.key]: to }); |
| 53 | 71 | note = made.ok ? `${lever.key} is ${to}: ${made.value.words}.` : "You cannot change the economics."; |
| 54 | 72 | ctx.invalidate(); |
| @@ -58,8 +76,8 @@ export function economicsScreen(f: Session, ctx: Ctx): Screen { | ||
| 58 | 76 | ctx.push( |
| 59 | 77 | promptScreen(ctx, screen, { |
| 60 | 78 | title: lever.key, |
| 61 | label: `a whole number, ${lever.range}`, | |
| 62 | value: String(now[lever.key]), | |
| 79 | label: lever.asks, | |
| 80 | value: String(lever.now(now)), | |
| 63 | 81 | hint: lever.unit, |
| 64 | 82 | onSubmit: (raw) => { |
| 65 | 83 | const to = lever.parse(raw); |
| @@ -68,7 +86,7 @@ export function economicsScreen(f: Session, ctx: Ctx): Screen { | ||
| 68 | 86 | ctx.push( |
| 69 | 87 | confirmScreen(ctx, screen, { |
| 70 | 88 | title: "Change the economics", |
| 71 | body: [`${lever.key}: ${now[lever.key]} → ${to}`], | |
| 89 | body: [`${lever.key}: ${lever.now(now)} → ${to}`], | |
| 72 | 90 | actions: [{ label: "Keep", variant: "ghost" }, { label: "Change" }], |
| 73 | 91 | onChoose: (i) => { |
| 74 | 92 | if (i === 1) apply(lever, to); |
| @@ -82,17 +100,21 @@ export function economicsScreen(f: Session, ctx: Ctx): Screen { | ||
| 82 | 100 | }; |
| 83 | 101 | |
| 84 | 102 | const rows = (e: EconomicsView): ListRow[] => |
| 85 | LEVERS.map((l) => ({ title: l.key, secondary: `${e[l.key]} · ${l.unit}`, meta: l.range })); | |
| 103 | LEVERS.map((l) => ({ title: l.key, secondary: `${l.now(e)} · ${l.unit}`, meta: l.range })); | |
| 86 | 104 | |
| 87 | 105 | const screen: Screen = { |
| 88 | 106 | title: "Economics", |
| 89 | 107 | keys: [binding(["j", "k"], "move cursor"), binding(["e"], "change")], |
| 90 | 108 | render(area, s) { |
| 91 | 109 | const c = ctx.theme.c; |
| 92 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 110 | const [head, where, list] = split(area, "v", [len(1), len(1), fill(1)]); | |
| 93 | 111 | const r = read(); |
| 94 | 112 | const text = note !== "" ? note : r.ok ? `At ${r.value.position}: ${r.value.words}.` : "The economics are set by a forge admin."; |
| 95 | 113 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 114 | if (r.ok) { | |
| 115 | const placed = `Placement: ${r.value.placement.words}.`; | |
| 116 | s.text(where!.x, where!.y, truncate(placed, where!.w), style(c.mutedFg, c.bg), where!.x + where!.w); | |
| 117 | } | |
| 96 | 118 | drawList(s, list!, c, state, r.ok ? rows(r.value) : []); |
| 97 | 119 | }, |
| 98 | 120 | key(e) { |
src/web/pages/board-notes.ts +6 −4
| @@ -44,12 +44,12 @@ function summaryLine(notes: readonly Note[]): Html { | ||
| 44 | 44 | |
| 45 | 45 | /** |
| 46 | 46 | * The live region for the notes. `fine` is the admin's quiet "All fine" |
| 47 | * when nothing is noted; anyone else gets an empty region, ready for the | |
| 48 | * first note to swap in. | |
| 47 | * when nothing is noted, followed by `aside` when there is one; anyone | |
| 48 | * else gets an empty region, ready for the first note to swap in. | |
| 49 | 49 | */ |
| 50 | 50 | export function notesRegion( |
| 51 | 51 | notes: readonly Note[], |
| 52 | o: { fine: boolean; live: string; ok: boolean | null }, | |
| 52 | o: { fine: boolean; live: string; ok: boolean | null; aside?: Html }, | |
| 53 | 53 | ): Html { |
| 54 | 54 | const attrs = html`id="notes" data-live="${o.live}"${ |
| 55 | 55 | o.ok === null ? "" : html` data-ok="${o.ok ? "yes" : "no"}"` |
| @@ -58,7 +58,9 @@ export function notesRegion( | ||
| 58 | 58 | html`<div ${attrs} role="status">${inner}</div>`; |
| 59 | 59 | if (notes.length === 0) |
| 60 | 60 | return wrap( |
| 61 | o.fine ? html`<span class="small muted">${dot("success")} All fine</span>` : null, | |
| 61 | o.fine | |
| 62 | ? html`<span class="small muted">${dot("success")} All fine${o.aside === undefined ? "" : html` · ${o.aside}`}</span>` | |
| 63 | : null, | |
| 62 | 64 | ); |
| 63 | 65 | return wrap( |
| 64 | 66 | disclosure( |
src/web/pages/board.ts +21 −5
| @@ -17,6 +17,8 @@ | ||
| 17 | 17 | */ |
| 18 | 18 | |
| 19 | 19 | import { usd } from "../../core/money.ts"; |
| 20 | import { NO_CLOUD_BODY } from "../../core/placement_policy.ts"; | |
| 21 | import { CLOUD_BODY_CONFIGURED } from "../../spawn/placement.ts"; | |
| 20 | 22 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 21 | 23 | import { sentinelNotice } from "../../crew/sentinel.ts"; |
| 22 | 24 | import { appsForRepos, type AppRecord } from "../../core/apps.ts"; |
| @@ -151,6 +153,7 @@ const KIND_WORDS: Record<DecisionKind, string> = { | ||
| 151 | 153 | hand: "hand-over", |
| 152 | 154 | // Never listed (`listDecisionsForRepos` leaves guards out); named for the type. |
| 153 | 155 | guard: "not started", |
| 156 | place: "where it runs", | |
| 154 | 157 | }; |
| 155 | 158 | |
| 156 | 159 | const money = (n: number): string => `$${n.toFixed(2)}`; |
| @@ -400,9 +403,10 @@ export function bigRedirect(ctx: Ctx): Response { | ||
| 400 | 403 | |
| 401 | 404 | /** |
| 402 | 405 | * The forge's health as notes, for a site admin only: each thing that needs |
| 403 | * them, and the lane when checks are queued. Nobody else pays for the reads. | |
| 406 | * them, the lane when checks are queued, and that bursts queue while no | |
| 407 | * cloud body is configured. Nobody else pays for the reads. | |
| 404 | 408 | */ |
| 405 | function forgeNotes(ctx: Ctx): { notes: Note[]; ok: boolean } | null { | |
| 409 | function forgeNotes(ctx: Ctx): { notes: Note[]; ok: boolean; placement: Html | null } | null { | |
| 406 | 410 | if (!ctx.auth.user?.isAdmin) return null; |
| 407 | 411 | const v = forgeVitals(ctx.app); |
| 408 | 412 | const fh = forgeHealth(); |
| @@ -434,7 +438,12 @@ function forgeNotes(ctx: Ctx): { notes: Note[]; ok: boolean } | null { | ||
| 434 | 438 | ...(lane === null ? [] : [{ key: "lane", need: false, line: html`<span>Lane: ${lane}</span>` }]), |
| 435 | 439 | ...(flaky === null ? [] : [{ key: "flakes", need: false, line: html`<span data-flakes>Flaky this week: ${flaky}</span>` }]), |
| 436 | 440 | ]; |
| 437 | return { notes, ok }; | |
| 441 | // Where a burst waits (#509): with no cloud body it queues for owned metal, | |
| 442 | // said on the board, never silently — beside "All fine", or among the notes. | |
| 443 | const placement = (CLOUD_BODY_CONFIGURED as boolean) | |
| 444 | ? null | |
| 445 | : html`<a data-placement href="/settings/economics">Placement: ${NO_CLOUD_BODY}</a>`; | |
| 446 | return { notes, ok, placement }; | |
| 438 | 447 | } |
| 439 | 448 | |
| 440 | 449 | // ---- agent pulls that wait for a person's review ---- |
| @@ -561,9 +570,16 @@ function boardNotes( | ||
| 561 | 570 | declined: readonly Note[], |
| 562 | 571 | review: readonly Note[], |
| 563 | 572 | ): Html { |
| 564 | const notes = distinct([...(forge?.notes ?? []), ...declined, ...review]); | |
| 573 | const others = distinct([...(forge?.notes ?? []), ...declined, ...review]); | |
| 574 | const placement = forge?.placement ?? null; | |
| 575 | const fine = forge !== null && forge.ok; | |
| 576 | // Beside "All fine" when nothing else is noted; one more note when something is. | |
| 577 | const aside = fine && others.length === 0 ? placement : null; | |
| 578 | const notes = | |
| 579 | placement === null || aside !== null ? others : [...others, { key: "placement", need: false, line: placement }]; | |
| 565 | 580 | return notesRegion(notes, { |
| 566 | fine: forge !== null && forge.ok, | |
| 581 | fine, | |
| 582 | ...(aside === null ? {} : { aside }), | |
| 567 | 583 | live: "run.queued,run.started,run.finished,issue.*,pull.*,app.*", |
| 568 | 584 | ok: forge === null ? null : forge.ok, |
| 569 | 585 | }); |
src/web/pages/economics.ts +23 −2
| @@ -2,13 +2,17 @@ | ||
| 2 | 2 | * Settings → Economics, for a forge admin who is a person: the profit |
| 3 | 3 | * slider's position (0 cheapest, 100 most responsive) with the policy it |
| 4 | 4 | * means in words beside it, and the margin on crew spend (docs/PROFIT.md). |
| 5 | * Server-rendered; the form is a same-origin POST (`readForm`). Nothing acts | |
| 6 | * on the numbers yet; 50 is today's behaviour. | |
| 5 | * Beside them, where work runs: owned metal first, selected by the owned-node | |
| 6 | * label set here, and whether a cloud body takes bursts — without one the | |
| 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. | |
| 7 | 10 | * Anyone else gets the not-found page. |
| 8 | 11 | */ |
| 9 | 12 | |
| 10 | 13 | import { ECONOMICS_WORDS, changeEconomics, readEconomics } from "../../actions/economics.ts"; |
| 11 | 14 | import { MARGIN_MAX, POSITION_MAX, POSITION_MIN } from "../../core/slider.ts"; |
| 15 | import { OWNED_NODE_LABEL_DEFAULT } from "../../core/placement_policy.ts"; | |
| 12 | 16 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 13 | 17 | import type { Ctx } from "../../http/context.ts"; |
| 14 | 18 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; |
| @@ -64,9 +68,24 @@ export function economicsPage(ctx: Ctx): Response { | ||
| 64 | 68 | step="1" |
| 65 | 69 | value="${String(e.marginPct)}" |
| 66 | 70 | /></label> |
| 71 | <label class="small" | |
| 72 | >Owned-node label | |
| 73 | <input | |
| 74 | class="input mono" | |
| 75 | type="text" | |
| 76 | name="ownedNodeLabel" | |
| 77 | maxlength="400" | |
| 78 | spellcheck="false" | |
| 79 | autocomplete="off" | |
| 80 | placeholder="${OWNED_NODE_LABEL_DEFAULT}" | |
| 81 | aria-describedby="owned-label-hint" | |
| 82 | value="${e.placement.ownedNodeLabel}" | |
| 83 | /></label> | |
| 67 | 84 | ${btn("Save", { size: "sm", variant: "primary", label: "Save economics" })} |
outdated
Principle 1: this paragraph restates the policy sentence just above it, and the label is already in the field. Say the placement once, e.g. only e.placement.words. Also, a blank field means keep, so there is no way to return to the default label. Offer a reset or treat blank as the default. |
||
| 68 | 85 | </form> |
| 86 | <p class="muted small" id="owned-label-hint">The owned-node label selects owned metal; blank is the default, ${OWNED_NODE_LABEL_DEFAULT}.</p> | |
| 69 | 87 | <p data-policy>${e.words}.</p> |
| 88 | <p data-placement>${e.placement.words}.</p> | |
| 70 | 89 | </section> |
| 71 | 90 | <section class="settings-block"> |
| 72 | 91 | <h2>Changes</h2> |
| @@ -95,6 +114,8 @@ export async function economicsSet(ctx: Ctx): Promise<Response> { | ||
| 95 | 114 | const changed = changeEconomics(ctx.app, ctx.auth.user, { |
| 96 | 115 | position: field(form, "position"), |
| 97 | 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"), | |
| 98 | 119 | }); |
| 99 | 120 | if (!changed.ok && changed.error === "not_found") return notFoundPage(ctx); |
| 100 | 121 | return flashTo( |
test/board.test.ts +18
| @@ -690,6 +690,24 @@ describe("the board's forge strip", () => { | ||
| 690 | 690 | app.db.query("UPDATE run SET state = 'cancelled' WHERE state IN ('queued','running')").run(); |
| 691 | 691 | }); |
| 692 | 692 | |
| 693 | test("the board says a burst queues while no cloud body is configured, to an admin only (#509)", async () => { | |
| 694 | app.db.query("UPDATE run SET state = 'cancelled' WHERE state IN ('queued','running')").run(); | |
| 695 | const line = '<a data-placement href="/settings/economics">Placement: no cloud body: bursts queue</a>'; | |
| 696 | // Nothing else noted: beside All fine. | |
| 697 | const idle = await board("root"); | |
| 698 | expect(idle).toContain(`All fine · ${line}`); | |
| 699 | // Something noted: one note among them, and All fine is gone. | |
| 700 | const made = await createRepo(app, admin, { name: "bursty" }); | |
| 701 | if (!made.ok) throw new Error(String(made.error)); | |
| 702 | createRun(app.db, { repoId: made.value.id, kind: "check" }); | |
| 703 | const busy = await board("root"); | |
| 704 | expect(busy).toContain("Lane: 1 check queued"); | |
| 705 | expect(busy).toContain(`<li>${line}</li>`); | |
| 706 | expect(busy).not.toContain("All fine"); | |
| 707 | expect(await board("bob")).not.toContain("bursts queue"); | |
| 708 | app.db.query("UPDATE run SET state = 'cancelled' WHERE state IN ('queued','running')").run(); | |
| 709 | }); | |
| 710 | ||
| 693 | 711 | test("a setting that is not the default is no incident: the strip stays fine and the cards render", async () => { |
| 694 | 712 | const made = await createRepo(app, admin, { name: "calm" }); |
| 695 | 713 | if (!made.ok) throw new Error(String(made.error)); |
test/economics.test.ts +43 −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 } from "../src/settings/economics.ts"; | |
| 18 | import { economicsChanges, getEconomics, getOwnedNodeLabel } from "../src/settings/economics.ts"; | |
| 19 | 19 | |
| 20 | 20 | describe("economics", () => { |
| 21 | 21 | let dataDir: string; |
| @@ -175,6 +175,48 @@ describe("economics", () => { | ||
| 175 | 175 | expect(getEconomics(app.db).position).toBe(0); |
| 176 | 176 | }); |
| 177 | 177 | |
| 178 | test("the owned-node label is a setting: shown with the queue line, set by API and form, validated (#509)", async () => { | |
| 179 | const token = createToken(app.db, alice.id, "t", "admin").token; | |
| 180 | const got = (await (await api(token, "GET")).json()) as { placement: Record<string, unknown> }; | |
| 181 | expect(got.placement).toEqual({ | |
| 182 | cloudBody: false, | |
| 183 | ownedNodeLabel: "plat.sh/site=vxrail", | |
| 184 | words: "no cloud body: bursts queue", | |
| 185 | }); | |
| 186 | for (const bad of ["vxrail", "a b=c", "=x", 7, "plat.sh/site=has space"]) { | |
| 187 | const res = await api(token, "PUT", { ownedNodeLabel: bad }); | |
| 188 | expect(res.status).toBe(422); | |
| 189 | expect(((await res.json()) as { error: string }).error).toBe("bad_label"); | |
| 190 | } | |
| 191 | expect(getOwnedNodeLabel(app.db)).toBe("plat.sh/site=vxrail"); | |
| 192 | const set = await api(token, "PUT", { ownedNodeLabel: "plat.sh/site=metal-2" }); | |
| 193 | expect(set.status).toBe(200); | |
| 194 | expect(((await set.json()) as { placement: { ownedNodeLabel: string } }).placement.ownedNodeLabel).toBe("plat.sh/site=metal-2"); | |
| 195 | ||
| 196 | const cookie = await login("alice"); | |
| 197 | const before = getEconomics(app.db); | |
| 198 | await post(cookie, { position: String(before.position), marginPct: String(before.marginPct), ownedNodeLabel: "site=home" }); | |
| 199 | expect(getOwnedNodeLabel(app.db)).toBe("site=home"); | |
| 200 | // A form without the field keeps the label that stands. | |
| 201 | await post(cookie, { position: String(before.position), marginPct: String(before.marginPct) }); | |
| 202 | expect(getOwnedNodeLabel(app.db)).toBe("site=home"); | |
| 203 | const page = await (await fetch(`${servers.url}/settings/economics`, { headers: { cookie } })).text(); | |
| 204 | // The placement is said once, and the label is the field's value. | |
| 205 | expect(page).toContain("<p data-placement>no cloud body: bursts queue.</p>"); | |
| 206 | expect(page).toContain('value="site=home"'); | |
| 207 | // Left blank, the field is the default again: no row stands for it. | |
| 208 | await post(cookie, { position: String(before.position), marginPct: String(before.marginPct), ownedNodeLabel: " " }); | |
| 209 | expect(getOwnedNodeLabel(app.db)).toBe("plat.sh/site=vxrail"); | |
| 210 | expect(app.db.query("SELECT 1 FROM forge_setting WHERE key = 'placement.owned_node_label'").get()).toBeNull(); | |
| 211 | expect(economicsChanges(app.db).filter((c) => c.key === "ownedNodeLabel").map((c) => c.to)).toEqual([ | |
| 212 | "plat.sh/site=vxrail", | |
| 213 | "site=home", | |
| 214 | "plat.sh/site=metal-2", | |
| 215 | ]); | |
| 216 | app.db.query("UPDATE forge_setting SET value = 'not a label' WHERE key = 'placement.owned_node_label'").run(); | |
| 217 | expect(getOwnedNodeLabel(app.db)).toBe("plat.sh/site=vxrail"); | |
| 218 | }); | |
| 219 | ||
| 178 | 220 | test("a hand-edited stored value reads as the default, never as itself", () => { |
| 179 | 221 | app.db.query("UPDATE forge_setting SET value = '250' WHERE key = 'economics.position'").run(); |
| 180 | 222 | expect(getEconomics(app.db).position).toBe(50); |
test/mcp-economics.test.ts +22 −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 } from "../src/settings/economics.ts"; | |
| 8 | import { getEconomics, getOwnedNodeLabel } 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,27 @@ describe("MCP economics", () => { | ||
| 94 | 94 | expect(JSON.parse((await call(admin, {})).text).position).toBe(70); |
| 95 | 95 | }); |
| 96 | 96 | |
| 97 | test("the owned-node label is planned, refused when not key=value, set, and reset by a blank (#509)", async () => { | |
| 98 | const read = JSON.parse((await call(admin, {})).text); | |
| 99 | expect(read.placement).toEqual({ cloudBody: false, ownedNodeLabel: "plat.sh/site=vxrail", words: "no cloud body: bursts queue" }); | |
| 100 | const plan = await call(admin, { ownedNodeLabel: "site=home" }); | |
| 101 | expect(plan.error).toBe(false); | |
| 102 | expect(JSON.parse(plan.text).to.ownedNodeLabel).toBe("site=home"); | |
| 103 | expect(getOwnedNodeLabel(app.db)).toBe("plat.sh/site=vxrail"); | |
| 104 | const badPlan = await call(admin, { ownedNodeLabel: "vxrail" }); | |
| 105 | expect(badPlan.error).toBe(true); | |
| 106 | expect(badPlan.text).toContain("key=value"); | |
| 107 | const badSet = await call(admin, { ownedNodeLabel: "a b=c", dryRun: false }); | |
| 108 | expect(badSet.error).toBe(true); | |
| 109 | expect(getOwnedNodeLabel(app.db)).toBe("plat.sh/site=vxrail"); | |
| 110 | const set = await call(admin, { ownedNodeLabel: "site=home", dryRun: false }); | |
| 111 | expect(set.error).toBe(false); | |
| 112 | expect(JSON.parse(set.text).placement.ownedNodeLabel).toBe("site=home"); | |
| 113 | expect(getOwnedNodeLabel(app.db)).toBe("site=home"); | |
| 114 | await call(admin, { ownedNodeLabel: "", dryRun: false }); | |
| 115 | expect(getOwnedNodeLabel(app.db)).toBe("plat.sh/site=vxrail"); | |
| 116 | }); | |
| 117 | ||
| 97 | 118 | test("a read-only token cannot set it", async () => { |
| 98 | 119 | const r = await call(adminRo, { position: 10, dryRun: false }); |
| 99 | 120 | expect(r.error).toBe(true); |
test/meter.test.ts +17
| @@ -21,6 +21,7 @@ import { | ||
| 21 | 21 | usageBetween, |
| 22 | 22 | } from "../src/core/meter.ts"; |
| 23 | 23 | import { getRepoById } from "../src/core/repos.ts"; |
| 24 | import { listDecisions, recordDecision } from "../src/core/decisions.ts"; | |
| 24 | 25 | import { listRuns } from "../src/core/runs.ts"; |
| 25 | 26 | import { createUser } from "../src/core/users.ts"; |
| 26 | 27 | import { repoDir } from "../src/git/repos.ts"; |
| @@ -220,6 +221,22 @@ describe("the runtime meters what it runs", () => { | ||
| 220 | 221 | let ledger = rows(app, record.repoId); |
| 221 | 222 | expect(ledger.length).toBe(1); |
| 222 | 223 | expect(ledger[0]!.placement).toBe("owned"); |
| 224 | // The policy placed it (#509): one `place` decision per opened interval. | |
| 225 | const placed = listDecisions(app.db, record.repoId, { kind: "place" }); | |
| 226 | expect(placed.length).toBe(1); | |
| 227 | expect(placed[0]!.answer).toBe("owned"); | |
| 228 | expect(placed[0]!.state).toMatchObject({ | |
| 229 | env: "production", | |
| 230 | reason: "Runs on owned metal; no cloud body: bursts queue.", | |
| 231 | target: { where: "owned", nodeLabel: "plat.sh/site=vxrail" }, | |
| 232 | seen: { alwaysOn: true, cloudBody: false, ownedCapacity: null, spillAtOwnedUse: 0.85 }, | |
| 233 | }); | |
| 234 | // The decisions API filters to them with `kind=place`, a crew decision left out. | |
| 235 | recordDecision(app.db, { kind: "next", repoId: record.repoId, state: {}, answer: "x", p: 1, by: "rule", ms: 0 }); | |
| 236 | const res = await fetch(`${servers.url}/api/v1/repos/alice/metered/decisions?kind=place`); | |
| 237 | expect(res.status).toBe(200); | |
| 238 | const listed = (await res.json()) as { decisions: { kind: string; answer: string }[] }; | |
| 239 | expect(listed.decisions.map((d) => [d.kind, d.answer])).toEqual([["place", "owned"]]); | |
| 223 | 240 | expect(ledger[0]!.started_at).toBe(run.servingAt!); |
| 224 | 241 | expect(ledger[0]!.ended_at).toBe(asleep.since!); |
| 225 | 242 |
test/placement-policy.test.ts added +80
| @@ -0,0 +1,80 @@ | ||
| 1 | /** | |
| 2 | * The placement policy (#509): owned metal first, a preview spills to cloud | |
| 3 | * at the slider's threshold, production only when owned is full, an app's | |
| 4 | * preference honoured unless it asks for full metal, and with no cloud body | |
| 5 | * every answer is owned — with the reason saying a burst queues. | |
| 6 | */ | |
| 7 | import { describe, expect, test } from "bun:test"; | |
| 8 | import { NO_CLOUD_BODY, parseNodeLabel, placeFor, type PlacementInput } from "../src/core/placement_policy.ts"; | |
| 9 | import { bodyTarget } from "../src/spawn/placement.ts"; | |
| 10 | ||
| 11 | const at = (over: Partial<PlacementInput>): PlacementInput => ({ | |
| 12 | ownedUsed: 5, | |
| 13 | ownedCapacity: 10, | |
| 14 | spillAtOwnedUse: 0.85, | |
| 15 | alwaysOn: false, | |
| 16 | preference: null, | |
| 17 | cloudBody: true, | |
| 18 | ...over, | |
| 19 | }); | |
| 20 | ||
| 21 | describe("placeFor", () => { | |
| 22 | test("below the threshold a preview stays owned", () => { | |
| 23 | const d = placeFor(at({ ownedUsed: 8 })); | |
| 24 | expect(d.where).toBe("owned"); | |
| 25 | expect(d.reason).toBe("Owned metal is at 80%, below the 85% threshold."); | |
| 26 | }); | |
| 27 | ||
| 28 | test("at or above the threshold a preview spills first; production stays owned", () => { | |
| 29 | const preview = placeFor(at({ ownedUsed: 9 })); | |
| 30 | expect(preview.where).toBe("cloud"); | |
| 31 | expect(preview.reason).toContain("A preview spills first"); | |
| 32 | expect(placeFor(at({ ownedUsed: 9, alwaysOn: true })).where).toBe("owned"); | |
| 33 | }); | |
| 34 | ||
| 35 | test("full owned spills production too", () => { | |
| 36 | expect(placeFor(at({ ownedUsed: 10, alwaysOn: true })).where).toBe("cloud"); | |
| 37 | expect(placeFor(at({ ownedUsed: 0, ownedCapacity: 0, alwaysOn: true })).where).toBe("cloud"); | |
| 38 | }); | |
| 39 | ||
| 40 | test("at 100% (slider 0) nothing spills until owned is full", () => { | |
| 41 | expect(placeFor(at({ ownedUsed: 9, spillAtOwnedUse: 1 })).where).toBe("owned"); | |
| 42 | expect(placeFor(at({ ownedUsed: 10, spillAtOwnedUse: 1 })).where).toBe("cloud"); | |
| 43 | }); | |
| 44 | ||
| 45 | test("a preference is honoured unless metal is asked for and owned is full", () => { | |
| 46 | expect(placeFor(at({ ownedUsed: 0, preference: "cloud" })).where).toBe("cloud"); | |
| 47 | expect(placeFor(at({ ownedUsed: 9, preference: "metal" })).where).toBe("owned"); | |
| 48 | expect(placeFor(at({ ownedUsed: 10, preference: "metal" })).where).toBe("cloud"); | |
| 49 | }); | |
| 50 | ||
| 51 | test("an unbounded owned capacity is never full", () => { | |
| 52 | const d = placeFor(at({ ownedUsed: 1_000, ownedCapacity: Infinity })); | |
| 53 | expect(d.where).toBe("owned"); | |
| 54 | expect(d.reason).toBe("Owned metal has room, below the 85% threshold."); | |
| 55 | }); | |
| 56 | ||
| 57 | test("with no cloud body every decision is owned, and says a burst queues", () => { | |
| 58 | for (const over of [{ ownedUsed: 9 }, { ownedUsed: 10 }, { preference: "cloud" as const }, { ownedCapacity: 0 }]) { | |
| 59 | const d = placeFor(at({ ...over, cloudBody: false })); | |
| 60 | expect(d.where).toBe("owned"); | |
| 61 | expect(d.reason).toContain(NO_CLOUD_BODY); | |
| 62 | } | |
| 63 | expect(placeFor(at({ ownedUsed: 10, cloudBody: false })).reason).toBe( | |
| 64 | "Owned metal is full and there is no cloud body: bursts queue.", | |
| 65 | ); | |
| 66 | }); | |
| 67 | }); | |
| 68 | ||
| 69 | describe("the owned-node label", () => { | |
| 70 | test("parses key=value node labels and refuses anything else", () => { | |
| 71 | for (const ok of ["plat.sh/site=vxrail", "site=home", "zone=", " a=b "]) expect(parseNodeLabel(ok)).toBe(ok.trim()); | |
| 72 | for (const no of ["vxrail", "=x", "a b=c", "Plat.SH/site=x", "a=b c", "a=-x", 3, null, `a=${"x".repeat(64)}`]) | |
| 73 | expect(parseNodeLabel(no)).toBeUndefined(); | |
| 74 | }); | |
| 75 | ||
| 76 | test("is passed to the body for owned, and not for cloud", () => { | |
| 77 | expect(bodyTarget("owned", "site=home")).toEqual({ where: "owned", nodeLabel: "site=home" }); | |
| 78 | expect(bodyTarget("cloud", "site=home")).toEqual({ where: "cloud" }); | |
| 79 | }); | |
| 80 | }); |
test/tui-economics.test.ts +32 −1
| @@ -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 } from "../src/settings/economics.ts"; | |
| 15 | import { getEconomics, getOwnedNodeLabel } 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"; |
| @@ -135,6 +135,37 @@ describe("economics in the terminal", () => { | ||
| 135 | 135 | h.stop(); |
| 136 | 136 | }); |
| 137 | 137 | |
| 138 | test("the owned-node label is a lever too: placement said under the head, set after a confirm, blank is the default (#509)", async () => { | |
| 139 | const h = boot("alice"); | |
| 140 | await open(h); | |
| 141 | expect(h.text()).toContain("Placement: no cloud body: bursts queue."); | |
| 142 | expect(h.text()).toContain("plat.sh/site=vxrail · selects owned metal"); | |
| 143 | const clearAll = async () => { | |
| 144 | for (let i = 0; i < 40; i++) await h.press("backspace"); | |
| 145 | }; | |
| 146 | await h.press("j", "j", "e"); | |
| 147 | await clearAll(); | |
| 148 | await h.type("vxrail"); | |
| 149 | await h.press("enter"); | |
| 150 | expect(h.text()).toContain("ownedNodeLabel is a node label as key=value"); // the dialog cuts the rest | |
| 151 | await h.press("esc"); | |
| 152 | await h.press("e"); | |
| 153 | await clearAll(); | |
| 154 | await h.type("site=home"); | |
| 155 | await h.press("enter"); | |
| 156 | expect(h.text()).toContain("ownedNodeLabel: plat.sh/site=vxrail → site=home"); | |
| 157 | expect(getOwnedNodeLabel(forge.db)).toBe("plat.sh/site=vxrail"); | |
| 158 | await h.press("right", "enter"); | |
| 159 | expect(getOwnedNodeLabel(forge.db)).toBe("site=home"); | |
| 160 | await h.press("e"); | |
| 161 | await clearAll(); | |
| 162 | await h.press("enter"); | |
| 163 | expect(h.text()).toContain("ownedNodeLabel: site=home → plat.sh/site=vxrail"); | |
| 164 | await h.press("right", "enter"); | |
| 165 | expect(getOwnedNodeLabel(forge.db)).toBe("plat.sh/site=vxrail"); | |
| 166 | h.stop(); | |
| 167 | }); | |
| 168 | ||
| 138 | 169 | test("a non-admin and an admin agent have no command, see nothing and change nothing", async () => { |
| 139 | 170 | for (const who of ["bob", "carol"]) { |
| 140 | 171 | const h = boot(who); |
This doc comment belongs to recordVariantVote, but recordPlacement now sits between them. Move recordPlacement above the comment.