@bongos/core 1.19.882 → 1.19.883

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 (34) hide show
  1. package/.bongos-core.json +52 -52
  2. package/clients/bongos-client/index.d.ts +2 -2
  3. package/docs/adr/0309-durable-module-enablement-db-override.md +116 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/api/openapi.json +6 -0
  6. package/docs/copy-inventory.md +24 -23
  7. package/docs/copy-registry.json +34 -25
  8. package/docs/file-map.md +1 -2
  9. package/docs/module-api-changelog.md +3 -1
  10. package/docs/recipes/bongos-cli-release.md +1 -1
  11. package/docs/recipes/managed-settings-remote-control.md +1 -1
  12. package/migrations/core_248_module_enablement_overrides.sql +50 -0
  13. package/modules/dev-box/app/README.md +4 -3
  14. package/modules/hall-ui/public/collab-lib.js +33 -1
  15. package/modules/hall-ui/public/collab.css +33 -4
  16. package/modules/hall-ui/public/collab.html +9 -2
  17. package/modules/hall-ui/public/collab.js +68 -15
  18. package/modules/hall-ui/public/dom-utils.js +23 -1
  19. package/package-lock.json +2 -2
  20. package/package.json +1 -1
  21. package/scripts/hall-preview/server.js +38 -1
  22. package/src/bongos/module-overrides.js +117 -0
  23. package/src/bongos/routes/modules.js +56 -23
  24. package/src/module-api.js +1 -1
  25. package/src/modules.js +120 -14
  26. package/src/platform-server.js +47 -5
  27. package/tests/box_sweep_docs.mjs +7 -3
  28. package/tests/collab_page.mjs +136 -1
  29. package/tests/modules.mjs +110 -0
  30. package/tests/modules_route_wiring.mjs +103 -0
  31. package/docs/recipes/builder-box-lifecycle.md +0 -181
  32. package/docs/recipes/devbox-app-release.md +0 -103
  33. package/docs/recipes/devbox-app-windows-test.md +0 -128
  34. package/docs/recipes/work-on-a-task-from-your-dev-box.md +0 -122
@@ -21,10 +21,36 @@
21
21
  sees it. Four bands, four treatments: faint, soft, warn, danger. The 3px
22
22
  coloured band the first cut painted down the row's left edge is gone — this
23
23
  world marks state with a pill, never with a bar. */
24
+ /* THE CHIP ONLY WEARS ITS RING PAST THE THRESHOLD (artist verdict, task
25
+ 1004041). All four bands still have their own ink, because that is what makes
26
+ the column readable at a glance and it is what the criterion asked for. What
27
+ changed is that the two CALM bands stop being pills: a ring around "just
28
+ asked" draws a chip on a row where nothing is wrong, so a board of healthy
29
+ asks opened as a wall of chips and the two bands that actually mean something
30
+ had no shape of their own left to mean it with. Faint and soft read as plain
31
+ quiet text; aging and stale keep the pill.
32
+
33
+ The WORDS carry the real age now (collab-lib ageLabel), so two stale rows no
34
+ longer read identically — which is the other half of the same verdict. */
24
35
  .cb-age--fresh { color: var(--ink-faint); }
25
36
  .cb-age--waiting { color: var(--ink-soft); }
26
37
  .cb-age--aging { color: var(--warn); }
27
38
  .cb-age--stale { color: var(--danger); font-weight: 600; }
39
+ .cb-age--fresh,
40
+ .cb-age--waiting {
41
+ border-color: transparent;
42
+ padding-left: 0;
43
+ padding-right: 0;
44
+ }
45
+
46
+ /* ---- the figure strip -----------------------------------------------------
47
+ The shell's .stat-row, unchanged, except that this page's third tile holds
48
+ WORDS (the roles you answer for) rather than a figure. The shell's own
49
+ --badge modifier says that at 15px, but its phone block restates
50
+ .stat-tile__num at the same specificity and later in the sheet, so on a phone
51
+ the modifier loses and a list of role names sets at 28px. Two classes here
52
+ outrank both without touching the shell. */
53
+ .cb-figs .stat-tile__num--badge { font-size: 15px; letter-spacing: -0.02em; }
28
54
 
29
55
  /* ---- the blocks ------------------------------------------------------------------
30
56
  The top band is the shell's two-up grid (it stacks to one column at the
@@ -103,9 +129,13 @@
103
129
  .cb-row .ov-row__facts a { color: var(--accent-ink); }
104
130
  /* Inside the two condensed blocks a row has half the page: the state and the
105
131
  actions drop UNDER the words as one line (the archetype's own phone shape),
106
- the date yields to the age pill, and the two-up itself becomes one column
107
- before the shell's phone breakpoint, at the width where two half-rows stop
108
- holding a sentence. The wide block below keeps the archetype's two columns. */
132
+ and the two-up itself becomes one column before the shell's phone breakpoint,
133
+ at the width where two half-rows stop holding a sentence. The wide block below
134
+ keeps the archetype's two columns.
135
+ The separate date line this block used to suppress is gone from the markup
136
+ entirely (task 1004041): the age pill says how long in words and carries the
137
+ exact timestamp on its title, so a date beside it was the same fact twice —
138
+ which is what the "All open asks" rows were doing at full width. */
109
139
  .cb-band .ov-row--lead { grid-template-columns: auto minmax(0, 1fr); row-gap: 8px; }
110
140
  .cb-band .ov-row--lead > .ov-row__right {
111
141
  grid-column: 2;
@@ -115,7 +145,6 @@
115
145
  text-align: left;
116
146
  }
117
147
  .cb-band .ov-row__actions { justify-content: flex-start; }
118
- .cb-band .ov-row__when { display: none; }
119
148
  @media (max-width: 1100px) {
120
149
  .cb-band { grid-template-columns: 1fr; }
121
150
  }
@@ -35,6 +35,13 @@
35
35
  where people are asking, their faces. The rows are the hall's row
36
36
  archetype (oversight.css) led by a face; collab.js fills them. -->
37
37
  <main id="hall-main">
38
+ <!-- The glass figure strip task 1003942 promised and did not build, and
39
+ the artist's verdict (task 1004041) asked for by name: three calm
40
+ numbers a person meets BEFORE any age pill. The shell's .stat-row,
41
+ the same band Harbor, Gate and the copy desk open on; collab.js
42
+ fills it from the two reads it already makes. -->
43
+ <div class="stat-row ov-summary cb-figs" id="cb-figs" aria-label="Where the asks stand"></div>
44
+
38
45
  <div class="view-zone view-zone--band cb-band">
39
46
  <section class="scroll cb-block" aria-labelledby="cb-for-me-h">
40
47
  <div class="cb-block__head">
@@ -98,7 +105,7 @@
98
105
  <script src="/builders/shell.js?v=2026-07-02-shell1"></script>
99
106
  <script src="/builders/palette.js" defer></script>
100
107
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
101
- <script src="/builders/collab-lib.js?v=2026-09-16-twosentence"></script>
102
- <script src="/builders/collab.js?v=2026-09-16-twosentence"></script>
108
+ <script src="/builders/collab-lib.js?v=2026-09-19-agewords"></script>
109
+ <script src="/builders/collab.js?v=2026-09-19-agewords"></script>
103
110
  </body>
104
111
  </html>
@@ -47,16 +47,15 @@
47
47
 
48
48
  let settling = false;
49
49
 
50
- // The age pill: the band's words, in the band's colour (collab.css .cb-age--*).
50
+ // The age pill: the band picks the colour (collab.css .cb-age--*), the LABEL
51
+ // carries the real age, so fourteen old rows read as fourteen different ages
52
+ // instead of fourteen copies of one alarm (artist verdict, task 1004041). The
53
+ // exact timestamp stays on the title, where a reader who wants the date can
54
+ // still reach it — which is also why the separate date line that used to sit
55
+ // under this pill is gone: the same fact twice is one fact too many.
51
56
  function ageChip(req, nowMs) {
52
57
  const band = L.ageBand(req.created_at, nowMs);
53
- return `<span class="fact-pill cb-age cb-age--${band.id}" title="Asked ${escapeHtml(String(req.created_at || ''))}">${escapeHtml(band.label)}</span>`;
54
- }
55
-
56
- function whenLine(iso) {
57
- const d = iso ? new Date(iso) : null;
58
- if (!d || Number.isNaN(d.getTime())) return '';
59
- return `<span class="ov-row__when">${escapeHtml(d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }))}</span>`;
58
+ return `<span class="fact-pill cb-age cb-age--${band.id}" title="Asked ${escapeHtml(String(req.created_at || ''))}">${escapeHtml(L.ageLabel(req.created_at, nowMs))}</span>`;
60
59
  }
61
60
 
62
61
  // A DOM id per row that is stable, unique and safe to put in an attribute. The
@@ -183,21 +182,30 @@
183
182
  <div class="ov-row__main">${askHtml(req, key)}
184
183
  <div class="ov-row__facts">${directionFact(req, 'in')}${contextChip(req)}</div>
185
184
  </div>
186
- <div class="ov-row__right">${ageChip(req, nowMs)}${whenLine(req.created_at)}</div>
185
+ <div class="ov-row__right">${ageChip(req, nowMs)}</div>
187
186
  </li>`;
188
187
  }
189
188
 
190
189
  // Your own ask, led by who you asked, with the two settle actions. Only the
191
190
  // asker can settle — the server enforces it in the UPDATE's WHERE, this is
192
191
  // just the affordance.
192
+ //
193
+ // THE AGE SITS WITH THE FACTS HERE, NOT IN THE RIGHT COLUMN (artist verdict,
194
+ // task 1004041). On every other block the right column holds one thing and the
195
+ // age is it. On this one it held the age AND the two settle buttons, so an
196
+ // alarm in the danger ink and a solid accent control ended up two centimetres
197
+ // apart — and DESIGN.md spends the accent only on what is pressable or live,
198
+ // which is precisely the distinction a reader could no longer make. Moving the
199
+ // pill down to the facts line puts the warning with the other facts about the
200
+ // ask and leaves the right column as what it is here: the place you act.
193
201
  function mineRow(req, nowMs, extra, key) {
194
202
  const id = escapeHtml(String(req.id));
195
203
  return `<li class="ov-row ov-row--lead cb-row" data-id="${id}"${extra ? ' data-extra hidden' : ''}>
196
204
  ${faceHtml(L.faceOf(req, 'out'))}
197
205
  <div class="ov-row__main">${askHtml(req, key)}
198
- <div class="ov-row__facts">${directionFact(req, 'out')}${contextChip(req)}</div>
206
+ <div class="ov-row__facts">${directionFact(req, 'out')}${contextChip(req)}${ageChip(req, nowMs)}</div>
199
207
  </div>
200
- <div class="ov-row__right">${ageChip(req, nowMs)}
208
+ <div class="ov-row__right">
201
209
  <div class="ov-row__actions">
202
210
  <button class="btn-accent" data-settle="answered" data-id="${id}">Someone helped</button>
203
211
  <button class="btn-ghost" data-settle="withdrawn" data-id="${id}">Withdraw</button>
@@ -214,7 +222,7 @@
214
222
  <div class="ov-row__main">${askHtml(req, key)}
215
223
  <div class="ov-row__facts">${boardFact(req)}${contextChip(req)}</div>
216
224
  </div>
217
- <div class="ov-row__right">${ageChip(req, nowMs)}${whenLine(req.created_at)}</div>
225
+ <div class="ov-row__right">${ageChip(req, nowMs)}</div>
218
226
  </li>`;
219
227
  }
220
228
 
@@ -247,6 +255,46 @@
247
255
  }
248
256
  }
249
257
 
258
+ // ---- the figure strip -------------------------------------------------------
259
+ //
260
+ // THREE CALM NUMBERS BEFORE ANY PILL (artist verdict, task 1004041). Task
261
+ // 1003942's description promised "a glass figure strip at the top (waiting on
262
+ // you / your open asks / crafts you answer for)" and it was never built — the
263
+ // grader's narc worker caught the omission and the artist named what it cost:
264
+ // with no numbers to open on, the first thing a person meets is the wall of
265
+ // age pills, which is why the two findings were one defect seen twice. So the
266
+ // strip is the shell's .stat-row, the same glass band Harbor, Gate and the
267
+ // copy desk open on, and it is drawn BEFORE the blocks in the markup.
268
+ //
269
+ // The figures repeat what the block heads count, and that is the point rather
270
+ // than a duplication: a head's count is found by reading the head, and a strip
271
+ // is read in one glance at the top of the page. The third one is not a count of
272
+ // asks at all — it is the roles that decide which asks reach you, which is the
273
+ // fact behind every empty "Asked of you" and has never been stated on the page.
274
+ // `badge` is the shell's own modifier for a tile whose value is WORDS rather
275
+ // than a figure — the display face at 34px is sized for a numeral and sets a
276
+ // list of role names as a headline.
277
+ function figureHtml(label, value, sub, badge) {
278
+ return `<div class="stat-tile">
279
+ <span class="stat-tile__label">${escapeHtml(label)}</span>
280
+ <span class="stat-tile__num${badge ? ' stat-tile__num--badge' : ''}">${escapeHtml(String(value))}</span>
281
+ <span class="stat-tile__sub">${escapeHtml(sub)}</span>
282
+ </div>`;
283
+ }
284
+
285
+ function paintFigures(forMe, mine, crafts) {
286
+ const host = $('#cb-figs');
287
+ if (!host) return;
288
+ const axis = window.OTB.roleAxisLabel().toLowerCase();
289
+ // A ROLE LIST IS NOT A NUMBER, so the third tile shows the roles themselves
290
+ // and falls back to the honest em dash rather than to "0", which would read
291
+ // as a count of something rather than as "you have not set one".
292
+ const roles = crafts.map((c) => window.OTB.roleWord(c)).join(', ');
293
+ host.innerHTML = figureHtml('Waiting on you', forMe.length, forMe.length === 1 ? 'ask' : 'asks')
294
+ + figureHtml('Your open asks', mine.length, mine.length === 1 ? 'ask' : 'asks')
295
+ + figureHtml('Roles you answer for', roles || '—', roles ? `your ${axis}` : `no ${axis} set yet`, true);
296
+ }
297
+
250
298
  // The per-row door. Independent of the block's "Show all N", which flips
251
299
  // [data-extra] on the <li>s: this flips one <p> inside one row, so opening a
252
300
  // row inside a condensed block and then expanding the block cannot leave either
@@ -323,6 +371,7 @@
323
371
  const rowsOf = (r) => ((r && r.ok && r.data && r.data.help_requests) || []);
324
372
  const crafts = (forMe.ok && forMe.data && forMe.data.crafts) || [];
325
373
 
374
+ paintFigures(rowsOf(forMe), rowsOf(mine), crafts);
326
375
  paintBlock('for-me', rowsOf(forMe), forMeRow, nowMs, true);
327
376
  paintBlock('mine', rowsOf(mine), mineRow, nowMs, false);
328
377
  paintBlock('all', rowsOf(all), allRow, nowMs, true);
@@ -330,11 +379,15 @@
330
379
  const sub = $('#cb-sub');
331
380
  if (sub) {
332
381
  // task 1003936: the axis word comes from OTB.roleAxisLabel(), never typed.
333
- // The VALUES are named through roleLabel() for the same reason the filters are.
382
+ // task 1004041: the VALUES take roleWord(), not roleLabel() — this is a
383
+ // sentence, and the page said "Asks to Builders" six lines above "You asked
384
+ // artists". The opening clause is gone with it: it named the same two
385
+ // parties the clause after it names, and the page's own head and its three
386
+ // block headings already say what the page is.
334
387
  const axis = window.OTB.roleAxisLabel().toLowerCase();
335
388
  sub.textContent = crafts.length
336
- ? `Builders asking builders. Asks to ${crafts.map((c) => `${window.OTB.roleLabel(c)}s`).join(', ')}, and to you by name, land in your block.`
337
- : `Builders asking builders. With no ${axis} set, only asks addressed to you by name land in your block.`;
389
+ ? `Asks to ${crafts.map((c) => `${window.OTB.roleWord(c)}s`).join(', ')}, and to you by name, land in your block.`
390
+ : `With no ${axis} set, only asks addressed to you by name land in your block.`;
338
391
  }
339
392
 
340
393
  // The social half. Best-effort and LAST: an introductions read that fails
@@ -209,6 +209,28 @@
209
209
  return s === 'ui' ? 'UI' : s.charAt(0).toUpperCase() + s.slice(1);
210
210
  }
211
211
 
212
+ // A CONTROL TAKES roleLabel(); A SENTENCE TAKES roleWord() — the rule task
213
+ // 1004041 exists to write down, next to the helper that made it necessary.
214
+ //
215
+ // roleLabel() capitalises, and that is right for a control: a picker option, a
216
+ // filter, a column head, a row tag. It is wrong in RUNNING PROSE, where a role
217
+ // is a common noun. Task 1003936 routed Collab's sub line through roleLabel and
218
+ // left collab-lib.js lowercase, reasoning about the two call sites separately —
219
+ // so one screen said "Asks to Builders" six lines above "You asked artists",
220
+ // which is the same word capitalised two ways on one page, the exact defect
221
+ // 1003936 existed to remove. Deciding per call site is what guarantees that;
222
+ // the axis needs two helpers, not one helper and a judgement each time.
223
+ //
224
+ // NOT ESCAPE-SAFE, like roleLabel(): a caller rendering it into markup escapes
225
+ // the result. The acronym exception is the same one, because 'UI' is not a
226
+ // common noun in any sentence either.
227
+ function roleWord(d) {
228
+ const s = String(d || '');
229
+ if (!s) return '';
230
+ if (s === ROLE_UNSET) return 'no role yet';
231
+ return s === 'ui' ? 'UI' : s.toLowerCase();
232
+ }
233
+
212
234
  // The option list for any Role <select>, so no surface types the values or the
213
235
  // labels again. Prefers the roles the SERVER serves (the same list
214
236
  // db.updateBuilderDisciplines validates a PATCH against), and falls back to the
@@ -422,7 +444,7 @@
422
444
  window.OTB = {
423
445
  escapeHtml, $, fmtNum, num, fmtInt, fmtCompact, fmtDate, rankLabel, toast, moduleOn, disciplines,
424
446
  // task 1003936 — ONE word for the role axis, all six surfaces
425
- roleAxisLabel, roleLabel, roleOptions, roleIsUnset, ROLE_UNSET,
447
+ roleAxisLabel, roleLabel, roleWord, roleOptions, roleIsUnset, ROLE_UNSET,
426
448
  fillKindFilter, kindLabel, kindIsUnset, KIND_UNSET, // task 1003453 — one Kind filler, all three surfaces
427
449
  errCode, errMessage, errDetails, // task 1002462 — ADR 0116 envelope readers
428
450
  contributeSettingsPanel, settingsPanels,
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.882",
3
+ "version": "1.19.883",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.882",
9
+ "version": "1.19.883",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.882",
3
+ "version": "1.19.883",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -124,9 +124,46 @@ const FIXTURE_FALLBACKS = [
124
124
  [/^connections\/degree\/[^/]+$/, 'connections__degree'],
125
125
  ];
126
126
 
127
+ // A DATE THAT ROTS CANNOT DEMO AN AGE SIGNAL (task 1004041). The help-request
128
+ // fixtures were written with absolute timestamps chosen to land one row in each
129
+ // of collab's four age bands — and three weeks later every one of them had
130
+ // drifted into the oldest band, so the harness rendered fourteen identical
131
+ // "waiting over a week" pills and an artist reviewing the page reasonably read
132
+ // that as the page's own defect. (Half of it was; the other half was this.)
133
+ //
134
+ // So a fixture may write a RELATIVE timestamp: "@-3d" is three days before the
135
+ // moment the page is served, "@-6h" six hours. Resolved on every load, so the
136
+ // bands the fixture was written to demonstrate are the bands it demonstrates a
137
+ // year from now. Any string field may use it; nothing else changes, so a fixture
138
+ // with absolute dates is served exactly as before.
139
+ //
140
+ // WRITE A FRACTIONAL OFFSET. "@-3d" lands exactly on the 3-day band boundary, so
141
+ // whether it reads as 2 days or 3 depends on the milliseconds between this
142
+ // resolve and the browser's own clock — the row flickers between two bands
143
+ // across reloads and a screenshot pass cannot be compared. "@-3.4d" cannot.
144
+ const RELATIVE_AT = /^@-(\d+(?:\.\d+)?)([dh])$/;
145
+ const HOUR_MS = 60 * 60 * 1000;
146
+
147
+ function resolveRelativeDates(v, nowMs) {
148
+ if (typeof v === 'string') {
149
+ const m = RELATIVE_AT.exec(v);
150
+ if (!m) return v;
151
+ const hours = Number(m[1]) * (m[2] === 'd' ? 24 : 1);
152
+ return new Date(nowMs - hours * HOUR_MS).toISOString();
153
+ }
154
+ if (Array.isArray(v)) return v.map((x) => resolveRelativeDates(x, nowMs));
155
+ if (v && typeof v === 'object') {
156
+ const out = {};
157
+ for (const [k, val] of Object.entries(v)) out[k] = resolveRelativeDates(val, nowMs);
158
+ return out;
159
+ }
160
+ return v;
161
+ }
162
+
127
163
  function loadFixture(name) {
128
164
  try {
129
- return JSON.parse(fs.readFileSync(path.join(FIXTURES, `${name}.json`), 'utf8'));
165
+ const raw = JSON.parse(fs.readFileSync(path.join(FIXTURES, `${name}.json`), 'utf8'));
166
+ return resolveRelativeDates(raw, Date.now());
130
167
  } catch (_) { return undefined; }
131
168
  }
132
169
 
@@ -0,0 +1,117 @@
1
+ // src/bongos/module-overrides.js — durable module enablement (task 1002857,
2
+ // migration core_248).
3
+ //
4
+ // WHAT THIS FIXES. The hall's Modules-tab toggle wrote config/modules.json and
5
+ // reloaded the cache, so the effect was immediate on the box — but that file is
6
+ // the committed instance pack, and the pull-deploy runs `git reset --hard
7
+ // origin/main`. The toggle therefore reverted on the next deploy, silently,
8
+ // unless a human remembered to commit it. This module moves the durable half of
9
+ // that decision into the database, where the rest of this instance's live state
10
+ // already lives.
11
+ //
12
+ // THE SHAPE, and why it is this shape. src/modules.js's loadModules() is
13
+ // SYNCHRONOUS — a lazy cached singleton every caller reaches through modules() —
14
+ // and making it async would ripple into effectively every consumer of a module
15
+ // flag. So the database is never read inline. Instead this file reads the
16
+ // overrides ONCE per refresh and hands src/modules.js a plain snapshot, which its
17
+ // pure resolver treats as one more precedence layer. The refresh points are the
18
+ // two moments the answer can change: server boot, and a write through this file.
19
+ //
20
+ // FAILURE IS A NO-OP, DELIBERATELY. Every function here swallows its database
21
+ // error and leaves the snapshot alone. An empty snapshot means "no opinion", not
22
+ // "everything off", so an unreachable database resolves modules exactly as this
23
+ // instance resolved them before the table existed. The alternative — letting a
24
+ // DB outage change which modules are mounted — would make a read-path dependency
25
+ // out of a feature whose entire purpose is durability.
26
+
27
+ const { pool } = require('./pool');
28
+ // Named imports rather than a namespace object (task 1004052 strand walk): knip cannot
29
+ // follow property access on a CommonJS namespace, so reaching these through one made
30
+ // moduleScopeKey and setModuleOverrides read as unused exports of src/modules.js and
31
+ // tripped the dead-code ratchet — which is what held this branch out of main for 20h.
32
+ const { moduleScopeKey, setModuleOverrides } = require('../modules');
33
+
34
+ // One log line per distinct failure reason, not per call: refreshOverrides() runs
35
+ // on boot and after every toggle, and a persistently unreachable database would
36
+ // otherwise fill the log with the same sentence.
37
+ const _warned = new Set();
38
+ function warnOnce(reason, err, log = console) {
39
+ if (_warned.has(reason)) return;
40
+ _warned.add(reason);
41
+ log.warn(
42
+ `[bongos] module overrides ${reason}: ${err && err.message}. Module enablement falls back to the `
43
+ + 'config packs and env for now — no module changes state because of this.',
44
+ );
45
+ }
46
+
47
+ // Read every override for THIS instance's scope and install it as the live
48
+ // snapshot. Returns the resolved module set on success, or null if the database
49
+ // could not be read (in which case the previous snapshot stands).
50
+ async function refreshOverrides({ scope = moduleScopeKey() } = {}) {
51
+ let rows;
52
+ try {
53
+ ({ rows } = await pool.query(
54
+ 'SELECT module_key, enabled FROM module_enablement_overrides WHERE scope = $1',
55
+ [scope],
56
+ ));
57
+ } catch (err) {
58
+ warnOnce('could not be read', err);
59
+ return null;
60
+ }
61
+ const map = {};
62
+ for (const r of rows) map[r.module_key] = r.enabled;
63
+ return setModuleOverrides(map);
64
+ }
65
+
66
+ // Record an operator's toggle and make it live in the same call. The UPSERT is
67
+ // keyed on (scope, module_key) so a second toggle of the same module updates the
68
+ // row rather than accumulating history — the audit trail this table carries is
69
+ // "who set the CURRENT state, and when", which is what an operator looking at a
70
+ // surprising module needs. Throws on a write failure: unlike a read, a write that
71
+ // silently did nothing would tell the operator their toggle was durable when it
72
+ // was not, which is the original bug wearing a different hat.
73
+ async function setOverride(moduleKey, enabled, { builderId = null, scope = moduleScopeKey() } = {}) {
74
+ await pool.query(
75
+ `INSERT INTO module_enablement_overrides (scope, module_key, enabled, set_by, set_at)
76
+ VALUES ($1, $2, $3, $4, now())
77
+ ON CONFLICT (scope, module_key)
78
+ DO UPDATE SET enabled = EXCLUDED.enabled, set_by = EXCLUDED.set_by, set_at = now()`,
79
+ [scope, moduleKey, enabled, builderId],
80
+ );
81
+ return refreshOverrides({ scope });
82
+ }
83
+
84
+ // Drop the override and fall back to whatever the packs and env say. This is the
85
+ // only way back to "no opinion": setting the override to the pack's own value
86
+ // would look identical in the UI but would keep outranking a later edit to the
87
+ // committed pack.
88
+ async function clearOverride(moduleKey, { scope = moduleScopeKey() } = {}) {
89
+ await pool.query(
90
+ 'DELETE FROM module_enablement_overrides WHERE scope = $1 AND module_key = $2',
91
+ [scope, moduleKey],
92
+ );
93
+ return refreshOverrides({ scope });
94
+ }
95
+
96
+ // The rows behind the current snapshot, for the hall's Modules tab: who set each
97
+ // override and when, which the file-write path never recorded. Read-only and
98
+ // failure-tolerant for the same reason refreshOverrides is — a broken audit read
99
+ // must not break the page that lists the modules.
100
+ async function listOverrides({ scope = moduleScopeKey() } = {}) {
101
+ try {
102
+ const { rows } = await pool.query(
103
+ `SELECT o.module_key, o.enabled, o.set_by, o.set_at, b.github_login AS set_by_login
104
+ FROM module_enablement_overrides o
105
+ LEFT JOIN builders b ON b.id = o.set_by
106
+ WHERE o.scope = $1
107
+ ORDER BY o.module_key`,
108
+ [scope],
109
+ );
110
+ return rows;
111
+ } catch (err) {
112
+ warnOnce('could not be listed', err);
113
+ return [];
114
+ }
115
+ }
116
+
117
+ module.exports = { refreshOverrides, setOverride, clearOverride, listOverrides };
@@ -23,23 +23,28 @@
23
23
  // /backup/trigger — it changes running behavior for the whole instance (or
24
24
  // files something into a shared review queue), not just the caller.
25
25
  //
26
- // Persistence note: /modules/:key/enable writes config/modules.json on disk
27
- // and reloads the in-process cache, so the effect is immediate on this box.
28
- // Like any other hand-edit of that file, surviving the next `git reset --hard`
29
- // deploy needs the change committed — this route does not auto-commit (an
30
- // HTTP handler auto-pushing to main bypasses the ship/grade/deploy pipeline,
31
- // which the project's trust boundary treats as a bigger risk than a toggle
32
- // that needs a human/agent to land it properly). Captured as a follow-up
33
- // idea: durable (DB-backed) module enablement that survives a deploy with no
34
- // extra step.
26
+ // Persistence note (task 1002857, migration core_248, ADR 0309):
27
+ // /modules/:key/enable writes a row in module_enablement_overrides and reloads
28
+ // the in-process cache, so the toggle is both immediate on this box AND durable
29
+ // past the next deploy. It used to write config/modules.json, which the
30
+ // pull-deploy's `git reset --hard origin/main` then reverted unless a human
31
+ // remembered to commit it — silently, which was the bug.
32
+ //
33
+ // The route still does not auto-commit, and that has not changed: an HTTP
34
+ // handler pushing to main would bypass the ship/grade/deploy pipeline and the
35
+ // ADR 0016 trust boundary. Durability comes from the database, where this
36
+ // instance's live state already lives, not from a writer on main.
37
+ //
38
+ // Precedence, stated where the toggle is: neutral pack -> instance pack -> THIS
39
+ // override -> env. An operator can still force a module off on one box with
40
+ // <PREFIX>_MODULE_<KEY>, and that env knob outranks anything this route writes —
41
+ // deliberately, because the incident lever must not need a reachable database.
35
42
  //
36
43
  // Why the review queue is a table in THIS instance's own DB (not a live
37
44
  // cross-repo call to the extracted core repo): see
38
45
  // docs/adr/0135-module-upstream-submission-interim-queue.md.
39
46
 
40
47
  const express = require('express');
41
- const fs = require('node:fs');
42
- const path = require('node:path');
43
48
  const auth = require('../auth');
44
49
  const { asyncHandler, validateOrRespond, LIMITS } = require('./_helpers');
45
50
  const loader = require('../../module-loader/loader');
@@ -53,6 +58,10 @@ const modulesLib = require('../../modules');
53
58
  // server process.
54
59
  const moduleCli = require('../../../scripts/gds/module');
55
60
  const moduleSubmissions = require('../module-submissions');
61
+ // Named imports, not a namespace object (task 1004052 strand walk): knip cannot follow
62
+ // property access on a CommonJS namespace, so reaching these through one made them read
63
+ // as unused exports and tripped the dead-code ratchet that held this branch out of main.
64
+ const { listOverrides, setOverride } = require('../module-overrides');
56
65
 
57
66
  // A packaged submission embeds every file's content (base64) — cap the total
58
67
  // so a runaway/misbuilt module can't bloat the review-queue table. Generous
@@ -75,11 +84,6 @@ function sourceRepoLabel() {
75
84
  } catch { return null; }
76
85
  }
77
86
 
78
- function readInstanceConfig() {
79
- try { return JSON.parse(fs.readFileSync(modulesLib.INSTANCE_PATH, 'utf8')); }
80
- catch (e) { if (e.code === 'ENOENT') return { modules: {} }; throw e; }
81
- }
82
-
83
87
  // The catalog model (src/module-loader/catalog.js) is the SINGLE builder behind
84
88
  // both surfaces — this route and `bongos module list` — so the hall and the
85
89
  // terminal can never disagree about who built a module or which tree it ships
@@ -108,8 +112,25 @@ module.exports = function buildModulesRouter() {
108
112
  // rank: any-builder — list every on-disk module with attribution + provenance (same read gate as the atlas/primer pages).
109
113
  router.get('/modules', auth.requireBuilder, asyncHandler('GET /modules', async (_req, res) => {
110
114
  const all = listModules();
115
+ // WHICH LAYER PRODUCED EACH STATE, so an operator can tell a durable override
116
+ // from a committed pack value from an env knob without opening a shell
117
+ // (task 1002857). Both reads are failure-tolerant, so a broken override table
118
+ // degrades this to the listing it was before rather than breaking the tab.
119
+ const resolvedSet = modulesLib.modules();
120
+ const sources = (resolvedSet && resolvedSet.sources) || {};
121
+ const overrideRows = await listOverrides();
122
+ const byKey = new Map(overrideRows.map((r) => [r.module_key, r]));
123
+ for (const m of all) {
124
+ m.enabled_source = sources[m.key] || 'default';
125
+ m.override = byKey.has(m.key)
126
+ ? { enabled: byKey.get(m.key).enabled, set_by: byKey.get(m.key).set_by_login || null, set_at: byKey.get(m.key).set_at }
127
+ : null;
128
+ }
111
129
  res.json({
112
130
  modules: all,
131
+ // The override scope this process runs under — two instances sharing one
132
+ // database differ only here, so a surprising answer is diagnosable.
133
+ scope: modulesLib.moduleScopeKey(),
113
134
  own_built: all.filter((m) => m.provenance === 'own').length,
114
135
  core_loaded: all.filter((m) => m.provenance === 'core').length,
115
136
  // The catalog roll-up (task 1001772): how many modules the core ships that
@@ -137,15 +158,27 @@ module.exports = function buildModulesRouter() {
137
158
  return res.fail('not_found', { status: 404, message: `No on-disk module "${key}" — only a real modules/<key>/ can be toggled.` });
138
159
  }
139
160
 
140
- const cfg = readInstanceConfig();
141
- if (!cfg.modules || typeof cfg.modules !== 'object') cfg.modules = {};
142
- cfg.modules[key] = enabled;
143
- fs.mkdirSync(path.dirname(modulesLib.INSTANCE_PATH), { recursive: true });
144
- fs.writeFileSync(modulesLib.INSTANCE_PATH, JSON.stringify(cfg, null, 2) + '\n');
145
- modulesLib.reload();
161
+ // setOverride throws if the write fails, and that is the point: a toggle
162
+ // that reported success while persisting nothing would be the original bug
163
+ // with better manners. The read path (refreshOverrides) is failure-tolerant;
164
+ // the write path deliberately is not.
165
+ const resolvedSet = await setOverride(key, enabled, { builderId: req.builder && req.builder.id });
166
+ const effective = resolvedSet ? resolvedSet[key] === true : enabled;
146
167
 
147
168
  console.log(`[gds] module "${key}" ${enabled ? 'enabled' : 'disabled'} by builder ${req.builder && req.builder.id}`);
148
- res.json({ ok: true, key, enabled, note: 'Live on this box now. Persisting past the next deploy needs config/modules.json committed.' });
169
+ res.json({
170
+ ok: true,
171
+ key,
172
+ enabled,
173
+ // What the module ACTUALLY resolved to, which is not always what was asked:
174
+ // an env override or the spend guard outranks this write, and an operator
175
+ // who is not told so will re-click the toggle and blame the feature.
176
+ effective,
177
+ source: (resolvedSet && resolvedSet.sources && resolvedSet.sources[key]) || 'override',
178
+ note: effective === enabled
179
+ ? 'Live on this box now, and durable — it survives the next deploy.'
180
+ : `Recorded, but "${key}" still resolves to ${effective}: a higher-precedence layer (env, or the spend guard) is winning.`,
181
+ });
149
182
  }));
150
183
 
151
184
  // POST /api/bongos/modules/:key/submit — ADR 0107 §1 upstreaming submission.
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.882'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.883'; // 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');