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

Merged opened this

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

Merged

mason/forge:mason/509-placement-policy-owned-capacity-first-bu → main 7 commits fork 2 threads · 2 unresolved

  • Merged. mason/509-placement-policy-owned-capacity-first-bu was merged into main as 53f065a.

Review

Sign in to review this change.
24 files changed +663 −71
docs/features/economics.md +37
@@ -35,3 +35,40 @@ before.
3535 Anyone else is told `not_found`, as the API does.
3636
3737 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 {
1818 policyWords,
1919 type Policy,
2020 } from "../core/slider.ts";
21 import { NO_CLOUD_BODY, labelInput } from "../core/placement_policy.ts";
22 import { CLOUD_BODY_CONFIGURED } from "../spawn/placement.ts";
2123 import {
2224 economicsChanges,
2325 getEconomics,
26 getOwnedNodeLabel,
2427 setEconomics,
28 setOwnedNodeLabel,
2529 type Economics,
2630 type EconomicsChange,
2731 } from "../settings/economics.ts";
2832
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
2942 export interface EconomicsView extends Economics {
3043 readonly policy: Policy;
3144 readonly words: string;
45 readonly placement: PlacementView;
3246 readonly changes: EconomicsChange[];
3347 }
3448
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
3560 function admin(actor: User | undefined): Result<User, "not_found"> {
3661 return actor?.isAdmin === true && actor.kind !== "agent" ? ok(actor) : err("not_found");
3762 }
@@ -39,7 +64,13 @@ function admin(actor: User | undefined): Result<User, "not_found"> {
3964 function view(app: App): EconomicsView {
4065 const now = getEconomics(app.db);
4166 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 };
4374 }
4475
4576 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
4778 return who.ok ? ok(view(app)) : who;
4879 }
4980
50 export type EconomicsError = "not_found" | "bad_position" | "bad_margin";
81 export type EconomicsError = "not_found" | "bad_position" | "bad_margin" | "bad_label";
5182
5283 export const ECONOMICS_WORDS: Record<Exclude<EconomicsError, "not_found">, string> = {
5384 bad_position: "position is a whole number from 0 to 100",
5485 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",
5587 };
5688
5789 /**
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.
6093 */
6194 export function changeEconomics(
6295 app: App,
6396 actor: User | undefined,
64 input: { position?: unknown; marginPct?: unknown },
97 input: { position?: unknown; marginPct?: unknown; ownedNodeLabel?: unknown },
6598 ): Result<EconomicsView, EconomicsError> {
6699 const who = admin(actor);
67100 if (!who.ok) return who;
@@ -70,6 +103,10 @@ export function changeEconomics(
70103 if (position === undefined) return err("bad_position");
71104 const marginPct = input.marginPct === undefined ? before.marginPct : parseMarginPct(input.marginPct);
72105 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");
73109 setEconomics(app.db, { position, marginPct }, who.value.id);
110 if (label !== undefined) setOwnedNodeLabel(app.db, label, who.value.id);
74111 return ok(view(app));
75112 }
src/apps/runtime.ts +49 −11
@@ -41,6 +41,9 @@ import { pullMayRun } from "../core/pull_trust.ts";
4141 import { guestWaits } from "../origins/guest.ts";
4242 import { canEnterApp, getRepoById, type Repo } from "../core/repos.ts";
4343 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";
4447 import type { User } from "../core/users.ts";
4548 import {
4649 cancelRun,
@@ -73,7 +76,6 @@ import {
7376 openAwake,
7477 recordStorage,
7578 SAMPLE_SPAN_MS,
76 type Placement,
7779 } from "../core/meter.ts";
7880 import { dirBytes } from "./bytes.ts";
7981 import { BUSY_WORDS, busyReason, type BusyReason } from "./busy.ts";
@@ -102,7 +104,7 @@ import { identityKey } from "./proxy.ts";
102104 import { noteReached } from "./egress_report.ts";
103105 import { egressSetting } from "../core/capacity.ts";
104106 import { policyAt } from "../core/slider.ts";
105 import { getEconomics } from "../settings/economics.ts";
107 import { getEconomics, getOwnedNodeLabel } from "../settings/economics.ts";
106108 import { asleepWords, sleepWindow, type WindowSource } from "./sleep_window.ts";
107109 import { noteProtection } from "../kernel/protection.ts";
108110 import { cellsAvailable, resolveLimits, type CellSpec } from "./contain.ts";
@@ -162,8 +164,17 @@ export interface RuntimeOptions {
162164 * its own, so proving `app.example.com` needs no network in a test.
163165 */
164166 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;
167178 /** How often storage is sampled for the meter. @default one hour */
168179 readonly storageEveryMs?: number | undefined;
169180 /**
@@ -2169,16 +2180,43 @@ export class AppsRuntime {
21692180 }
21702181 }
21712182
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 */
21732188 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, {
21762204 repoId: state.app.repoId,
21772205 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;
21822220 }
21832221
21842222 /** 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";
33 export const route: Route = {
44 method: "get",
55 path: "/api/v1/forge/economics",
6 summary: "The profit slider: position (0 cheapest to 100 most responsive), marginPct, the policy the position means (sleep windows in ms, idle-pull window, cloud spill share; null is never) 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)",
77 auth: true,
88 refusals: {
99 not_found: "the token is not a person who administers the forge (404)",
src/contract/openapi/routes/putApiV1ForgeEconomics.ts +3 −2
@@ -3,13 +3,14 @@ import type { Route } from "../shared.ts";
33 export const route: Route = {
44 method: "put",
55 path: "/api/v1/forge/economics",
6 summary: "Set the profit slider's position 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)",
77 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" },
99 refusals: {
1010 not_found: "the token is not a person who administers the forge (404)",
1111 not_admin: "the person's role may not administer the forge (403)",
1212 bad_position: "position is not a whole number from 0 to 100 (422)",
1313 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)",
1415 },
1516 };
src/core/decisions.ts +39 −4
@@ -20,6 +20,12 @@ import { id as newId } from "../kernel/id.ts";
2020 *
2121 * `hand` is a person's tap on "Let the decider" on a hand-over card
2222 * (`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.
2329 */
2430 export type DecisionKind =
2531 | "next"
@@ -27,7 +33,8 @@ export type DecisionKind =
2733 | "retry"
2834 | "lean"
2935 | "guard"
30 | "hand";
36 | "hand"
37 | "place";
3138
3239 export interface Decision {
3340 readonly id: string;
@@ -119,6 +126,33 @@ export function recordDecision(
119126 return getDecision(db, id)!;
120127 }
121128
129 /**
outdated

This doc comment belongs to recordVariantVote, but recordPlacement now sits between them. Move recordPlacement above the comment.

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
122156 /** A person's like or dislike of one pull among its variant siblings; the newest replaces their earlier one. */
123157 export function recordVariantVote(
124158 db: Database,
@@ -351,11 +385,12 @@ export function listDecisionsForRepos(
351385 if (unique.length === 0) return [];
352386 return db
353387 .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`).
356391 `SELECT * FROM decision
357392 WHERE repo_id IN (${unique.map((_, n) => `?${n + 1}`).join(",")})
358 AND kind != 'guard'
393 AND kind NOT IN ('guard', 'place')
359394 ORDER BY created_at DESC, id DESC LIMIT ?${unique.length + 1}`,
360395 )
361396 .all(...unique, Math.min(500, Math.max(1, limit)))
src/core/meter.ts +4 −4
@@ -46,17 +46,17 @@ function instant(at: string): number {
4646 /**
4747 * An environment began serving. Idempotent: while an interval is open for
4848 * 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.
5050 */
5151 export function openAwake(
5252 db: Database,
5353 input: { repoId: string; env: string; placement: Placement; at: string },
54 ): void {
54 ): boolean {
5555 const at = iso(instant(input.at));
56 db.query(
56 return db.query(
5757 `INSERT OR IGNORE INTO meter (repo_id, env, placement, started_at, beat_at)
5858 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;
6060 }
6161
6262 /** 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 {
24202420 kind === "next" ||
24212421 kind === "land" ||
24222422 kind === "retry" ||
2423 kind === "guard"
2423 kind === "guard" ||
2424 kind === "place"
24242425 ? kind
24252426 : undefined,
24262427 limit,
src/http/api/economics.ts +2
@@ -19,6 +19,7 @@ export function wire(e: EconomicsView) {
1919 marginPct: e.marginPct,
2020 policy: e.policy,
2121 words: e.words,
22 placement: e.placement,
2223 changes: e.changes,
2324 };
2425 }
@@ -39,6 +40,7 @@ export function economicsRoutes(router: Router<Handler>): void {
3940 const changed = changeEconomics(ctx.app, ctx.auth.user, {
4041 position: input.position,
4142 marginPct: input.marginPct,
43 ownedNodeLabel: input.ownedNodeLabel,
4244 });
4345 if (changed.ok) return json(wire(changed.value));
4446 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";
22 import { parseMarginPct, parsePosition } from "../../../core/slider.ts";
3 import { labelInput } from "../../../core/placement_policy.ts";
34 import { wire } from "../../api/economics.ts";
45 import { scopeAllows } from "../../../core/tokens.ts";
56 import { good, bad, type Tool } from "../shared.ts";
67
78 export const tool: Tool = {
89 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`",
1011 description: "Read the profit slider and its policy, or set it (admin).",
1112 inputSchema: {
1213 type: "object",
1314 properties: {
1415 position: { type: "integer" },
1516 marginPct: { type: "integer" },
17 ownedNodeLabel: { type: "string" },
1618 dryRun: { type: "boolean" },
1719 },
1820 },
1921 writes: true,
2022 dryByDefault: true,
21 readsWithout: ["position", "marginPct"],
23 readsWithout: ["position", "marginPct", "ownedNodeLabel"],
2224 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) =>
2527 error === "not_found" ? bad("not_found", "Not found.") : bad("invalid", ECONOMICS_WORDS[error]);
2628 const read = readEconomics(ctx.app, user);
2729 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));
2931 if (args["dryRun"] !== false) {
3032 const before = read.value;
31 const invalid = checkOnly(position, marginPct);
33 const invalid = checkOnly(position, marginPct, ownedNodeLabel);
3234 if (invalid) return refuse(invalid);
3335 const to = {
3436 position: position === undefined ? before.position : position,
3537 marginPct: marginPct === undefined ? before.marginPct : marginPct,
38 ownedNodeLabel: ownedNodeLabel === undefined ? before.placement.ownedNodeLabel : labelInput(ownedNodeLabel),
3639 };
3740 return good({
38 from: { position: before.position, marginPct: before.marginPct },
41 from: { position: before.position, marginPct: before.marginPct, ownedNodeLabel: before.placement.ownedNodeLabel },
3942 to,
4043 says: "This is the plan; nothing changed. Send dryRun: false to set it.",
4144 });
4245 }
4346 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 });
4548 return changed.ok ? good(wire(changed.value)) : refuse(changed.error);
4649 },
4750 };
4851
4952 /** 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 {
5154 if (position !== undefined && parsePosition(position) === undefined) return "bad_position";
5255 if (marginPct !== undefined && parseMarginPct(marginPct) === undefined) return "bad_margin";
56 if (label !== undefined && labelInput(label) === undefined) return "bad_label";
5357 return undefined;
5458 }
src/settings/economics.ts +39 −7
@@ -17,6 +17,7 @@ import {
1717 POSITION_MAX,
1818 POSITION_MIN,
1919 } from "../core/slider.ts";
20 import { OWNED_NODE_LABEL_DEFAULT, parseNodeLabel } from "../core/placement_policy.ts";
2021 import { writeSetting } from "./limits.ts";
2122
2223 export interface Economics {
@@ -60,29 +61,60 @@ export function setEconomics(db: Database, next: Economics, actorId: string): vo
6061 }
6162 }
6263
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
6389 export interface EconomicsChange {
64 readonly key: EconomicsKey;
90 readonly key: EconomicsKey | "ownedNodeLabel";
6591 readonly from: string;
6692 readonly to: string;
6793 readonly actorName: string;
6894 readonly at: string;
6995 }
7096
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. */
72104 export function economicsChanges(db: Database, limit = 20): EconomicsChange[] {
73105 const rows = db
74106 .query<
75107 { key: string; from_value: string; to_value: string; actor_name: string | null; at: string },
76 [string, string, number]
108 [string, string, string, number]
77109 >(
78110 `SELECT c.key, c.from_value, c.to_value, u.username AS actor_name, c.at
79111 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`,
82114 )
83 .all(STORE.position.key, STORE.marginPct.key, limit);
115 .all(STORE.position.key, STORE.marginPct.key, LABEL_KEY, limit);
84116 return rows.map((r) => ({
85 key: r.key === STORE.position.key ? "position" : "marginPct",
117 key: KEY_OF[r.key] ?? "marginPct",
86118 from: r.from_value,
87119 to: r.to_value,
88120 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
5151 sleepAfterMs: own.options.sleepAfterMs,
5252 sweepEveryMs: own.options.sweepEveryMs,
5353 dns: own.options.dns,
54 placement: own.options.placement,
54 cloudBody: own.options.cloudBody,
55 ownedCapacity: own.options.ownedCapacity,
5556 });
5657 // Claimed before the first await: a second request cannot start it twice.
5758 const parentChecks = checksFor(parent);
src/spawn/placement.ts +21
@@ -12,6 +12,27 @@
1212 import { err, ok, type Result } from "../kernel/result.ts";
1313 import type { SpawnPlan } from "./plan.ts";
1414
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
1536 /** Where a child runs: inside the parent's process, or as a process in its own cell. */
1637 export type PlacementKind = "hosted" | "cell";
1738
src/tui/screens/economics.ts +33 −11
@@ -1,14 +1,16 @@
11 /**
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,
45 * 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,
67 * through `changeEconomics`. The gate is the action's: an admin who is not
78 * an agent; anyone else sees nothing and changes nothing.
89 */
910
1011 import { ECONOMICS_WORDS, changeEconomics, readEconomics, type EconomicsView } from "../../actions/economics.ts";
1112 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";
1214 import type { Ctx, Screen } from "../app/app.ts";
1315 import { binding } from "../app/keys.ts";
1416 import { style } from "../term/style.ts";
@@ -19,9 +21,12 @@ import { ListState, confirmScreen, drawList, promptScreen, type ListRow } from "
1921 import type { PaletteEntry } from "./entries.ts";
2022
2123 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;
2427 readonly words: string;
28 /** What the prompt asks for. */
29 readonly asks: string;
2530 readonly unit: string;
2631 readonly range: string;
2732 }
@@ -30,17 +35,30 @@ const LEVERS: readonly Lever[] = [
3035 {
3136 key: "position",
3237 parse: parsePosition,
38 now: (e) => e.position,
3339 words: ECONOMICS_WORDS.bad_position,
40 asks: `a whole number, ${POSITION_MIN}–${POSITION_MAX}`,
3441 unit: "0 cheapest, 100 most responsive",
3542 range: `${POSITION_MIN}–${POSITION_MAX}`,
3643 },
3744 {
3845 key: "marginPct",
3946 parse: parseMarginPct,
47 now: (e) => e.marginPct,
4048 words: ECONOMICS_WORDS.bad_margin,
49 asks: `a whole number, 0–${MARGIN_MAX}`,
4150 unit: "% on crew spend",
4251 range: `0–${MARGIN_MAX}`,
4352 },
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 },
4462 ];
4563
4664 export function economicsScreen(f: Session, ctx: Ctx): Screen {
@@ -48,7 +66,7 @@ export function economicsScreen(f: Session, ctx: Ctx): Screen {
4866 let note = "";
4967 const read = () => readEconomics(f.app, f.user);
5068
51 const apply = (lever: Lever, to: number): void => {
69 const apply = (lever: Lever, to: number | string): void => {
5270 const made = changeEconomics(f.app, f.user, { [lever.key]: to });
5371 note = made.ok ? `${lever.key} is ${to}: ${made.value.words}.` : "You cannot change the economics.";
5472 ctx.invalidate();
@@ -58,8 +76,8 @@ export function economicsScreen(f: Session, ctx: Ctx): Screen {
5876 ctx.push(
5977 promptScreen(ctx, screen, {
6078 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)),
6381 hint: lever.unit,
6482 onSubmit: (raw) => {
6583 const to = lever.parse(raw);
@@ -68,7 +86,7 @@ export function economicsScreen(f: Session, ctx: Ctx): Screen {
6886 ctx.push(
6987 confirmScreen(ctx, screen, {
7088 title: "Change the economics",
71 body: [`${lever.key}: ${now[lever.key]} → ${to}`],
89 body: [`${lever.key}: ${lever.now(now)} → ${to}`],
7290 actions: [{ label: "Keep", variant: "ghost" }, { label: "Change" }],
7391 onChoose: (i) => {
7492 if (i === 1) apply(lever, to);
@@ -82,17 +100,21 @@ export function economicsScreen(f: Session, ctx: Ctx): Screen {
82100 };
83101
84102 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 }));
86104
87105 const screen: Screen = {
88106 title: "Economics",
89107 keys: [binding(["j", "k"], "move cursor"), binding(["e"], "change")],
90108 render(area, s) {
91109 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)]);
93111 const r = read();
94112 const text = note !== "" ? note : r.ok ? `At ${r.value.position}: ${r.value.words}.` : "The economics are set by a forge admin.";
95113 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 }
96118 drawList(s, list!, c, state, r.ok ? rows(r.value) : []);
97119 },
98120 key(e) {
src/web/pages/board-notes.ts +6 −4
@@ -44,12 +44,12 @@ function summaryLine(notes: readonly Note[]): Html {
4444
4545 /**
4646 * 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.
4949 */
5050 export function notesRegion(
5151 notes: readonly Note[],
52 o: { fine: boolean; live: string; ok: boolean | null },
52 o: { fine: boolean; live: string; ok: boolean | null; aside?: Html },
5353 ): Html {
5454 const attrs = html`id="notes" data-live="${o.live}"${
5555 o.ok === null ? "" : html` data-ok="${o.ok ? "yes" : "no"}"`
@@ -58,7 +58,9 @@ export function notesRegion(
5858 html`<div ${attrs} role="status">${inner}</div>`;
5959 if (notes.length === 0)
6060 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,
6264 );
6365 return wrap(
6466 disclosure(
src/web/pages/board.ts +21 −5
@@ -17,6 +17,8 @@
1717 */
1818
1919 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";
2022 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
2123 import { sentinelNotice } from "../../crew/sentinel.ts";
2224 import { appsForRepos, type AppRecord } from "../../core/apps.ts";
@@ -151,6 +153,7 @@ const KIND_WORDS: Record<DecisionKind, string> = {
151153 hand: "hand-over",
152154 // Never listed (`listDecisionsForRepos` leaves guards out); named for the type.
153155 guard: "not started",
156 place: "where it runs",
154157 };
155158
156159 const money = (n: number): string => `$${n.toFixed(2)}`;
@@ -400,9 +403,10 @@ export function bigRedirect(ctx: Ctx): Response {
400403
401404 /**
402405 * 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.
404408 */
405 function forgeNotes(ctx: Ctx): { notes: Note[]; ok: boolean } | null {
409 function forgeNotes(ctx: Ctx): { notes: Note[]; ok: boolean; placement: Html | null } | null {
406410 if (!ctx.auth.user?.isAdmin) return null;
407411 const v = forgeVitals(ctx.app);
408412 const fh = forgeHealth();
@@ -434,7 +438,12 @@ function forgeNotes(ctx: Ctx): { notes: Note[]; ok: boolean } | null {
434438 ...(lane === null ? [] : [{ key: "lane", need: false, line: html`<span>Lane: ${lane}</span>` }]),
435439 ...(flaky === null ? [] : [{ key: "flakes", need: false, line: html`<span data-flakes>Flaky this week: ${flaky}</span>` }]),
436440 ];
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 };
438447 }
439448
440449 // ---- agent pulls that wait for a person's review ----
@@ -561,9 +570,16 @@ function boardNotes(
561570 declined: readonly Note[],
562571 review: readonly Note[],
563572 ): 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 }];
565580 return notesRegion(notes, {
566 fine: forge !== null && forge.ok,
581 fine,
582 ...(aside === null ? {} : { aside }),
567583 live: "run.queued,run.started,run.finished,issue.*,pull.*,app.*",
568584 ok: forge === null ? null : forge.ok,
569585 });
src/web/pages/economics.ts +23 −2
@@ -2,13 +2,17 @@
22 * Settings → Economics, for a forge admin who is a person: the profit
33 * slider's position (0 cheapest, 100 most responsive) with the policy it
44 * 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.
710 * Anyone else gets the not-found page.
811 */
912
1013 import { ECONOMICS_WORDS, changeEconomics, readEconomics } from "../../actions/economics.ts";
1114 import { MARGIN_MAX, POSITION_MAX, POSITION_MIN } from "../../core/slider.ts";
15 import { OWNED_NODE_LABEL_DEFAULT } from "../../core/placement_policy.ts";
1216 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1317 import type { Ctx } from "../../http/context.ts";
1418 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
@@ -64,9 +68,24 @@ export function economicsPage(ctx: Ctx): Response {
6468 step="1"
6569 value="${String(e.marginPct)}"
6670 /></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>
6784 ${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.

6885 </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>
6987 <p data-policy>${e.words}.</p>
88 <p data-placement>${e.placement.words}.</p>
7089 </section>
7190 <section class="settings-block">
7291 <h2>Changes</h2>
@@ -95,6 +114,8 @@ export async function economicsSet(ctx: Ctx): Promise<Response> {
95114 const changed = changeEconomics(ctx.app, ctx.auth.user, {
96115 position: field(form, "position"),
97116 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"),
98119 });
99120 if (!changed.ok && changed.error === "not_found") return notFoundPage(ctx);
100121 return flashTo(
test/board.test.ts +18
@@ -690,6 +690,24 @@ describe("the board's forge strip", () => {
690690 app.db.query("UPDATE run SET state = 'cancelled' WHERE state IN ('queued','running')").run();
691691 });
692692
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
693711 test("a setting that is not the default is no incident: the strip stays fine and the cards render", async () => {
694712 const made = await createRepo(app, admin, { name: "calm" });
695713 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";
1515 import { createToken } from "../src/core/tokens.ts";
1616 import { createUser, type User } from "../src/core/users.ts";
1717 import { serve, type Servers } from "../src/http/server.ts";
18 import { economicsChanges, getEconomics } from "../src/settings/economics.ts";
18 import { economicsChanges, getEconomics, getOwnedNodeLabel } from "../src/settings/economics.ts";
1919
2020 describe("economics", () => {
2121 let dataDir: string;
@@ -175,6 +175,48 @@ describe("economics", () => {
175175 expect(getEconomics(app.db).position).toBe(0);
176176 });
177177
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
178220 test("a hand-edited stored value reads as the default, never as itself", () => {
179221 app.db.query("UPDATE forge_setting SET value = '250' WHERE key = 'economics.position'").run();
180222 expect(getEconomics(app.db).position).toBe(50);
test/mcp-economics.test.ts +22 −1
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 import { createApp, type App } from "../src/app.ts";
77 import { createAgent } from "../src/actions/crew.ts";
8 import { getEconomics } from "../src/settings/economics.ts";
8 import { getEconomics, getOwnedNodeLabel } from "../src/settings/economics.ts";
99 import { createToken } from "../src/core/tokens.ts";
1010 import { createUser } from "../src/core/users.ts";
1111 import { serve, type Servers } from "../src/http/server.ts";
@@ -94,6 +94,27 @@ describe("MCP economics", () => {
9494 expect(JSON.parse((await call(admin, {})).text).position).toBe(70);
9595 });
9696
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
97118 test("a read-only token cannot set it", async () => {
98119 const r = await call(adminRo, { position: 10, dryRun: false });
99120 expect(r.error).toBe(true);
test/meter.test.ts +17
@@ -21,6 +21,7 @@ import {
2121 usageBetween,
2222 } from "../src/core/meter.ts";
2323 import { getRepoById } from "../src/core/repos.ts";
24 import { listDecisions, recordDecision } from "../src/core/decisions.ts";
2425 import { listRuns } from "../src/core/runs.ts";
2526 import { createUser } from "../src/core/users.ts";
2627 import { repoDir } from "../src/git/repos.ts";
@@ -220,6 +221,22 @@ describe("the runtime meters what it runs", () => {
220221 let ledger = rows(app, record.repoId);
221222 expect(ledger.length).toBe(1);
222223 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"]]);
223240 expect(ledger[0]!.started_at).toBe(run.servingAt!);
224241 expect(ledger[0]!.ended_at).toBe(asleep.since!);
225242
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";
1212 import { ECONOMICS_WORDS } from "../src/actions/economics.ts";
1313 import { createApp, type App as Forge } from "../src/app.ts";
1414 import { createUser, getUserByName } from "../src/core/users.ts";
15 import { getEconomics } from "../src/settings/economics.ts";
15 import { getEconomics, getOwnedNodeLabel } from "../src/settings/economics.ts";
1616 import { createForgeApp } from "../src/tui/forge.ts";
1717 import { economicsScreen } from "../src/tui/screens/economics.ts";
1818 import { Session, type TuiIdentity } from "../src/tui/session.ts";
@@ -135,6 +135,37 @@ describe("economics in the terminal", () => {
135135 h.stop();
136136 });
137137
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
138169 test("a non-admin and an admin agent have no command, see nothing and change nothing", async () => {
139170 for (const who of ["bob", "carol"]) {
140171 const h = boot(who);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs