@bongos/core 1.21.120 → 1.21.122

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 (36) hide show
  1. package/.bongos-core.json +54 -39
  2. package/.claude/skills/builder-ship/SKILL.md +2 -0
  3. package/clients/bongos-client/index.d.ts +2 -2
  4. package/docs/adr/0199-restart-an-unreachable-active-project.md +4 -0
  5. package/docs/adr/0369-ship-mode-decides-who-starts-a-ship-not-how-it-lands.md +1 -1
  6. package/docs/api/openapi.json +7 -3
  7. package/docs/copy-inventory.md +13 -11
  8. package/docs/copy-registry.json +30 -12
  9. package/docs/module-api-changelog.md +4 -0
  10. package/docs/page-readings.json +3 -3
  11. package/modules/government/protected-surfaces.json +2 -0
  12. package/modules/hall-ui/public/modules.js +29 -3
  13. package/modules/lifecycle/routes/project-settings.js +22 -1
  14. package/modules/provisioning/provisioning.js +5 -3
  15. package/modules/public-landing/public/projects.html +4 -2
  16. package/package-lock.json +2 -2
  17. package/package.json +1 -1
  18. package/release-notes.json +12 -0
  19. package/scripts/gds/exec-path-guard.js +2 -2
  20. package/scripts/gds/provision.js +4 -2
  21. package/scripts/gds/ship-flow.js +22 -2
  22. package/scripts/gds/ship-mode-ask.js +138 -0
  23. package/scripts/gds/ship.js +6 -0
  24. package/src/bongos/module-scope-map.js +3 -1
  25. package/src/bongos/routes/modules.js +22 -3
  26. package/src/module-api.js +1 -1
  27. package/src/module-loader/loader.js +42 -2
  28. package/tests/government_protected_surfaces.mjs +9 -1
  29. package/tests/hall_catalog_world.mjs +1 -1
  30. package/tests/module_loader.mjs +31 -0
  31. package/tests/module_toggle_restart.mjs +73 -0
  32. package/tests/projects_hub_pre_uat.mjs +4 -1
  33. package/tests/provision_restart.mjs +3 -2
  34. package/tests/provisioning_restart.mjs +6 -3
  35. package/tests/provisioning_settings_apply.mjs +2 -2
  36. package/tests/ship_mode_first_ship.mjs +219 -0
@@ -77,6 +77,8 @@ const express = require('express');
77
77
  const auth = require('../auth');
78
78
  const { asyncHandler, validateOrRespond, LIMITS } = require('./_helpers');
79
79
  const loader = require('../../module-loader/loader');
80
+ // named, not loader.restartPendingFor: knip sees a named require, never a member read
81
+ const { restartPendingFor } = require('../../module-loader/loader');
80
82
  const catalog = require('../../module-loader/catalog');
81
83
  const modulesLib = require('../../modules');
82
84
  // scripts/gds/ is a sibling CORE-package dir (both resolve under
@@ -110,6 +112,15 @@ const log = require('../logger').logger.child({ src: 'modules' });
110
112
  const { listOverrides, setOverride } = require('../module-overrides');
111
113
  const { listCatalog } = require('../module-listing');
112
114
 
115
+ // The sentence for a toggle that waits on a restart (task 1004463). 'routes' are the
116
+ // module's pages, 'pollers' its background jobs. PURE.
117
+ function restartNote(waits, enabled) {
118
+ const parts = enabled
119
+ ? [waits.includes('routes') && 'its pages do not open', waits.includes('pollers') && 'its background jobs do not run']
120
+ : [waits.includes('routes') && 'its pages still open', waits.includes('pollers') && 'its background jobs still run'];
121
+ return `It turns ${enabled ? 'on' : 'off'} at the next restart of this server. Until then ${parts.filter(Boolean).join(' and ')}.`;
122
+ }
123
+
113
124
  // A packaged submission embeds every file's content (base64) — cap the total
114
125
  // so a runaway/misbuilt module can't bloat the review-queue table. Generous
115
126
  // headroom over what a real feature module carries (routes + migrations + UI
@@ -258,6 +269,8 @@ module.exports = function buildModulesRouter() {
258
269
  ? `/builders/modules?howto=${encodeURIComponent(m.key)}&version=${encodeURIComponent(String(m.version))}`
259
270
  : null;
260
271
  m.store_delisted = m.source === 'instance' && delistedKeys.has(m.key);
272
+ // what of this module still waits for a restart to match its toggle (task 1004463)
273
+ m.restart_pending = restartPendingFor(m.key, m.enabled === true);
261
274
  }
262
275
  res.json({
263
276
  modules: all,
@@ -297,6 +310,9 @@ module.exports = function buildModulesRouter() {
297
310
  // the write path deliberately is not.
298
311
  const resolvedSet = await setOverride(key, enabled, { builderId: req.builder && req.builder.id });
299
312
  const effective = resolvedSet ? resolvedSet[key] === true : enabled;
313
+ // Routes mount and pollers start once, at boot (task 1004463): say so rather than
314
+ // "live now" when this change waits for the next restart of this server.
315
+ const waits = restartPendingFor(key, effective);
300
316
 
301
317
  console.log(`[gds] module "${key}" ${enabled ? 'enabled' : 'disabled'} by builder ${req.builder && req.builder.id}`);
302
318
  res.json({
@@ -308,9 +324,12 @@ module.exports = function buildModulesRouter() {
308
324
  // who is not told so will re-click the toggle and blame the feature.
309
325
  effective,
310
326
  source: (resolvedSet && resolvedSet.sources && resolvedSet.sources[key]) || 'override',
311
- note: effective === enabled
312
- ? 'Live on this box now, and durable — it survives the next deploy.'
313
- : `Recorded, but "${key}" still resolves to ${effective}: a higher-precedence layer (env, or the spend guard) is winning.`,
327
+ restart_pending: waits,
328
+ note: effective !== enabled
329
+ ? `Recorded, but "${key}" still resolves to ${effective}: a higher-precedence layer (env, or the spend guard) is winning.`
330
+ : waits.length
331
+ ? `Saved, and it survives the next deploy. ${restartNote(waits, enabled)}`
332
+ : 'Live on this box now, and durable — it survives the next deploy.',
314
333
  });
315
334
  }));
316
335
 
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.120'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.122'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -112,7 +112,7 @@ function discoverModules({ roots = DEFAULT_ROOTS, core = coreVersion() } = {}) {
112
112
  // Cached singleton for app code (lazy, like src/modules.js's modules()).
113
113
  let _cache = null;
114
114
  function discovered(opts) { return (_cache ||= discoverModules(opts)); }
115
- function _reset() { _cache = null; _startedPollers = []; }
115
+ function _reset() { _cache = null; _startedPollers = []; _boot = { routes: null, pollers: null }; _bootShape = null; }
116
116
 
117
117
  // The discovered manifests as MODULE_REGISTRY-shaped entries, so src/modules.js
118
118
  // can merge them with its built-in registry. Empty until a module exists.
@@ -190,8 +190,10 @@ function mountModuleRoutes(router, { isEnabled } = {}, opts) {
190
190
  const enabled = enabledModules({ isEnabled }, opts);
191
191
  verifyEnabledPorts(enabled);
192
192
  let mounted = 0;
193
- for (const { dir, manifest } of enabled) {
193
+ _boot.routes = new Set();
194
+ for (const { key, dir, manifest } of enabled) {
194
195
  const routeKeys = (manifest.contributes && manifest.contributes.routes) || [];
196
+ if (routeKeys.length) _boot.routes.add(key);
195
197
  for (const rk of routeKeys) {
196
198
  const factory = require(path.join(dir, 'routes', `${rk}.js`));
197
199
  const sub = typeof factory === 'function' ? factory() : factory;
@@ -284,6 +286,41 @@ function moduleWebSurfaces({ isEnabled } = {}, opts) {
284
286
  return out;
285
287
  }
286
288
 
289
+ // What this process mounted at boot (task 1004463): the keys whose routes were mounted
290
+ // and whose pollers started. null until that boot step has run in this process — so a
291
+ // script or test that never booted is never told a restart is pending.
292
+ let _boot = { routes: null, pollers: null };
293
+ // key → { routes, pollers }: whether each discovered module mounts routes / runs a poller in
294
+ // this process. Module files do not change under a running process, so it is worked out
295
+ // once, on first ask, rather than stat'ing disk on every GET /modules.
296
+ let _bootShape = null;
297
+ function bootShape(opts) {
298
+ if (_bootShape) return _bootShape;
299
+ _bootShape = new Map();
300
+ for (const { key, dir, manifest } of discovered(opts).loaded) {
301
+ const c = manifest.contributes || {};
302
+ _bootShape.set(key, {
303
+ routes: (c.routes || []).length > 0,
304
+ // the same rule startModulePollers applies: a poller with no file here runs elsewhere
305
+ pollers: (c.pollers || []).some((pk) => fs.existsSync(path.join(dir, 'pollers', `${pk}.js`))),
306
+ });
307
+ }
308
+ return _bootShape;
309
+ }
310
+
311
+ // Does turning module `key` to `enabled` wait for a restart in THIS process (task
312
+ // 1004463)? Routes mount and pollers start once, at boot, so a module switched on after
313
+ // boot has no pages yet, and one switched off keeps answering until the restart. Returns
314
+ // what waits — [] when the change is live now. PURE over the boot record and the manifest.
315
+ function restartPendingFor(key, enabled, opts) {
316
+ const shape = bootShape(opts).get(key);
317
+ if (!shape) return [];
318
+ const waits = [];
319
+ if (_boot.routes && shape.routes && _boot.routes.has(key) !== !!enabled) waits.push('routes');
320
+ if (_boot.pollers && shape.pollers && _boot.pollers.has(key) !== !!enabled) waits.push('pollers');
321
+ return waits;
322
+ }
323
+
287
324
  // Started poller handles (key, name, module), so stopModulePollers stops exactly
288
325
  // what startModulePollers started — and a test can introspect them.
289
326
  let _startedPollers = [];
@@ -299,6 +336,7 @@ let _startedPollers = [];
299
336
  // No-op when nothing is discovered/enabled. Returns the started-handle list.
300
337
  function startModulePollers(deps = {}, { isEnabled } = {}, opts) {
301
338
  const started = [];
339
+ _boot.pollers = new Set();
302
340
  for (const { key, dir, manifest } of discovered(opts).loaded) {
303
341
  if (typeof isEnabled === 'function' && !isEnabled(key)) continue;
304
342
  const pollerKeys = (manifest.contributes && manifest.contributes.pollers) || [];
@@ -323,6 +361,7 @@ function startModulePollers(deps = {}, { isEnabled } = {}, opts) {
323
361
  r.catch((e) => console.error(`[module-loader] poller ${key}/${pk} start error: ${e && e.message ? e.message : e}`));
324
362
  }
325
363
  started.push({ key, poller: pk, handle: poller });
364
+ _boot.pollers.add(key);
326
365
  }
327
366
  } catch (e) {
328
367
  console.error(`[module-loader] poller ${key}/${pk} start threw: ${e && e.message ? e.message : e}`);
@@ -370,6 +409,7 @@ module.exports = {
370
409
  moduleWebSurfaces,
371
410
  startModulePollers,
372
411
  stopModulePollers,
412
+ restartPendingFor,
373
413
  loaderSnapshot,
374
414
  coreVersion,
375
415
  dedupeRoots,
@@ -266,11 +266,19 @@ const ADDED_SINCE_R101 = [
266
266
  + 'score guards a model cannot talk past.'),
267
267
  ...carve('scripts/gds/ship.js', ['ship-check', 'ship-deploy-target', 'ship-escalation',
268
268
  'ship-finish', 'ship-flow', 'ship-git', 'ship-grade-steps', 'ship-grade', 'ship-honesty',
269
- 'ship-io', 'ship-land', 'ship-memory-sync', 'ship-merge-gate', 'ship-merge',
269
+ 'ship-io', 'ship-land', 'ship-memory-sync', 'ship-merge-gate', 'ship-merge', 'ship-mode-ask',
270
270
  'ship-preflight-steps', 'ship-regen', 'ship-resume', 'ship-session-upload', 'ship-state',
271
271
  'ship-surprise', 'ship-visual'],
272
272
  'the 2026-08-23 ship.js carve: the land path, the ship state machine, the grade gate '
273
273
  + 'steps and pushVia, ~6,200 lines behind a 373-line protected entry point.'),
274
+ // task 1004630: a ship.js sibling added after the carve — registered so the carve
275
+ // tripwire below stays green and the file sits at ship.js's floor.
276
+ {
277
+ after: 'scripts/gds/ship-visual.js',
278
+ glob: 'scripts/gds/ship-mode-ask.js',
279
+ why: 'split out of the protected ship.js: the ship-mode step every ship runs '
280
+ + 'before it lands, so an edit here could change how work reaches main.',
281
+ },
274
282
  // task 1002839 (ADR 0089 §1). Not a `grader-*.js` sibling, so the carve tripwire
275
283
  // below would not demand it — registered by hand because it owns the gate.
276
284
  {
@@ -58,7 +58,7 @@ test('modules: the row is the oversight row — title with the mono key, clamped
58
58
  assert.match(js, /<span class="ov-row__title">\$\{escapeHtml\(m\.title \|\| m\.key\)\}<span class="ov-mono">\$\{escapeHtml\(m\.key\)\}<\/span>/, 'the key rides inside the title in mono');
59
59
  assert.match(js, /class="ov-row__sub mod-row__desc"/, 'the description is the sub line');
60
60
  assert.match(js, /<details class="mod-more"/, 'a details disclosure opens the rest');
61
- assert.match(js, /<div class="ov-row__facts">\$\{provenancePill\(m\)\}\$\{attributionFacts\(m\)\}\$\{upkeepFact\(m\)\}\$\{howtoFact\(m\)\}\$\{delistedFact\(m\)\}<\/div>/, 'provenance, credit, upkeep, (for a store version, task 1004366) the how-to link and (task 1003797) the delisted pill are the facts line');
61
+ assert.match(js, /<div class="ov-row__facts">\$\{provenancePill\(m\)\}\$\{attributionFacts\(m\)\}\$\{upkeepFact\(m\)\}\$\{howtoFact\(m\)\}\$\{delistedFact\(m\)\}\$\{restartFact\(m\)\}<\/div>/, 'provenance, credit, upkeep, (for a store version, task 1004366) the how-to link and (task 1003797) the delisted pill and (task 1004463) the waits-for-a-restart pill are the facts line');
62
62
  assert.match(js, /<div class="ov-row__right mod-row__right">\$\{rightHtml\(m\)\}<\/div>/, 'the right column is one function');
63
63
  const css = read('modules.css');
64
64
  assert.match(css, /\.mod-row__desc \{[^}]*-webkit-line-clamp: 2/, 'the description clamps to two lines');
@@ -462,3 +462,34 @@ test('the demo proof module mounts + serves through the loader (BV1.R52 / task 1
462
462
  assert.deepEqual(routes.sort(), ['GET /demo/health', 'POST /demo/ping']);
463
463
  loader._reset();
464
464
  });
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // task 1004463 — a toggle says whether it waits for a restart. Routes mount and pollers
468
+ // start once, at boot, so restartPendingFor compares the toggle with what THIS process
469
+ // mounted; the Modules toggle used to claim "Live on this box now" regardless.
470
+ // ---------------------------------------------------------------------------
471
+
472
+ test('task 1004463: restartPendingFor names what waits for a restart, and nothing before boot', () => {
473
+ loader._reset();
474
+ const factory = "module.exports = () => function moduleRoute(req, res, next) { next(); };";
475
+ const poller = 'module.exports = { start() {}, stop() {} };';
476
+ const root = fixtureRoot({
477
+ pages: { manifest: validManifest('pages', { contributes: { routes: ['main'] } }), files: { 'routes/main.js': factory } },
478
+ jobs: { manifest: validManifest('jobs', { contributes: { routes: ['main'], pollers: ['tick'] } }), files: { 'routes/main.js': factory, 'pollers/tick.js': poller } },
479
+ remote: { manifest: validManifest('remote', { contributes: { pollers: ['elsewhere'] } }) },
480
+ quiet: { manifest: validManifest('quiet') },
481
+ });
482
+ const opts = { roots: [root], core: '1.0.0' };
483
+ assert.deepEqual(loader.restartPendingFor('pages', true, opts), [], 'a process that never booted is never told a restart is pending');
484
+ const bootOn = (k) => k === 'jobs';
485
+ loader.mountModuleRoutes({ use: () => {} }, { isEnabled: bootOn }, opts);
486
+ loader.startModulePollers({}, { isEnabled: bootOn }, opts);
487
+ assert.deepEqual(loader.restartPendingFor('pages', true, opts), ['routes'], 'switched on after boot: its pages wait');
488
+ assert.deepEqual(loader.restartPendingFor('pages', false, opts), [], 'back off: matches what is mounted');
489
+ assert.deepEqual(loader.restartPendingFor('jobs', false, opts), ['routes', 'pollers'], 'switched off after boot: pages and jobs keep running');
490
+ assert.deepEqual(loader.restartPendingFor('jobs', true, opts), []);
491
+ assert.deepEqual(loader.restartPendingFor('remote', true, opts), [], 'a poller with no file here never runs in this process');
492
+ assert.deepEqual(loader.restartPendingFor('quiet', true, opts), [], 'nothing mounted at boot, nothing to wait for');
493
+ assert.deepEqual(loader.restartPendingFor('nope', true, opts), []);
494
+ loader._reset();
495
+ });
@@ -0,0 +1,73 @@
1
+ // tests/module_toggle_restart.mjs — task 1004463.
2
+ //
3
+ // Measured on cloudbongos.com: switching a module on stored the override and the toggle
4
+ // said "Live on this box now", but the module's pages stayed 404 until the next restart,
5
+ // because routes mount (and pollers start) once, at boot. The toggle now asks the loader
6
+ // what still waits (restartPendingFor) and says so; GET /modules carries the same field
7
+ // so the hall can show "waits for a restart" on the row.
8
+ //
9
+ // Hermetic: setOverride is stubbed BEFORE the route file binds it, and the loader's
10
+ // answer is stubbed on the loader object the route calls through. The loader's own rule
11
+ // is tests/module_loader.mjs (task 1004463 case).
12
+ //
13
+ // Run: node --test tests/module_toggle_restart.mjs
14
+
15
+ import { strict as assert } from 'node:assert';
16
+ import { test } from 'node:test';
17
+ import { createRequire } from 'node:module';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const overrides = require('../src/bongos/module-overrides.js');
21
+ overrides.setOverride = async (key, enabled) => ({ [key]: enabled, sources: { [key]: 'override' } });
22
+ const loader = require('../src/module-loader/loader.js');
23
+ // the route binds restartPendingFor at require time, so stub it BEFORE the route loads and
24
+ // let each case choose the answer through `pending`
25
+ let pending = () => [];
26
+ loader.restartPendingFor = (...a) => pending(...a);
27
+ const buildModulesRouter = require('../src/bongos/routes/modules.js');
28
+
29
+ function handlerFor(router, method, path) {
30
+ const layer = router.stack.find((l) => l.route && l.route.path === path && l.route.methods[method]);
31
+ assert.ok(layer, `no ${method.toUpperCase()} ${path} on the router`);
32
+ return layer.route.stack[layer.route.stack.length - 1].handle;
33
+ }
34
+ async function invoke(handler, req) {
35
+ const res = {
36
+ statusCode: 200, body: undefined,
37
+ json(b) { this.body = b; return this; },
38
+ status(c) { this.statusCode = c; return this; },
39
+ fail(code, opts) { this.body = { error: code, ...opts }; return this; },
40
+ };
41
+ let err = null;
42
+ await handler(req, res, (e) => { err = e; });
43
+ if (err) throw err;
44
+ return res;
45
+ }
46
+ const router = buildModulesRouter();
47
+ const key = buildModulesRouter.listModules()[0].key;
48
+ const toggle = (enabled) => invoke(handlerFor(router, 'post', '/modules/:key/enable'), { params: { key }, query: {}, body: { enabled }, builder: { id: 1 } });
49
+
50
+ test('a toggle that waits for a restart says so, instead of "live now"', async () => {
51
+ pending = () => ['routes', 'pollers'];
52
+ const on = await toggle(true);
53
+ assert.deepEqual(on.body.restart_pending, ['routes', 'pollers']);
54
+ assert.doesNotMatch(on.body.note, /Live on this box now/);
55
+ assert.equal(on.body.note, 'Saved, and it survives the next deploy. It turns on at the next restart of this server. Until then its pages do not open and its background jobs do not run.');
56
+ pending = () => ['routes'];
57
+ const off = await toggle(false);
58
+ assert.equal(off.body.note, 'Saved, and it survives the next deploy. It turns off at the next restart of this server. Until then its pages still open.');
59
+ });
60
+
61
+ test('a toggle with nothing waiting is still live now', async () => {
62
+ pending = () => [];
63
+ const r = await toggle(true);
64
+ assert.deepEqual(r.body.restart_pending, []);
65
+ assert.equal(r.body.note, 'Live on this box now, and durable — it survives the next deploy.');
66
+ });
67
+
68
+ test('GET /modules carries restart_pending on every row', async () => {
69
+ pending = (k) => (k === key ? ['routes'] : []);
70
+ const r = await invoke(handlerFor(router, 'get', '/modules'), { params: {}, query: {}, body: {} });
71
+ for (const m of r.body.modules) assert.ok(Array.isArray(m.restart_pending), `${m.key} carries restart_pending`);
72
+ assert.deepEqual(r.body.modules.find((m) => m.key === key).restart_pending, ['routes']);
73
+ });
@@ -86,7 +86,10 @@ test('the restart door appears ONLY where it has a reason to exist, and mirrors
86
86
  // task 1004182 folded into 'cloud-host'. A `!== 'dedicated'` test against a value no row
87
87
  // can hold is always true, so keeping it would have been a condition that reads like a
88
88
  // guard and guards nothing.
89
- assert.doesNotMatch(body, /hosting_shape/, 'no shape term survives — the one it excluded no longer exists');
89
+ // task 1004463: the ONE shape term is an inclusion — the platform's own row always has the
90
+ // door, because a module toggled in its hall waits for a restart
91
+ assert.match(body, /inst.last_verified_up === false || inst.hosting_shape === 'control-plane'/, 'the self row is the one healthy card with a door');
92
+ assert.doesNotMatch(body, /dedicated/, 'the shape it once excluded no longer exists');
90
93
  assert.match(body, /!inst\.open_intent/, 'the strip tells the story while work is in flight');
91
94
  });
92
95
 
@@ -167,9 +167,9 @@ await ta('dry-run runs no real work and observes nothing', async () => {
167
167
  assert.equal(writes.length, 0);
168
168
  });
169
169
 
170
- await ta('a co-tenant restarts on the co-hosting box; a cloud-host on the control plane', async () => {
170
+ await ta("a co-tenant restarts on the co-hosting box; a cloud-host and the platform's own row on the control plane", async () => {
171
171
  const seen = {};
172
- for (const shape of ['co-tenant', 'cloud-host']) {
172
+ for (const shape of ['co-tenant', 'cloud-host', 'control-plane']) {
173
173
  const box = [], control = [];
174
174
  const { deps } = recorder();
175
175
  deps.exec = (cmd) => { box.push(cmd); return /healthz/.test(cmd) ? { ok: true, stdout: '{}' } : { ok: true, stdout: '' }; };
@@ -179,6 +179,7 @@ await ta('a co-tenant restarts on the co-hosting box; a cloud-host on the contro
179
179
  }
180
180
  assert.ok(seen['co-tenant'].box > 0 && seen['co-tenant'].control === 0, 'a co-tenant runs on the box');
181
181
  assert.ok(seen['cloud-host'].control > 0 && seen['cloud-host'].box === 0, 'a cloud-host runs control-plane-local (task 2074) — restart it THERE');
182
+ assert.ok(seen['control-plane'].control > 0 && seen['control-plane'].box === 0, 'the self row IS this box (task 1004463)');
182
183
  });
183
184
 
184
185
  await ta('privileged mode escalates the restart', async () => {
@@ -89,14 +89,17 @@ t('RESTART_FROM is exactly {active} — every other state is already served by t
89
89
  }
90
90
  });
91
91
 
92
- t('RESTART_SHAPES is the runner-reachable pair, and the set is a subset of HOSTING_SHAPES', () => {
93
- assert.deepEqual([...provisioning.RESTART_SHAPES].sort(), ['cloud-host', 'co-tenant']);
92
+ t('RESTART_SHAPES is what the runner can reach, and the set is a subset of HOSTING_SHAPES', () => {
93
+ // task 1004463 added the control plane's own row: its hall's module toggles wait for a restart
94
+ assert.deepEqual([...provisioning.RESTART_SHAPES].sort(), ['cloud-host', 'co-tenant', 'control-plane']);
95
+ const G = require('../modules/provisioning/control-plane-guard.js');
96
+ assert.equal(G.intentAllowedForShape('restart', 'control-plane'), true, 'the self-row guard lets the restart through, so the route never promises one it refuses');
94
97
  for (const s of provisioning.RESTART_SHAPES) assert.ok(provisioning.HOSTING_SHAPES.includes(s), `${s} is a real shape`);
95
98
  // Was `!has('dedicated')`. Task 1004182 removed that value from HOSTING_SHAPES, which made
96
99
  // the assertion vacuously true — it would now pass against any set at all. Name the shapes
97
100
  // that still EXIST and are still unreachable, and assert they are real first, so this
98
101
  // cannot rot the same way twice.
99
- for (const unreachable of ['byo-host', 'control-plane']) {
102
+ for (const unreachable of ['byo-host']) {
100
103
  assert.ok(provisioning.HOSTING_SHAPES.includes(unreachable), `${unreachable} must be a real shape or this assertion is vacuous`);
101
104
  assert.ok(!provisioning.RESTART_SHAPES.has(unreachable), unreachable);
102
105
  }
@@ -92,8 +92,8 @@ t('the two gates agree: every settings-apply shape is one the self-row guard let
92
92
  // "a restart is coming" and then answer the owner with control_plane_protected.
93
93
  const G = require('../modules/provisioning/control-plane-guard.js');
94
94
  for (const s of provisioning.SETTINGS_APPLY_SHAPES) assert.equal(G.intentAllowedForShape('settings-apply', s), true, s);
95
- // and the restart door was NOT opened on the self row as a side effect
96
- assert.ok(!provisioning.RESTART_SHAPES.has('control-plane'), 'task 1004334 widened the settings push only');
95
+ // the restart door on the self row came later, deliberately (task 1004463)
96
+ assert.ok(provisioning.RESTART_SHAPES.has('control-plane'), 'task 1004463 opened the restart door on the self row');
97
97
  });
98
98
 
99
99
  await ta('enqueueSettingsApply: a fresh intent → created:true; the INSERT carries the settings-apply action', async () => {
@@ -0,0 +1,219 @@
1
+ // tests/ship_mode_first_ship.mjs — the first ship asks for the ship mode once
2
+ // (task 1004630, ADR 0369).
3
+ //
4
+ // Pinned here:
5
+ // 1. the verdict: ask only when the mode was never chosen, the caller can answer,
6
+ // and someone is there to answer; an answer is saved; a chosen mode never asks;
7
+ // 2. the flow: an ask stops the ship with exit 5 and prints the two choices plus
8
+ // the re-run; the re-run saves the answer once and the ship goes on; a refused
9
+ // save never blocks the ship; a bad --ship-mode value is refused;
10
+ // 3. asked exactly once: after the answer is saved, the next ship does not ask;
11
+ // 4. the wiring: shipMain runs the ask after the claim is found and before the
12
+ // budget cap, the grader and the resolve, so nothing resolves while it waits;
13
+ // 5. the read route reports can_change for the caller, and null when authority
14
+ // cannot be established.
15
+ //
16
+ // Run: node --test tests/ship_mode_first_ship.mjs
17
+ import { strict as assert } from 'node:assert';
18
+ import { test } from 'node:test';
19
+ import { createRequire } from 'node:module';
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+
27
+ const {
28
+ ASK_EXIT, ASK_MARKER, unattended, shipModeFlag, shipModeAskVerdict, askLines, runFirstShipAsk,
29
+ } = require('../scripts/gds/ship-mode-ask.js');
30
+
31
+ const unchosen = { mode: 'auto', chosen: false, source: 'default', can_change: true };
32
+ const chosen = { mode: 'manual', chosen: true, source: 'project_settings', can_change: true };
33
+
34
+ // ---- 1. the verdict --------------------------------------------------------
35
+
36
+ test('a never-chosen mode asks; a chosen one never does', () => {
37
+ assert.deepEqual(shipModeAskVerdict({ shipMode: unchosen, flag: null, unattended: false }), { action: 'ask' });
38
+ assert.equal(shipModeAskVerdict({ shipMode: chosen, flag: null, unattended: false }).action, 'proceed');
39
+ });
40
+
41
+ test('an answer on an unchosen project is saved; on a chosen one it changes nothing', () => {
42
+ assert.deepEqual(shipModeAskVerdict({ shipMode: unchosen, flag: { mode: 'manual' }, unattended: false }), { action: 'save', mode: 'manual' });
43
+ assert.equal(shipModeAskVerdict({ shipMode: chosen, flag: { mode: 'auto' }, unattended: false }).action, 'proceed',
44
+ 'the flag answers the first-ship question only; changing a chosen mode is the settings page or shipping-mode.js');
45
+ });
46
+
47
+ test('never blocks a ship nobody can answer for', () => {
48
+ assert.equal(shipModeAskVerdict({ shipMode: null, flag: null, unattended: false }).why, 'unreadable');
49
+ assert.equal(shipModeAskVerdict({ shipMode: { ...unchosen, can_change: false }, flag: null, unattended: false }).why, 'cannot_change');
50
+ assert.equal(shipModeAskVerdict({ shipMode: unchosen, flag: null, unattended: true }).why, 'unattended');
51
+ });
52
+
53
+ test('an older server with no can_change still asks (unknown is not "no")', () => {
54
+ const { can_change, ...old } = unchosen;
55
+ assert.equal(shipModeAskVerdict({ shipMode: old, flag: null, unattended: false }).action, 'ask');
56
+ assert.equal(shipModeAskVerdict({ shipMode: { ...unchosen, can_change: null }, flag: null, unattended: false }).action, 'ask');
57
+ });
58
+
59
+ test('unattended means an Autobongos worker or a subagent', () => {
60
+ assert.equal(unattended({}), false);
61
+ assert.equal(unattended({ OTB_SUBAGENT: '1' }), true);
62
+ assert.equal(unattended({ AUTOBONGOS_GRADE_LEDGER: '/tmp/ledger.json' }), true);
63
+ });
64
+
65
+ test('--ship-mode parses auto|manual in any case and flags anything else', () => {
66
+ assert.equal(shipModeFlag(['1', '--notes', 'x']), null);
67
+ assert.deepEqual(shipModeFlag(['1', '--ship-mode', 'Auto']), { mode: 'auto' });
68
+ assert.deepEqual(shipModeFlag(['1', '--ship-mode', 'manual']), { mode: 'manual' });
69
+ assert.deepEqual(shipModeFlag(['1', '--ship-mode', 'sometimes']), { bad: 'sometimes' });
70
+ assert.deepEqual(shipModeFlag(['1', '--ship-mode']), { bad: '(missing)' });
71
+ });
72
+
73
+ // ---- 2. the flow -----------------------------------------------------------
74
+
75
+ function harness(initial, { saveStatus = 200 } = {}) {
76
+ let current = { ...initial };
77
+ const saves = [];
78
+ const logs = [];
79
+ return {
80
+ saves, logs,
81
+ deps: (args, env = {}) => ({
82
+ args,
83
+ env,
84
+ log: (l) => logs.push(l),
85
+ getShipMode: async () => ({ ...current }),
86
+ saveShipMode: async (mode) => {
87
+ saves.push(mode);
88
+ if (saveStatus !== 200) return { ok: false, status: saveStatus, data: { error: 'permission_forbidden' } };
89
+ current = { ...current, mode, chosen: true, source: 'project_settings' };
90
+ return { ok: true, status: 200, data: { mode, chosen: true, sentence: `Saved ${mode}.` } };
91
+ },
92
+ }),
93
+ };
94
+ }
95
+
96
+ test('an unchosen project stops the ship with exit 5 and asks Auto first', async () => {
97
+ const h = harness(unchosen);
98
+ const args = ['1004630', '--notes-file', 'notes.md', '--summary', 'two words'];
99
+ const r = await runFirstShipAsk(h.deps(args));
100
+ assert.equal(r.stop, true);
101
+ assert.equal(r.exit, ASK_EXIT);
102
+ assert.equal(ASK_EXIT, 5);
103
+ const text = r.lines.join('\n');
104
+ assert.ok(r.lines[0].startsWith(ASK_MARKER));
105
+ assert.match(text, /Auto \(recommended\)[\s\S]*Manual/, 'Auto is offered first and named as recommended');
106
+ assert.match(text, /node scripts\/gds\/ship\.js 1004630 --notes-file notes\.md --summary "two words" --ship-mode auto\|manual/);
107
+ assert.match(text, /claim is still active/);
108
+ assert.deepEqual(h.saves, [], 'asking saves nothing');
109
+ });
110
+
111
+ test('the re-run with the answer saves it and the ship goes on', async () => {
112
+ const h = harness(unchosen);
113
+ const r = await runFirstShipAsk(h.deps(['1004630', '--ship-mode', 'manual']));
114
+ assert.equal(r.stop, false);
115
+ assert.deepEqual(h.saves, ['manual']);
116
+ assert.match(h.logs.join('\n'), /Ship mode saved: manual/);
117
+ });
118
+
119
+ test('asked exactly once: after the answer, the next ship neither asks nor saves', async () => {
120
+ const h = harness(unchosen);
121
+ const first = await runFirstShipAsk(h.deps(['1']));
122
+ assert.equal(first.stop, true);
123
+ await runFirstShipAsk(h.deps(['1', '--ship-mode', 'auto']));
124
+ const next = await runFirstShipAsk(h.deps(['2']));
125
+ assert.equal(next.stop, false, 'the second ship does not ask again');
126
+ const nextWithStaleFlag = await runFirstShipAsk(h.deps(['3', '--ship-mode', 'manual']));
127
+ assert.equal(nextWithStaleFlag.stop, false);
128
+ assert.deepEqual(h.saves, ['auto'], 'the answer was written once and never overwritten by a later ship');
129
+ });
130
+
131
+ test('a refused save never blocks the ship, and says who can set it', async () => {
132
+ const h = harness(unchosen, { saveStatus: 403 });
133
+ const r = await runFirstShipAsk(h.deps(['1', '--ship-mode', 'manual']));
134
+ assert.equal(r.stop, false);
135
+ assert.match(h.logs.join('\n'), /Only the project owner[\s\S]*default, Auto/);
136
+ });
137
+
138
+ test('a bad --ship-mode value is refused before anything resolves', async () => {
139
+ const h = harness(unchosen);
140
+ const r = await runFirstShipAsk(h.deps(['1', '--ship-mode', 'sometimes']));
141
+ assert.equal(r.stop, true);
142
+ assert.equal(r.exit, 2);
143
+ assert.match(r.lines[0], /must be auto or manual/);
144
+ assert.deepEqual(h.saves, []);
145
+ });
146
+
147
+ test('an unreadable mode and an unattended run both ship on without asking', async () => {
148
+ const dead = await runFirstShipAsk({ args: ['1'], getShipMode: async () => { throw new Error('down'); }, saveShipMode: async () => assert.fail('no save'), env: {}, log: () => {} });
149
+ assert.equal(dead.stop, false);
150
+ const h = harness(unchosen);
151
+ const alone = await runFirstShipAsk(h.deps(['1'], { OTB_SUBAGENT: '1' }));
152
+ assert.equal(alone.stop, false);
153
+ });
154
+
155
+ test('the ask falls back to its own wording when the server sends no choices', () => {
156
+ const lines = askLines({ mode: 'auto', chosen: false }, ['1']);
157
+ assert.match(lines.join('\n'), /Auto \(recommended\)[\s\S]*Manual/);
158
+ });
159
+
160
+ // ---- 4. the wiring ---------------------------------------------------------
161
+
162
+ test('shipMain asks after finding the claim and before the budget cap and grader', () => {
163
+ const src = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'ship-flow.js'), 'utf8');
164
+ const main = src.slice(src.indexOf('async function shipMain'));
165
+ const claimFound = main.indexOf("const ac = claims.find(");
166
+ const ask = main.indexOf('runFirstShipAsk(');
167
+ const budget = main.indexOf('Hard cap: refuse to ship over the monthly budget');
168
+ assert.ok(claimFound > 0 && ask > claimFound, 'asks only once a real claim is in hand');
169
+ assert.ok(budget > ask, 'asks before the budget cap, so nothing has resolved or been spent');
170
+ assert.match(main.slice(ask, budget), /cliExit\(firstShip\.exit\);\s*return;/, 'stops with cliExit, never a hard exit after a request');
171
+ });
172
+
173
+ test('the --ship-mode flag is documented in the ship usage banner', () => {
174
+ const src = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'ship.js'), 'utf8');
175
+ assert.match(src, /--ship-mode auto\|manual/);
176
+ });
177
+
178
+ // ---- 5. the read route's can_change ----------------------------------------
179
+
180
+ const api = require(path.join(ROOT, 'src', 'module-api.js'));
181
+ const fakeRes = () => ({
182
+ code: null, statusCode: 200, payload: null, headers: {},
183
+ fail(code, opts) { this.code = code; this.statusCode = opts?.status ?? 400; this.payload = { error: code }; return this; },
184
+ status(c) { this.statusCode = c; return this; },
185
+ set(k, v) { this.headers[k] = v; return this; },
186
+ json(p) { this.payload = p; return this; },
187
+ });
188
+
189
+ async function readAs({ builder, government }) {
190
+ const saved = { resolve: api.projectSettings.resolveShipMode, resolveOptional: api.resolveOptional };
191
+ api.projectSettings.resolveShipMode = async () => ({ mode: 'auto', source: 'default', chosen: false, invalid: null });
192
+ api.resolveOptional = (port) => (port === 'government' ? government : saved.resolveOptional(port));
193
+ try {
194
+ const file = path.join(ROOT, 'modules', 'lifecycle', 'routes', 'project-settings.js');
195
+ delete require.cache[require.resolve(file)];
196
+ const router = require(file)();
197
+ const route = router.stack.find((l) => l.route && l.route.path === '/project-settings/ship-mode' && l.route.methods.get).route;
198
+ const res = fakeRes();
199
+ await route.stack[route.stack.length - 1].handle({ builder }, res);
200
+ return res;
201
+ } finally {
202
+ api.projectSettings.resolveShipMode = saved.resolve;
203
+ api.resolveOptional = saved.resolveOptional;
204
+ }
205
+ }
206
+
207
+ test('can_change is true for a settings manager and false for anyone else', async () => {
208
+ const government = { resolveBuilderPermissions: async (id) => (id === 1 ? ['project.settings.manage'] : ['task.claim.any']) };
209
+ assert.equal((await readAs({ builder: { id: 1 }, government })).payload.can_change, true);
210
+ assert.equal((await readAs({ builder: { id: 2 }, government })).payload.can_change, false);
211
+ });
212
+
213
+ test('can_change is null, never true, when authority cannot be established', async () => {
214
+ assert.equal((await readAs({ builder: { id: 1 }, government: null })).payload.can_change, null);
215
+ const throwing = { resolveBuilderPermissions: async () => { throw new Error('boom'); } };
216
+ const res = await readAs({ builder: { id: 1 }, government: throwing });
217
+ assert.equal(res.statusCode, 200, 'a permission read failure never fails the read');
218
+ assert.equal(res.payload.can_change, null);
219
+ });