@bongos/core 1.21.156 → 1.21.158
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +43 -33
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +9 -1
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +164 -5
- package/docs/api-reference.md +5 -2
- package/docs/copy-inventory.md +28 -22
- package/docs/copy-registry.json +77 -23
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +3 -1
- package/docs/page-readings.json +29 -24
- package/modules/government/catalog.js +1 -1
- package/modules/hall-ui/public/modules.html +11 -0
- package/modules/hall-ui/public/modules.js +104 -0
- package/modules/hall-ui/records/catalog.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/module.js +3 -0
- package/src/bongos/module-store.js +15 -3
- package/src/bongos/module-testing.js +73 -0
- package/src/bongos/project-settings.js +3 -0
- package/src/bongos/routes/modules.js +46 -2
- package/src/module-api.js +1 -1
- package/tests/hall_catalog_world.mjs +18 -2
- package/tests/module_store_publish.mjs +32 -0
- package/tests/module_testing.mjs +171 -0
package/docs/page-readings.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"adr": "0341 D3",
|
|
6
6
|
"reread": "node scripts/gds/page-reader.js --stale",
|
|
7
7
|
"how": ["Generated by node scripts/gds/page-reader.js (BV2.TW04, task 1004315; ADR 0341 D3). Do not hand-edit.","One entry per page of docs/page-inventory.json. Lines are in page order: the default state first, and a line only another state shows sits where that state shows it, with its state named.","placement: placed = a docs/copy-registry.json row renders it (file, line, string_id); shared = shell text every page of the surface carries (top bar, sidebar, footer), with its row when the registry has one; unplaced = the registry cannot place it (a rewrite becomes an engineer task, ADR 0341 D4).","text keeps {…} where the registry row has a hole, or where a value the page read from its API was filled in. A string that is only such data is counted under omitted.data, not listed.","reading_hash is over the non-shared lines (key, section, text); files_hash is over the page's inventory files. --check compares files_hash only and never opens a browser.","Rendered through the hall-preview harness (hall) and the ui-design kit stub (landing, status), at 1280x800 with the clock pinned, so a reading is the fixture world's page."],
|
|
8
|
-
"counts": {"pages":46,"lines":
|
|
8
|
+
"counts": {"pages":46,"lines":5568,"placed":1711,"shared":398,"unplaced":3459,"omitted_data":3606},
|
|
9
9
|
"pages": [
|
|
10
10
|
{
|
|
11
11
|
"id": "builders:atlas",
|
|
@@ -1885,10 +1885,10 @@
|
|
|
1885
1885
|
"surface": "builders",
|
|
1886
1886
|
"title": "Modules",
|
|
1887
1887
|
"states_read": ["browse","search-empty","lens","details","out"],
|
|
1888
|
-
"files_hash": "
|
|
1889
|
-
"reading_hash": "
|
|
1890
|
-
"counts": {"lines":
|
|
1891
|
-
"omitted": {"data":
|
|
1888
|
+
"files_hash": "50ce60ad3dac0401",
|
|
1889
|
+
"reading_hash": "a17f501b72321b7b",
|
|
1890
|
+
"counts": {"lines":62,"placed":29,"shared":8,"unplaced":25},
|
|
1891
|
+
"omitted": {"data":106},
|
|
1892
1892
|
"notes": ["details: an action failed (page.click: Timeout 6000ms exceeded.); read as it stood"],
|
|
1893
1893
|
"lines": [
|
|
1894
1894
|
{"key":"L0001","section":"skip link","text":"Skip to content","placement":"shared","file":"modules/hall-ui/public/shell.js","line":872,"string_id":"87cee78ca134"},
|
|
@@ -1928,26 +1928,31 @@
|
|
|
1928
1928
|
{"key":"L0035","section":"Module catalog","text":"{…} file{…}","placement":"placed","file":"modules/hall-ui/public/gate.js","line":151,"string_id":"3efa67dc6e07"},
|
|
1929
1929
|
{"key":"L0036","section":"Module catalog","text":"sessions","placement":"placed","file":"modules/hall-ui/public/task.js","line":467,"string_id":"732b4c935e36"},
|
|
1930
1930
|
{"key":"L0037","section":"Module catalog","text":"Built by this project","placement":"placed","file":"modules/hall-ui/public/modules.html","line":70,"string_id":"b51edc826cbe"},
|
|
1931
|
-
{"key":"L0038","section":"Module catalog","text":"submit for review","placement":"placed","file":"modules/hall-ui/public/modules.js","line":
|
|
1931
|
+
{"key":"L0038","section":"Module catalog","text":"submit for review","placement":"placed","file":"modules/hall-ui/public/modules.js","line":286,"string_id":"0215b2cdca8c"},
|
|
1932
1932
|
{"key":"L0039","section":"Module catalog","text":"A module built here can be submitted upstream for review, so it can join the shared catalog with your name and this project credited.","placement":"placed","file":"modules/hall-ui/public/modules.html","line":74,"string_id":"2e17d7e236d9"},
|
|
1933
|
-
{"key":"L0040","section":"Module catalog","text":"
|
|
1934
|
-
{"key":"L0041","section":"Module catalog","text":"
|
|
1935
|
-
{"key":"L0042","section":"Module catalog","text":"
|
|
1936
|
-
{"key":"L0043","section":"Module catalog","text":"
|
|
1937
|
-
{"key":"L0044","section":"Module catalog","text":"
|
|
1938
|
-
{"key":"L0045","section":"Module catalog","text":"
|
|
1939
|
-
{"key":"L0046","section":"Module catalog","text":"
|
|
1940
|
-
{"key":"L0047","section":"Module catalog","text":"
|
|
1941
|
-
{"key":"L0048","section":"
|
|
1942
|
-
{"key":"L0049","section":"Module catalog","text":"
|
|
1943
|
-
{"key":"L0050","section":"
|
|
1944
|
-
{"key":"L0051","section":"
|
|
1945
|
-
{"key":"L0052","section":"
|
|
1946
|
-
{"key":"L0053","section":"
|
|
1947
|
-
{"key":"L0054","section":"
|
|
1948
|
-
{"key":"L0055","section":"What are you doing now?","text":"
|
|
1949
|
-
{"key":"L0056","section":"What are you doing now?","text":"
|
|
1950
|
-
{"key":"L0057","section":"What are you doing now?","text":"
|
|
1933
|
+
{"key":"L0040","section":"Module catalog","text":"Testing new versions","placement":"placed","file":"modules/hall-ui/public/modules.html","line":82,"string_id":"d52c87fba7da"},
|
|
1934
|
+
{"key":"L0041","section":"Module catalog","text":"This project tests new versions first","placement":"placed","file":"modules/hall-ui/public/modules.js","line":211,"string_id":"1116d2cad470"},
|
|
1935
|
+
{"key":"L0042","section":"Module catalog","text":"testing","placement":"unplaced"},
|
|
1936
|
+
{"key":"L0043","section":"Module catalog","text":"kept on general: {…}","placement":"placed","file":"modules/hall-ui/public/modules.js","line":207,"string_id":"cf5047ea06d8"},
|
|
1937
|
+
{"key":"L0044","section":"Module catalog","text":"A testing project gets each new version of its modules before everyone else, and what it finds feeds the module's score. It is never switched on for you. To keep one module on the versions everyone gets, open its details.","placement":"placed","file":"modules/hall-ui/public/modules.html","line":85,"string_id":"c68c7532aa78"},
|
|
1938
|
+
{"key":"L0045","section":"Module catalog","text":"In the store","placement":"placed","file":"modules/hall-ui/public/modules.html","line":94,"string_id":"ecfebcd52894"},
|
|
1939
|
+
{"key":"L0046","section":"Module catalog","text":"Sort by","placement":"placed","file":"modules/hall-ui/public/modules.html","line":98,"string_id":"70383b194ec0"},
|
|
1940
|
+
{"key":"L0047","section":"Module catalog","text":"Name","placement":"unplaced"},
|
|
1941
|
+
{"key":"L0048","section":"Module catalog","text":"No store module matches that {…}.","placement":"unplaced","state":"search-empty"},
|
|
1942
|
+
{"key":"L0049","section":"Module catalog","text":"scores","placement":"unplaced","occurrences":2},
|
|
1943
|
+
{"key":"L0050","section":"Module catalog","text":"strong at {…}","placement":"placed","file":"modules/hall-ui/public/modules.js","line":340,"string_id":"e294c3429c2e","occurrences":3},
|
|
1944
|
+
{"key":"L0051","section":"Module catalog","text":"weak at {…}","placement":"placed","file":"modules/hall-ui/public/modules.js","line":342,"string_id":"20318b6da94e"},
|
|
1945
|
+
{"key":"L0052","section":"Module catalog","text":"A part is strong at 80 out of 100 or more, and weak below the listing floor. A part with no data yet is not counted against a module. Sort by a part to see the modules strongest at it first; a module with no score for that part goes last, and a new module is never ranked.","placement":"placed","file":"modules/hall-ui/public/modules.html","line":101,"string_id":"e395cd11edab"},
|
|
1946
|
+
{"key":"L0053","section":"footer","text":"Sign out","placement":"shared","file":"modules/hall-ui/public/shell.js","line":520,"string_id":"1883ad7f07fc"},
|
|
1947
|
+
{"key":"L0054","section":"Module catalog","text":"Builders hall · this session","placement":"placed","file":"modules/hall-ui/public/craft-mode.js","line":810,"string_id":"fd1903586e8e"},
|
|
1948
|
+
{"key":"L0055","section":"What are you doing now?","text":"What are you doing now?","placement":"placed","file":"modules/hall-ui/public/craft-mode.js","line":811,"string_id":"60853319f290"},
|
|
1949
|
+
{"key":"L0056","section":"What are you doing now?","text":"Pick one. You can switch any time from the top bar.","placement":"placed","file":"modules/hall-ui/public/craft-mode.js","line":812,"string_id":"4f87d5b17b54"},
|
|
1950
|
+
{"key":"L0057","section":"What are you doing now?","text":"Building","placement":"unplaced"},
|
|
1951
|
+
{"key":"L0058","section":"What are you doing now?","text":"{…} and {…}","placement":"placed","file":"modules/hall-ui/public/profile-nudge.js","line":77,"string_id":"56b00d773232","occurrences":3},
|
|
1952
|
+
{"key":"L0059","section":"What are you doing now?","text":"Artistry","placement":"unplaced"},
|
|
1953
|
+
{"key":"L0060","section":"What are you doing now?","text":"Ideating","placement":"unplaced"},
|
|
1954
|
+
{"key":"L0061","section":"What are you doing now?","text":"Turn thoughts into {…}.","placement":"unplaced"},
|
|
1955
|
+
{"key":"L0062","section":"What are you doing now?","text":"Ask me again next session","placement":"placed","file":"modules/hall-ui/public/craft-mode.js","line":815,"string_id":"fa86bf403775"}
|
|
1951
1956
|
]
|
|
1952
1957
|
},
|
|
1953
1958
|
{
|
|
@@ -223,7 +223,7 @@ const PERMISSIONS = [
|
|
|
223
223
|
{ key: 'goal.create', system: false, floor: 'metic', guards: 'POST /goals (protected scope → Archon in-handler; ADR 0086)' },
|
|
224
224
|
{ key: 'goal.archive', system: false, floor: 'metic', guards: 'POST /goals/:id/{archive,reopen}' },
|
|
225
225
|
{ key: 'goal.scope.widen', system: false, floor: 'metic', guards: 'POST /goals/:id/scope (same-tier; cross-tier = goal.scope.manage, system)' },
|
|
226
|
-
{ key: 'module.enable', system: false, floor: 'metic', guards: 'POST /modules/:key/enable' },
|
|
226
|
+
{ key: 'module.enable', system: false, floor: 'metic', guards: 'POST /modules/:key/enable, POST /modules/testing and POST /modules/:key/general-only (whether this project tests new module versions first, and which modules it keeps on general — task 1003802, ADR 0344 D1)' },
|
|
227
227
|
{ key: 'module.submit', system: false, floor: 'metic', guards: 'POST /modules/:key/submit, GET /modules/submissions' },
|
|
228
228
|
// Re-run a store version's automatic assessment (task 1003796, ADR 0359). Never a
|
|
229
229
|
// hand-set score: the owner ruled a person may ask for a re-check, not change the number.
|
|
@@ -74,6 +74,17 @@
|
|
|
74
74
|
<p class="ov-foot" id="submit-lede">A module built here can be submitted upstream for review, so it can join the shared catalog with your name and this project credited.</p>
|
|
75
75
|
</div>
|
|
76
76
|
|
|
77
|
+
<!-- Testing new versions (task 1003802, ADR 0344 D1): the PROJECT opts in,
|
|
78
|
+
once, by its admin; a module can be kept on general versions from its
|
|
79
|
+
own details. Hidden until GET /modules/testing answers. -->
|
|
80
|
+
<div class="ov-group" id="testing-group" hidden>
|
|
81
|
+
<div class="ov-group__head">
|
|
82
|
+
<span class="ov-group__name">Testing new versions</span>
|
|
83
|
+
</div>
|
|
84
|
+
<ul class="ov-rows" id="testing-grid"></ul>
|
|
85
|
+
<p class="ov-foot" id="testing-lede">A testing project gets each new version of its modules before everyone else, and what it finds feeds the module's score. It is never switched on for you. To keep one module on the versions everyone gets, open its details.</p>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
77
88
|
<!-- In the store — what GET /store/modules lists, each with what it is
|
|
78
89
|
strong and weak at (task 1003799, ADR 0343 D4), so two modules with
|
|
79
90
|
the same score can be told apart. Hidden until the store answers
|
|
@@ -171,9 +171,108 @@
|
|
|
171
171
|
if (mt.removeAfter) line('removable after core', `<span class="ov-mono">${escapeHtml(String(mt.removeAfter))}</span>`);
|
|
172
172
|
if (mt.note) line('note', escapeHtml(String(mt.note)));
|
|
173
173
|
if (m.default != null) line('ships', m.default ? 'default on' : 'default off');
|
|
174
|
+
const versions = testingLine(m);
|
|
175
|
+
if (versions) line('new versions', versions);
|
|
174
176
|
return lines.length ? `<ul class="ov-lines">${lines.join('')}</ul>` : '';
|
|
175
177
|
}
|
|
176
178
|
|
|
179
|
+
// ---- testing new versions (task 1003802, ADR 0344 D1) ----
|
|
180
|
+
//
|
|
181
|
+
// GET /modules/testing says whether this project tests new versions first and
|
|
182
|
+
// which modules it keeps on general. The server owns the answer; the page shows
|
|
183
|
+
// it and sends the two switches, which take the same permission as the
|
|
184
|
+
// enable toggle (canToggle mirrors it, the server decides).
|
|
185
|
+
let testing = null;
|
|
186
|
+
|
|
187
|
+
function generalOnly(key) {
|
|
188
|
+
return !!(testing && Array.isArray(testing.general_only) && testing.general_only.includes(key));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// One module's line in its details: only while the project is testing.
|
|
192
|
+
function testingLine(m) {
|
|
193
|
+
if (!testing || !testing.tester) return '';
|
|
194
|
+
const kept = generalOnly(m.key);
|
|
195
|
+
const says = kept ? 'kept on the versions everyone gets' : 'gets new versions first';
|
|
196
|
+
if (!canToggle) return escapeHtml(says);
|
|
197
|
+
return `${escapeHtml(says)} <button type="button" class="btn-ghost btn--sm mod-general-only" data-key="${escapeHtml(m.key)}" data-on="${kept ? 'false' : 'true'}">${kept ? 'test new versions' : 'keep on general versions'}</button>`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function testingRowHTML() {
|
|
201
|
+
const on = !!testing.tester;
|
|
202
|
+
const kept = (testing.general_only || [])
|
|
203
|
+
.map((k) => (allModules.find((m) => m.key === k) || {}).title || k);
|
|
204
|
+
const facts = [on
|
|
205
|
+
? '<span class="fact-pill fact-pill--info">testing</span>'
|
|
206
|
+
: '<span class="ov-fact">not testing</span>'];
|
|
207
|
+
if (on && kept.length) facts.push(`<span class="ov-fact">kept on general: ${kept.map((t) => escapeHtml(t)).join(', ')}</span>`);
|
|
208
|
+
const control = canToggle
|
|
209
|
+
? `<button type="button" class="ov-toggle mod-testing" aria-pressed="${on ? 'true' : 'false'}">${on ? 'on' : 'off'}</button>`
|
|
210
|
+
: `<span class="fact-pill ${on ? 'fact-pill--ok' : ''}" title="${escapeHtml(floor())} and above can switch this">${on ? 'on' : 'off'}</span>`;
|
|
211
|
+
return `
|
|
212
|
+
<li class="ov-row mod-row" data-key="testing">
|
|
213
|
+
<div class="ov-row__main">
|
|
214
|
+
<span class="ov-row__title">This project tests new versions first</span>
|
|
215
|
+
<div class="ov-row__facts">${facts.join('')}</div>
|
|
216
|
+
</div>
|
|
217
|
+
<div class="ov-row__right mod-row__right">${control}</div>
|
|
218
|
+
</li>`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function renderTesting() {
|
|
222
|
+
const group = $('#testing-group');
|
|
223
|
+
const grid = $('#testing-grid');
|
|
224
|
+
if (!group || !grid || !testing) return;
|
|
225
|
+
group.hidden = false;
|
|
226
|
+
grid.innerHTML = testingRowHTML();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Best-effort, like the store: a switch that cannot be read hides its group
|
|
230
|
+
// and costs nothing else on the page.
|
|
231
|
+
async function loadTesting() {
|
|
232
|
+
try {
|
|
233
|
+
const res = await api.request('GET', `${API}/modules/testing`);
|
|
234
|
+
if (!res.ok) return;
|
|
235
|
+
testing = res.data || null;
|
|
236
|
+
renderTesting();
|
|
237
|
+
renderRows();
|
|
238
|
+
} catch (err) {
|
|
239
|
+
console.error('[modules] testing switch failed to load', err);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function postTesting(url, body, btn) {
|
|
244
|
+
btn.disabled = true;
|
|
245
|
+
try {
|
|
246
|
+
const res = await api.request('POST', url, { body });
|
|
247
|
+
const out = res.data || {};
|
|
248
|
+
if (!res.ok) throw new Error(out.message || `testing → ${res.status}`);
|
|
249
|
+
testing = { tester: !!out.tester, general_only: out.general_only || [] };
|
|
250
|
+
renderTesting();
|
|
251
|
+
renderRows();
|
|
252
|
+
return out;
|
|
253
|
+
} catch (err) {
|
|
254
|
+
console.error('[modules] testing change failed', err);
|
|
255
|
+
toast(err.message || 'Could not change that.', { duration: 3200 });
|
|
256
|
+
return null;
|
|
257
|
+
} finally {
|
|
258
|
+
btn.disabled = false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function onTesterToggle(btn) {
|
|
263
|
+
const next = btn.getAttribute('aria-pressed') !== 'true';
|
|
264
|
+
const out = await postTesting(`${API}/modules/testing`, { tester: next }, btn);
|
|
265
|
+
if (out) toast(out.note || (next ? 'Testing is on.' : 'Testing is off.'), { duration: 4200 });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function onGeneralOnly(btn) {
|
|
269
|
+
const key = btn.dataset.key;
|
|
270
|
+
if (!key) return;
|
|
271
|
+
const on = btn.dataset.on === 'true';
|
|
272
|
+
const out = await postTesting(`${API}/modules/${encodeURIComponent(key)}/general-only`, { general_only: on }, btn);
|
|
273
|
+
if (out) toast(on ? `${key} stays on the versions everyone gets.` : `${key} gets new versions first again.`, { duration: 3200 });
|
|
274
|
+
}
|
|
275
|
+
|
|
177
276
|
// The right column: ONE control. The family's grant toggle when the viewer
|
|
178
277
|
// can flip it (enabled in ink, disabled faint, the accent on hover); the
|
|
179
278
|
// state as a pill when they cannot. A module built here also carries its
|
|
@@ -386,6 +485,10 @@
|
|
|
386
485
|
panel.addEventListener('click', (ev) => {
|
|
387
486
|
const toggle = ev.target.closest('.mod-toggle');
|
|
388
487
|
if (toggle) { onToggle(toggle); return; }
|
|
488
|
+
const tester = ev.target.closest('.mod-testing');
|
|
489
|
+
if (tester) { onTesterToggle(tester); return; }
|
|
490
|
+
const keep = ev.target.closest('.mod-general-only');
|
|
491
|
+
if (keep) { onGeneralOnly(keep); return; }
|
|
389
492
|
const submit = ev.target.closest('.mod-submit');
|
|
390
493
|
if (submit) onSubmit(submit);
|
|
391
494
|
});
|
|
@@ -481,6 +584,7 @@
|
|
|
481
584
|
if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
|
|
482
585
|
if (!res.ok) throw new Error(`modules → ${res.status}`);
|
|
483
586
|
render(res.data);
|
|
587
|
+
loadTesting();
|
|
484
588
|
loadStore();
|
|
485
589
|
} catch (err) {
|
|
486
590
|
console.error('[modules] failed to load', err);
|
|
@@ -14,3 +14,5 @@ Also gone, and the one rule in that list: `.ab-caption` takes the lede's `--meas
|
|
|
14
14
|
**The store's modules are a third group on the same panel, read as strengths and weaknesses (task 1003799, ADR 0343 D4).** `#store-group` ("In the store") lists what `GET /store/modules` lists, hidden until that read answers with something; it loads after the modules on disk and best-effort, so a store that cannot be read costs this group and nothing else. A row is the same `.ov-row`: the overall score as a plain fact (or the `new` info pill), `strong at Tests 96, How-to 88` as a plain fact, and `weak at …` as the warn pill, because a weakness is the exception a buyer must not miss. The sentence behind each part ("Tests scored 96 out of 100, from 40 test cases.") and the parts with no data yet sit in the row's `<details class="mod-more">`, the `splitCriterion` idea: short label in the list, the detail one click away. The reading is the server's (`readParts` in `src/bongos/module-listing.js`: strong at 80 or more, weak below the listing floor, unmeasured never weak); the page re-derives nothing. Harness fixture: `store__modules.json`.
|
|
15
15
|
|
|
16
16
|
**The buyer sorts the store by the part that matters to them (task 1003800, owner decision 2026-10-22).** A "Sort by" menu sits in the store group's head (`#store-sort`, `.ov-group__note`): Name, then the parts the server lists in `sort_parts`. Picking one re-reads `GET /store/modules?sort=<part>`, and the server orders the listed modules (`sortByPart` in `src/bongos/module-listing.js`: highest for that part first, a tie to the overall score then the key, a module with no score for the part last). The page never sorts the rows itself, and New modules keep name order because they are never ranked. While a part is picked, each listed row carries that part's score as a plain fact (`Reliability 91`, or `Reliability not measured yet`), so the order explains itself. The owner chose the buyer over a per-category "key part" because nothing records what a category is for, and a hidden weighting would break the score an author can check (ADR 0343 D3).
|
|
17
|
+
|
|
18
|
+
**Testing new versions is a fourth group, between this project's modules and the store (task 1003802, ADR 0344 D1).** `#testing-group` holds one `.ov-row`, "This project tests new versions first": a `testing` info pill (or the plain fact `not testing`), the modules kept on general as a plain fact, and on the right the family grant toggle (`.ov-toggle.mod-testing`) for a builder holding `module.enable`, a state pill for everyone else. It is hidden until `GET /modules/testing` answers and loads best-effort, like the store. The per-module control is not a second toggle on every row: while testing is on, each module's details gain a "new versions" line (`gets new versions first` / `kept on the versions everyone gets`) with a ghost button that flips it, so the one-control-per-row rule holds and a project that is not testing sees nothing new. The lede says it is never switched on for you, because the criterion turns on the tester having chosen it. Harness fixture: `modules__testing.json` (testing on, `economy` kept on general).
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.158",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.158",
|
|
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.21.
|
|
3
|
+
"version": "1.21.158",
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -9970,5 +9970,17 @@
|
|
|
9970
9970
|
"id": "1004387",
|
|
9971
9971
|
"text": "The Studio's approval-queue button now carries the artist's new label, ready for the artist to approve before it goes live."
|
|
9972
9972
|
}
|
|
9973
|
+
],
|
|
9974
|
+
"1.21.157": [
|
|
9975
|
+
{
|
|
9976
|
+
"id": "1003802",
|
|
9977
|
+
"text": "A project can now volunteer to try new versions of its modules before everyone else, and keep any single module on the normal versions."
|
|
9978
|
+
}
|
|
9979
|
+
],
|
|
9980
|
+
"1.21.158": [
|
|
9981
|
+
{
|
|
9982
|
+
"id": "1003803",
|
|
9983
|
+
"text": "When a module author publishes a new version, it now goes only to projects that volunteered to test, never straight to everyone."
|
|
9984
|
+
}
|
|
9973
9985
|
]
|
|
9974
9986
|
}
|
package/scripts/gds/module.js
CHANGED
|
@@ -1006,6 +1006,9 @@ async function cmdPublish(args, {
|
|
|
1006
1006
|
}
|
|
1007
1007
|
const row = res.data && res.data.version;
|
|
1008
1008
|
log(`\n✓ Published "${key}" ${row ? row.version : sidecar.module_version} to the store${res.data && res.data.created_module ? ' — its first version, so you are its author' : ''}.`);
|
|
1009
|
+
if (res.data && res.data.channel === 'tester') {
|
|
1010
|
+
log(' It goes to testing projects first, and reaches every project once it has been tested (ADR 0344).');
|
|
1011
|
+
}
|
|
1009
1012
|
return 0;
|
|
1010
1013
|
}
|
|
1011
1014
|
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
// the store_modules row on a key's first publish (the publisher becomes its
|
|
15
15
|
// author), refuse anyone else's key, refuse a delisted key (ADR 0338 D2: delisting
|
|
16
16
|
// stops what comes next), refuse a version that already exists or is not newer
|
|
17
|
-
// than the latest, then INSERT the version row
|
|
17
|
+
// than the latest, then INSERT the version row and put it on the tester
|
|
18
|
+
// channel (task 1003803, ADR 0344 D2). Rows are only ever INSERTed.
|
|
18
19
|
//
|
|
19
20
|
// If step 2 refuses, the route removes the temp file step 1 wrote, so a refused
|
|
20
21
|
// publish leaves nothing behind.
|
|
@@ -164,8 +165,14 @@ function relativeArtifactPath(file, { dir = storeDir() } = {}) {
|
|
|
164
165
|
return path.relative(dir, file).split(path.sep).join('/');
|
|
165
166
|
}
|
|
166
167
|
|
|
168
|
+
// Every new version, first release or update, starts on the tester channel (task
|
|
169
|
+
// 1003803, ADR 0344 D2). It is not an input: a publish cannot name a channel, so no
|
|
170
|
+
// omitted or mistyped flag can send an untested version to every project at once.
|
|
171
|
+
// Reaching general is a separate, later act — the promotion gate (task 1003804).
|
|
172
|
+
const PUBLISH_CHANNEL = 'tester';
|
|
173
|
+
|
|
167
174
|
// One transaction. `db` is a pg Pool (anything with connect()). Returns
|
|
168
|
-
// { ok: true, version: row, created: bool } or { ok: false, code, message, status }.
|
|
175
|
+
// { ok: true, version: row, created: bool, channel } or { ok: false, code, message, status }.
|
|
169
176
|
async function publishVersion(input, { db, beforeCommit } = {}) {
|
|
170
177
|
const {
|
|
171
178
|
key, title, description, version, coreVersion, manifest,
|
|
@@ -217,11 +224,16 @@ async function publishVersion(input, { db, beforeCommit } = {}) {
|
|
|
217
224
|
RETURNING id, module_key, version, core_version, tree_sha256, tarball_sha256,
|
|
218
225
|
tarball_bytes, published_by, published_at`,
|
|
219
226
|
[key, version, coreVersion, manifest, treeSha256, tarballSha256, bytes, relPath, builderId]);
|
|
227
|
+
// Same transaction as the version row, so no version is ever without a channel.
|
|
228
|
+
await client.query(
|
|
229
|
+
`INSERT INTO store_module_version_channels (version_id, channel, reason, set_by)
|
|
230
|
+
VALUES ($1, $2, $3, $4)`,
|
|
231
|
+
[row.id, PUBLISH_CHANNEL, 'new_version', builderId]);
|
|
220
232
|
// Put the tarball in place while the row is still uncommitted: if this throws,
|
|
221
233
|
// the catch below rolls the row back, so the two can never disagree.
|
|
222
234
|
if (beforeCommit) await beforeCommit();
|
|
223
235
|
await client.query('COMMIT');
|
|
224
|
-
return { ok: true, created, version: row };
|
|
236
|
+
return { ok: true, created, version: row, channel: PUBLISH_CHANNEL };
|
|
225
237
|
} catch (e) {
|
|
226
238
|
try { await client.query('ROLLBACK'); } catch { /* the connection may be gone */ }
|
|
227
239
|
// Two publishes of the same version racing past the check: the UNIQUE wins.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// src/bongos/module-testing.js — whether this project tests new module versions
|
|
2
|
+
// first (task 1003802, criterion wa5 staged rollout, ADR 0344 D1).
|
|
3
|
+
//
|
|
4
|
+
// A PROJECT opts in, once, and it is the project's own admin who does it: the
|
|
5
|
+
// project is what runs the module, so no builder can volunteer someone else's
|
|
6
|
+
// project. A tester project gets tester-channel versions of every module it has,
|
|
7
|
+
// except the modules it marked "general only". Both choices are reversible, and
|
|
8
|
+
// "willing" is load-bearing: a project is never a tester by default, and a choice
|
|
9
|
+
// that cannot be read reads as NOT a tester (an early version nobody chose is an
|
|
10
|
+
// early victim, not a tester).
|
|
11
|
+
//
|
|
12
|
+
// Storage is two project_settings rows (src/bongos/project-settings.js), the same
|
|
13
|
+
// durable, survives-a-deploy store the project's other knobs use, so no new table:
|
|
14
|
+
//
|
|
15
|
+
// modules.tester 'on' | 'off' absent = off
|
|
16
|
+
// modules.general_only JSON array of module keys absent = []
|
|
17
|
+
//
|
|
18
|
+
// This file records the choice and answers it. Handing a tester project tester
|
|
19
|
+
// versions is task 1003805, which asks testsVersionsOf(key).
|
|
20
|
+
|
|
21
|
+
const { getSetting, setSetting, SETTING_KEYS } = require('./project-settings');
|
|
22
|
+
const { KEY_RE } = require('../module-loader/manifest-schema');
|
|
23
|
+
|
|
24
|
+
function parseTester(raw) {
|
|
25
|
+
return raw === 'on';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A stored list that will not parse, or holds something that is not a key, keeps
|
|
29
|
+
// only the keys it can read: losing a "general only" mark would hand a project an
|
|
30
|
+
// early version of a module it asked to keep on general.
|
|
31
|
+
function parseGeneralOnly(raw) {
|
|
32
|
+
if (raw == null) return [];
|
|
33
|
+
let list;
|
|
34
|
+
try { list = JSON.parse(raw); } catch { return []; }
|
|
35
|
+
if (!Array.isArray(list)) return [];
|
|
36
|
+
return [...new Set(list.filter((k) => typeof k === 'string' && KEY_RE.test(k)))].sort();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// { tester, general_only, updated_at, updated_by } — updated_* from the switch row.
|
|
40
|
+
async function readTesting(deps = {}) {
|
|
41
|
+
const get = deps.getSetting || getSetting;
|
|
42
|
+
const [t, g] = await Promise.all([get(SETTING_KEYS.MODULE_TESTER), get(SETTING_KEYS.MODULE_GENERAL_ONLY)]);
|
|
43
|
+
return {
|
|
44
|
+
tester: parseTester(t && t.value),
|
|
45
|
+
general_only: parseGeneralOnly(g && g.value),
|
|
46
|
+
updated_at: t ? t.updated_at : null,
|
|
47
|
+
updated_by: t && t.updated_by != null ? String(t.updated_by) : null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function setTester(on, builderId = null, deps = {}) {
|
|
52
|
+
const set = deps.setSetting || setSetting;
|
|
53
|
+
await set(SETTING_KEYS.MODULE_TESTER, on ? 'on' : 'off', builderId);
|
|
54
|
+
return readTesting(deps);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function setGeneralOnly(moduleKey, on, builderId = null, deps = {}) {
|
|
58
|
+
if (!KEY_RE.test(String(moduleKey))) throw new Error(`not a module key: ${moduleKey}`);
|
|
59
|
+
const set = deps.setSetting || setSetting;
|
|
60
|
+
const now = await readTesting(deps);
|
|
61
|
+
const keys = new Set(now.general_only);
|
|
62
|
+
if (on) keys.add(moduleKey); else keys.delete(moduleKey);
|
|
63
|
+
await set(SETTING_KEYS.MODULE_GENERAL_ONLY, JSON.stringify([...keys].sort()), builderId);
|
|
64
|
+
return readTesting(deps);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Whether this project takes tester-channel versions of one module. Pure over a
|
|
68
|
+
// readTesting() result.
|
|
69
|
+
function testsVersionsOf(testing, moduleKey) {
|
|
70
|
+
return !!(testing && testing.tester && !testing.general_only.includes(moduleKey));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { readTesting, setTester, setGeneralOnly, testsVersionsOf, parseGeneralOnly };
|
|
@@ -53,6 +53,9 @@ const SETTING_KEYS = {
|
|
|
53
53
|
TWEAK_AUTOPICKUP: 'tweak.autopickup',
|
|
54
54
|
TWEAK_AUTOPICKUP_CAP: 'tweak.autopickup_monthly_cap',
|
|
55
55
|
SPEND_CAP: 'treasury.spend_cap_usd',
|
|
56
|
+
// Testing new module versions first (task 1003802, ADR 0344 D1): module-testing.js.
|
|
57
|
+
MODULE_TESTER: 'modules.tester',
|
|
58
|
+
MODULE_GENERAL_ONLY: 'modules.general_only',
|
|
56
59
|
};
|
|
57
60
|
|
|
58
61
|
// SHIP MODE (task 1004628, ADR 0369) — WHO starts a ship, never HOW it lands.
|
|
@@ -111,6 +111,7 @@ const log = require('../logger').logger.child({ src: 'modules' });
|
|
|
111
111
|
// as unused exports and tripped the dead-code ratchet that held this branch out of main.
|
|
112
112
|
const { listOverrides, setOverride } = require('../module-overrides');
|
|
113
113
|
const { listCatalog, SORT_PARTS } = require('../module-listing');
|
|
114
|
+
const { readTesting, setTester, setGeneralOnly } = require('../module-testing');
|
|
114
115
|
|
|
115
116
|
// The sentence for a toggle that waits on a restart (task 1004463). 'routes' are the
|
|
116
117
|
// module's pages, 'pollers' its background jobs. PURE.
|
|
@@ -333,6 +334,48 @@ module.exports = function buildModulesRouter() {
|
|
|
333
334
|
});
|
|
334
335
|
}));
|
|
335
336
|
|
|
337
|
+
// Testing new module versions first (task 1003802, ADR 0344 D1): whether THIS
|
|
338
|
+
// project gets tester-channel versions, and which modules it keeps on general.
|
|
339
|
+
// A read anyone signed in can make: the switch is meant to be visible.
|
|
340
|
+
//
|
|
341
|
+
// rank: any-builder — a read of this project's own setting; nothing changes.
|
|
342
|
+
router.get('/modules/testing', auth.requireBuilder,
|
|
343
|
+
asyncHandler('GET /modules/testing', async (_req, res) => {
|
|
344
|
+
res.set('Cache-Control', 'no-store');
|
|
345
|
+
res.json(await readTesting());
|
|
346
|
+
}));
|
|
347
|
+
|
|
348
|
+
// rank: metic+archon — the project's admin volunteers the whole project, so it
|
|
349
|
+
// takes the same permission that chooses which modules this project runs
|
|
350
|
+
// (module.enable): a builder cannot opt in a project they cannot run modules for.
|
|
351
|
+
router.post('/modules/testing', auth.requireBuilder, auth.requirePermission('module.enable'),
|
|
352
|
+
asyncHandler('POST /modules/testing', async (req, res) => {
|
|
353
|
+
if (validateOrRespond(req, res, { tester: { type: 'boolean', required: true } })) return;
|
|
354
|
+
const out = await setTester(req.body.tester, req.builder && req.builder.id);
|
|
355
|
+
log.info({ tester: out.tester, builder: req.builder && req.builder.id }, 'module testing switched');
|
|
356
|
+
res.json({
|
|
357
|
+
ok: true,
|
|
358
|
+
...out,
|
|
359
|
+
note: out.tester
|
|
360
|
+
? 'This project now gets new module versions first, except the modules marked general only.'
|
|
361
|
+
: 'This project is back on general versions from its next update.',
|
|
362
|
+
});
|
|
363
|
+
}));
|
|
364
|
+
|
|
365
|
+
// rank: metic+archon — same reasoning as POST /modules/testing: which versions
|
|
366
|
+
// a module arrives in is part of choosing the modules this project runs.
|
|
367
|
+
router.post('/modules/:key/general-only', auth.requireBuilder, auth.requirePermission('module.enable'),
|
|
368
|
+
asyncHandler('POST /modules/:key/general-only', async (req, res) => {
|
|
369
|
+
const { key } = req.params;
|
|
370
|
+
if (!KEY_RE.test(key)) return res.fail('bad_module_key', { status: 400, message: 'A module key is lowercase kebab-case.' });
|
|
371
|
+
if (validateOrRespond(req, res, { general_only: { type: 'boolean', required: true } })) return;
|
|
372
|
+
if (!listModules().some((m) => m.key === key)) {
|
|
373
|
+
return res.fail('not_found', { status: 404, message: `This project has no module "${key}".` });
|
|
374
|
+
}
|
|
375
|
+
const out = await setGeneralOnly(key, req.body.general_only, req.builder && req.builder.id);
|
|
376
|
+
res.json({ ok: true, key, general_only_this: out.general_only.includes(key), ...out });
|
|
377
|
+
}));
|
|
378
|
+
|
|
336
379
|
// POST /api/bongos/modules/:key/submit — ADR 0107 §1 upstreaming submission.
|
|
337
380
|
//
|
|
338
381
|
// task 1770. Body: { signed_by?: "Name <email>" }. Re-runs the §4
|
|
@@ -426,7 +469,8 @@ module.exports = function buildModulesRouter() {
|
|
|
426
469
|
// in one transaction (module-store.js). A key's first
|
|
427
470
|
// publish makes the caller its author; after that only the author may publish,
|
|
428
471
|
// a delisted key takes nothing new, and a version is published once and never
|
|
429
|
-
// changed.
|
|
472
|
+
// changed. Every new version lands on the tester channel; the body names no
|
|
473
|
+
// channel, so nothing here can skip testers (task 1003803, ADR 0344 D2).
|
|
430
474
|
//
|
|
431
475
|
// rank: metic+archon — it puts code in a shared store other instances install
|
|
432
476
|
// from. Gate: requirePermission('module.submit') — the atom that already guards
|
|
@@ -473,7 +517,7 @@ module.exports = function buildModulesRouter() {
|
|
|
473
517
|
if (!result.ok) return res.fail(result.code, { status: result.status, message: result.message });
|
|
474
518
|
|
|
475
519
|
console.log(`[gds] module "${key}" ${v.version} published to the store by builder ${req.builder.id}`);
|
|
476
|
-
res.status(201).json({ ok: true, created_module: result.created, version: result.version });
|
|
520
|
+
res.status(201).json({ ok: true, created_module: result.created, version: result.version, channel: result.channel });
|
|
477
521
|
// A new version is a new thing to judge (task 1003794, ADR 0343): queue its
|
|
478
522
|
// assessment after answering, since the Security gate asks npm about its
|
|
479
523
|
// dependencies and can take a minute. The queue runs one at a time and is
|
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.
|
|
78
|
+
const CORE_VERSION = '1.21.158'; // 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');
|
|
@@ -39,7 +39,7 @@ const noAlias = (rawCss, where) => {
|
|
|
39
39
|
|
|
40
40
|
// ---- modules ----------------------------------------------------------------
|
|
41
41
|
|
|
42
|
-
test('modules: ONE panel of family rows — the filter bar, the figure band and two groups on one .scroll (
|
|
42
|
+
test('modules: ONE panel of family rows — the filter bar, the figure band and two groups on one .scroll (the store since task 1003799, testing since task 1003802)', () => {
|
|
43
43
|
const html = read('modules.html');
|
|
44
44
|
assert.match(html, /<link rel="stylesheet" href="\/builders\/oversight\.css">\s*<link rel="stylesheet" href="\/builders\/modules\.css">/, 'the family sheet loads before the page sheet');
|
|
45
45
|
assert.equal((html.match(/class="scroll[ "]/g) || []).length, 1, 'one panel, not three');
|
|
@@ -47,7 +47,7 @@ test('modules: ONE panel of family rows — the filter bar, the figure band and
|
|
|
47
47
|
assert.match(html, /class="ov-figs" id="mod-figs"/, 'the figure band is the family\'s');
|
|
48
48
|
assert.match(html, /<ul class="ov-rows" id="core-grid">/, 'the core catalog is a run of rows');
|
|
49
49
|
assert.match(html, /<ul class="ov-rows" id="own-grid">/, 'the project\'s own modules are a run of rows');
|
|
50
|
-
assert.equal((html.match(/class="ov-group__head"/g) || []).length,
|
|
50
|
+
assert.equal((html.match(/class="ov-group__head"/g) || []).length, 4, 'four group heads: the core, this project, testing, the store');
|
|
51
51
|
assert.doesNotMatch(decomment(html), /mod-grid|mod-card|mod-browse|submit-scroll|class="settings-status"/, 'the card grid, the boxed search and the third panel are gone');
|
|
52
52
|
assert.doesNotMatch(html, /\d+ module\(s\)/, 'the page head no longer says the numbers as prose');
|
|
53
53
|
});
|
|
@@ -106,6 +106,22 @@ test('modules: the store group reads strengths and weaknesses off the server row
|
|
|
106
106
|
assert.notDeepEqual(two[0].strengths.map((s) => s.part), two[1].strengths.map((s) => s.part), '…that are strong at different things');
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
+
test('modules: the project opts in to testing new versions, and can keep a module on general (task 1003802)', () => {
|
|
110
|
+
const js = read('modules.js');
|
|
111
|
+
const html = read('modules.html');
|
|
112
|
+
assert.match(html, /<div class="ov-group" id="testing-group" hidden>/, 'hidden until the switch is read');
|
|
113
|
+
assert.match(html, /It is never switched on for you\./, 'the page says a project is never a tester by default');
|
|
114
|
+
assert.match(js, /api\.request\('GET', `\$\{API\}\/modules\/testing`\)/, 'it reads GET /modules/testing');
|
|
115
|
+
assert.match(js, /`\$\{API\}\/modules\/testing`, \{ tester: next \}/, 'the switch posts an explicit true or false');
|
|
116
|
+
assert.match(js, /\/modules\/\$\{encodeURIComponent\(key\)\}\/general-only`, \{ general_only: on \}/, 'a module is kept on general by its own key');
|
|
117
|
+
assert.match(js, /class="ov-toggle mod-testing"/, 'the switch is the family grant toggle');
|
|
118
|
+
assert.match(js, /if \(!testing \|\| !testing\.tester\) return '';/, 'the per-module line appears only while the project is testing');
|
|
119
|
+
assert.match(js, /if \(!canToggle\) return escapeHtml\(says\);/, 'a builder who cannot switch it reads the state, with no dead control');
|
|
120
|
+
const fixture = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'modules__testing.json'), 'utf8'));
|
|
121
|
+
assert.equal(fixture.tester, true, 'the harness demos the switch on');
|
|
122
|
+
assert.ok(fixture.general_only.length >= 1, '…with a module kept on general');
|
|
123
|
+
});
|
|
124
|
+
|
|
109
125
|
test('modules: the buyer sorts the store by the part that matters to them (task 1003800)', () => {
|
|
110
126
|
const js = read('modules.js');
|
|
111
127
|
const html = read('modules.html');
|