@bongos/core 1.19.763 → 1.19.765
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +46 -26
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +4 -2
- package/docs/copy-inventory.md +29 -24
- package/docs/copy-registry.json +87 -41
- package/docs/module-api-changelog.md +4 -0
- package/modules/builder-settings/interaction-prefs.js +31 -10
- package/modules/hall-ui/public/panel.css +11 -0
- package/modules/hall-ui/public/settings-interaction.js +127 -0
- package/modules/hall-ui/public/settings.html +22 -0
- package/modules/hall-ui/public/settings.js +3 -0
- package/modules/provisioning/capacity.js +162 -0
- package/modules/provisioning/routes/provisioning.js +4 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision-capacity.js +74 -0
- package/scripts/gds/provision-config.js +11 -0
- package/scripts/gds/provision.js +4 -2
- package/scripts/hall-preview/live-identity.js +1 -0
- package/src/module-api.js +1 -1
- package/tests/conductor.mjs +36 -0
- package/tests/hall_settings_world.mjs +49 -0
- package/tests/interaction_prefs.mjs +47 -8
- package/tests/provisioning_capacity.mjs +411 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// modules/provisioning/capacity.js — how many projects a shared box can hold, and
|
|
2
|
+
// whether one more fits (ADR 0285, task 1003927).
|
|
3
|
+
//
|
|
4
|
+
// ADR 0285 measured the live control plane and priced a project at its WARM size:
|
|
5
|
+
// ~80 MB (a 70 MB process plus one busy Postgres backend), on top of a fixed base
|
|
6
|
+
// the box pays whatever the project count (~750 MB with the control plane on it,
|
|
7
|
+
// ~630 MB on an overflow box). The ceiling is `floor((usable MiB − fixed) / 80)`,
|
|
8
|
+
// which reproduces the ADR's table from the box's own MemTotal — 15 on today's
|
|
9
|
+
// s-1vcpu-2gb, 39 on an s-2vcpu-4gb control plane, 41 on a 4 GB overflow box — so
|
|
10
|
+
// a resize (task 1003923) raises the ceiling by itself, with no knob to remember.
|
|
11
|
+
//
|
|
12
|
+
// Two walls, asked in order, both honest refusals rather than a silent overcommit:
|
|
13
|
+
// 1. the CEILING, counted against the rows the runner has placed or is placing
|
|
14
|
+
// on that box (`provisioning` + `active`). Not `error`: those rows hold no
|
|
15
|
+
// running unit (verified on the live fleet, 2026-09-16), and a re-provision
|
|
16
|
+
// runs this same check for itself.
|
|
17
|
+
// 2. a live MemAvailable FLOOR, because the ledger cannot see a stray unit or a
|
|
18
|
+
// project that grew — the rows say how many, the kernel says how much is left.
|
|
19
|
+
//
|
|
20
|
+
// PURE except the two I/O helpers at the bottom (`countPlaced` runs one query on
|
|
21
|
+
// the db it is handed; `readLocalMeminfo` reads this host's /proc/meminfo, for the
|
|
22
|
+
// web tier and the CLI, which run ON the control plane). The runner reads the same
|
|
23
|
+
// file through its box executor instead, so an overflow box is measured where the
|
|
24
|
+
// project will actually run (scripts/gds/provision-capacity.js). No module-api, no
|
|
25
|
+
// express, no pool of its own — this file is the shared arithmetic, not a surface.
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
|
|
30
|
+
// ADR 0285 §1–§2. PROJECT_MB is the warm unit; FIXED_MB is the box's own overhead
|
|
31
|
+
// by role; DEFAULT_CEILING is the ADR's smallest-tier number, used ONLY when the box
|
|
32
|
+
// cannot be measured (dry-run, a fake executor, an unreadable /proc) — refusing
|
|
33
|
+
// conservatively beats guessing generously. RESIZE_AT is the ADR's "resize at 12 of
|
|
34
|
+
// 15" trigger. The floor is two warm projects plus margin, per the task's brief.
|
|
35
|
+
const PROJECT_MB = 80;
|
|
36
|
+
const FIXED_MB = Object.freeze({ 'control-plane': 750, overflow: 630 });
|
|
37
|
+
const DEFAULT_CEILING = 15;
|
|
38
|
+
const DEFAULT_MEM_FLOOR_MB = 256;
|
|
39
|
+
const RESIZE_AT = 0.8;
|
|
40
|
+
// The states that hold (or are about to hold) a slot on the box.
|
|
41
|
+
const PLACED_STATES = Object.freeze(['provisioning', 'active']);
|
|
42
|
+
|
|
43
|
+
function numEnv(name, dflt) {
|
|
44
|
+
const v = process.env[name];
|
|
45
|
+
if (v == null || v === '') return dflt;
|
|
46
|
+
const n = Number(v);
|
|
47
|
+
return Number.isFinite(n) && n > 0 ? n : dflt;
|
|
48
|
+
}
|
|
49
|
+
// The two knobs, read at call time so a test can set them per case. Both live in
|
|
50
|
+
// the runner's box.env; the web tier's copy (web.env) must match if the override
|
|
51
|
+
// is used — deriving from MemTotal is what makes the two agree with no knob at all.
|
|
52
|
+
function envOverrides() {
|
|
53
|
+
return { override: numEnv('PROVISION_BOX_CEILING', null), floorMb: numEnv('PROVISION_MEM_FLOOR_MB', DEFAULT_MEM_FLOOR_MB) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// parseMeminfo — MemTotal / MemAvailable out of /proc/meminfo text, in MiB.
|
|
57
|
+
// Null for a field that is absent, so "unmeasured" stays distinguishable from 0.
|
|
58
|
+
function parseMeminfo(text) {
|
|
59
|
+
const grab = (key) => {
|
|
60
|
+
const m = new RegExp(`^${key}:\\s+(\\d+)\\s*kB`, 'm').exec(String(text || ''));
|
|
61
|
+
return m ? Math.floor(Number(m[1]) / 1024) : null;
|
|
62
|
+
};
|
|
63
|
+
return { memTotalMb: grab('MemTotal'), memAvailableMb: grab('MemAvailable') };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// placementBox — WHICH box a shape lands on, and therefore which rows share its
|
|
67
|
+
// ceiling and which fixed base applies. A standalone always runs control-plane-
|
|
68
|
+
// local (task 2074); a co-tenant runs on the remote co-hosting box only when the
|
|
69
|
+
// runner is in remote-exec mode (ADR 0130), else here. A dedicated droplet is its
|
|
70
|
+
// own box and has no shared ceiling → null (no preflight).
|
|
71
|
+
function placementBox(hostingShape, { remoteHost = null } = {}) {
|
|
72
|
+
if (hostingShape === 'dedicated') return null;
|
|
73
|
+
if (hostingShape === 'co-tenant' && remoteHost) {
|
|
74
|
+
return { box: 'overflow', shapes: ['co-tenant'], fixedMb: FIXED_MB.overflow };
|
|
75
|
+
}
|
|
76
|
+
return { box: 'control-plane', shapes: remoteHost ? ['standalone'] : ['standalone', 'co-tenant'], fixedMb: FIXED_MB['control-plane'] };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ceilingFor — the slot count for a box: an explicit override wins, else the ADR
|
|
80
|
+
// 0285 formula over the measured MemTotal, else the conservative default. `source`
|
|
81
|
+
// says which, so every surface can show where its number came from.
|
|
82
|
+
function ceilingFor({ override = null, memTotalMb = null, fixedMb = FIXED_MB['control-plane'] } = {}) {
|
|
83
|
+
if (Number.isFinite(override) && override > 0) return { ceiling: Math.floor(override), source: 'env' };
|
|
84
|
+
if (Number.isFinite(memTotalMb) && memTotalMb > fixedMb) {
|
|
85
|
+
return { ceiling: Math.floor((memTotalMb - fixedMb) / PROJECT_MB), source: 'measured' };
|
|
86
|
+
}
|
|
87
|
+
return { ceiling: DEFAULT_CEILING, source: 'default' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// placementVerdict — may ONE more project be placed? Ceiling first (the ledger's
|
|
91
|
+
// answer), then the live floor (the kernel's). The reason is written for the
|
|
92
|
+
// owner's card, not the journal: what is full, that nothing was placed, and the
|
|
93
|
+
// two ways out. An unmeasured MemAvailable cannot fail the floor — the ceiling is
|
|
94
|
+
// the wall that always applies, and a missing reading is logged by the caller.
|
|
95
|
+
function placementVerdict({ placed, ceiling, memAvailableMb = null, floorMb = DEFAULT_MEM_FLOOR_MB }) {
|
|
96
|
+
if (placed >= ceiling) {
|
|
97
|
+
return { ok: false, wall: 'ceiling', reason: `The shared box is full: ${placed} of ${ceiling} project slots are taken (ADR 0285). Nothing was placed. Free a slot by tearing down a project, or resize the box (task 1003923), then request again.` };
|
|
98
|
+
}
|
|
99
|
+
if (Number.isFinite(memAvailableMb) && memAvailableMb < floorMb) {
|
|
100
|
+
return { ok: false, wall: 'memory', reason: `The shared box has only ${memAvailableMb} MB of memory free, under the ${floorMb} MB a new project needs to start safely (${placed} of ${ceiling} slots taken). Nothing was placed. Free memory or a slot, or resize the box (task 1003923), then request again.` };
|
|
101
|
+
}
|
|
102
|
+
return { ok: true, wall: null, reason: null };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// capacitySummary — the one shape every reader gets: the fleet API, the runner
|
|
106
|
+
// log, the CLI status. `resize_due` is the ADR's 80% trigger, so the resize is
|
|
107
|
+
// scheduled at 12 of 15 rather than discovered at 15.
|
|
108
|
+
function capacitySummary({ box, placed, ceiling, source, memAvailableMb = null, floorMb = DEFAULT_MEM_FLOOR_MB }) {
|
|
109
|
+
const verdict = placementVerdict({ placed, ceiling, memAvailableMb, floorMb });
|
|
110
|
+
return {
|
|
111
|
+
box, placed, ceiling, source,
|
|
112
|
+
remaining: Math.max(0, ceiling - placed),
|
|
113
|
+
resize_at: Math.ceil(ceiling * RESIZE_AT),
|
|
114
|
+
resize_due: placed >= Math.ceil(ceiling * RESIZE_AT),
|
|
115
|
+
mem_available_mb: memAvailableMb,
|
|
116
|
+
mem_floor_mb: floorMb,
|
|
117
|
+
verdict,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// countPlaced — rows holding a slot on the box: the given shapes in PLACED_STATES.
|
|
122
|
+
// `excludeId` keeps a row from counting itself when a reconcile re-runs its own
|
|
123
|
+
// provision (it is already 'provisioning' then). Fail-soft on a fake db that
|
|
124
|
+
// answers with no rows.
|
|
125
|
+
async function countPlaced(db, shapes, { excludeId = null } = {}) {
|
|
126
|
+
const params = [shapes, PLACED_STATES];
|
|
127
|
+
let where = 'hosting_shape = ANY($1) AND status = ANY($2)';
|
|
128
|
+
if (excludeId != null) { params.push(excludeId); where += ` AND id <> $${params.length}`; }
|
|
129
|
+
const { rows } = await db.query(`SELECT count(*)::int AS n FROM provisioning_instances WHERE ${where}`, params);
|
|
130
|
+
return Number((rows && rows[0] && rows[0].n) || 0);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// readLocalMeminfo — THIS host's /proc/meminfo, for the surfaces that run on the
|
|
134
|
+
// control plane itself. Null (not a throw) where it does not exist, e.g. a dev box
|
|
135
|
+
// that is not Linux — the summary then reads 'default' and says so.
|
|
136
|
+
function readLocalMeminfo() {
|
|
137
|
+
try { return fs.readFileSync('/proc/meminfo', 'utf8'); } catch { return null; }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// fleetCapacity — the control-plane box's summary for the fleet API and the CLI:
|
|
141
|
+
// its rows against its ceiling, measured from this host. `meminfo` is injectable
|
|
142
|
+
// for tests; `remoteHost` mirrors the runner's PROVISION_REMOTE_HOST so the two
|
|
143
|
+
// count the same shapes (set it in web.env too if co-tenants ever run remotely).
|
|
144
|
+
async function fleetCapacity(db, { meminfo = readLocalMeminfo(), remoteHost = process.env.PROVISION_REMOTE_HOST || null } = {}) {
|
|
145
|
+
const target = placementBox('standalone', { remoteHost });
|
|
146
|
+
const { override, floorMb } = envOverrides();
|
|
147
|
+
const mem = parseMeminfo(meminfo);
|
|
148
|
+
const { ceiling, source } = ceilingFor({ override, memTotalMb: mem.memTotalMb, fixedMb: target.fixedMb });
|
|
149
|
+
const placed = await countPlaced(db, target.shapes);
|
|
150
|
+
return capacitySummary({ box: target.box, placed, ceiling, source, memAvailableMb: mem.memAvailableMb, floorMb });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// The constants a caller needs to reason about a verdict, plus the helpers the
|
|
154
|
+
// runner preflight and the fleet API compose. PROJECT_MB / FIXED_MB / RESIZE_AT
|
|
155
|
+
// and the two env readers stay private: nothing outside asks them separately from
|
|
156
|
+
// the summary they produce, and an export nobody consumes is one more thing to
|
|
157
|
+
// keep true (the paid-shape-gate precedent).
|
|
158
|
+
module.exports = {
|
|
159
|
+
DEFAULT_CEILING, DEFAULT_MEM_FLOOR_MB, PLACED_STATES,
|
|
160
|
+
parseMeminfo, placementBox, ceilingFor, placementVerdict, capacitySummary,
|
|
161
|
+
countPlaced, fleetCapacity,
|
|
162
|
+
};
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
// POST /provisioning/instances/:id/teardown — request teardown of one's own
|
|
26
26
|
// GET /provisioning/instances/:id/invite-suggestions — who is worth inviting (R28, ADR 0251)
|
|
27
27
|
// Archon-scoped (requireRank('archon') — the fleet, the /boxes precedent):
|
|
28
|
-
// GET /provisioning/fleet — the full instance roster
|
|
28
|
+
// GET /provisioning/fleet — the full instance roster + the box's slot `capacity` (ADR 0285)
|
|
29
29
|
// GET /provisioning/fleet/cost-ledger — per-instance + total cost estimate
|
|
30
30
|
// POST /provisioning/instances/:id/force-teardown — tear down ANY instance
|
|
31
31
|
// public:
|
|
@@ -44,8 +44,7 @@
|
|
|
44
44
|
const crypto = require('node:crypto');
|
|
45
45
|
const express = require('express');
|
|
46
46
|
const api = require('../../../src/module-api');
|
|
47
|
-
const auth = api;
|
|
48
|
-
const { pool, validateOrRespond, parseId } = api;
|
|
47
|
+
const auth = api, { pool, validateOrRespond, parseId } = api;
|
|
49
48
|
const provisioning = require('../provisioning');
|
|
50
49
|
const { onboardPlan, stepsForShape } = require('../onboard-plan');
|
|
51
50
|
const { ALWAYS_ON_CORE, OPTIONAL_MODULES, starterBundleAnswerTable, optionalModules, effectiveModules } = require('../starter-bundles');
|
|
@@ -54,6 +53,7 @@ const { makeAskRateLimiter, clientIp } = require('../rate-limit');
|
|
|
54
53
|
const { requireAuthorityForMeteredShape } = require('../paid-shape-gate');
|
|
55
54
|
const { adoptCredentialFault } = require('../credential-preflight');
|
|
56
55
|
const catalogBridge = require('../catalog-bridge');
|
|
56
|
+
const capacity = require('../capacity'); // the box's slot ceiling (ADR 0285, task 1003927)
|
|
57
57
|
const { callbackPage } = require('./callback-page');
|
|
58
58
|
// the LOUD body validators (task 1003504) — lifted out when this file hit the size ratchet
|
|
59
59
|
const { badProjectDetail, badModuleSelection, badProjectType } = require('./body-validators');
|
|
@@ -223,7 +223,7 @@ module.exports = function provisioningRoutes() {
|
|
|
223
223
|
try {
|
|
224
224
|
const status = req.query.status || undefined;
|
|
225
225
|
const rows = await provisioning.listAllInstances(pool, { status });
|
|
226
|
-
res.json({ instances: rows.map(fleetInstance) });
|
|
226
|
+
res.json({ instances: rows.map(fleetInstance), capacity: await capacity.fleetCapacity(pool).catch(() => null) });
|
|
227
227
|
} catch (err) {
|
|
228
228
|
log.error('[provisioning] GET /provisioning/fleet', err);
|
|
229
229
|
res.fail('fleet_failed', { status: 500, message: 'internal error' });
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.765",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.765",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.765",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// scripts/gds/provision-capacity.js — the runner's BOX-CAPACITY preflight
|
|
2
|
+
// (ADR 0285, task 1003927): before a standalone / co-tenant project is placed on a
|
|
3
|
+
// shared box, ask whether it fits, and refuse with an owner-readable reason when it
|
|
4
|
+
// does not. Nothing enforced the ceiling before this — the runner stood up project
|
|
5
|
+
// N+1 on a full box and swap hid the overcommit until a reboot or a fleet upgrade
|
|
6
|
+
// woke every project at once.
|
|
7
|
+
//
|
|
8
|
+
// The arithmetic and the two walls (row ceiling, live MemAvailable floor) live in
|
|
9
|
+
// modules/provisioning/capacity.js, shared with the fleet API so both surfaces show
|
|
10
|
+
// one number. This file is the runner-side composition: it reads /proc/meminfo
|
|
11
|
+
// through the BOX executor — the control plane for a standalone (task 2074), the
|
|
12
|
+
// remote co-hosting box for a co-tenant under remote-exec (ADR 0130) — so an
|
|
13
|
+
// overflow box is measured where the project will actually run. A sibling of
|
|
14
|
+
// provision-config / -repo / -units / -net; provision.js requires it back.
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const { CONFIG } = require('./provision-config.js');
|
|
18
|
+
const capacity = require('../../modules/provisioning/capacity.js');
|
|
19
|
+
|
|
20
|
+
// The kernel's own two lines, kept small so the journal stays readable. allowFail:
|
|
21
|
+
// a box without procfs (never, on Linux) reads as "unmeasured", and the ceiling
|
|
22
|
+
// then falls to ADR 0285's conservative default rather than aborting the standup.
|
|
23
|
+
const MEMINFO_CMD = "grep -E '^Mem(Total|Available):' /proc/meminfo";
|
|
24
|
+
|
|
25
|
+
// capacityPreflight — { ok:true, …summary } when the project fits (or the shape has
|
|
26
|
+
// no shared box), { ok:false, error, …summary } when it does not. The refusal is a
|
|
27
|
+
// RETURN, never a throw: the drain loop retries it (a teardown may free a slot), and
|
|
28
|
+
// on retirement writes `error` onto the instance's card without flipping its status
|
|
29
|
+
// — so a project that was never placed is never labelled broken. Dry-run measures
|
|
30
|
+
// and reports but refuses nothing, since it places nothing either way.
|
|
31
|
+
async function capacityPreflight(inst, deps) {
|
|
32
|
+
const { db, provisioning, exec, log, apply } = deps;
|
|
33
|
+
const target = capacity.placementBox(inst.hosting_shape, { remoteHost: CONFIG.remoteHost });
|
|
34
|
+
if (!target) return { ok: true, skipped: 'dedicated' };
|
|
35
|
+
const boxExec = target.box === 'control-plane' ? (deps.controlExec || exec) : exec;
|
|
36
|
+
let res = null;
|
|
37
|
+
try { res = boxExec(MEMINFO_CMD, { allowFail: true }); } catch { res = null; }
|
|
38
|
+
const mem = capacity.parseMeminfo(res && res.stdout);
|
|
39
|
+
const { ceiling, source } = capacity.ceilingFor({ override: CONFIG.boxCeiling, memTotalMb: mem.memTotalMb, fixedMb: target.fixedMb });
|
|
40
|
+
const placed = await capacity.countPlaced(db, target.shapes, { excludeId: inst.id });
|
|
41
|
+
const floorMb = CONFIG.memFloorMb || capacity.DEFAULT_MEM_FLOOR_MB;
|
|
42
|
+
const summary = capacity.capacitySummary({ box: target.box, placed, ceiling, source, memAvailableMb: mem.memAvailableMb, floorMb });
|
|
43
|
+
const free = mem.memAvailableMb != null ? `${mem.memAvailableMb} MB free` : 'memory unmeasured';
|
|
44
|
+
const where = `${target.box} box at ${placed} of ${ceiling} slots (${source}, ${free})`;
|
|
45
|
+
if (!apply) {
|
|
46
|
+
log(` [preflight] ${where} — would refuse if full (dry-run measures nothing and places nothing)`);
|
|
47
|
+
return { ok: true, dryRun: true, ...summary };
|
|
48
|
+
}
|
|
49
|
+
if (source === 'default') log(` [preflight] ⚠ could not measure the box — using ADR 0285's smallest-tier ceiling (${ceiling}); set PROVISION_BOX_CEILING to override`);
|
|
50
|
+
if (!summary.verdict.ok) {
|
|
51
|
+
log(` ✖ [preflight] ${where} — refusing to place ${inst.slug} (${summary.verdict.wall} wall)`);
|
|
52
|
+
if (provisioning && provisioning.recordEvent) {
|
|
53
|
+
await provisioning.recordEvent(db, {
|
|
54
|
+
instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'placement-refused',
|
|
55
|
+
detail: summary.verdict.reason, actor: 'runner',
|
|
56
|
+
}).catch(() => {});
|
|
57
|
+
}
|
|
58
|
+
return { ok: false, error: summary.verdict.reason, ...summary };
|
|
59
|
+
}
|
|
60
|
+
log(` [preflight] ${where} ✓${summary.resize_due ? ' — at or past 80%: schedule the resize (task 1003923) or open the next box' : ''}`);
|
|
61
|
+
return { ok: true, ...summary };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// statusCapacityLine — the "box at N of M" line `provision.js status` prints, read
|
|
65
|
+
// from this host (the CLI runs on the control plane), so the resize is scheduled
|
|
66
|
+
// at 80% rather than discovered at the ceiling.
|
|
67
|
+
async function statusCapacityLine(db) {
|
|
68
|
+
const c = await capacity.fleetCapacity(db);
|
|
69
|
+
const free = c.mem_available_mb != null ? `, ${c.mem_available_mb} MB free` : '';
|
|
70
|
+
const due = c.resize_due ? ' — at or past 80%: resize or open the next box (ADR 0285, task 1003923)' : '';
|
|
71
|
+
return ` ${c.box} box: ${c.placed} of ${c.ceiling} project slots taken (${c.source}${free})${due}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { MEMINFO_CMD, capacityPreflight, statusCapacityLine };
|
|
@@ -123,6 +123,17 @@ const CONFIG = {
|
|
|
123
123
|
// fails fast (never hangs on a prompt); accept-new pins the host key on first
|
|
124
124
|
// connect (the operator can pre-seed known_hosts for strict pinning).
|
|
125
125
|
sshOpts: process.env.PROVISION_SSH_OPTS || '-o BatchMode=yes -o StrictHostKeyChecking=accept-new',
|
|
126
|
+
// ── Box capacity (ADR 0285, task 1003927) ─────────────────────────────────
|
|
127
|
+
// How many projects the box the runner places on may hold. UNSET (default)
|
|
128
|
+
// derives it from that box's own MemTotal by the ADR's formula (15 on today's
|
|
129
|
+
// s-1vcpu-2gb, 39 after the resize), so a resize raises it with no knob to
|
|
130
|
+
// remember; set it only to pin a number the measurement should not decide (a box
|
|
131
|
+
// that also hosts something the ledger cannot see). The floor is the live
|
|
132
|
+
// MemAvailable a placement must still find — two warm projects plus margin
|
|
133
|
+
// (modules/provisioning/capacity.js holds the default). Both live in box.env;
|
|
134
|
+
// mirror an override into web.env so the fleet API reports the same number.
|
|
135
|
+
boxCeiling: numEnv('PROVISION_BOX_CEILING', null),
|
|
136
|
+
memFloorMb: numEnv('PROVISION_MEM_FLOOR_MB', null),
|
|
126
137
|
};
|
|
127
138
|
|
|
128
139
|
// task 1002757 — the provisioner's git identity resolves from the instance's own
|
package/scripts/gds/provision.js
CHANGED
|
@@ -53,6 +53,7 @@ const { CONFIG, MANIFEST_UA, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES,
|
|
|
53
53
|
const { APP_USER, alreadyScaffolded, buildCorePinRefreshCommit, dbCreateCmd, dbName, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd, ensureInstanceUserCmd, generateDbPassword, grantInstanceRepoReadCmd, instanceDbRole, instanceStateDir, instanceUser, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
|
|
54
54
|
const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
|
|
55
55
|
const { caddyBlock, caddySnippetPath, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, shouldFederate, zoneForDomain } = require('./provision-net.js');
|
|
56
|
+
const { capacityPreflight, statusCapacityLine } = require('./provision-capacity.js');
|
|
56
57
|
|
|
57
58
|
function makeExec({ apply, log = console.log, remoteHost = null, remoteCwd = null, sshOpts = '' }) {
|
|
58
59
|
// `shown` (task 1003140): what to LOG in place of the command text — for a command
|
|
@@ -258,7 +259,8 @@ async function provisionInstance(inst, deps) {
|
|
|
258
259
|
log(` [preflight] would verify the Cloudflare token can reach zone ${zoneForDomain(inst.domain)} before the address step`);
|
|
259
260
|
}
|
|
260
261
|
}
|
|
261
|
-
|
|
262
|
+
const room = await capacityPreflight(inst, deps); // box capacity (ADR 0285, task 1003927): an honest refusal BEFORE the status flip — never a silent oversubscribe
|
|
263
|
+
if (room.ok === false) return room;
|
|
262
264
|
if (apply) await provisioning.setInstanceStatus(db, inst.id, 'provisioning');
|
|
263
265
|
const patch = {};
|
|
264
266
|
|
|
@@ -1358,7 +1360,7 @@ async function cmdStatus(deps) {
|
|
|
1358
1360
|
monthly += Number(r.cost_estimate_usd || 0);
|
|
1359
1361
|
log(` ${String(r.slug).padEnd(20)} ${String(r.status).padEnd(13)} ${r.hosting_shape.padEnd(10)} port=${r.port || '-'} @${r.github_login} $${Number(r.cost_estimate_usd || 0).toFixed(2)}/mo ${r.domain || ''}`);
|
|
1360
1362
|
}
|
|
1361
|
-
log(`\n estimated run-rate: ~$${monthly.toFixed(2)}/mo`);
|
|
1363
|
+
log(`\n estimated run-rate: ~$${monthly.toFixed(2)}/mo\n${await statusCapacityLine(db)}`); // + "box at N of M" (ADR 0285, task 1003927)
|
|
1362
1364
|
return { count: rows.length };
|
|
1363
1365
|
}
|
|
1364
1366
|
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.765'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/conductor.mjs
CHANGED
|
@@ -130,6 +130,42 @@ test('wandering: lowest priority — emitted after methodology/status advisories
|
|
|
130
130
|
assert.equal(a[a.length - 1].id, 'wandering', 'wandering is emitted last');
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
+
// ---------- evaluateTriggers: interaction profile (task 2010, delivered 1002514) ----------
|
|
134
|
+
//
|
|
135
|
+
// This branch shipped with task 2010 and had NO coverage here, which is part of
|
|
136
|
+
// why the profile could be believed to have no consumer at all. It relays only —
|
|
137
|
+
// interaction-prefs.js resolves — so these pin the relay, plus the one end-to-end
|
|
138
|
+
// fact clause 1 of `accessibility-fluency-profile` turns on.
|
|
139
|
+
|
|
140
|
+
test('interaction: a profile with inject:true emits the pitch contract', () => {
|
|
141
|
+
const a = conductor.evaluateTriggers({
|
|
142
|
+
interaction: { inject: true, contract: 'Interaction profile for this builder — pitch replies accordingly:' },
|
|
143
|
+
});
|
|
144
|
+
const i = a.find((x) => x.id === 'interaction');
|
|
145
|
+
assert.ok(i, 'expected an interaction advisory');
|
|
146
|
+
assert.match(i.text, /pitch replies accordingly/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('interaction: inject:false, an empty contract, or a null knob emit nothing', () => {
|
|
150
|
+
assert.ok(!ids(conductor.evaluateTriggers({ interaction: { inject: false, contract: 'x' } })).includes('interaction'));
|
|
151
|
+
assert.ok(!ids(conductor.evaluateTriggers({ interaction: { inject: true, contract: '' } })).includes('interaction'));
|
|
152
|
+
assert.ok(!ids(conductor.evaluateTriggers({ interaction: null })).includes('interaction'));
|
|
153
|
+
assert.ok(!ids(conductor.evaluateTriggers({})).includes('interaction'));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// The clause-1 proof, run against the REAL resolver rather than a hand-written
|
|
157
|
+
// payload: the accessible DEFAULT profile — the one a builder who never opens
|
|
158
|
+
// Settings has — must reach a session. Before task 1002514 every default field
|
|
159
|
+
// carried an empty fragment, so describeForApi({}) came back inject:false and
|
|
160
|
+
// this advisory was never emitted for the builder it was written for.
|
|
161
|
+
test('interaction: the DEFAULT profile reaches a session end to end', () => {
|
|
162
|
+
const interactionPrefs = require('../modules/builder-settings/interaction-prefs.js');
|
|
163
|
+
const a = conductor.evaluateTriggers({ interaction: interactionPrefs.describeForApi({}) });
|
|
164
|
+
const i = a.find((x) => x.id === 'interaction');
|
|
165
|
+
assert.ok(i, 'a builder on all defaults must still be described to the session');
|
|
166
|
+
assert.match(i.text, /non-technical/);
|
|
167
|
+
});
|
|
168
|
+
|
|
133
169
|
// The nudge says "your claim's", singular, so it is measured against the claim
|
|
134
170
|
// bound to THIS worktree (task 1003837). These three carry a worktree_name and a
|
|
135
171
|
// matching `worktree` for that reason; the four below pin what changed.
|
|
@@ -160,6 +160,55 @@ test('the wandering pick is a radio row, wired on change', () => {
|
|
|
160
160
|
assert.match(radio, /height:\s*24px/, 'a native radio is 13px; the floor is 24');
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
+
// ---- the interaction panel (task 1002514) ----------------------------------
|
|
164
|
+
//
|
|
165
|
+
// Three facts a browser is silent about: the panel is rendered by a script that
|
|
166
|
+
// is not settings.js, so the wiring between the two halves is the thing that can
|
|
167
|
+
// silently break; and the carve exists because settings.js is against the
|
|
168
|
+
// 1500-line ratchet, which is a fact a reader will otherwise undo.
|
|
169
|
+
|
|
170
|
+
test('the interaction panel is wired end to end across its two files', () => {
|
|
171
|
+
const html = read('settings.html');
|
|
172
|
+
const panelJs = read('settings-interaction.js');
|
|
173
|
+
|
|
174
|
+
// The markup half lives in settings.html and must join a group that already
|
|
175
|
+
// exists — the rail renders groups, so a section in an unknown data-group is
|
|
176
|
+
// markup nothing ever shows.
|
|
177
|
+
const section = html.match(/<section class="scroll" id="interaction-scroll"[^>]*>/);
|
|
178
|
+
assert.ok(section, 'the panel has a section');
|
|
179
|
+
assert.match(section[0], /data-group="preferences"/, 'it joins a group the rail already renders');
|
|
180
|
+
assert.match(section[0], /\bhidden\b/, 'hidden until its own read succeeds, like every other panel');
|
|
181
|
+
assert.match(html, /<script src="\/builders\/settings-interaction\.js/, 'the page loads the panel script');
|
|
182
|
+
|
|
183
|
+
// The script half self-boots and reveals the section. If it stopped doing
|
|
184
|
+
// either, the panel would simply never appear and no test would notice.
|
|
185
|
+
assert.match(panelJs, /\$\('#interaction-scroll'\)\.hidden = false/, 'the script reveals its own section');
|
|
186
|
+
assert.match(panelJs, /setup\(\);/, 'it self-boots — settings.js does not call it');
|
|
187
|
+
|
|
188
|
+
// settings.js keeps ONLY the anchor entry, so /settings#interaction activates
|
|
189
|
+
// the group before the panel paints.
|
|
190
|
+
assert.match(settingsJs, /'interaction': 'interaction-scroll'/, 'the deep-link anchor stays in settings.js');
|
|
191
|
+
assert.doesNotMatch(settingsJs, /function renderInteraction\(/, 'the renderer moved out and must not come back inline');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('the interaction panel stays out of settings.js, which is against the size ratchet', () => {
|
|
195
|
+
// Not style: inlining this panel put settings.js at 1601 lines and regressed
|
|
196
|
+
// oversized_file_count 0→1 and excess_lines_total 0→102 (fitness-ratchets.js,
|
|
197
|
+
// OVERSIZE_LINES = 1500). The settings-sessions.js precedent, same reason.
|
|
198
|
+
const lines = settingsJs.split('\n').length;
|
|
199
|
+
assert.ok(lines < 1500, `settings.js is ${lines} lines — at or over the 1500 ratchet; carve the next panel out too`);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('a default value is named once, by the API, not twice by the row', () => {
|
|
203
|
+
// The first render read "Non-technical (default) · DEFAULT": interaction-prefs.js
|
|
204
|
+
// already ends every default value's LABEL with "(default)", and the row also
|
|
205
|
+
// carried the wandering picker's .prow__tag. One source for the word.
|
|
206
|
+
const panelJs = read('settings-interaction.js');
|
|
207
|
+
const render = panelJs.match(/function render\([\s\S]*?\n \}\n/);
|
|
208
|
+
assert.ok(render, 'render is a whole function');
|
|
209
|
+
assert.doesNotMatch(render[0], /prow__tag/, 'the row must not re-state "default" beside a label that already says it');
|
|
210
|
+
});
|
|
211
|
+
|
|
163
212
|
test('.craft-pref is a 24px target with a 14px drawn box — it was 13x13, the smallest control in the hall', () => {
|
|
164
213
|
const box = ruleOf(panelCss, '.craft-pref');
|
|
165
214
|
assert.ok(box, '.craft-pref has a rule');
|
|
@@ -6,19 +6,47 @@ import { createRequire } from 'node:module';
|
|
|
6
6
|
const require = createRequire(import.meta.url);
|
|
7
7
|
const ip = require('../modules/builder-settings/interaction-prefs.js');
|
|
8
8
|
|
|
9
|
-
test('defaults: unset prefs → all field defaults
|
|
9
|
+
test('defaults: unset prefs → all field defaults', () => {
|
|
10
10
|
const eff = ip.effectiveProfile({});
|
|
11
11
|
assert.equal(eff.technical_level, 'non_technical');
|
|
12
12
|
assert.equal(eff.toolchain_familiarity, 'new');
|
|
13
13
|
assert.equal(eff.verbosity, 'concise');
|
|
14
|
-
assert.equal(ip.contractFor({}), '', 'all-default profile injects nothing');
|
|
15
|
-
assert.equal(ip.shouldInject({}), false);
|
|
16
14
|
});
|
|
17
15
|
|
|
18
|
-
|
|
16
|
+
// task 1002514, clause 1 of `accessibility-fluency-profile`. This REPLACES an
|
|
17
|
+
// assertion that the all-default profile injected nothing. That was correct while
|
|
18
|
+
// CLAUDE.md §2 carried the posture as prose, and it meant the accessible-by-
|
|
19
|
+
// default builder — the one who most needs the posture and least likely to open
|
|
20
|
+
// Settings — was the only builder the profile said nothing about.
|
|
21
|
+
test('the DEFAULT profile injects the accessible posture (it is a posture, not an absence)', () => {
|
|
22
|
+
const c = ip.contractFor({});
|
|
23
|
+
assert.notEqual(c, '', 'the accessible default must not be the silent one');
|
|
24
|
+
assert.equal(ip.shouldInject({}), true);
|
|
25
|
+
assert.match(c, /non-technical/, 'the vocabulary posture must arrive');
|
|
26
|
+
assert.match(c, /define a term the first time/);
|
|
27
|
+
assert.match(c, /cost, time and what the end user will see/, 'the framing posture must arrive');
|
|
28
|
+
assert.match(c, /step by step/, 'the toolchain posture must arrive');
|
|
29
|
+
assert.match(c, /concise/, 'the length posture must arrive');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('null / missing behaviorPrefs is safe, and still resolves to the default posture', () => {
|
|
19
33
|
assert.deepEqual(ip.storedProfile(null), {});
|
|
20
|
-
assert.equal(ip.contractFor(null), '');
|
|
21
|
-
assert.equal(ip.shouldInject(undefined),
|
|
34
|
+
assert.equal(ip.contractFor(null), ip.contractFor({}), 'absent prefs resolve exactly like empty prefs');
|
|
35
|
+
assert.equal(ip.shouldInject(undefined), true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// The criterion's hard constraint: "an expert's terse experience is never
|
|
39
|
+
// degraded to help a newcomer." The expert contract must not merely differ from
|
|
40
|
+
// the newcomer one — it must be SHORTER, and it must tell the agent to skip.
|
|
41
|
+
test('an expert profile stays terse where a non-technical one is explained to', () => {
|
|
42
|
+
const beginner = ip.contractFor({});
|
|
43
|
+
const expert = ip.contractFor({
|
|
44
|
+
interaction: { technical_level: 'engineer', toolchain_familiarity: 'comfortable' },
|
|
45
|
+
});
|
|
46
|
+
assert.ok(expert.length < beginner.length, 'the expert contract must be shorter, not just different');
|
|
47
|
+
assert.match(expert, /skip basic explanations/);
|
|
48
|
+
assert.doesNotMatch(expert, /do not assume software-engineering vocabulary/);
|
|
49
|
+
assert.doesNotMatch(expert, /walk local tooling/);
|
|
22
50
|
});
|
|
23
51
|
|
|
24
52
|
test('a non-default field produces an injected contract', () => {
|
|
@@ -76,10 +104,21 @@ test('describeForApi exposes catalog + effective + contract + inject', () => {
|
|
|
76
104
|
// The SURFACE half of the profile: how much of the work behind a reply gets
|
|
77
105
|
// narrated. Default `show` keeps every existing builder on today's behavior.
|
|
78
106
|
|
|
79
|
-
|
|
107
|
+
// The assertion here used to be `contractFor({machinery:'show'}) === ''`, which
|
|
108
|
+
// measured the WHOLE contract's emptiness to prove one field contributed nothing.
|
|
109
|
+
// That conflated "this field is silent" with "every field is silent", so once the
|
|
110
|
+
// other defaults gained text (task 1002514) it would have gone red for a reason
|
|
111
|
+
// that had nothing to do with machinery. Same intent, pointed at the actual claim:
|
|
112
|
+
// adding `show` changes nothing, and `show` never names itself.
|
|
113
|
+
test('machinery defaults to show and contributes nothing to the contract', () => {
|
|
80
114
|
assert.equal(ip.defaultValue('machinery'), 'show');
|
|
81
115
|
assert.equal(ip.effectiveProfile({}).machinery, 'show');
|
|
82
|
-
assert.equal(
|
|
116
|
+
assert.equal(
|
|
117
|
+
ip.contractFor({ interaction: { machinery: 'show' } }),
|
|
118
|
+
ip.contractFor({}),
|
|
119
|
+
'an explicit `show` must read identically to leaving it unset',
|
|
120
|
+
);
|
|
121
|
+
assert.doesNotMatch(ip.contractFor({}), /Machinery/, 'the default must never spend session context');
|
|
83
122
|
});
|
|
84
123
|
|
|
85
124
|
test('machinery=quiet injects an outcomes-not-mechanism contract', () => {
|