@bongos/core 1.19.870 → 1.19.872

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 (102) hide show
  1. package/.bongos-core.json +164 -94
  2. package/.claude/skills/merge-mode/SKILL.md +1 -1
  3. package/CONTRIBUTING.md +1 -1
  4. package/README.md +1 -1
  5. package/clients/bongos-client/README.md +1 -1
  6. package/clients/bongos-client/bongos-client.global.js +4 -0
  7. package/clients/bongos-client/index.cjs +4 -0
  8. package/clients/bongos-client/index.d.ts +5 -0
  9. package/clients/bongos-client/index.mjs +4 -0
  10. package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +10 -0
  11. package/docs/api/openapi.json +61 -3
  12. package/docs/api-reference.md +7 -1
  13. package/docs/architecture.md +7 -7
  14. package/docs/branding-contract.md +1 -1
  15. package/docs/copy-inventory.md +190 -189
  16. package/docs/copy-registry.json +264 -255
  17. package/docs/design/hall-direction-v2.md +2 -2
  18. package/docs/module-api-changelog.md +4 -0
  19. package/docs/onboarding/diagrams/README.md +4 -4
  20. package/docs/onboarding/drachmae-and-karma.md +2 -2
  21. package/docs/onboarding/primer.md +1 -1
  22. package/docs/recipes/README.md +1 -1
  23. package/docs/recipes/bongos-cli-release.md +4 -4
  24. package/docs/recipes/core-release-pipeline.md +3 -3
  25. package/docs/recipes/instance-deploy.md +2 -2
  26. package/docs/recipes/ops-gotchas.md +2 -2
  27. package/docs/recipes/search-index-ingestion.md +2 -2
  28. package/docs/recipes/upgrading-the-core.md +1 -1
  29. package/modules/autonomy/gauge.js +218 -0
  30. package/modules/dev-box/app/README.md +1 -1
  31. package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
  32. package/modules/hall-ui/public/blockers.js +138 -34
  33. package/modules/hall-ui/public/builders.js +2 -1
  34. package/modules/hall-ui/public/collab.html +2 -2
  35. package/modules/hall-ui/public/collab.js +6 -3
  36. package/modules/hall-ui/public/dom-utils.js +46 -5
  37. package/modules/hall-ui/public/goals-page.js +7 -3
  38. package/modules/hall-ui/public/goals-render.js +4 -3
  39. package/modules/hall-ui/public/hall-render.js +1 -1
  40. package/modules/hall-ui/public/people.js +3 -3
  41. package/modules/hall-ui/public/profile-charts.js +5 -2
  42. package/modules/hall-ui/public/profile.html +3 -3
  43. package/modules/hall-ui/public/profile.js +6 -6
  44. package/modules/hall-ui/public/settings-specialities.js +2 -2
  45. package/modules/hall-ui/public/settings.html +4 -4
  46. package/modules/hall-ui/public/settings.js +2 -2
  47. package/modules/hall-ui/public/shell.js +70 -0
  48. package/modules/hall-ui/public/style.css +31 -0
  49. package/modules/hall-ui/public/work.js +12 -14
  50. package/modules/provisioning/module.json +3 -3
  51. package/modules/public-landing/public/terms.html +1 -1
  52. package/package-lock.json +2 -2
  53. package/package.json +1 -1
  54. package/scripts/gds/autobongos-loop.js +170 -0
  55. package/scripts/gds/autobongos-run.js +317 -0
  56. package/scripts/gds/autobongos-verify.js +377 -0
  57. package/scripts/gds/autonomy-gauge-statusline.js +124 -0
  58. package/scripts/gds/autonomy-gauge.js +84 -0
  59. package/scripts/gds/run-unit-tests.js +120 -2
  60. package/scripts/gds/sequence.js +3 -0
  61. package/scripts/gds/start.js +16 -10
  62. package/src/bongos/core-update.js +147 -0
  63. package/src/bongos/routes/core-update.js +49 -0
  64. package/src/bongos/routes.js +2 -0
  65. package/src/module-api.js +1 -1
  66. package/tests/account_privacy_flags.mjs +3 -1
  67. package/tests/autobongos_loop.mjs +446 -0
  68. package/tests/autobongos_verify.mjs +325 -0
  69. package/tests/autonomy_gauge.mjs +344 -0
  70. package/tests/blockers_hall.mjs +20 -6
  71. package/tests/blockers_kit_pager.mjs +260 -0
  72. package/tests/blockers_page_ui.mjs +5 -4
  73. package/tests/box_ship_permissions.mjs +30 -0
  74. package/tests/core_update_banner.mjs +308 -0
  75. package/tests/eviction_policy.mjs +22 -1
  76. package/tests/git_guard.mjs +68 -0
  77. package/tests/goal_criterion_confirm_ui.mjs +7 -0
  78. package/tests/goal_invite_consent.mjs +7 -0
  79. package/tests/goal_sort_staleness.mjs +6 -2
  80. package/tests/goal_subtitle.mjs +48 -4
  81. package/tests/hall_board_world.mjs +4 -1
  82. package/tests/hall_builders_page_pass.mjs +7 -0
  83. package/tests/hall_landing_boot.mjs +7 -0
  84. package/tests/hall_profile_world.mjs +20 -2
  85. package/tests/hall_record_world.mjs +7 -0
  86. package/tests/hall_roster_world.mjs +2 -1
  87. package/tests/hall_studio_world.mjs +7 -0
  88. package/tests/hook_shell_tools.mjs +23 -1
  89. package/tests/idea_detail_page.mjs +7 -0
  90. package/tests/idea_spark_hall.mjs +7 -0
  91. package/tests/integration_parked_ratchet.mjs +79 -0
  92. package/tests/module_contributions.mjs +134 -8
  93. package/tests/people_directory_clip.mjs +7 -0
  94. package/tests/profile_activity.mjs +7 -0
  95. package/tests/profile_ui_cross_project.mjs +9 -1
  96. package/tests/provision_disconnect.mjs +7 -0
  97. package/tests/provision_restart.mjs +7 -0
  98. package/tests/provision_settings_apply.mjs +7 -0
  99. package/tests/sessions_kit_pager.mjs +7 -0
  100. package/tests/watch_applications_queue.mjs +7 -0
  101. package/tests/watch_roster_self_row.mjs +7 -0
  102. package/tests/watch_sealed_floor.mjs +7 -0
@@ -35,6 +35,13 @@ const PER_TEST_TIMEOUT_MS = 90_000;
35
35
  // DB-free unit file, so the `--integration` gate (task 1002221 / audit H1) gives each one a much
36
36
  // longer budget than the 90s unit cap.
37
37
  const INTEGRATION_TIMEOUT_MS = 300_000;
38
+ // A PARKED test is expected to fail, and a failing test fails fast — it throws or
39
+ // trips an assertion. Handing it the full run-to-completion budget only buys the
40
+ // right to burn five billable CPU-minutes per HUNG entry on every main push, and
41
+ // this set is re-checked on each one. A minute is ample for a verdict that is
42
+ // expected to arrive in seconds; an entry that needs longer than that to fail is
43
+ // itself the finding.
44
+ const PARKED_RECHECK_TIMEOUT_MS = 60_000;
38
45
 
39
46
  // Feature-module test sets (R59 / task 1198). A test that exercises a feature
40
47
  // MODULE runs only when this instance enables that module — a vanilla Cloud
@@ -268,6 +275,79 @@ function discoverModuleTests({ modulesDir = MODULES_DIR, isEnabled = isModuleEna
268
275
  return { included, skippedDisabled };
269
276
  }
270
277
 
278
+ // ── INTEGRATION_PARKED — the integration lane's known-failing set (task 1004034) ──
279
+ //
280
+ // The integration lane (task 1000862) gave these 20 their FIRST EVER run on
281
+ // 2026-09-17 and all 20 failed. None is a regression: nothing had ever executed
282
+ // them, in any lane, so nothing was keeping them honest. Parked by owner decision
283
+ // so the lane can gate the tests that DO pass — a gate that is already red gates
284
+ // nothing, the same argument task 1002617 made for the unit lane.
285
+ //
286
+ // WHY A SEPARATE SET, AND NOT KNOWN_FAILING. KNOWN_FAILING is paired with
287
+ // tests/known_failing_ratchet.mjs, which re-executes each parked test and reds the
288
+ // suite the moment one starts passing. That ratchet runs in the DB-FREE lane: an
289
+ // integration test executed there fails for want of a database, so `still fails`
290
+ // would be trivially true and a genuine fix would go unnoticed. Parking these there
291
+ // would have bought the appearance of tracking and none of it.
292
+ //
293
+ // So the guarantee is kept where it can actually hold: `--integration-parked` runs
294
+ // this set WITH the database and FAILS when one PASSES. Same ratchet semantics, in
295
+ // the only environment that can tell the difference. The integration workflow runs
296
+ // it as a blocking step, so fixing one of these reds the lane until its entry here
297
+ // is removed.
298
+ //
299
+ // Triage per test, and the owner decision, are on task 1004034. The causes are
300
+ // heterogeneous — stale post-de-branding expectations, unseeded fixtures, temp-dir
301
+ // assumptions, a missing core tarball, DB-state collisions — so these come off this
302
+ // list one at a time, not in a batch.
303
+ //
304
+ // acclaim_feed_evidence_db WAS LEFT OUT OF THE FIRST PASS, deliberately and on the
305
+ // evidence then available: it PASSED in CI run 35291948008, and parking a test that
306
+ // passes makes --integration-parked red the lane immediately. Two later readings
307
+ // reversed that — it fails 2/2 alone on a freshly migrated database, and it FAILED in
308
+ // run 35404478232, the first run after the parking landed. The single pass was the
309
+ // anomaly. It fails on `duplicate key value violates unique constraint
310
+ // "versions_one_planning_idx"`: the seeds create a version at status='planning', a
311
+ // partial unique index permits exactly one, and the test adds another — so it leans on
312
+ // the seeded state of `versions` rather than establishing its own, which is also how
313
+ // its verdict moves between runs. needs_rebase trips the same constraint and is
314
+ // already here. IF THE PARKED RATCHET EVER REDS NAMING THIS TEST, that is this known
315
+ // oscillation, not a fix: the real repair is to stop it colliding — give it its own
316
+ // version row, or roll back in a transaction the way the other DB-writing entries do.
317
+ //
318
+ // handle_machinery IS THE SAME DEFECT SEEN FROM THE OTHER SIDE, and it is why the
319
+ // ratchet moved to its own job with its own database (task 1004043). It PASSES once
320
+ // the suite has run and FAILS from a pristine one — verified by migrating an empty
321
+ // database and running it alone. So it is correctly parked: it fails from exactly the
322
+ // state the gate starts in. Do NOT unpark it on the strength of a green post-suite
323
+ // reading; that reading answers a different question. Like acclaim_feed_evidence_db
324
+ // above, the real repair is for the test to establish the state it needs instead of
325
+ // inheriting whatever ran before it.
326
+ //
327
+ // THE THREE DEV-BOX TESTS ARE PARKED, NOT DELETED, and that reverses a first pass
328
+ // of this task. The owner ruled that dev-box work can go, and boxes / box_onboard /
329
+ // box_connect_e2e were duly deleted — but modules/dev-box/ is still present, still
330
+ // mounted and unmodified by that change, and those files carry the only regression
331
+ // coverage for LIVE guards: the setBoxState column allowlist (SQL injection), the
332
+ // rank/authz floors, the spoofed X-Forwarded-Host that must never reach the served
333
+ // connect.sh, and the authorized_keys command= / embedded-newline rejections.
334
+ // Deleting a security regression test for code that still runs is not retirement,
335
+ // it is removing the alarm and leaving the door. modules/dev-box/CLAUDE.md also
336
+ // still names boxes + box_onboard as that module's required suite, and
337
+ // scripts/gds/smoke-box.sh still invokes all three by path — deleting the files
338
+ // turned that script into an immediate MODULE_NOT_FOUND. They come out when the
339
+ // module does, in the change that removes it.
340
+ const INTEGRATION_PARKED = new Set([
341
+ 'acclaim_feed_evidence_db',
342
+ 'bfg', 'bfg_write', 'box_connect_e2e', 'box_onboard', 'boxes',
343
+ 'claim_gate_open_blocker', 'claim_gate_rebase',
344
+ 'clawback_confirmed_backlog', 'consumer_layout_boot', 'copy_desk_flags_db',
345
+ 'government_board_membership_db', 'handle_machinery', 'hub_profile_leak',
346
+ 'idea_credit_streams_db', 'memory_isolation', 'memory_pull', 'needs_rebase',
347
+ 'profile_fields', 'project_visibility_db', 'reward_mode_cost_plus_only',
348
+ 'session_earnings_mirror_db', 'setup_smoke', 'status-probe',
349
+ ]);
350
+
271
351
  function main() {
272
352
  // Two gates from one runner (task 1002221 / audit H1):
273
353
  // default — the DB-free UNIT gate: every test file MINUS the integration set (+ known-
@@ -278,6 +358,44 @@ function main() {
278
358
  // is what promotes tests/consumer_layout_boot.mjs from "excluded + ad-hoc" to a
279
359
  // first-class gate a release step can invoke with one command.
280
360
  const integrationMode = process.argv.includes('--integration');
361
+
362
+ // --integration-parked — the ratchet for INTEGRATION_PARKED. Runs ONLY the parked
363
+ // set and inverts the verdict: a parked test that PASSES fails this run, because an
364
+ // allowlist that outlives the failures it documents is how a graveyard starts. Needs
365
+ // the same database the integration lane provides; running it DB-free would report
366
+ // every entry as still-failing and prove nothing.
367
+ if (process.argv.includes('--integration-parked')) {
368
+ const parked = [...INTEGRATION_PARKED].sort();
369
+ console.log(`run-unit-tests --integration-parked: re-checking ${parked.length} parked test(s)`);
370
+ const nowPassing = [];
371
+ for (const name of parked) {
372
+ const file = path.join(TESTS_DIR, `${name}.mjs`);
373
+ if (!fs.existsSync(file)) {
374
+ console.log(` GONE ${name} — parked but the file no longer exists; remove its entry`);
375
+ nowPassing.push(name);
376
+ continue;
377
+ }
378
+ try {
379
+ execFileSync(process.execPath, [file], {
380
+ stdio: ['ignore', 'ignore', 'ignore'],
381
+ timeout: PARKED_RECHECK_TIMEOUT_MS,
382
+ env: { ...process.env, NODE_ENV: 'test' },
383
+ });
384
+ console.log(` PASSES ${name} — fixed; remove it from INTEGRATION_PARKED`);
385
+ nowPassing.push(name);
386
+ } catch (_) {
387
+ console.log(` still red ${name}`);
388
+ }
389
+ }
390
+ if (nowPassing.length) {
391
+ console.error(`\nrun-unit-tests --integration-parked: ${nowPassing.length} parked test(s) no longer fail — ${nowPassing.join(', ')}.`);
392
+ console.error('Remove each from INTEGRATION_PARKED in scripts/gds/run-unit-tests.js so the lane starts gating it again (task 1004034).');
393
+ process.exit(1);
394
+ }
395
+ console.log('every parked test still fails — the list is current.');
396
+ process.exit(0);
397
+ }
398
+
281
399
  const perTestTimeout = integrationMode ? INTEGRATION_TIMEOUT_MS : PER_TEST_TIMEOUT_MS;
282
400
 
283
401
  const files = fs
@@ -285,7 +403,7 @@ function main() {
285
403
  .filter((f) => f.endsWith('.mjs'))
286
404
  .map((f) => f.replace(/\.mjs$/, ''))
287
405
  .filter((name) => integrationMode
288
- ? (INTEGRATION.has(name) && !KNOWN_FAILING.has(name) && !SUPPORT_MODULES.has(name))
406
+ ? (INTEGRATION.has(name) && !KNOWN_FAILING.has(name) && !INTEGRATION_PARKED.has(name) && !SUPPORT_MODULES.has(name))
289
407
  : (!INTEGRATION.has(name) && !KNOWN_FAILING.has(name) && !SUPPORT_MODULES.has(name) && !MODULE_DISABLED.has(name)))
290
408
  .sort();
291
409
 
@@ -385,4 +503,4 @@ function main() {
385
503
 
386
504
  if (require.main === module) main();
387
505
 
388
- module.exports = { discoverModuleTests, isCleanTeardownAbort, describeFailures, STATUS_DESCRIPTION_MAX };
506
+ module.exports = { discoverModuleTests, isCleanTeardownAbort, describeFailures, STATUS_DESCRIPTION_MAX, INTEGRATION, INTEGRATION_PARKED, KNOWN_FAILING };
@@ -576,6 +576,9 @@ module.exports = {
576
576
  readArgv,
577
577
  edgeIds,
578
578
  rowsOf,
579
+ // exported for the Autobongos supervisor (task 1003902) so the claimable feed
580
+ // is read one way, not copied into a second place that can drift.
581
+ fetchClaimableIds,
579
582
  fetchGoalTasks,
580
583
  fetchGoalTaskRows,
581
584
  declaredSurfaceExcludesMigrations,
@@ -221,9 +221,15 @@ function cell(s) {
221
221
  // Per-column values, shared by the markdown table and the widget. Version
222
222
  // (V2 / GDS-V4) is its own column; Type is the work lane (Engineer / Artist /
223
223
  // Ideator), its own column rather than baked into the title.
224
- function disciplineLabel(t) {
224
+ // task 1003936: ONE word for this axis — Role, per ADR 0174 Decision 5. The hall's
225
+ // copy of this lives in modules/hall-ui/public/dom-utils.js (OTB.roleLabel); this
226
+ // card is a separate runtime and cannot read it, so the word is restated here and
227
+ // nowhere else in this file. 'ui' keeps its upper case, as the hall's does.
228
+ const ROLE_AXIS_LABEL = 'Role';
229
+ function roleLabel(t) {
225
230
  const d = t.discipline;
226
- return d && d !== 'unclassified' ? d.charAt(0).toUpperCase() + d.slice(1) : '';
231
+ if (!d || d === 'unclassified') return '';
232
+ return d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1);
227
233
  }
228
234
  // Per-lane accent — the OTB palette (sea / terracotta / olive); mid-ramp hexes
229
235
  // that read in both light and dark. Colours the Type column so the work lane
@@ -249,11 +255,11 @@ function claimableTable(rows, rewardFromServer) {
249
255
  // and footnote what IS paid below the table (task 1002469).
250
256
  const perTask = rewardPolicy(rewardFromServer).perTaskCredit;
251
257
  const rewardHead = perTask ? 'Reward' : 'Reward (est.)';
252
- const headers = ['#', 'Version', 'Type', rewardHead, 'Est.', 'What it is'];
258
+ const headers = ['#', 'Version', ROLE_AXIS_LABEL, rewardHead, 'Est.', 'What it is'];
253
259
  const body = rows.map((t) => [
254
260
  surface.taskRef(t.id, HALL_TASK),
255
261
  cell(t.version_id || '?'),
256
- cell(disciplineLabel(t) || '—'),
262
+ cell(roleLabel(t) || '—'),
257
263
  `${t.credits_reward ?? 0}c`,
258
264
  estimateShort(t),
259
265
  cell(whatItIs(t)),
@@ -462,7 +468,7 @@ function renderWidget({ builder, claims, claimable, total, streak, recommendatio
462
468
  return `<tr class="bs-tr" role="button" tabindex="0" title="${titleAttr}" onclick="sendPrompt('${claimMsg}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();sendPrompt('${claimMsg}')}">`
463
469
  + TD(`#${hesc(t.id)}`, 'bs-id')
464
470
  + TD(hesc(t.version_id || '?'), 'bs-nowrap')
465
- + TD(`${laneDot}${hesc(disciplineLabel(t) || '—')}`, 'bs-ellip-sub')
471
+ + TD(`${laneDot}${hesc(roleLabel(t) || '—')}`, 'bs-ellip-sub')
466
472
  + TD(hesc((t.credits_reward ?? 0) + 'c'))
467
473
  + TD(hesc(estimateShort(t)), 'bs-sub')
468
474
  + TD(hesc(t.title || ''), 'bs-ellip')
@@ -472,7 +478,7 @@ function renderWidget({ builder, claims, claimable, total, streak, recommendatio
472
478
  const menu = claimable.length
473
479
  ? `<table class="bs-table">
474
480
  <colgroup><col style="width:44px;"><col style="width:52px;"><col style="width:68px;"><col style="width:48px;"><col style="width:44px;"><col></colgroup>
475
- <thead><tr class="bs-thead">${TH('#')}${TH('Version')}${TH('Type')}${TH('Reward')}${TH('Est.')}${TH('What it is')}</tr></thead>
481
+ <thead><tr class="bs-thead">${TH('#')}${TH('Version')}${TH(ROLE_AXIS_LABEL)}${TH('Reward')}${TH('Est.')}${TH('What it is')}</tr></thead>
476
482
  <tbody>${rows}</tbody>
477
483
  </table>`
478
484
  : `<div class="bs-line">Nothing claimable right now — everything is in flight, blocked, or shipped. <a href="#" onclick="event.preventDefault();sendPrompt('Run blocker-review')" class="bs-link">See what's stuck ↗</a></div>`;
@@ -487,9 +493,9 @@ function renderWidget({ builder, claims, claimable, total, streak, recommendatio
487
493
  let crossLane = '';
488
494
  if (recommendations.length > 0) {
489
495
  const ids = recommendations.map((t) => '#' + t.id).join(', ');
490
- crossLane = ` · <a href="#" onclick="event.preventDefault();sendPrompt('${jsq('Show me the tasks outside my disciplines: ' + ids)}')" class="bs-link">Outside your lanes: ${hesc(ids)} ↗</a>`;
496
+ crossLane = ` · <a href="#" onclick="event.preventDefault();sendPrompt('${jsq('Show me the tasks outside my disciplines: ' + ids)}')" class="bs-link">Outside your roles: ${hesc(ids)} ↗</a>`;
491
497
  } else if (routingByPreference) {
492
- crossLane = ` · <a href="#" onclick="event.preventDefault();sendPrompt('Show me every lane, not just mine')" class="bs-link">Show all lanes ↗</a>`;
498
+ crossLane = ` · <a href="#" onclick="event.preventDefault();sendPrompt('Show me every role, not just mine')" class="bs-link">Show all roles ↗</a>`;
493
499
  }
494
500
 
495
501
  const summary = `builder-start: ${claimable.length} task${claimable.length === 1 ? '' : 's'} you can claim now, plus your current build status. Click a task to claim it.`;
@@ -977,10 +983,10 @@ async function main() {
977
983
  if (recommendations.length > 0) {
978
984
  const ids = recommendations.map((t) => `#${t.id}`).join(', ');
979
985
  console.log('');
980
- console.log(`Outside your lanes: ${ids} — say the word to see them, or run with \`--all-disciplines\`.`);
986
+ console.log(`Outside your roles: ${ids} — say the word to see them, or run with \`--all-disciplines\`.`);
981
987
  } else if (routingByPreference) {
982
988
  console.log('');
983
- console.log('_Showing your lanes — run with `--all-disciplines` to see every lane._');
989
+ console.log('_Showing your roles — run with `--all-disciplines` to see every role._');
984
990
  }
985
991
 
986
992
  // ---------- next ----------
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/core-update.js — "is a newer @bongos/core published, and could THIS
4
+ // instance take it?", answered by the instance itself (task 1004045).
5
+ //
6
+ // WHY THIS IS NOT THE PROVISIONING SNAPSHOT, which already holds the answer for
7
+ // every project the platform hosts. `provisioning_core_version_snapshot` (task
8
+ // 1003159) lives in the CONTROL PLANE's database, and the provisioning module is
9
+ // not installed on a hosted project at all — probed 2026-09-18:
10
+ // `/api/bongos/provisioning/health` answers 200 on the platform and 404 on every
11
+ // tenant. So a banner in an owner's OWN hall cannot read it, and a self-hosted
12
+ // instance with no control plane anywhere could not read it even in principle.
13
+ // Asking the registry directly is the only source EVERY instance has, which is
14
+ // what "every owner's hall" requires.
15
+ //
16
+ // NEVER `npm view` HERE. update-channel.js's `listAvailableVersions` shells out
17
+ // SYNCHRONOUSLY, and ADR 0293 names exactly that as the reason the deploy route
18
+ // does not consult the registry: it would block the event loop for every other
19
+ // request on the process. This is a plain HTTPS GET of the abbreviated packument,
20
+ // cached for hours, with a hard timeout — and fail-SOFT, because an instance
21
+ // behind a firewall that cannot reach the registry must render no banner rather
22
+ // than a wrong one.
23
+ //
24
+ // THE CHANNEL RULE IS NOT RESTATED HERE. `resolveChannelTarget` is the runner's
25
+ // OWN function (scripts/gds/update-channel.js) and is imported, not copied: the
26
+ // banner must never offer a version the runner would then refuse, and ADR 0280
27
+ // is the record of what a second copy of this contract costs when it drifts.
28
+
29
+ const https = require('node:https');
30
+ const {
31
+ CORE_PKG,
32
+ DEFAULT_CHANNEL,
33
+ normalizeChannel,
34
+ resolveChannelTarget,
35
+ } = require('../../scripts/gds/update-channel.js');
36
+
37
+ const REGISTRY_ORIGIN = 'https://registry.npmjs.org';
38
+ // Six hours. The core publishes many times a day, but a banner is not a deploy
39
+ // signal — it is "you are behind", which does not become more true for being
40
+ // re-checked every minute, and each check is an outbound request from someone
41
+ // else's server.
42
+ const TTL_MS = 6 * 60 * 60 * 1000;
43
+ const FETCH_TIMEOUT_MS = 4000;
44
+
45
+ /**
46
+ * The banner's whole decision. PURE — every input injected, no clock, no I/O.
47
+ *
48
+ * `reading` is a named state rather than a bare null so a caller cannot render
49
+ * "up to date" over an ANSWER WE DO NOT HAVE. previewFromSnapshot carries the
50
+ * same field for the same reason; the failure it prevents is a project that is
51
+ * months behind being told, confidently, that it is current.
52
+ */
53
+ function coreUpdateVerdict({ running, available, channel } = {}) {
54
+ const ch = normalizeChannel(channel == null ? DEFAULT_CHANNEL : channel);
55
+ const versions = Array.isArray(available) ? available.filter((v) => typeof v === 'string') : [];
56
+ if (!running || typeof running !== 'string' || !versions.length) {
57
+ return { running: running || null, latest: null, behind: false, reading: 'no_answer', channel: ch };
58
+ }
59
+ // null when nothing published is both newer AND within the channel — which is
60
+ // the ordinary "you are current" case, not an error.
61
+ const latest = resolveChannelTarget({ installed: running, channel: ch, available: versions });
62
+ return { running, latest: latest || null, behind: !!latest, reading: 'ok', channel: ch };
63
+ }
64
+
65
+ /**
66
+ * Published versions of the core, from the registry. Fail-SOFT: any error, any
67
+ * non-200, any unparseable body ⇒ { ok:false, versions:[] }, never a throw.
68
+ *
69
+ * Asks for the ABBREVIATED packument (the `install-v1` media type): the full
70
+ * document for a package with ~900 releases is megabytes, and the only field
71
+ * this needs is the version list.
72
+ */
73
+ function fetchPublishedVersions({ pkg = CORE_PKG, origin = REGISTRY_ORIGIN, timeoutMs = FETCH_TIMEOUT_MS, get = https.get } = {}) {
74
+ return new Promise((resolve) => {
75
+ let settled = false;
76
+ const done = (v) => { if (!settled) { settled = true; resolve(v); } };
77
+ let req;
78
+ try {
79
+ req = get(`${origin}/${pkg.replace('/', '%2f')}`, {
80
+ headers: { accept: 'application/vnd.npm.install-v1+json' },
81
+ timeout: timeoutMs,
82
+ }, (res) => {
83
+ if (res.statusCode !== 200) { res.resume(); return done({ ok: false, versions: [], error: `registry ${res.statusCode}` }); }
84
+ let body = '';
85
+ res.setEncoding('utf8');
86
+ res.on('data', (c) => { body += c; });
87
+ res.on('end', () => {
88
+ try {
89
+ const doc = JSON.parse(body);
90
+ const versions = Object.keys((doc && doc.versions) || {});
91
+ done(versions.length ? { ok: true, versions } : { ok: false, versions: [], error: 'no versions in packument' });
92
+ } catch (e) { done({ ok: false, versions: [], error: `unparseable packument: ${e.message}` }); }
93
+ });
94
+ });
95
+ } catch (e) { return done({ ok: false, versions: [], error: e.message }); }
96
+ if (req && req.on) {
97
+ req.on('error', (e) => done({ ok: false, versions: [], error: e.message }));
98
+ // `timeout` only fires the event; it does not abort the request.
99
+ req.on('timeout', () => { try { req.destroy(); } catch { /* already gone */ } done({ ok: false, versions: [], error: 'registry timeout' }); });
100
+ }
101
+ });
102
+ }
103
+
104
+ // Process-local cache. Deliberately not a table: it is a derived fact with a
105
+ // six-hour life, and a restart re-deriving it costs one request.
106
+ const cache = { at: 0, versions: [], ok: false, inflight: null };
107
+
108
+
109
+
110
+ async function publishedVersions({ now = Date.now, ttlMs = TTL_MS, fetch = fetchPublishedVersions, store = cache } = {}) {
111
+ const t = now();
112
+ if (store.ok && t - store.at < ttlMs) return { ok: true, versions: store.versions, cached: true };
113
+ // IN-FLIGHT DE-DUPE. Without it, every request arriving during a cold-cache
114
+ // window starts its OWN registry read — the cache only collapses them once the
115
+ // first one has landed. Small, because the audience is archon-only and the TTL
116
+ // is hours, but a second outbound call to someone else's registry for a fact
117
+ // already being fetched is simply wrong, and the fix is one shared promise.
118
+ if (store.inflight) return store.inflight;
119
+ store.inflight = (async () => {
120
+ try {
121
+ const r = await fetch();
122
+ if (r.ok) { store.ok = true; store.at = t; store.versions = r.versions; }
123
+ return { ok: r.ok, versions: r.versions, cached: false, error: r.error };
124
+ } finally {
125
+ store.inflight = null;
126
+ }
127
+ })();
128
+ return store.inflight;
129
+ }
130
+
131
+ /**
132
+ * What the route answers — and the module's ONLY export, deliberately.
133
+ *
134
+ * Everything above is reachable through it: `get` swaps the HTTPS transport (so
135
+ * the real packument parsing and its failure modes are exercised, not stubbed
136
+ * past), `fetch` swaps the whole read, and `now`/`ttlMs`/`store` drive the cache.
137
+ * Exporting the internals individually instead would add eight exports that no
138
+ * non-test caller uses, which the dead-code ratchet counts as regression — and
139
+ * the fix for that is a smaller surface, never a raised baseline.
140
+ */
141
+ async function readCoreUpdate({ running, channel, get, ...deps } = {}) {
142
+ const fetch = deps.fetch || (get ? () => fetchPublishedVersions({ get }) : fetchPublishedVersions);
143
+ const r = await publishedVersions({ ...deps, fetch });
144
+ return coreUpdateVerdict({ running, available: r.ok ? r.versions : [], channel });
145
+ }
146
+
147
+ module.exports = { readCoreUpdate };
@@ -0,0 +1,49 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/routes/core-update.js — GET /api/bongos/core-update (task 1004045).
4
+ //
5
+ // "Is this instance behind, and what would it move to?" — the one fact the hall's
6
+ // upgrade banner needs. It lives in the CORE, not in the provisioning module, for
7
+ // the reason src/bongos/core-update.js opens with: a hosted project does not have
8
+ // the provisioning module, so a banner that depended on it could never appear in
9
+ // the hall of any project but the platform's own — which is the whole ask.
10
+ //
11
+ // GATED ON `core.pin.move`, the same atom as the deploy door, and for the same two
12
+ // reasons ADR 0293 gives for gating its PREVIEW as tightly as its move:
13
+ // 1. It names the exact core this box runs and how far behind it is, which is a
14
+ // vulnerability-window disclosure (ADR 0043) and must not sit on an
15
+ // unauthenticated surface.
16
+ // 2. The banner it feeds offers an action. Showing it to someone who cannot
17
+ // press the button is a worse experience than showing nothing.
18
+ // The gate costs its real audience nothing: an owner holds the atom on their own
19
+ // instance by construction.
20
+
21
+ const express = require('express');
22
+ const { asyncHandler } = require('./_helpers');
23
+ const auth = require('../auth');
24
+ const { readCoreUpdate } = require('../core-update');
25
+
26
+ module.exports = function buildCoreUpdateRouter() {
27
+ const router = express.Router();
28
+
29
+ router.get(
30
+ '/core-update',
31
+ auth.requireBuilder,
32
+ auth.requirePermission('core.pin.move'),
33
+ // asyncHandler(TAG, handler, opts) — the tag is the first argument, and
34
+ // passing the handler there instead makes `handler` undefined, so EVERY
35
+ // request 500s with "handler is not a function". That is exactly the bug the
36
+ // grader caught here, and it is invisible to any test that does not drive a
37
+ // real request through the assembled router; tests/core_update_banner.mjs now
38
+ // does, and fails if this call is mis-shaped again.
39
+ asyncHandler('GET /core-update', async (_req, res) => {
40
+ // Fail-soft on the version read, exactly as GET /version does: an instance
41
+ // that cannot name its own core reports `no_answer` and renders no banner.
42
+ let running = null;
43
+ try { running = require('../../module-api').CORE_VERSION; } catch { /* version unknown */ }
44
+ res.json({ ok: true, update: await readCoreUpdate({ running }) });
45
+ }, { errorCode: 'core_update_failed', message: false }),
46
+ );
47
+
48
+ return router;
49
+ };
@@ -19,6 +19,7 @@ const express = require('express');
19
19
 
20
20
  const buildHealthzRouter = require('./routes/healthz');
21
21
  const buildInstanceRouter = require('./routes/instance');
22
+ const buildCoreUpdateRouter = require('./routes/core-update');
22
23
  const buildAuthRouter = require('./routes/auth');
23
24
  const buildGithubRouter = require('./routes/github');
24
25
  const buildMeRouter = require('./routes/me');
@@ -133,6 +134,7 @@ function buildGdsRouter() {
133
134
 
134
135
  router.use(buildHealthzRouter());
135
136
  router.use(buildInstanceRouter());
137
+ router.use(buildCoreUpdateRouter());
136
138
  router.use(buildAuthRouter());
137
139
  router.use(buildGithubRouter());
138
140
  router.use(buildMeRouter());
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.870'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.872'; // 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');
@@ -329,7 +329,9 @@ const GRANT_WORDS = {
329
329
  handle: /your name/,
330
330
  display_name: /your name/,
331
331
  avatar_url: /your picture/,
332
- disciplines: /your disciplines/,
332
+ // task 1003936: the axis is named "role" to a person now; the invariant is that
333
+ // the toggle NAMES it in the sliver a recruiter receives, not which word it used.
334
+ disciplines: /your roles/,
333
335
  active_recently: /active in the last 30 days/,
334
336
  };
335
337