@bongos/core 1.19.999 → 1.19.1001

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.
Files changed (89) hide show
  1. package/.bongos-core.json +116 -91
  2. package/.claude/skills/collab-review/SKILL.md +24 -13
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -0
  5. package/clients/bongos-client/index.cjs +4 -0
  6. package/clients/bongos-client/index.d.ts +7 -0
  7. package/clients/bongos-client/index.mjs +4 -0
  8. package/docs/adr/0330-an-ask-is-answered-where-it-was-asked.md +155 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +155 -5
  11. package/docs/api-reference.md +6 -4
  12. package/docs/copy-inventory.md +18 -13
  13. package/docs/copy-registry.json +70 -15
  14. package/docs/module-api-changelog.md +4 -0
  15. package/modules/hall-ui/public/collab-lib.js +79 -0
  16. package/modules/hall-ui/public/collab.css +60 -0
  17. package/modules/hall-ui/public/collab.js +134 -5
  18. package/modules/lifecycle/help-requests.js +118 -8
  19. package/modules/lifecycle/migrations/lifecycle_011_help_request_replies.sql +113 -0
  20. package/modules/lifecycle/routes/help-requests.js +93 -13
  21. package/modules/provisioning/capacity.js +3 -3
  22. package/modules/provisioning/control-plane-guard.js +1 -1
  23. package/modules/provisioning/core-upgrade.js +7 -7
  24. package/modules/provisioning/cost-ledger.js +74 -0
  25. package/modules/provisioning/migrations/provisioning_027_cloud_host_shape_rename.sql +58 -0
  26. package/modules/provisioning/onboard-plan.js +1 -1
  27. package/modules/provisioning/paid-shape-gate.js +28 -15
  28. package/modules/provisioning/provisioning.js +20 -39
  29. package/modules/provisioning/routes/provisioning.js +4 -3
  30. package/modules/provisioning/routes/repo-private.js +1 -1
  31. package/modules/provisioning/tests/liveness.mjs +16 -8
  32. package/modules/provisioning/tests/provisioning.mjs +1 -1
  33. package/modules/public-landing/public/projects.html +26 -27
  34. package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -1
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/scripts/gds/init.js +12 -4
  38. package/scripts/gds/onboard.js +1 -1
  39. package/scripts/gds/provision-capacity.js +6 -1
  40. package/scripts/gds/provision-config.js +6 -3
  41. package/scripts/gds/provision-core-upgrade.js +8 -8
  42. package/scripts/gds/provision-disconnect.js +2 -2
  43. package/scripts/gds/provision-process.js +2 -2
  44. package/scripts/gds/provision-repo-private.js +1 -1
  45. package/scripts/gds/provision-repo.js +13 -3
  46. package/scripts/gds/provision-teardown.js +2 -2
  47. package/scripts/gds/provision-units.js +2 -2
  48. package/scripts/gds/provision.js +6 -6
  49. package/src/module-api.js +1 -1
  50. package/tests/collab_page.mjs +140 -7
  51. package/tests/core_upgrade_door.mjs +1 -1
  52. package/tests/core_upgrade_runner.mjs +20 -20
  53. package/tests/deploy_page_projects.mjs +5 -5
  54. package/tests/hall_collab_world.mjs +6 -2
  55. package/tests/help_requests.mjs +201 -6
  56. package/tests/hub_disconnect_ui.mjs +4 -1
  57. package/tests/init.mjs +6 -6
  58. package/tests/onboard.mjs +5 -4
  59. package/tests/project_catalog_backfill.mjs +1 -1
  60. package/tests/project_catalog_bridge.mjs +2 -2
  61. package/tests/project_catalog_routes.mjs +4 -4
  62. package/tests/projects_hub.mjs +1 -1
  63. package/tests/projects_hub_pre_uat.mjs +22 -5
  64. package/tests/projects_hub_repo_private.mjs +1 -1
  65. package/tests/provision.mjs +32 -32
  66. package/tests/provision_byo_host.mjs +1 -1
  67. package/tests/provision_caddy_wiring.mjs +1 -1
  68. package/tests/provision_disconnect.mjs +3 -3
  69. package/tests/provision_repo_private.mjs +1 -1
  70. package/tests/provision_restart.mjs +5 -5
  71. package/tests/provision_settings_apply.mjs +4 -4
  72. package/tests/provision_teardown_address.mjs +2 -2
  73. package/tests/provisioning_capacity.mjs +40 -34
  74. package/tests/provisioning_capacity_gate.mjs +5 -12
  75. package/tests/provisioning_cost_ledger.mjs +98 -0
  76. package/tests/provisioning_default_domain.mjs +1 -1
  77. package/tests/provisioning_domain_attach.mjs +4 -4
  78. package/tests/provisioning_domain_unique.mjs +2 -2
  79. package/tests/provisioning_error_note_scope.mjs +1 -1
  80. package/tests/provisioning_module_selection.mjs +5 -5
  81. package/tests/provisioning_onboard_mode.mjs +2 -2
  82. package/tests/provisioning_paid_shape_gate.mjs +57 -44
  83. package/tests/provisioning_repo_private_route.mjs +1 -1
  84. package/tests/provisioning_restart.mjs +11 -4
  85. package/tests/provisioning_settings_apply.mjs +12 -5
  86. package/tests/provisioning_settings_env.mjs +1 -1
  87. package/tests/provisioning_teardown_intent.mjs +3 -3
  88. package/tests/public_landing_projects.mjs +13 -6
  89. package/tests/upgrade.mjs +2 -2
@@ -78,17 +78,23 @@ t('ceilingFor: an explicit override wins; an unmeasured box falls to the ADR sma
78
78
  assert.equal(DEFAULT_CEILING, 15);
79
79
  });
80
80
 
81
- t('placementBox: standalone → control plane always; co-tenant → the remote box only under remote-exec; dedicated → no shared box', () => {
82
- assert.deepEqual(placementBox('standalone', {}), { box: 'control-plane', shapes: ['standalone', 'co-tenant'], fixedMb: 750 });
83
- assert.deepEqual(placementBox('standalone', { remoteHost: 'cohost' }), { box: 'control-plane', shapes: ['standalone'], fixedMb: 750 });
84
- assert.deepEqual(placementBox('co-tenant', {}), { box: 'control-plane', shapes: ['standalone', 'co-tenant'], fixedMb: 750 });
81
+ t('placementBox: cloud-host → control plane always; co-tenant → the remote box only under remote-exec; byo-host → no box of ours', () => {
82
+ assert.deepEqual(placementBox('cloud-host', {}), { box: 'control-plane', shapes: ['cloud-host', 'co-tenant'], fixedMb: 750 });
83
+ assert.deepEqual(placementBox('cloud-host', { remoteHost: 'cohost' }), { box: 'control-plane', shapes: ['cloud-host'], fixedMb: 750 });
84
+ assert.deepEqual(placementBox('co-tenant', {}), { box: 'control-plane', shapes: ['cloud-host', 'co-tenant'], fixedMb: 750 });
85
85
  assert.deepEqual(placementBox('co-tenant', { remoteHost: 'cohost' }), { box: 'overflow', shapes: ['co-tenant'], fixedMb: 630 });
86
- assert.equal(placementBox('dedicated', {}), null);
87
- // ADR 0323: a byo-host runs on the owner's own machine, so it has no box of ours and
88
- // no shared ceiling — null for a STRONGER reason than dedicated's (dedicated is our
89
- // droplet, just not a shared one). remoteHost must not change that answer.
86
+ // ADR 0323: a byo-host runs on the owner's own machine, so it has no box of ours and no
87
+ // shared ceiling. This used to be one of TWO null answers — 'dedicated' was the other,
88
+ // null for a weaker reason (our droplet, just not a shared one) — until ADR 0327
89
+ // decision 4 removed that shape. remoteHost must not change the answer.
90
90
  assert.equal(placementBox('byo-host', {}), null);
91
91
  assert.equal(placementBox('byo-host', { remoteHost: 'cohost' }), null);
92
+ // Everything else still falls through to the control-plane box — including the
93
+ // 'control-plane' row itself, which is unchanged by this rename. So 'byo-host' is now the
94
+ // ONLY null answer, where there used to be two; the skip reason it produces is named for
95
+ // the question ('no-shared-box') rather than for the shape, so removing the other one did
96
+ // not leave a label naming a shape that no longer exists.
97
+ assert.deepEqual(placementBox('control-plane', {}), { box: 'control-plane', shapes: ['cloud-host', 'co-tenant'], fixedMb: 750 });
92
98
  });
93
99
 
94
100
  t('placementVerdict: the ceiling refuses at N of N, the floor refuses under 256 MB free, an unmeasured floor cannot refuse', () => {
@@ -119,14 +125,14 @@ t('capacitySummary: resize_due at 80% (12 of 15), remaining never negative, verd
119
125
 
120
126
  await ta('countPlaced counts ONLY provisioning + active rows of the box\'s shapes, and can exclude the row being re-run', async () => {
121
127
  const db = fakeDb(10);
122
- assert.equal(await countPlaced(db, ['standalone']), 10);
123
- assert.deepEqual(db.queries[0].params, [['standalone'], PLACED_STATES]);
128
+ assert.equal(await countPlaced(db, ['cloud-host']), 10);
129
+ assert.deepEqual(db.queries[0].params, [['cloud-host'], PLACED_STATES]);
124
130
  assert.deepEqual(PLACED_STATES, ['provisioning', 'active'], 'error rows hold no running unit (live fleet, 2026-09-16) and must not take a slot');
125
131
  assert.doesNotMatch(db.queries[0].sql, /id <>/, 'no exclusion unless asked');
126
- await countPlaced(db, ['standalone', 'co-tenant'], { excludeId: 42 });
132
+ await countPlaced(db, ['cloud-host', 'co-tenant'], { excludeId: 42 });
127
133
  assert.match(db.queries[1].sql, /id <> \$3/);
128
- assert.deepEqual(db.queries[1].params, [['standalone', 'co-tenant'], PLACED_STATES, 42]);
129
- assert.equal(await countPlaced({ query: async () => ({ rows: [] }) }, ['standalone']), 0, 'a recording fake with no rows reads 0, not NaN');
134
+ assert.deepEqual(db.queries[1].params, [['cloud-host', 'co-tenant'], PLACED_STATES, 42]);
135
+ assert.equal(await countPlaced({ query: async () => ({ rows: [] }) }, ['cloud-host']), 0, 'a recording fake with no rows reads 0, not NaN');
130
136
  });
131
137
 
132
138
  await ta('fleetCapacity (the API + CLI summary) measures THIS host and counts the control plane\'s shapes', async () => {
@@ -135,7 +141,7 @@ await ta('fleetCapacity (the API + CLI summary) measures THIS host and counts th
135
141
  const c = await fleetCapacity(db, { meminfo: MEMINFO_2GB, remoteHost: null });
136
142
  assert.equal(c.box, 'control-plane'); assert.equal(c.placed, 10); assert.equal(c.ceiling, 15); assert.equal(c.source, 'measured');
137
143
  assert.equal(c.mem_available_mb, 1108); assert.equal(c.resize_due, false); assert.equal(c.verdict.ok, true);
138
- assert.deepEqual(db.queries[0].params[0], ['standalone', 'co-tenant']);
144
+ assert.deepEqual(db.queries[0].params[0], ['cloud-host', 'co-tenant']);
139
145
  const remote = await fleetCapacity(fakeDb(3), { meminfo: MEMINFO_4GB, remoteHost: 'cohost' });
140
146
  assert.equal(remote.ceiling, 39, 'the control plane\'s own 4 GB ceiling, not the overflow box\'s');
141
147
  // The env override reaches the summary, and is read per call.
@@ -147,7 +153,7 @@ await ta('fleetCapacity (the API + CLI summary) measures THIS host and counts th
147
153
 
148
154
  console.log('\nthe runner preflight (scripts/gds/provision-capacity.js):');
149
155
 
150
- const standalone = { id: 7, slug: 'lantern', hosting_shape: 'standalone', status: 'requested', owner_builder_id: 3, port: null, db_name: 'lantern', domain: null };
156
+ const cloudHost = { id: 7, slug: 'lantern', hosting_shape: 'cloud-host', status: 'requested', owner_builder_id: 3, port: null, db_name: 'lantern', domain: null };
151
157
 
152
158
  // Recording deps: which executor was asked, what was logged, which events landed.
153
159
  function preflightDeps({ apply = true, placed = 10, controlMeminfo = MEMINFO_2GB, boxMeminfo = MEMINFO_4GB } = {}) {
@@ -164,11 +170,11 @@ function preflightDeps({ apply = true, placed = 10, controlMeminfo = MEMINFO_2GB
164
170
  };
165
171
  }
166
172
 
167
- await ta('a standalone with room: ok, measured on the CONTROL executor, the count excludes its own row', async () => {
173
+ await ta('a cloud-host with room: ok, measured on the CONTROL executor, the count excludes its own row', async () => {
168
174
  const h = preflightDeps({ placed: 10 });
169
- const r = await capacityPreflight(standalone, h.deps);
175
+ const r = await capacityPreflight(cloudHost, h.deps);
170
176
  assert.equal(r.ok, true);
171
- assert.deepEqual(h.asked, [['control', MEMINFO_CMD]], 'a standalone runs control-plane-local, so its box is measured there');
177
+ assert.deepEqual(h.asked, [['control', MEMINFO_CMD]], 'a cloud-host runs control-plane-local, so its box is measured there');
172
178
  assert.equal(r.ceiling, 15); assert.equal(r.source, 'measured'); assert.equal(r.placed, 10);
173
179
  assert.deepEqual(h.deps.db.queries[0].params[2], 7, 'the row being provisioned never counts itself');
174
180
  assert.equal(h.events.length, 0, 'nothing to record when it fits');
@@ -177,7 +183,7 @@ await ta('a standalone with room: ok, measured on the CONTROL executor, the coun
177
183
 
178
184
  await ta('a full box REFUSES with the owner-readable reason, records placement-refused, and returns (never throws)', async () => {
179
185
  const h = preflightDeps({ placed: 15 });
180
- const r = await capacityPreflight(standalone, h.deps);
186
+ const r = await capacityPreflight(cloudHost, h.deps);
181
187
  assert.equal(r.ok, false);
182
188
  assert.match(r.error, /15 of 15 project slots are taken/);
183
189
  assert.equal(h.events.length, 1);
@@ -189,7 +195,7 @@ await ta('a full box REFUSES with the owner-readable reason, records placement-r
189
195
 
190
196
  await ta('the live memory floor refuses even with slots to spare (a stray unit the ledger cannot see)', async () => {
191
197
  const h = preflightDeps({ placed: 10, controlMeminfo: MEMINFO_TIGHT });
192
- const r = await capacityPreflight(standalone, h.deps);
198
+ const r = await capacityPreflight(cloudHost, h.deps);
193
199
  assert.equal(r.ok, false);
194
200
  assert.equal(r.verdict.wall, 'memory');
195
201
  assert.match(r.error, /117 MB of memory free/);
@@ -198,7 +204,7 @@ await ta('the live memory floor refuses even with slots to spare (a stray unit t
198
204
 
199
205
  await ta('at 80% it still places, but the log names the resize trigger', async () => {
200
206
  const h = preflightDeps({ placed: 12 });
201
- const r = await capacityPreflight(standalone, h.deps);
207
+ const r = await capacityPreflight(cloudHost, h.deps);
202
208
  assert.equal(r.ok, true); assert.equal(r.resize_due, true);
203
209
  assert.ok(h.logs.some((l) => /at or past 80%: schedule the resize \(task 1003923\)/.test(l)), h.logs.join(' | '));
204
210
  });
@@ -209,35 +215,35 @@ await ta('a co-tenant under remote-exec is measured on the BOX executor against
209
215
  CONFIG.remoteHost = 'cohost';
210
216
  try {
211
217
  const h = preflightDeps({ placed: 40 });
212
- const r = await capacityPreflight({ ...standalone, hosting_shape: 'co-tenant' }, h.deps);
218
+ const r = await capacityPreflight({ ...cloudHost, hosting_shape: 'co-tenant' }, h.deps);
213
219
  assert.deepEqual(h.asked, [['box', MEMINFO_CMD]]);
214
220
  assert.equal(r.box, 'overflow'); assert.equal(r.ceiling, 41); assert.equal(r.ok, true);
215
221
  assert.deepEqual(h.deps.db.queries[0].params[0], ['co-tenant'], 'only the shapes that share that box count');
216
222
  } finally { CONFIG.remoteHost = prior; }
217
223
  });
218
224
 
219
- await ta('a dedicated droplet is its own box: no measurement, no count, no refusal', async () => {
225
+ await ta('a shape with no box of ours: no measurement, no count, no refusal', async () => {
220
226
  const h = preflightDeps({ placed: 99 });
221
- const r = await capacityPreflight({ ...standalone, hosting_shape: 'dedicated' }, h.deps);
222
- assert.deepEqual(r, { ok: true, skipped: 'dedicated' });
227
+ const r = await capacityPreflight({ ...cloudHost, hosting_shape: 'byo-host' }, h.deps);
228
+ assert.deepEqual(r, { ok: true, skipped: 'no-shared-box' });
223
229
  assert.equal(h.asked.length, 0); assert.equal(h.deps.db.queries.length, 0);
224
230
  });
225
231
 
226
232
  await ta('dry-run measures nothing and refuses nothing, even on a full box; an unmeasured --apply falls to the default and warns', async () => {
227
233
  const dry = preflightDeps({ apply: false, placed: 15 });
228
- const r = await capacityPreflight(standalone, dry.deps);
234
+ const r = await capacityPreflight(cloudHost, dry.deps);
229
235
  assert.equal(r.ok, true); assert.equal(r.dryRun, true);
230
236
  assert.equal(dry.events.length, 0);
231
237
  assert.ok(dry.logs.some((l) => /would refuse if full/.test(l)));
232
238
  // --apply with an executor that answers no stdout (a fake, or a box without procfs)
233
239
  const blind = preflightDeps({ placed: 3, controlMeminfo: '' });
234
- const b = await capacityPreflight(standalone, blind.deps);
240
+ const b = await capacityPreflight(cloudHost, blind.deps);
235
241
  assert.equal(b.ok, true); assert.equal(b.source, 'default'); assert.equal(b.ceiling, DEFAULT_CEILING);
236
242
  assert.ok(blind.logs.some((l) => /could not measure the box.*PROVISION_BOX_CEILING/.test(l)), blind.logs.join(' | '));
237
243
  // …and an executor that throws is the same as one that answers nothing.
238
244
  const throwing = preflightDeps({ placed: 3 });
239
245
  throwing.deps.controlExec = () => { throw new Error('ssh down'); };
240
- assert.equal((await capacityPreflight(standalone, throwing.deps)).source, 'default');
246
+ assert.equal((await capacityPreflight(cloudHost, throwing.deps)).source, 'default');
241
247
  });
242
248
 
243
249
  await ta('statusCapacityLine prints "box at N of M" for the CLI', async () => {
@@ -272,7 +278,7 @@ function recorder({ apply, placed, meminfo = MEMINFO_2GB }) {
272
278
 
273
279
  await ta('provisionInstance(--apply) on a full box returns ok:false BEFORE any status write or standup command', async () => {
274
280
  const h = recorder({ apply: true, placed: 15 });
275
- const r = await P.provisionInstance(standalone, h.deps);
281
+ const r = await P.provisionInstance(cloudHost, h.deps);
276
282
  assert.equal(r.ok, false);
277
283
  assert.match(r.error, /15 of 15 project slots/);
278
284
  assert.deepEqual(h.statuses, [], 'never flipped to provisioning — a project that was not placed is not "provisioning"');
@@ -283,7 +289,7 @@ await ta('provisionInstance(--apply) on a full box returns ok:false BEFORE any s
283
289
 
284
290
  await ta('provisionInstance(--apply) with room proceeds into the runbook exactly as before', async () => {
285
291
  const h = recorder({ apply: true, placed: 10 });
286
- const r = await P.provisionInstance({ ...standalone, hosting_shape: 'co-tenant', port: 3003 }, h.deps);
292
+ const r = await P.provisionInstance({ ...cloudHost, hosting_shape: 'co-tenant', port: 3003 }, h.deps);
287
293
  assert.equal(r.ok, true);
288
294
  assert.equal(h.statuses[0], 'provisioning');
289
295
  assert.equal(h.cmds[0], MEMINFO_CMD, 'measured first');
@@ -292,7 +298,7 @@ await ta('provisionInstance(--apply) with room proceeds into the runbook exactly
292
298
 
293
299
  await ta('provisionInstance(dry-run) on a full box still prints the whole plan — dry-run places nothing, so it refuses nothing', async () => {
294
300
  const h = recorder({ apply: false, placed: 15 });
295
- const r = await P.provisionInstance({ ...standalone, hosting_shape: 'co-tenant', port: 3003 }, h.deps);
301
+ const r = await P.provisionInstance({ ...cloudHost, hosting_shape: 'co-tenant', port: 3003 }, h.deps);
296
302
  assert.equal(r.ok, true);
297
303
  assert.ok(h.cmds.some((c) => /createdb lantern/.test(c)));
298
304
  });
@@ -306,7 +312,7 @@ await ta('cmdRunIntents: a refused placement retires onto the card as error_note
306
312
  db: fakeDb(15),
307
313
  provisioning: {
308
314
  claimNextIntent: async () => (claims++ === 0 ? { id: 9, instance_id: 7, action: 'provision', attempts: P.MAX_INTENT_ATTEMPTS } : null),
309
- getInstanceById: async () => ({ ...standalone }),
315
+ getInstanceById: async () => ({ ...cloudHost }),
310
316
  resolveIntent: async (_db, id, state, err) => { resolved.push({ id, state, err }); },
311
317
  setInstanceStatus: async (_db, id, status, patch) => { statusWrites.push({ id, status, patch }); },
312
318
  recordEvent: async (_db, e) => { events.push(e); },
@@ -335,7 +341,7 @@ await ta('cmdRunIntents: below the attempt cap a refusal goes back to pending wi
335
341
  apply: true, log: () => {}, db,
336
342
  provisioning: {
337
343
  claimNextIntent: async () => (claims++ === 0 ? { id: 9, instance_id: 7, action: 'provision', attempts: 1 } : null),
338
- getInstanceById: async () => ({ ...standalone }),
344
+ getInstanceById: async () => ({ ...cloudHost }),
339
345
  resolveIntent: async () => { throw new Error('must not resolve on a retryable refusal'); },
340
346
  setInstanceStatus: async () => { throw new Error('must not touch the instance on a retryable refusal'); },
341
347
  recordEvent: async () => {},
@@ -357,7 +363,7 @@ console.log('\nthe fleet API carries the same summary:');
357
363
  // Stub the route's data access at the same seams tests/provisioning_paid_shape_gate.mjs
358
364
  // uses; the capacity module object is shared with the route through the require cache.
359
365
  const provisioning = require('../modules/provisioning/provisioning.js');
360
- provisioning.listAllInstances = async () => [{ id: 1, owner_builder_id: 7, slug: 'lantern', status: 'active', hosting_shape: 'standalone', settings: {}, detail: {}, github_login: 'o', display_name: 'O', rank: 'archon' }];
366
+ provisioning.listAllInstances = async () => [{ id: 1, owner_builder_id: 7, slug: 'lantern', status: 'active', hosting_shape: 'cloud-host', settings: {}, detail: {}, github_login: 'o', display_name: 'O', rank: 'archon' }];
361
367
  const api = require('../src/module-api.js');
362
368
  api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
363
369
  api.requirePermission = () => (_req, _res, next) => next();
@@ -8,7 +8,7 @@
8
8
  // naming N of M, before any row or intent exists.
9
9
  //
10
10
  // What these tests pin: the refusal lands before createInstance; a box the web tier cannot
11
- // count (dedicated, a remote co-host) is never refused here; a re-request of a row that
11
+ // count (a remote co-host, or byo-host) is never refused here; a re-request of a row that
12
12
  // already holds its slot stays idempotent, while a revive (which takes a slot) is counted;
13
13
  // the live-memory floor stays the runner's; and a capacity read that fails ADMITS, because
14
14
  // the runner remains the wall.
@@ -100,7 +100,7 @@ function reset({ count = 0, row = null, mem = MEMINFO_2GB, fails = false } = {})
100
100
  delete process.env.PROVISION_REMOTE_HOST;
101
101
  }
102
102
 
103
- const WIZARD = { slug: 'lantern', hosting_shape: 'standalone', no_address: true };
103
+ const WIZARD = { slug: 'lantern', hosting_shape: 'cloud-host', no_address: true };
104
104
 
105
105
  // ── the wall ──────────────────────────────────────────────────────────────────────
106
106
 
@@ -141,13 +141,6 @@ await ta('an absent shape takes the byo-host default, so a full box does NOT ref
141
141
 
142
142
  // ── boxes the web tier cannot count are the runner's ──────────────────────────────
143
143
 
144
- await ta('dedicated is its own box: never refused here, and the count is never read', async () => {
145
- reset({ count: 15 });
146
- const res = await post({ slug: 'lantern', hosting_shape: 'dedicated', no_address: true });
147
- assert.equal(res.status, 201, res.raw);
148
- assert.equal(capacityReads, 0);
149
- });
150
-
151
144
  await ta('byo-host runs on the OWNER\'s machine: never refused here, and the count is never read', async () => {
152
145
  // ADR 0323's new shape, and the strongest form of "not our box": there is no machine
153
146
  // of ours to place it on at all, so a full control plane must not refuse it and the
@@ -176,14 +169,14 @@ await ta('the live-memory floor is left to the runner: slots free + memory tight
176
169
  // ── re-requests ───────────────────────────────────────────────────────────────────
177
170
 
178
171
  await ta('a re-request of the owner\'s ACTIVE project on a full box stays idempotent (no box_full)', async () => {
179
- reset({ count: 15, row: { id: 42, owner_builder_id: 7, slug: 'lantern', hosting_shape: 'standalone', status: 'active' } });
172
+ reset({ count: 15, row: { id: 42, owner_builder_id: 7, slug: 'lantern', hosting_shape: 'cloud-host', status: 'active' } });
180
173
  const res = await post(WIZARD);
181
174
  assert.notEqual(res.status, 409, res.raw);
182
175
  assert.ok(res.status < 300, res.raw);
183
176
  });
184
177
 
185
178
  await ta('a revive of a torn-down project takes a slot, so a full box refuses it', async () => {
186
- reset({ count: 15, row: { id: 42, owner_builder_id: 7, slug: 'lantern', hosting_shape: 'standalone', status: 'torn_down' } });
179
+ reset({ count: 15, row: { id: 42, owner_builder_id: 7, slug: 'lantern', hosting_shape: 'cloud-host', status: 'torn_down' } });
187
180
  const res = await post(WIZARD);
188
181
  assert.equal(res.status, 409, res.raw);
189
182
  assert.equal(res.body.error.code, 'box_full');
@@ -199,7 +192,7 @@ await ta('a capacity read that fails ADMITS — the runner remains the wall', as
199
192
  });
200
193
 
201
194
  await ta('a malformed slug or shape gets the handler\'s 400, not box_full', async () => {
202
- for (const body of [{ slug: 'Bad Slug!', hosting_shape: 'standalone' }, { slug: 'lantern', hosting_shape: 'giant' }]) {
195
+ for (const body of [{ slug: 'Bad Slug!', hosting_shape: 'cloud-host' }, { slug: 'lantern', hosting_shape: 'giant' }]) {
203
196
  reset({ count: 15 });
204
197
  const res = await post(body);
205
198
  assert.equal(res.status, 400, `${JSON.stringify(body)} → ${res.raw}`);
@@ -0,0 +1,98 @@
1
+ // tests/provisioning_cost_ledger.mjs — the fleet cost ledger asks two questions, one
2
+ // column each (task 1004148, ADR 0327): what a row costs US, and an estimate of what it
3
+ // costs its OWNER. A row neither can price reads null (unpriced), never 0 — the old
4
+ // single column reported a confident $0 for every project, with no way to tell
5
+ // "costs nothing" from "we don't know".
6
+ //
7
+ // DB-free: CI has no Postgres, so costLedger runs against a recording fake.
8
+ //
9
+ // Run: node tests/provisioning_cost_ledger.mjs
10
+ import { strict as assert } from 'node:assert';
11
+ import { makeRunner } from './helpers.mjs';
12
+ import {
13
+ platformMonthlyUsd, ownerMonthlyUsd, costLedger, RENDER_PLAN_MONTHLY_USD,
14
+ } from '../modules/provisioning/cost-ledger.js';
15
+
16
+ const { test, summary } = makeRunner();
17
+
18
+ const PLAN = Object.keys(RENDER_PLAN_MONTHLY_USD)[0];
19
+
20
+ await test('a cloud-host with no plan named is unpriced for its owner, not $0', () => {
21
+ assert.equal(ownerMonthlyUsd({ hostingShape: 'cloud-host', tier: null }), null);
22
+ assert.equal(ownerMonthlyUsd({ hostingShape: 'cloud-host' }), null);
23
+ assert.equal(ownerMonthlyUsd({ hostingShape: 'cloud-host', tier: '' }), null);
24
+ });
25
+
26
+ await test('a cloud-host on a named plan is priced from the plan table', () => {
27
+ assert.equal(ownerMonthlyUsd({ hostingShape: 'cloud-host', tier: PLAN }), RENDER_PLAN_MONTHLY_USD[PLAN]);
28
+ assert.ok(RENDER_PLAN_MONTHLY_USD[PLAN] > 0);
29
+ });
30
+
31
+ await test('an unrecognised plan is unpriced, including an Object.prototype key', () => {
32
+ assert.equal(ownerMonthlyUsd({ hostingShape: 'cloud-host', tier: 'pro-max+pro-64gb' }), null);
33
+ assert.equal(ownerMonthlyUsd({ hostingShape: 'cloud-host', tier: 'constructor' }), null);
34
+ assert.equal(platformMonthlyUsd({ hostingShape: 'control-plane', tier: 'toString' }), null);
35
+ });
36
+
37
+ await test('a cloud-host costs US a correct $0: it runs on its owner\'s own account', () => {
38
+ assert.equal(platformMonthlyUsd({ hostingShape: 'cloud-host', tier: PLAN }), 0);
39
+ assert.equal(platformMonthlyUsd({ hostingShape: 'cloud-host', tier: null }), 0);
40
+ });
41
+
42
+ await test('byo-host: $0 to us, and unpriced for the owner (their own machine)', () => {
43
+ assert.equal(platformMonthlyUsd({ hostingShape: 'byo-host' }), 0);
44
+ assert.equal(ownerMonthlyUsd({ hostingShape: 'byo-host' }), null);
45
+ });
46
+
47
+ await test('co-tenant shares our box: no owner is billed, and the box is priced once', () => {
48
+ assert.equal(platformMonthlyUsd({ hostingShape: 'co-tenant' }), 0);
49
+ assert.equal(ownerMonthlyUsd({ hostingShape: 'co-tenant' }), 0);
50
+ });
51
+
52
+ await test('the control plane is OUR cost, priced by its droplet size; no size is unpriced', () => {
53
+ assert.equal(platformMonthlyUsd({ hostingShape: 'control-plane', tier: 's-1vcpu-2gb' }), 12);
54
+ assert.equal(platformMonthlyUsd({ hostingShape: 'control-plane', tier: null }), null);
55
+ assert.equal(ownerMonthlyUsd({ hostingShape: 'control-plane', tier: 's-1vcpu-2gb' }), 0);
56
+ });
57
+
58
+ await test('an unknown shape is unpriced in both columns', () => {
59
+ assert.equal(platformMonthlyUsd({ hostingShape: 'dedicated', tier: 's-2vcpu-4gb' }), null);
60
+ assert.equal(ownerMonthlyUsd({ hostingShape: 'dedicated', tier: 's-2vcpu-4gb' }), null);
61
+ });
62
+
63
+ function recordingDb(rows) {
64
+ const calls = [];
65
+ return { calls, query: async (sql, params) => { calls.push({ sql, params }); return { rows }; } };
66
+ }
67
+ const row = (id, hosting_shape, tier = null) =>
68
+ ({ id, slug: `p${id}`, hosting_shape, tier, status: 'active', github_login: 'owner' });
69
+
70
+ await test('the live fleet today: every total says how many rows it could not price', async () => {
71
+ // Measured 2026-09-24: the control plane (no size recorded) + five cloud-hosts (no plan).
72
+ const db = recordingDb([row(1, 'control-plane'), ...[2, 3, 4, 5, 6].map((id) => row(id, 'cloud-host'))]);
73
+ const out = await costLedger(db);
74
+ assert.deepEqual(out.totals.platform, { monthly_usd: 0, unpriced: 1 });
75
+ assert.deepEqual(out.totals.owner, { monthly_usd: 0, unpriced: 5 });
76
+ assert.equal(out.instances[0].platform_monthly_usd, null);
77
+ assert.equal(out.instances[1].owner_monthly_usd, null);
78
+ });
79
+
80
+ await test('totals sum only priced rows, per column', async () => {
81
+ const db = recordingDb([
82
+ row(1, 'control-plane', 's-1vcpu-2gb'), row(2, 'cloud-host', PLAN), row(3, 'cloud-host', PLAN),
83
+ row(4, 'cloud-host'), row(5, 'byo-host'),
84
+ ]);
85
+ const out = await costLedger(db);
86
+ assert.deepEqual(out.totals.platform, { monthly_usd: 12, unpriced: 0 });
87
+ assert.deepEqual(out.totals.owner, { monthly_usd: 2 * RENDER_PLAN_MONTHLY_USD[PLAN], unpriced: 2 });
88
+ assert.equal('cost_estimate_usd' in out.instances[0], false, 'the one-column-two-questions field is gone');
89
+ });
90
+
91
+ await test('the query skips torn-down rows and clamps the limit', async () => {
92
+ const db = recordingDb([]);
93
+ await costLedger(db, { limit: 99999 });
94
+ assert.match(db.calls[0].sql, /status <> 'torn_down'/);
95
+ assert.deepEqual(db.calls[0].params, [2000]);
96
+ });
97
+
98
+ summary();
@@ -133,7 +133,7 @@ function post(path, body = {}) {
133
133
  }
134
134
 
135
135
  const freshRow = (over = {}) => ({
136
- id: 41, owner_builder_id: 7, slug: 'spike', status: 'requested', hosting_shape: 'standalone',
136
+ id: 41, owner_builder_id: 7, slug: 'spike', status: 'requested', hosting_shape: 'cloud-host',
137
137
  domain: 'spike.cloudbongos.com', onboard_mode: 'greenfield', type: null, created_at: '', updated_at: '', ...over,
138
138
  });
139
139
 
@@ -192,7 +192,7 @@ function reset({ inst, holder } = {}) {
192
192
  console.log('\nPOST /provisioning/instances/:id/domain — give an existing instance its address:');
193
193
 
194
194
  await ta('ACTIVE domainless instance: domain is validated, recorded, and a domain-attach intent queues', async () => {
195
- reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'standalone', domain: null } });
195
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'cloud-host', domain: null } });
196
196
  const res = await post('/provisioning/instances/18/domain', { domain: 'Spike.CloudBongos.com.' });
197
197
  assert.equal(res.status, 200, res.raw);
198
198
  assert.equal(res.body.ok, true);
@@ -419,7 +419,7 @@ console.log('\nPOST /provisioning/instances — slug re-requests are truthful ab
419
419
  await ta('re-request with a domain on an existing instance SAYS the domain was ignored (and where to send it)', async () => {
420
420
  reset();
421
421
  createResult = { instance: { id: 18, owner_builder_id: 7, slug: 'spike', status: 'active', domain: null }, created: false };
422
- const res = await post('/provisioning/instances', { slug: 'spike', hosting_shape: 'standalone', domain: 'spike.cloudbongos.com' });
422
+ const res = await post('/provisioning/instances', { slug: 'spike', hosting_shape: 'cloud-host', domain: 'spike.cloudbongos.com' });
423
423
  assert.equal(res.status, 200, res.raw);
424
424
  assert.ok(res.body.domain_ignored, 'the drop is surfaced, not silent');
425
425
  assert.equal(res.body.domain_ignored.requested, 'spike.cloudbongos.com');
@@ -429,7 +429,7 @@ await ta('re-request with a domain on an existing instance SAYS the domain was i
429
429
  await ta('re-request with the SAME domain the instance already has → no ignored flag (nothing was dropped)', async () => {
430
430
  reset();
431
431
  createResult = { instance: { id: 18, owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com' }, created: false };
432
- const res = await post('/provisioning/instances', { slug: 'spike', hosting_shape: 'standalone', domain: 'spike.cloudbongos.com' });
432
+ const res = await post('/provisioning/instances', { slug: 'spike', hosting_shape: 'cloud-host', domain: 'spike.cloudbongos.com' });
433
433
  assert.equal(res.status, 200, res.raw);
434
434
  assert.equal(res.body.domain_ignored, undefined);
435
435
  });
@@ -437,7 +437,7 @@ await ta('re-request with the SAME domain the instance already has → no ignore
437
437
  await ta('a fresh create with a domain is untouched (the domain is stored at creation — nothing ignored)', async () => {
438
438
  reset();
439
439
  createResult = { instance: { id: 43, owner_builder_id: 7, slug: 'new', status: 'requested', domain: 'new.cloudbongos.com' }, created: true };
440
- const res = await post('/provisioning/instances', { slug: 'new', hosting_shape: 'standalone', domain: 'new.cloudbongos.com' });
440
+ const res = await post('/provisioning/instances', { slug: 'new', hosting_shape: 'cloud-host', domain: 'new.cloudbongos.com' });
441
441
  assert.equal(res.status, 201, res.raw);
442
442
  assert.equal(res.body.domain_ignored, undefined);
443
443
  });
@@ -249,7 +249,7 @@ console.log('\nPOST /provisioning/instances/:id/domain — the index refusing a
249
249
 
250
250
  await ta('the index catching the race answers domain_taken 409, and nothing is queued', async () => {
251
251
  reset();
252
- instance = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'standalone', domain: null };
252
+ instance = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'cloud-host', domain: null };
253
253
  updateThrows = DOMAIN_VIOLATION();
254
254
  const res = await post('/provisioning/instances/18/domain', { domain: 'mercury.example.com' });
255
255
  assert.equal(res.status, 409, res.raw);
@@ -260,7 +260,7 @@ await ta('the index catching the race answers domain_taken 409, and nothing is q
260
260
 
261
261
  await ta('any OTHER failure on attach is still the honest 500', async () => {
262
262
  reset();
263
- instance = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'standalone', domain: null };
263
+ instance = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'cloud-host', domain: null };
264
264
  updateThrows = Object.assign(new Error('connection terminated'), { code: '57P01' });
265
265
  const res = await post('/provisioning/instances/18/domain', { domain: 'mercury.example.com' });
266
266
  assert.equal(res.status, 500, res.raw);
@@ -30,7 +30,7 @@ const provisioning = require('../modules/provisioning/provisioning.js');
30
30
  const { test, summary } = makeRunner();
31
31
 
32
32
  const activeStandalone = {
33
- id: 18, slug: 'spike', hosting_shape: 'standalone', status: 'active', owner_builder_id: 7,
33
+ id: 18, slug: 'spike', hosting_shape: 'cloud-host', status: 'active', owner_builder_id: 7,
34
34
  port: 3010, db_name: 'spike', domain: null, target_ref: 'jaxri/spike-harbor',
35
35
  };
36
36
 
@@ -74,7 +74,7 @@ await ta('createInstance: a chosen set is stored as a jsonb array, in catalog or
74
74
 
75
75
  // Act
76
76
  await provisioning.createInstance(db, {
77
- ownerBuilderId: 7, slug: 'demo', hostingShape: 'standalone', type: 'game',
77
+ ownerBuilderId: 7, slug: 'demo', hostingShape: 'cloud-host', type: 'game',
78
78
  modules: ['discord', 'agents'],
79
79
  });
80
80
 
@@ -90,7 +90,7 @@ await ta('createInstance: NO answer stores NULL — the skip has to stay readabl
90
90
  for (const modules of [undefined, null, 'discord', 42]) {
91
91
  const db = recordingDb();
92
92
  await provisioning.createInstance(db, {
93
- ownerBuilderId: 7, slug: 'demo', hostingShape: 'standalone', type: 'game', modules,
93
+ ownerBuilderId: 7, slug: 'demo', hostingShape: 'cloud-host', type: 'game', modules,
94
94
  });
95
95
  const params = insertParams(db);
96
96
  assert.equal(params[13], null, `modules=${JSON.stringify(modules)} stores NULL, not an empty list`);
@@ -100,7 +100,7 @@ await ta('createInstance: NO answer stores NULL — the skip has to stay readabl
100
100
  await ta('createInstance: an explicit empty answer is stored, and is NOT the same as absent', async () => {
101
101
  const db = recordingDb();
102
102
  await provisioning.createInstance(db, {
103
- ownerBuilderId: 7, slug: 'demo', hostingShape: 'standalone', type: 'game', modules: [],
103
+ ownerBuilderId: 7, slug: 'demo', hostingShape: 'cloud-host', type: 'game', modules: [],
104
104
  });
105
105
  assert.equal(insertParams(db)[13], '[]', 'the owner said none of them, and the row says so');
106
106
  });
@@ -110,7 +110,7 @@ await ta('createInstance: the always-on core is never written into the selection
110
110
  // the column disagree with ALWAYS_ON_CORE the day a manifest flips its default.
111
111
  const db = recordingDb();
112
112
  await provisioning.createInstance(db, {
113
- ownerBuilderId: 7, slug: 'demo', hostingShape: 'standalone', modules: ['memory', 'discord', 'provisioning'],
113
+ ownerBuilderId: 7, slug: 'demo', hostingShape: 'cloud-host', modules: ['memory', 'discord', 'provisioning'],
114
114
  });
115
115
  assert.equal(insertParams(db)[13], JSON.stringify(['discord']));
116
116
  });
@@ -164,7 +164,7 @@ provisioning.createInstance = async (_db, args) => {
164
164
  lastCreateArgs = args;
165
165
  return existingRow
166
166
  ? { instance: existingRow, created: false }
167
- : { instance: { id: 41, owner_builder_id: 7, slug: args.slug, status: 'requested', hosting_shape: 'standalone', type: args.type || null, detail: {} }, created: true };
167
+ : { instance: { id: 41, owner_builder_id: 7, slug: args.slug, status: 'requested', hosting_shape: 'cloud-host', type: args.type || null, detail: {} }, created: true };
168
168
  };
169
169
  provisioning.enqueueIntent = async (_db, id, action) => ({ intent: { id: 9, action, state: 'pending' }, created: true });
170
170
  provisioning.getOpenIntent = async () => null;
@@ -53,7 +53,7 @@ await ta('createInstance: onboardMode "adopt" is persisted', async () => {
53
53
  const db = recordingDb();
54
54
  await provisioning.createInstance(db, {
55
55
  ownerBuilderId: 7, slug: 'demo', targetRef: 'example-owner/demo',
56
- hostingShape: 'standalone', onboardMode: 'adopt',
56
+ hostingShape: 'cloud-host', onboardMode: 'adopt',
57
57
  });
58
58
  const insert = db.calls.find((c) => /INSERT INTO provisioning_instances/i.test(c.text));
59
59
  assert.ok(insert, 'issued an INSERT');
@@ -68,7 +68,7 @@ await ta('createInstance: absent / unknown onboardMode → greenfield, the SAFE
68
68
  for (const mode of [undefined, null, '', 'ADOPT', 'brownfield', 'nonsense']) {
69
69
  const db = recordingDb();
70
70
  await provisioning.createInstance(db, {
71
- ownerBuilderId: 7, slug: 'demo', hostingShape: 'standalone', onboardMode: mode,
71
+ ownerBuilderId: 7, slug: 'demo', hostingShape: 'cloud-host', onboardMode: mode,
72
72
  });
73
73
  const insert = db.calls.find((c) => /INSERT INTO provisioning_instances/i.test(c.text));
74
74
  assert.ok(insert.params.includes('greenfield'), `onboardMode=${JSON.stringify(mode)} → greenfield`);