@bongos/core 1.21.167 → 1.21.169
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 +157 -72
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +13 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +308 -5
- package/docs/api-reference.md +7 -3
- package/docs/architecture.md +2 -0
- package/docs/copy-inventory.md +21 -20
- package/docs/copy-registry.json +34 -25
- package/docs/module-api-changelog.md +5 -1
- package/docs/modules-contract.md +2 -1
- package/docs/page-inventory.json +3 -0
- package/docs/page-readings.json +430 -430
- package/modules/autonomy/governor-docket.js +10 -5
- package/modules/builder-settings/builder-needs.js +29 -3
- package/modules/copy-desk/guarded-lines.js +89 -0
- package/modules/copy-desk/guarded-lines.json +14 -0
- package/modules/copy-desk/module.json +2 -2
- package/modules/copy-desk/page-status.js +77 -7
- package/modules/copy-desk/pages.js +4 -0
- package/modules/copy-desk/pollers/stuck-page-digest.js +34 -0
- package/modules/copy-desk/routes/copy-desk.js +8 -0
- package/modules/copy-desk/stuck-tweaks.js +79 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +6 -1
- package/modules/copy-desk/tests/copy_page_status.mjs +2 -2
- package/modules/government/asks-db.js +38 -2
- package/modules/government/catalog.js +1 -1
- package/modules/government/docket.js +20 -2
- package/modules/government/migrations/government_032_ask_flags.sql +35 -0
- package/modules/government/routes/government.js +43 -1
- package/modules/hall-ui/public/approval-queue-lib.js +4 -0
- package/modules/hall-ui/public/approval-queue.js +1 -0
- package/modules/hall-ui/public/city-decide.js +642 -0
- package/modules/hall-ui/public/city-draw.js +14 -3
- package/modules/hall-ui/public/city-switchboard.js +141 -0
- package/modules/hall-ui/public/city.css +176 -10
- package/modules/hall-ui/public/city.html +8 -3
- package/modules/hall-ui/public/city.js +185 -38
- package/modules/hall-ui/public/tweak-editor-lib.js +21 -2
- package/modules/hall-ui/public/tweak-editor.css +2 -0
- package/modules/hall-ui/public/tweak-editor.js +3 -1
- package/modules/lifecycle/db-claims.js +20 -5
- package/modules/lifecycle/rebase-gate.js +78 -0
- package/modules/lifecycle/routes/claims.js +10 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/scripts/gds/ask.js +68 -1
- package/scripts/gds/claim.js +6 -1
- package/scripts/gds/copy-apply.js +2 -0
- package/src/bongos/module-promotion.js +219 -0
- package/src/bongos/module-store.js +47 -9
- package/src/bongos/route-rank-check.js +7 -0
- package/src/bongos/routes/me.js +11 -0
- package/src/bongos/routes/modules.js +72 -10
- package/src/module-api.js +1 -1
- package/src/platform-server.js +5 -0
- package/tests/claim_gate_ci_unblock.mjs +4 -0
- package/tests/claim_gate_rebase.mjs +56 -3
- package/tests/copy_desk_page_editor.mjs +1 -1
- package/tests/copy_desk_page_submit.mjs +42 -0
- package/tests/copy_guarded_lines.mjs +54 -0
- package/tests/copy_stuck_page_tweaks.mjs +118 -0
- package/tests/government_ask_flags.mjs +80 -0
- package/tests/government_seed.mjs +1 -0
- package/tests/government_treasury.mjs +1 -0
- package/tests/governor_docket.mjs +1 -1
- package/tests/hall_approval_queue.mjs +15 -0
- package/tests/hall_city.mjs +5 -4
- package/tests/hall_city_living.mjs +118 -12
- package/tests/hall_city_rewording.mjs +51 -0
- package/tests/hall_city_switchboard.mjs +63 -0
- package/tests/hall_gov_real_site.mjs +2 -2
- package/tests/hall_street_deck.mjs +6 -4
- package/tests/hall_street_depth.mjs +1 -1
- package/tests/hall_street_desk.mjs +5 -3
- package/tests/hall_tweak_editor.mjs +26 -0
- package/tests/module_channel_install.mjs +112 -0
- package/tests/module_promotion.mjs +290 -0
- package/tests/module_upgrade_updates.mjs +2 -2
- package/tests/rebase_gate_set_aside.mjs +73 -0
package/scripts/gds/ask.js
CHANGED
|
@@ -135,11 +135,68 @@ async function check(file, deps = {}) {
|
|
|
135
135
|
return { code: v.ok ? 0 : 1, lines: verdictLines(v) };
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
// ---------- the reword list (owner, 2026-10-23) ----------
|
|
139
|
+
// A reader pressed "?" on a city card: the matter's words did not make sense.
|
|
140
|
+
// `unclear` prints what waits for a rewrite (the flagged matters, or with
|
|
141
|
+
// --legacy every open matter that waits on a person and has no stored card
|
|
142
|
+
// yet), each with the words it has now; `put` stores the rewritten card
|
|
143
|
+
// (PUT /government/asks/:matter), which closes the matter's flag.
|
|
144
|
+
async function client(deps) {
|
|
145
|
+
if (deps && deps.client) return deps.client;
|
|
146
|
+
const lib = require('./cli-lib');
|
|
147
|
+
if (!lib.readSessionToken()) throw new Error('no Bongos session — run /builder-setup first');
|
|
148
|
+
return lib.cliClient();
|
|
149
|
+
}
|
|
150
|
+
function matterLines(m) {
|
|
151
|
+
const out = [`${m.id} [${m.building}]${m.flag && m.flag.times > 1 ? ` flagged ${m.flag.times}x` : ''}`];
|
|
152
|
+
out.push(` title: ${String(m.title || '').trim()}`);
|
|
153
|
+
if (m.detail) out.push(` detail: ${String(m.detail).replace(/\s+/g, ' ').trim().slice(0, 600)}`);
|
|
154
|
+
if (m.ask) out.push(` card: ${m.ask.question} (${(m.ask.options || []).map((o) => o.label).join(' / ')})`);
|
|
155
|
+
if (m.flag && m.flag.note) out.push(` why unclear: ${m.flag.note}`);
|
|
156
|
+
if (Array.isArray(m.acts) && m.acts.length) out.push(` acts: ${m.acts.map((a) => a.key + (Array.isArray(a.options) && a.options.length ? '(' + a.options.map((o) => o.key).join('|') + ')' : '')).join(', ')}`);
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
async function unclear({ legacy = false, json = false } = {}, deps = {}) {
|
|
160
|
+
const api = await client(deps);
|
|
161
|
+
let list;
|
|
162
|
+
if (legacy) {
|
|
163
|
+
const r = await api.request('GET', '/government/docket');
|
|
164
|
+
if (!r || !r.ok) return { code: 1, lines: [`ask: the docket could not be read (${r && r.status}).`] };
|
|
165
|
+
list = ((r.data && r.data.matters) || []).filter((m) => m && m.state === 'needs' && !m.ask)
|
|
166
|
+
.map((m) => ({ id: m.id, building: m.building, source: m.source, title: m.title, detail: m.detail || null, ask: null, acts: m.acts || [], flag: m.unclear || null }));
|
|
167
|
+
} else {
|
|
168
|
+
const r = await api.request('GET', '/government/asks/unclear');
|
|
169
|
+
if (!r || !r.ok) return { code: 1, lines: [`ask: the reword list could not be read (${r && r.status}).`] };
|
|
170
|
+
list = (r.data && r.data.matters) || [];
|
|
171
|
+
}
|
|
172
|
+
if (json) return { code: 0, lines: [JSON.stringify(list, null, 2)] };
|
|
173
|
+
if (!list.length) return { code: 0, lines: [legacy ? 'ask: every open matter already carries a card.' : 'ask: nothing on the reword list.'] };
|
|
174
|
+
const lines = [`${list.length} ${legacy ? 'open matter(s) with no card yet' : 'matter(s) flagged as unclear'}:`, ''];
|
|
175
|
+
list.forEach((m) => { lines.push(...matterLines(m), ''); });
|
|
176
|
+
lines.push('Write each one a card (node scripts/gds/ask.js template), check it, then: node scripts/gds/ask.js put <matter-id> <file>');
|
|
177
|
+
return { code: 0, lines };
|
|
178
|
+
}
|
|
179
|
+
async function put(matterId, file, deps = {}) {
|
|
180
|
+
let body;
|
|
181
|
+
try { body = readJson(file); } catch (e) { return { code: 2, lines: [`ask: could not read the card as JSON (${e.message}).`] }; }
|
|
182
|
+
const local = asks.checkAsk(askOf(body));
|
|
183
|
+
if (!local.ok) return { code: 1, lines: verdictLines({ ok: false, ask: null, errors: local.errors, where: 'local' }) };
|
|
184
|
+
const api = await client(deps);
|
|
185
|
+
const r = await api.request('PUT', '/government/asks/' + encodeURIComponent(matterId), { body: { ask: askOf(body) } });
|
|
186
|
+
if (r && r.ok && r.data && r.data.ok) return { code: 0, lines: [`ask: saved the card on ${matterId}; its "?" flag is closed.`, '', ...verdictLines({ ok: true, ask: r.data.ask, where: 'server' })] };
|
|
187
|
+
const err = r && r.data && r.data.error;
|
|
188
|
+
return { code: 1, lines: [`ask: ${matterId} was not saved (${r && r.status}${err ? ' ' + err.code : ''}). ${err && err.message ? err.message : ''}`.trim()] };
|
|
189
|
+
}
|
|
190
|
+
|
|
138
191
|
const USAGE = [
|
|
139
192
|
'usage:',
|
|
140
193
|
' node scripts/gds/ask.js template print a blank card to fill in',
|
|
141
194
|
' node scripts/gds/ask.js check <file|-> check a card; on a pass, print it as a person reads it',
|
|
142
195
|
' --local check with the rules here, without the server',
|
|
196
|
+
' node scripts/gds/ask.js unclear the reword list: matters a reader flagged with "?"',
|
|
197
|
+
' --legacy instead: every open matter on a person with no card yet',
|
|
198
|
+
' --json as JSON',
|
|
199
|
+
' node scripts/gds/ask.js put <matter> <file> store a rewritten card on a matter (closes its flag)',
|
|
143
200
|
].join('\n');
|
|
144
201
|
|
|
145
202
|
async function main(argv = process.argv.slice(2)) {
|
|
@@ -153,6 +210,16 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
153
210
|
console.log(r.lines.join('\n'));
|
|
154
211
|
return r.code;
|
|
155
212
|
}
|
|
213
|
+
if (cmd === 'unclear') {
|
|
214
|
+
const r = await unclear({ legacy: argv.includes('--legacy'), json: argv.includes('--json') });
|
|
215
|
+
console.log(r.lines.join('\n'));
|
|
216
|
+
return r.code;
|
|
217
|
+
}
|
|
218
|
+
if (cmd === 'put' && argv[1] && argv[2]) {
|
|
219
|
+
const r = await put(argv[1], argv[2]);
|
|
220
|
+
console.log(r.lines.join('\n'));
|
|
221
|
+
return r.code;
|
|
222
|
+
}
|
|
156
223
|
console.error(USAGE);
|
|
157
224
|
return 2;
|
|
158
225
|
}
|
|
@@ -161,4 +228,4 @@ if (require.main === module) {
|
|
|
161
228
|
main().then((code) => { process.exitCode = code; }, (e) => { console.error(`ask: ${e && e.message}`); process.exitCode = 1; });
|
|
162
229
|
}
|
|
163
230
|
|
|
164
|
-
module.exports = { TEMPLATE, askOf, checkedAsk, postWithAsk, verdictLines, serverVerdict, check, main };
|
|
231
|
+
module.exports = { TEMPLATE, askOf, checkedAsk, postWithAsk, verdictLines, serverVerdict, check, unclear, put, main };
|
package/scripts/gds/claim.js
CHANGED
|
@@ -369,7 +369,10 @@ function claimFailureGuidance({ code, message, d }, r, taskId, ctx) {
|
|
|
369
369
|
// posture) and this surface dropped it on the floor. Name the owed tasks, then
|
|
370
370
|
// print the server's menu.
|
|
371
371
|
if (code === 'REBASE_REQUIRED') {
|
|
372
|
-
|
|
372
|
+
// task 1004788: only the fixable ones gate; set-aside ones are listed apart.
|
|
373
|
+
const all = (d.rebaseTasks || d.rebase_tasks || []);
|
|
374
|
+
const owed = all.filter((t) => !t.waiting).slice(0, MAX_LISTED_TASKS);
|
|
375
|
+
const waiting = all.filter((t) => t.waiting).slice(0, MAX_LISTED_TASKS);
|
|
373
376
|
lines.push(owed.length
|
|
374
377
|
? `Your queue is gated — ${owed.length} confirmed task(s) are waiting on your rebase:`
|
|
375
378
|
: 'Your queue is gated — a confirmed task is waiting on your rebase.');
|
|
@@ -377,6 +380,8 @@ function claimFailureGuidance({ code, message, d }, r, taskId, ctx) {
|
|
|
377
380
|
lines.push(` - task ${t.id}${t.title ? ` ${clip(t.title)}` : ''}`);
|
|
378
381
|
if (t.reason) lines.push(` flagged: ${clip(t.reason)}`);
|
|
379
382
|
}
|
|
383
|
+
if (waiting.length) lines.push(' Waiting, not on you (set aside; they land on their own):');
|
|
384
|
+
for (const t of waiting) lines.push(` - task ${t.id}${t.title ? ` ${clip(t.title)}` : ''} — ${clip(t.waits_on || t.reason || 'outside you')}`);
|
|
380
385
|
lines.push(` ${clip(d.hint, 600) || 'Clear it with /strand-fix N, or rebase the branch and re-ship — or /builder-release it to give up on one.'}`);
|
|
381
386
|
return { lines, exit: QUEUE_GATED_EXIT };
|
|
382
387
|
}
|
|
@@ -598,6 +598,8 @@ async function batchMain({ taskId, batchFile, dryRun, pageId = null, now = () =>
|
|
|
598
598
|
log(` - ${a.before}`);
|
|
599
599
|
log(` + ${a.after}`);
|
|
600
600
|
}
|
|
601
|
+
// task 1004776: the submit's guarded-line warnings, so whoever applies sees them too.
|
|
602
|
+
for (const w of batch.warnings || []) log(` ${w.key} WARNING: no longer says what it must: ${w.must_still_say}${w.guarded_by ? ` (guarded by ${w.guarded_by})` : ''}`);
|
|
601
603
|
for (const x of result.refused) {
|
|
602
604
|
log(` ${x.key} REFUSED (${x.code})${x.detail && x.detail.what_this_means ? `: ${x.detail.what_this_means}` : ''}`);
|
|
603
605
|
log(` line: ${JSON.stringify(x.before)}`);
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/bongos/module-promotion.js — the promotion gate: when a module version on
|
|
4
|
+
// the tester channel moves to general, and when it is held (task 1003804, ADR 0344
|
|
5
|
+
// D3; owner decisions 2026-10-23 recorded on the task).
|
|
6
|
+
//
|
|
7
|
+
// THE RULE, in the order it is applied:
|
|
8
|
+
// 1. A version only ever moves while its Security gate has passed and its own
|
|
9
|
+
// tests pass (ADR 0343). Tests pass = every test file passed (score 100), or
|
|
10
|
+
// the module declares no tests (`no_data` is not a fail). Anything else —
|
|
11
|
+
// failed, pending, not yet run — waits; it is never held for it.
|
|
12
|
+
// 2. HOLD: any tester reports a failed install, or any runtime error, on the
|
|
13
|
+
// version. A held version stays on tester until a Metic+ person releases it
|
|
14
|
+
// (`module.delist`, owner 2026-10-23) or a newer version replaces it.
|
|
15
|
+
// 3. EARLY: from day 2 on tester, 5 or more holders reported a clean install and
|
|
16
|
+
// nobody reported a problem.
|
|
17
|
+
// 4. SEVEN DAYS: on tester for 7 days. This applies whether or not any tester
|
|
18
|
+
// picked it up, so a module nobody volunteers for still gets updated — a
|
|
19
|
+
// version is only ever held on evidence, never for lack of testers.
|
|
20
|
+
//
|
|
21
|
+
// Every report on a tester-channel version comes from a tester, because only
|
|
22
|
+
// testers are handed one (task 1003805), so the reports are counted as they are.
|
|
23
|
+
//
|
|
24
|
+
// A move is a NEW store_module_version_channels row (core_276), never an update:
|
|
25
|
+
// set_by NULL when the sweep moves it, the person when a person releases it, and a
|
|
26
|
+
// short reason code. decidePromotion is the whole rule and is pure, so the tests
|
|
27
|
+
// drive it directly; sweepPromotions and releaseHeld are the two writers.
|
|
28
|
+
|
|
29
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
30
|
+
const PROMOTE_AFTER_DAYS = 7;
|
|
31
|
+
const EARLY_AFTER_DAYS = 2;
|
|
32
|
+
const EARLY_CLEAN_INSTALLS = 5;
|
|
33
|
+
const SWEEP_LIMIT = 200;
|
|
34
|
+
|
|
35
|
+
const REASONS = Object.freeze({
|
|
36
|
+
AFTER_7_DAYS: 'promoted_after_7_days',
|
|
37
|
+
EARLY: 'promoted_early_clean_installs',
|
|
38
|
+
HELD: 'held_tester_errors',
|
|
39
|
+
RELEASED: 'released_by_person',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function poolOf(db) { return db || require('./pool').pool; }
|
|
43
|
+
|
|
44
|
+
// Did the ADR 0343 checks pass? signals: the current row per part.
|
|
45
|
+
function checksPass({ security, tests } = {}) {
|
|
46
|
+
if (!security || security.outcome !== 'passed') return false;
|
|
47
|
+
if (!tests) return false;
|
|
48
|
+
if (tests.outcome === 'no_data') return true;
|
|
49
|
+
return tests.outcome === 'scored' && Number(tests.score) === 100;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The rule. evidence: { onTesterSince, held, security, tests, cleanInstalls,
|
|
53
|
+
// failedInstalls, runtimeErrors }. Returns { action: 'promote'|'hold'|'wait',
|
|
54
|
+
// reason, days_on_tester, ... } — the same object an author reads.
|
|
55
|
+
function decidePromotion(evidence, now = new Date()) {
|
|
56
|
+
const since = new Date(evidence.onTesterSince);
|
|
57
|
+
const days = Math.max(0, (now.getTime() - since.getTime()) / DAY_MS);
|
|
58
|
+
const base = {
|
|
59
|
+
days_on_tester: Math.floor(days),
|
|
60
|
+
clean_installs: evidence.cleanInstalls || 0,
|
|
61
|
+
failed_installs: evidence.failedInstalls || 0,
|
|
62
|
+
runtime_errors: evidence.runtimeErrors || 0,
|
|
63
|
+
checks_passed: checksPass(evidence),
|
|
64
|
+
general_on: new Date(since.getTime() + PROMOTE_AFTER_DAYS * DAY_MS).toISOString(),
|
|
65
|
+
};
|
|
66
|
+
if (evidence.held) return { action: 'wait', reason: 'held_until_released', ...base };
|
|
67
|
+
if (base.failed_installs > 0 || base.runtime_errors > 0) return { action: 'hold', reason: REASONS.HELD, ...base };
|
|
68
|
+
if (!base.checks_passed) return { action: 'wait', reason: 'checks_not_passed', ...base };
|
|
69
|
+
if (days >= PROMOTE_AFTER_DAYS) return { action: 'promote', reason: REASONS.AFTER_7_DAYS, ...base };
|
|
70
|
+
if (days >= EARLY_AFTER_DAYS && base.clean_installs >= EARLY_CLEAN_INSTALLS) {
|
|
71
|
+
return { action: 'promote', reason: REASONS.EARLY, ...base };
|
|
72
|
+
}
|
|
73
|
+
return { action: 'wait', reason: 'waiting_for_time_or_installs', ...base };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The evidence for every version currently on tester (optionally one version),
|
|
77
|
+
// bounded. onTesterSince is when it reached tester (its first tester row since
|
|
78
|
+
// it was last off tester — in practice its publish), held is whether its current
|
|
79
|
+
// row is a hold.
|
|
80
|
+
async function readTesterEvidence({ db, versionId = null, limit = SWEEP_LIMIT } = {}) {
|
|
81
|
+
const pool = poolOf(db);
|
|
82
|
+
const { rows } = await pool.query(
|
|
83
|
+
// One probe per version into idx_store_module_version_channels_current (its
|
|
84
|
+
// newest row, LIMIT 1), never a DISTINCT ON over every version's whole move
|
|
85
|
+
// history: the work grows with versions, not with how often they moved.
|
|
86
|
+
`SELECT v.id AS version_id, v.module_key, v.version, cur.reason AS current_reason,
|
|
87
|
+
(SELECT min(t.set_at) FROM store_module_version_channels t
|
|
88
|
+
WHERE t.version_id = v.id AND t.channel = 'tester') AS on_tester_since,
|
|
89
|
+
(SELECT count(*) FROM module_install_reports r WHERE r.version_id = v.id AND r.ok)::int AS clean_installs,
|
|
90
|
+
(SELECT count(*) FROM module_install_reports r WHERE r.version_id = v.id AND NOT r.ok)::int AS failed_installs,
|
|
91
|
+
(SELECT coalesce(sum(e.error_count), 0) FROM module_runtime_error_counts e WHERE e.version_id = v.id)::int AS runtime_errors
|
|
92
|
+
FROM store_module_versions v
|
|
93
|
+
CROSS JOIN LATERAL (
|
|
94
|
+
SELECT c.channel, c.reason FROM store_module_version_channels c
|
|
95
|
+
WHERE c.version_id = v.id ORDER BY c.set_at DESC, c.id DESC LIMIT 1
|
|
96
|
+
) cur
|
|
97
|
+
WHERE cur.channel = 'tester' AND ($1::bigint IS NULL OR v.id = $1)
|
|
98
|
+
ORDER BY v.id
|
|
99
|
+
LIMIT $2`, [versionId, limit]);
|
|
100
|
+
if (!rows.length) return [];
|
|
101
|
+
const { rows: signals } = await pool.query(
|
|
102
|
+
`SELECT DISTINCT ON (version_id, part) version_id, part, outcome, score
|
|
103
|
+
FROM module_assessment_signals
|
|
104
|
+
WHERE version_id = ANY($1) AND part IN ('security', 'tests')
|
|
105
|
+
ORDER BY version_id, part, measured_at DESC, id DESC`, [rows.map((r) => r.version_id)]);
|
|
106
|
+
const parts = new Map();
|
|
107
|
+
for (const s of signals) {
|
|
108
|
+
const k = String(s.version_id);
|
|
109
|
+
if (!parts.has(k)) parts.set(k, {});
|
|
110
|
+
parts.get(k)[s.part] = s;
|
|
111
|
+
}
|
|
112
|
+
return rows.map((r) => ({
|
|
113
|
+
versionId: r.version_id,
|
|
114
|
+
moduleKey: r.module_key,
|
|
115
|
+
version: r.version,
|
|
116
|
+
onTesterSince: r.on_tester_since,
|
|
117
|
+
currentReason: r.current_reason,
|
|
118
|
+
held: r.current_reason === REASONS.HELD,
|
|
119
|
+
cleanInstalls: r.clean_installs,
|
|
120
|
+
failedInstalls: r.failed_installs,
|
|
121
|
+
runtimeErrors: r.runtime_errors,
|
|
122
|
+
security: (parts.get(String(r.version_id)) || {}).security || null,
|
|
123
|
+
tests: (parts.get(String(r.version_id)) || {}).tests || null,
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Insert the move only if the version's current row is still the one we read, so
|
|
128
|
+
// a sweep racing a release (or another sweep) writes at most one move.
|
|
129
|
+
async function insertMove(pool, { versionId, channel, reason, setBy = null, fromReason }) {
|
|
130
|
+
const { rowCount } = await pool.query(
|
|
131
|
+
`INSERT INTO store_module_version_channels (version_id, channel, reason, set_by)
|
|
132
|
+
SELECT $1, $2, $3, $4
|
|
133
|
+
WHERE (SELECT c.reason FROM store_module_version_channels c
|
|
134
|
+
WHERE c.version_id = $1 ORDER BY c.set_at DESC, c.id DESC LIMIT 1) = $5`,
|
|
135
|
+
[versionId, channel, reason, setBy, fromReason]);
|
|
136
|
+
return rowCount === 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// One pass: promote or hold every version on tester the rule says to. Returns
|
|
140
|
+
// { promoted: [...], held: [...] }. Per-version failures are isolated.
|
|
141
|
+
async function sweepPromotions({ db, now = new Date(), log = null } = {}) {
|
|
142
|
+
const pool = poolOf(db);
|
|
143
|
+
const out = { promoted: [], held: [] };
|
|
144
|
+
const evidence = await readTesterEvidence({ db: pool });
|
|
145
|
+
for (const e of evidence) {
|
|
146
|
+
const d = decidePromotion(e, now);
|
|
147
|
+
if (d.action === 'wait') continue;
|
|
148
|
+
try {
|
|
149
|
+
const channel = d.action === 'promote' ? 'general' : 'tester';
|
|
150
|
+
const moved = await insertMove(pool, { versionId: e.versionId, channel, reason: d.reason, fromReason: e.currentReason });
|
|
151
|
+
if (!moved) continue;
|
|
152
|
+
const entry = { version_id: String(e.versionId), module_key: e.moduleKey, version: e.version, reason: d.reason };
|
|
153
|
+
(d.action === 'promote' ? out.promoted : out.held).push(entry);
|
|
154
|
+
if (log) log.info(entry, `${e.moduleKey} ${e.version}: ${d.reason}`);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if (log) log.warn({ versionId: e.versionId, err: err.message }, 'promotion move failed; the next sweep retries');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Where one version stands against the rule, for its author. null when the
|
|
163
|
+
// version has no channel row.
|
|
164
|
+
async function readStanding(versionId, { db, now = new Date() } = {}) {
|
|
165
|
+
const pool = poolOf(db);
|
|
166
|
+
const { rows } = await pool.query(
|
|
167
|
+
`SELECT channel, reason, set_by, set_at FROM store_module_version_channels
|
|
168
|
+
WHERE version_id = $1 ORDER BY set_at DESC, id DESC LIMIT 1`, [versionId]);
|
|
169
|
+
if (!rows.length) return null;
|
|
170
|
+
const cur = rows[0];
|
|
171
|
+
const standing = {
|
|
172
|
+
channel: cur.channel,
|
|
173
|
+
reason: cur.reason,
|
|
174
|
+
set_at: cur.set_at,
|
|
175
|
+
moved_by: cur.set_by == null ? 'system' : String(cur.set_by),
|
|
176
|
+
rule: RULE_TEXT,
|
|
177
|
+
};
|
|
178
|
+
if (cur.channel !== 'tester') return standing;
|
|
179
|
+
const [e] = await readTesterEvidence({ db: pool, versionId });
|
|
180
|
+
if (!e) return standing;
|
|
181
|
+
return { ...standing, next: decidePromotion(e, now) };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// A Metic+ person releases a HELD version to general. Only a held version: a
|
|
185
|
+
// version still waiting for its time is the rule's to move, not a person's.
|
|
186
|
+
async function releaseHeld(versionId, builderId, { db } = {}) {
|
|
187
|
+
const pool = poolOf(db);
|
|
188
|
+
const moved = await insertMove(pool, {
|
|
189
|
+
versionId, channel: 'general', reason: REASONS.RELEASED, setBy: builderId, fromReason: REASONS.HELD,
|
|
190
|
+
});
|
|
191
|
+
if (!moved) {
|
|
192
|
+
return { ok: false, code: 'not_held', status: 409, message: 'Only a version held on the tester channel can be released by a person. Others move on their own after seven days.' };
|
|
193
|
+
}
|
|
194
|
+
return { ok: true, channel: 'general', reason: REASONS.RELEASED };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const RULE_TEXT = `A new version goes to testers first. It reaches everyone after ${PROMOTE_AFTER_DAYS} days, `
|
|
198
|
+
+ `or after ${EARLY_AFTER_DAYS} days once ${EARLY_CLEAN_INSTALLS} or more testers installed it cleanly, `
|
|
199
|
+
+ 'as long as its security check and its own tests pass. Any failed tester install or runtime error '
|
|
200
|
+
+ 'holds it on testers until a reviewer releases it.';
|
|
201
|
+
|
|
202
|
+
// The in-process sweep, started by src/platform-server.js after listen (never by
|
|
203
|
+
// a test). Hourly is plenty for a rule counted in days. MODULE_PROMOTION_DISABLED=1
|
|
204
|
+
// switches it off.
|
|
205
|
+
const SWEEP_MS = 60 * 60 * 1000;
|
|
206
|
+
let timer = null;
|
|
207
|
+
|
|
208
|
+
function start({ env = process.env, log = require('./logger').logger.child({ src: 'module-promotion' }) } = {}) {
|
|
209
|
+
if (env.MODULE_PROMOTION_DISABLED === '1' || timer) return false;
|
|
210
|
+
const run = () => sweepPromotions({ log }).catch((err) => log.warn({ err: err.message }, 'promotion sweep failed'));
|
|
211
|
+
setTimeout(run, 30_000).unref();
|
|
212
|
+
timer = setInterval(run, SWEEP_MS);
|
|
213
|
+
timer.unref();
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
module.exports = {
|
|
218
|
+
PROMOTE_AFTER_DAYS, EARLY_AFTER_DAYS, EARLY_CLEAN_INSTALLS, decidePromotion, sweepPromotions, readStanding, releaseHeld, start,
|
|
219
|
+
};
|
|
@@ -256,12 +256,34 @@ function isFreePrice(priceRow) {
|
|
|
256
256
|
return priceRow.price_cents === 0 || priceRow.price_credits === 0;
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
// The release channels a project is handed versions from (task 1003805, ADR 0344
|
|
260
|
+
// D1/D2): a project that tests this module gets tester AND general versions, every
|
|
261
|
+
// other project general only. 'withdrawn' is never in either list, so a withdrawn
|
|
262
|
+
// version is never handed out (a copy already installed keeps working, ADR 0338 D2).
|
|
263
|
+
function servedChannels(tester) {
|
|
264
|
+
return tester ? ['tester', 'general'] : ['general'];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// A version's current channel is its newest store_module_version_channels row
|
|
268
|
+
// (core_276): a move is a new row, never an update. One probe per version into
|
|
269
|
+
// idx_store_module_version_channels_current. `idCol` is the outer query's version
|
|
270
|
+
// id column, and only these two literals are accepted, so no text reaches the SQL.
|
|
271
|
+
const VERSION_ID_COLS = new Set(['store_module_versions.id', 'v.id']);
|
|
272
|
+
function currentChannelSql(idCol) {
|
|
273
|
+
if (!VERSION_ID_COLS.has(idCol)) throw new Error(`currentChannelSql: unexpected column ${idCol}`);
|
|
274
|
+
return `(SELECT c.channel FROM store_module_version_channels c
|
|
275
|
+
WHERE c.version_id = ${idCol} ORDER BY c.set_at DESC, c.id DESC LIMIT 1)`;
|
|
276
|
+
}
|
|
277
|
+
|
|
259
278
|
// Resolve what an acquire of `key` (optionally at an exact `version`) would hand
|
|
260
279
|
// over. Refuses an unknown or delisted module (ADR 0338 D2: a delisted module takes
|
|
261
280
|
// no new acquisitions and serves no further versions) and an unknown version. With
|
|
262
281
|
// no version asked for, the newest published version (semver order, not text).
|
|
282
|
+
// `channels` (from servedChannels) limits it to versions currently on those
|
|
283
|
+
// channels — the newest one there, or a refusal for an exact version that is not;
|
|
284
|
+
// null skips the channel check (recording a version already placed).
|
|
263
285
|
// Returns { ok: true, module, version, price, free } or { ok: false, status, code, message }.
|
|
264
|
-
async function resolveAcquirable(key, { version = null, db } = {}) {
|
|
286
|
+
async function resolveAcquirable(key, { version = null, channels = null, db } = {}) {
|
|
265
287
|
const pool = db || require('./pool').pool;
|
|
266
288
|
const refuse = (status, code, message) => ({ ok: false, status, code, message });
|
|
267
289
|
const { rows: [mod] } = await pool.query(
|
|
@@ -270,16 +292,28 @@ async function resolveAcquirable(key, { version = null, db } = {}) {
|
|
|
270
292
|
if (mod.status === 'delisted') return refuse(409, 'module_delisted', `"${key}" is delisted: it can no longer be acquired or downloaded (ADR 0338 D2).`);
|
|
271
293
|
const { rows: versions } = await pool.query(
|
|
272
294
|
`SELECT id, module_key, version, core_version, tree_sha256, tarball_sha256, tarball_bytes,
|
|
273
|
-
artifact_path, published_at
|
|
295
|
+
artifact_path, published_at, ${currentChannelSql('store_module_versions.id')} AS channel
|
|
274
296
|
FROM store_module_versions WHERE module_key = $1`, [key]);
|
|
297
|
+
const served = (r) => !channels || channels.includes(r.channel);
|
|
275
298
|
let row;
|
|
276
299
|
if (version) {
|
|
277
300
|
row = versions.find((r) => r.version === version);
|
|
278
301
|
if (!row) return refuse(404, 'version_not_found', `"${key}" has no published version ${version}.`);
|
|
302
|
+
if (!served(row)) {
|
|
303
|
+
return refuse(409, 'version_not_on_channel', row.channel === 'withdrawn'
|
|
304
|
+
? `"${key}" ${version} was withdrawn: it is no longer handed out.`
|
|
305
|
+
: `"${key}" ${version} is still with testers, and this project takes general versions of it.`);
|
|
306
|
+
}
|
|
279
307
|
} else {
|
|
280
|
-
row = versions.filter((r) => parseVersion(r.version))
|
|
308
|
+
row = versions.filter((r) => parseVersion(r.version) && served(r))
|
|
281
309
|
.sort((a, b) => cmp(parseVersion(a.version), parseVersion(b.version))).pop();
|
|
282
|
-
if (!row)
|
|
310
|
+
if (!row) {
|
|
311
|
+
if (!versions.length) return refuse(404, 'no_versions', `"${key}" has no published version yet.`);
|
|
312
|
+
const testing = versions.some((r) => r.channel === 'tester');
|
|
313
|
+
return refuse(404, 'no_versions_on_channel', testing
|
|
314
|
+
? `"${key}" has no version on the channel this project takes yet: its newest is still with testers.`
|
|
315
|
+
: `"${key}" has no version this project can be handed: every version was withdrawn.`);
|
|
316
|
+
}
|
|
283
317
|
}
|
|
284
318
|
const { rows: [price] } = await pool.query(
|
|
285
319
|
`SELECT price_cents, price_credits, set_at FROM store_module_prices
|
|
@@ -321,21 +355,25 @@ async function publishedVersionSet(keys, { db } = {}) {
|
|
|
321
355
|
// takes no entitlement and refuses nothing, so a delisted module still answers,
|
|
322
356
|
// with its status, and the caller says it gets no further versions (ADR 0338 D2).
|
|
323
357
|
// A key the store does not know is simply absent. One query for the whole list.
|
|
324
|
-
//
|
|
325
|
-
|
|
358
|
+
// `channelsFor(key)` (servedChannels per module) counts only versions on the
|
|
359
|
+
// channels this project takes, so upgrade reports what update would hand over
|
|
360
|
+
// (task 1003805); a key with nothing on them is absent too.
|
|
361
|
+
// Returns { [key]: { version, core_version, status, channel } }.
|
|
362
|
+
async function latestVersions(keys, { db, channelsFor = null } = {}) {
|
|
326
363
|
if (!keys.length) return {};
|
|
327
364
|
const pool = db || require('./pool').pool;
|
|
328
365
|
const { rows } = await pool.query(
|
|
329
|
-
`SELECT v.module_key, v.version, v.core_version, m.status
|
|
366
|
+
`SELECT v.module_key, v.version, v.core_version, m.status, ${currentChannelSql('v.id')} AS channel
|
|
330
367
|
FROM store_module_versions v JOIN store_modules m ON m.module_key = v.module_key
|
|
331
368
|
WHERE v.module_key = ANY($1)`, [keys]);
|
|
332
369
|
const out = {};
|
|
333
370
|
for (const r of rows) {
|
|
334
371
|
const next = parseVersion(r.version);
|
|
335
372
|
if (!next) continue;
|
|
373
|
+
if (channelsFor && !channelsFor(r.module_key).includes(r.channel)) continue;
|
|
336
374
|
const have = out[r.module_key];
|
|
337
375
|
if (have && cmp(parseVersion(have.version), next) >= 0) continue;
|
|
338
|
-
out[r.module_key] = { version: r.version, core_version: r.core_version, status: r.status };
|
|
376
|
+
out[r.module_key] = { version: r.version, core_version: r.core_version, status: r.status, channel: r.channel };
|
|
339
377
|
}
|
|
340
378
|
return out;
|
|
341
379
|
}
|
|
@@ -383,6 +421,6 @@ function versionArtifactFile(row, { dir = storeDir() } = {}) {
|
|
|
383
421
|
|
|
384
422
|
module.exports = {
|
|
385
423
|
stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
|
|
386
|
-
isFreePrice, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
|
|
424
|
+
isFreePrice, servedChannels, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
|
|
387
425
|
delistModule, delistedKeySet, storeDir, storeDirFor, migrateLegacyStore,
|
|
388
426
|
};
|
|
@@ -245,6 +245,9 @@ const EXPECTED_RANKS = {
|
|
|
245
245
|
// Delisting a store module (task 1003797, ADR 0338 D2) stops every new install of
|
|
246
246
|
// it for every instance, so it is pinned to its own atom.
|
|
247
247
|
'POST /store/modules/:key/delist': 'perm:module.delist',
|
|
248
|
+
// Releasing a version the promotion gate held on tester (task 1003804) hands it to
|
|
249
|
+
// every project; the owner put it on the delist atom (2026-10-23), so it is pinned.
|
|
250
|
+
'POST /store/modules/:key/versions/:version/release': 'perm:module.delist',
|
|
248
251
|
};
|
|
249
252
|
|
|
250
253
|
// The authz-core route pins (ADR 0174). Paths and the gating atom were renamed
|
|
@@ -320,6 +323,10 @@ const GOVERNMENT_PINS = {
|
|
|
320
323
|
'POST /government/asks/check': 'perm:board.vote.cast',
|
|
321
324
|
'PUT /government/asks/:matterId': 'perm:board.vote.cast',
|
|
322
325
|
'POST /government/asks/:matterId/pass': 'perm:board.vote.cast',
|
|
326
|
+
// the reword list (owner 2026-10-23): a reader flags a card it cannot follow,
|
|
327
|
+
// a session reads the open flags; both under the docket's own atom
|
|
328
|
+
'POST /government/asks/:matterId/unclear': 'perm:board.vote.cast',
|
|
329
|
+
'GET /government/asks/unclear': 'perm:board.vote.cast',
|
|
323
330
|
// task 1004564: a proposer withdraws their own open sitting before any vote —
|
|
324
331
|
// the proposer's half of putting one up, so the same atom. The proposer-only
|
|
325
332
|
// and no-votes walls are in withdrawItem (board-withdraw.js) and its write.
|
package/src/bongos/routes/me.js
CHANGED
|
@@ -184,6 +184,15 @@ module.exports = function buildMeRouter() {
|
|
|
184
184
|
// same GET /me -> Conductor path, deliberately not a second mechanism.
|
|
185
185
|
// default-on, so present in prod; null when the module is off.
|
|
186
186
|
const specialities = seams.resolveOptional('specialities');
|
|
187
|
+
// task 1004777: stuck page tweaks this builder should be told about, through
|
|
188
|
+
// the copy-desk PORT (core never imports the module, ADR 0093). Started here so
|
|
189
|
+
// it runs beside the fan-out below; [] when copy-desk is off or the read fails.
|
|
190
|
+
const stuckPageTweaksP = (async () => {
|
|
191
|
+
try {
|
|
192
|
+
const port = seams.resolveOptional('copy-desk.stuckPageTweaksFor');
|
|
193
|
+
return typeof port === 'function' ? await port(req.builder.id, req.builder.rank) : [];
|
|
194
|
+
} catch (_) { return []; }
|
|
195
|
+
})();
|
|
187
196
|
const [
|
|
188
197
|
builder, activeClaims, estVsActual, unlocked, lockedWithProgress, streakInfo,
|
|
189
198
|
recentGrades, builderAvg, onboardingRow, shipCount, recentKarma, karma30d, discordLink, cliAuthed,
|
|
@@ -593,6 +602,8 @@ module.exports = function buildMeRouter() {
|
|
|
593
602
|
openTaskRecommendations: taskRecommendationCount,
|
|
594
603
|
// task 1003178 (C08): open mingle introductions waiting on this builder.
|
|
595
604
|
openMingleIntroductions: mingleIntroductionCount,
|
|
605
|
+
// task 1004777: one Needs-attention line per stuck page tweak.
|
|
606
|
+
stuckPageTweaks: await stuckPageTweaksP,
|
|
596
607
|
}) : [],
|
|
597
608
|
};
|
|
598
609
|
// Module→core contribution seam (BV1.R42, ADR 0083). Let each enabled module
|
|
@@ -96,7 +96,7 @@ const moduleAssessVersion = require('../../../scripts/gds/module-assess-version'
|
|
|
96
96
|
const { checkEntitlement, listEntitlements, grantEntitlement, recordAcquired } = require('../module-entitlements');
|
|
97
97
|
const {
|
|
98
98
|
stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
|
|
99
|
-
resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
|
|
99
|
+
servedChannels, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
|
|
100
100
|
delistModule, delistedKeySet, migrateLegacyStore,
|
|
101
101
|
} = require('../module-store');
|
|
102
102
|
const {
|
|
@@ -111,7 +111,19 @@ 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
|
+
const { readTesting, setTester, setGeneralOnly, testsVersionsOf } = require('../module-testing');
|
|
115
|
+
const { readStanding, releaseHeld } = require('../module-promotion');
|
|
116
|
+
|
|
117
|
+
// The channels this project takes, per module (task 1003805, ADR 0344 D1/D2): a
|
|
118
|
+
// tester project gets tester and general versions, except of a module it marked
|
|
119
|
+
// "general only"; everyone else gets general. A choice that cannot be read reads as
|
|
120
|
+
// NOT a tester (module-testing.js), so it hands out general, never tester.
|
|
121
|
+
async function projectChannels() {
|
|
122
|
+
let testing = null;
|
|
123
|
+
try { testing = await readTesting(); }
|
|
124
|
+
catch (e) { log.warn({ err: e.message }, 'tester choice unreadable; serving general versions'); }
|
|
125
|
+
return (key) => servedChannels(testsVersionsOf(testing, key));
|
|
126
|
+
}
|
|
115
127
|
|
|
116
128
|
// The sentence for a toggle that waits on a restart (task 1004463). 'routes' are the
|
|
117
129
|
// module's pages, 'pollers' its background jobs. PURE.
|
|
@@ -578,6 +590,51 @@ module.exports = function buildModulesRouter() {
|
|
|
578
590
|
res.json({ ok: true, module_key: key, status: r.module.status, delisted_at: r.module.delisted_at });
|
|
579
591
|
}));
|
|
580
592
|
|
|
593
|
+
// GET /api/bongos/store/modules/:key/versions/:version/channel — where a version
|
|
594
|
+
// stands on the promotion gate (task 1003804, ADR 0344 D3): its current channel,
|
|
595
|
+
// who moved it there and why, the rule in plain words, and on tester the numbers
|
|
596
|
+
// the rule reads and what it is waiting for. Any signed-in builder may read it, so
|
|
597
|
+
// an author is never left guessing why a version has not reached everyone.
|
|
598
|
+
router.get('/store/modules/:key/versions/:version/channel', auth.requireBuilder,
|
|
599
|
+
asyncHandler('GET /store/modules/:key/versions/:version/channel', async (req, res) => {
|
|
600
|
+
const { key, version } = req.params;
|
|
601
|
+
if (!KEY_RE.test(key) || !parseVersion(version)) {
|
|
602
|
+
return res.fail('bad_request', { status: 400, message: 'A module key is lowercase kebab-case and a version is exact X.Y.Z.' });
|
|
603
|
+
}
|
|
604
|
+
const r = await resolveReadable(key, version);
|
|
605
|
+
if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
|
|
606
|
+
const standing = await readStanding(r.version.id);
|
|
607
|
+
if (!standing) return res.fail('version_has_no_channel', { status: 404, message: `${key} ${version} has no release channel recorded.` });
|
|
608
|
+
res.set('Cache-Control', 'no-store');
|
|
609
|
+
res.json({ module_key: key, version, ...standing });
|
|
610
|
+
}));
|
|
611
|
+
|
|
612
|
+
// POST /api/bongos/store/modules/:key/versions/:version/release — a Metic+ person
|
|
613
|
+
// releases a version the promotion gate HELD on tester (a tester reported a failed
|
|
614
|
+
// install or errors) to general (task 1003804). Gated on module.delist: the owner
|
|
615
|
+
// ruled (2026-10-23) the people who may take a module out of the store are the ones
|
|
616
|
+
// who may let a held version out, with no new permission. Only a held version: one
|
|
617
|
+
// still waiting for its time is the rule's to move. A reason is required; the audit
|
|
618
|
+
// middleware records it with who released it, and set_by on the new channel row.
|
|
619
|
+
router.post('/store/modules/:key/versions/:version/release', auth.requireBuilder, auth.requirePermission('module.delist'),
|
|
620
|
+
asyncHandler('POST /store/modules/:key/versions/:version/release', async (req, res) => {
|
|
621
|
+
if (validateOrRespond(req, res, { reason: { type: 'string', maxLength: 2000 } })) return;
|
|
622
|
+
const { key, version } = req.params;
|
|
623
|
+
if (!KEY_RE.test(key) || !parseVersion(version)) {
|
|
624
|
+
return res.fail('bad_request', { status: 400, message: 'A module key is lowercase kebab-case and a version is exact X.Y.Z.' });
|
|
625
|
+
}
|
|
626
|
+
const reason = String(req.body?.reason || '').trim();
|
|
627
|
+
if (!reason) {
|
|
628
|
+
return res.fail('reason_required', { status: 400, message: 'Say why this held version is safe for everyone, in reason.' });
|
|
629
|
+
}
|
|
630
|
+
const r = await resolveReadable(key, version);
|
|
631
|
+
if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
|
|
632
|
+
const out = await releaseHeld(r.version.id, req.builder.id);
|
|
633
|
+
if (!out.ok) return res.fail(out.code, { status: out.status, message: out.message });
|
|
634
|
+
log.info({ versionId: r.version.id, builderId: req.builder.id }, `${key} ${version} released from hold to general`);
|
|
635
|
+
res.json({ ok: true, module_key: key, version, channel: out.channel, reason: out.reason });
|
|
636
|
+
}));
|
|
637
|
+
|
|
581
638
|
// GET /api/bongos/store/entitlements and /store/modules/:key/entitlement — the
|
|
582
639
|
// read side of the entitlement record (task 1003813, ADR 0338 D1). Own-scoped
|
|
583
640
|
// like /me/sessions: the holder is ALWAYS the caller (holder_kind 'builder',
|
|
@@ -621,9 +678,12 @@ module.exports = function buildModulesRouter() {
|
|
|
621
678
|
// here changes what runs on this instance (install never enables; the CLI places
|
|
622
679
|
// files on the CALLER's checkout).
|
|
623
680
|
//
|
|
624
|
-
// Channels:
|
|
625
|
-
//
|
|
626
|
-
//
|
|
681
|
+
// Channels (task 1003805, ADR 0344): steps 1 and 2 hand out only versions on a
|
|
682
|
+
// channel this project takes (projectChannels) — with no version asked for, the
|
|
683
|
+
// newest there. Install and update share step 1, so both honour it. A project
|
|
684
|
+
// that stops testing keeps the tester version it has; its next update is the
|
|
685
|
+
// newest general one, and update only moves forward, so nothing rolls back.
|
|
686
|
+
// Step 3 records what was already placed, so it does not re-check the channel.
|
|
627
687
|
router.post('/store/modules/:key/acquire', auth.requireBuilder,
|
|
628
688
|
asyncHandler('POST /store/modules/:key/acquire', async (req, res) => {
|
|
629
689
|
const { key } = req.params;
|
|
@@ -635,7 +695,7 @@ module.exports = function buildModulesRouter() {
|
|
|
635
695
|
if (wanted != null && !parseVersion(String(wanted))) {
|
|
636
696
|
return res.fail('bad_version', { status: 400, message: 'A version is exact X.Y.Z.' });
|
|
637
697
|
}
|
|
638
|
-
const r = await resolveAcquirable(key, { version: wanted || null });
|
|
698
|
+
const r = await resolveAcquirable(key, { version: wanted || null, channels: (await projectChannels())(key) });
|
|
639
699
|
if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
|
|
640
700
|
|
|
641
701
|
const holder = { moduleKey: key, holderKind: 'builder', holderRef: String(req.builder.id) };
|
|
@@ -660,7 +720,7 @@ module.exports = function buildModulesRouter() {
|
|
|
660
720
|
tarball_sha256: v.tarball_sha256,
|
|
661
721
|
tree_sha256: v.tree_sha256,
|
|
662
722
|
tarball_bytes: Number(v.tarball_bytes),
|
|
663
|
-
channel:
|
|
723
|
+
channel: v.channel,
|
|
664
724
|
entitlement: held.entitlement,
|
|
665
725
|
});
|
|
666
726
|
}));
|
|
@@ -671,7 +731,7 @@ module.exports = function buildModulesRouter() {
|
|
|
671
731
|
if (!KEY_RE.test(key) || !parseVersion(version)) {
|
|
672
732
|
return res.fail('bad_request', { status: 400, message: 'A module key is lowercase kebab-case and a version is exact X.Y.Z.' });
|
|
673
733
|
}
|
|
674
|
-
const r = await resolveAcquirable(key, { version });
|
|
734
|
+
const r = await resolveAcquirable(key, { version, channels: (await projectChannels())(key) });
|
|
675
735
|
if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
|
|
676
736
|
const held = await checkEntitlement({ moduleKey: key, holderKind: 'builder', holderRef: String(req.builder.id) });
|
|
677
737
|
if (!held.entitled) {
|
|
@@ -755,7 +815,9 @@ module.exports = function buildModulesRouter() {
|
|
|
755
815
|
|
|
756
816
|
// The newest published version of each named module — what `bongos upgrade`
|
|
757
817
|
// compares an instance's installed store modules against to report the updates
|
|
758
|
-
// available (task 1003787). `?keys=a,b,c`, at most STORE_LATEST_MAX_KEYS.
|
|
818
|
+
// available (task 1003787). `?keys=a,b,c`, at most STORE_LATEST_MAX_KEYS. Only
|
|
819
|
+
// versions on a channel this project takes count (task 1003805), so it reports
|
|
820
|
+
// exactly what `bongos module update` would hand over.
|
|
759
821
|
//
|
|
760
822
|
// rank: any-builder — a read of public store metadata (version numbers and the
|
|
761
823
|
// core range each needs, the same facts the how-to page shows). It takes no
|
|
@@ -768,7 +830,7 @@ module.exports = function buildModulesRouter() {
|
|
|
768
830
|
if (!keys.length || keys.length > STORE_LATEST_MAX_KEYS || !keys.every((k) => KEY_RE.test(k))) {
|
|
769
831
|
return res.fail('bad_request', { status: 400, message: `keys is a comma-separated list of 1 to ${STORE_LATEST_MAX_KEYS} module keys (lowercase kebab-case).` });
|
|
770
832
|
}
|
|
771
|
-
const latest = await latestVersions(keys);
|
|
833
|
+
const latest = await latestVersions(keys, { channelsFor: await projectChannels() });
|
|
772
834
|
res.set('Cache-Control', 'no-store');
|
|
773
835
|
res.json({ latest });
|
|
774
836
|
}));
|