@bongos/core 1.20.35 → 1.20.36
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 +18 -18
- package/docs/copy-inventory.md +54 -43
- package/docs/copy-registry.json +189 -70
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +3 -3
- package/modules/hall-ui/public/deploy.js +231 -11
- package/modules/hall-ui/public/project-settings.css +28 -0
- package/modules/provisioning/core-upgrade.js +4 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/module-api.js +1 -1
- package/tests/core_upgrade_door.mjs +8 -1
- package/tests/deploy_page_projects.mjs +165 -1
|
@@ -130,6 +130,178 @@
|
|
|
130
130
|
return v ? `<span class="ov-mono">${escapeHtml(String(v))}</span>` : '<span class="ps-pill ps-pill--absent">no reading</span>';
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// ---- why a move stopped, in words (task 1004450) ---------------------------
|
|
134
|
+
//
|
|
135
|
+
// The runner classifies every failed move (scripts/gds/upgrade-outcome.js) into one of
|
|
136
|
+
// four kinds, each with a fixed reason code. The page used to show only the runner's raw
|
|
137
|
+
// output, addressed to someone holding a shell. These are the same reasons in the
|
|
138
|
+
// owner's words. A code with no entry falls back to a plain "no words for this" rather
|
|
139
|
+
// than a guess — the raw output is still one click away.
|
|
140
|
+
const REASON_WORDS = {
|
|
141
|
+
registry_unreadable: 'the list of published versions could not be read',
|
|
142
|
+
not_published: 'that version is not published yet',
|
|
143
|
+
npm_install: 'installing the new core stopped part-way',
|
|
144
|
+
dirty_tree: 'the project had unsaved file changes on its machine',
|
|
145
|
+
disk_full: 'the machine ran out of disk space',
|
|
146
|
+
schema_pending: 'the project’s database was behind its code',
|
|
147
|
+
outside_channel: 'that version is outside this project’s update rule',
|
|
148
|
+
shape_not_automated: 'this kind of project is not moved from this page',
|
|
149
|
+
downgrade: 'that version is older than the one running',
|
|
150
|
+
artist_gate: 'an artist review has to pass first',
|
|
151
|
+
module_incompatible: 'a module this project uses is not ready for that core',
|
|
152
|
+
remedy_did_not_hold: 'the automatic fix was tried once and the same problem came back',
|
|
153
|
+
remedy_failed: 'the automatic fix itself did not work',
|
|
154
|
+
health: 'the project did not answer after restarting on the new core',
|
|
155
|
+
migrate: 'updating the project’s database failed',
|
|
156
|
+
restart: 'the project would not restart',
|
|
157
|
+
rollback_failed: 'the new core failed, and putting the old one back failed too',
|
|
158
|
+
no_target: 'the move did not say which version to go to',
|
|
159
|
+
bad_slug: 'the project’s name on the machine could not be used',
|
|
160
|
+
installed_unreadable: 'the version installed on the machine could not be read',
|
|
161
|
+
served_mismatch: 'the project was not serving the version its machine had installed',
|
|
162
|
+
already_on: 'the project is already on that version',
|
|
163
|
+
db_unresolvable: 'the machine could not tell which database belongs to this project',
|
|
164
|
+
db_identity: 'the project’s database did not match the one the move expected',
|
|
165
|
+
health_url_missing: 'there was no address to check the project was answering after the move',
|
|
166
|
+
core_source: 'the new core could not be found to install',
|
|
167
|
+
integrity_pin: 'the new core did not match the checksum it was published with',
|
|
168
|
+
};
|
|
169
|
+
const reasonWords = (f) => (f && REASON_WORDS[f.reason]) || 'the upgrade stopped for a reason this page has no words for yet';
|
|
170
|
+
// What the runner's one automatic fix (scripts/gds/wedge-remedy.js) did for a known snag.
|
|
171
|
+
const REMEDY_WORDS = {
|
|
172
|
+
dirty_tree: 'saved those changes',
|
|
173
|
+
disk_full: 'cleared out old backups',
|
|
174
|
+
schema_pending: 'brought the database up to date',
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// A pending move whose next try is in the FUTURE is waiting out a retry, not moving. The
|
|
178
|
+
// runner re-queues a failed move as pending with a not_before (task 1004447), so "pending"
|
|
179
|
+
// alone cannot tell the two apart — and treating a six-hour wait as a move in flight is
|
|
180
|
+
// how the page used to poll for five minutes and then say "still working".
|
|
181
|
+
function waitingOf(i) {
|
|
182
|
+
return !!(i && i.state === 'pending' && i.not_before && new Date(i.not_before).getTime() > Date.now());
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// "14:05, in about 40 min" — when a waiting move tries again, as a clock time the owner
|
|
186
|
+
// can hold the page to, and the distance to it.
|
|
187
|
+
function whenNext(iso) {
|
|
188
|
+
const t = new Date(iso);
|
|
189
|
+
const mins = Math.max(1, Math.round((t.getTime() - Date.now()) / 60000));
|
|
190
|
+
const dist = mins < 90 ? `${mins} min` : `${Math.round(mins / 60)}h`;
|
|
191
|
+
let clock = '';
|
|
192
|
+
try { clock = t.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch (e) { clock = ''; }
|
|
193
|
+
return clock ? `${clock}, in about ${dist}` : `in about ${dist}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// A move waiting to try again. The press it offers is the SAME move, sooner: the route
|
|
197
|
+
// brings a waiting move's retry forward rather than refusing it (enqueueCoreUpgrade).
|
|
198
|
+
function waitingHtml(p) {
|
|
199
|
+
const i = p.open_intent;
|
|
200
|
+
const f = i.failure;
|
|
201
|
+
const at = escapeHtml(whenNext(i.not_before));
|
|
202
|
+
const to = escapeHtml(String(i.target_version || ''));
|
|
203
|
+
let body;
|
|
204
|
+
if (f && f.class === 'known_wedge') {
|
|
205
|
+
const did = REMEDY_WORDS[f.reason];
|
|
206
|
+
body = `<strong>A known snag stopped the move to core ${to}:</strong> ${escapeHtml(reasonWords(f))}.
|
|
207
|
+
${did ? `It ${escapeHtml(did)} on its own, and` : 'It'} tries the move once more at ${at}.`;
|
|
208
|
+
} else if (f && f.class === 'transient') {
|
|
209
|
+
body = `<strong>Waiting to try core ${to} again at ${at}.</strong> The last try stopped because
|
|
210
|
+
${escapeHtml(reasonWords(f))} — that is usually temporary, so it tries again on its own.`;
|
|
211
|
+
} else {
|
|
212
|
+
body = `<strong>Waiting to try core ${to} again at ${at}.</strong>`;
|
|
213
|
+
}
|
|
214
|
+
return `
|
|
215
|
+
<div class="dp-settled dp-settled--waiting">
|
|
216
|
+
<p class="ov-row__sub">${body} Nothing needs doing — this page shows the result when it lands.</p>
|
|
217
|
+
<div class="ps-controls">
|
|
218
|
+
<button type="button" class="btn" id="dp-retry-${escapeHtml(String(p.instance_id))}">Try now</button>
|
|
219
|
+
</div>
|
|
220
|
+
</div>`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// A move that stopped for good, worded by what KIND of stop it was. Each kind says what
|
|
224
|
+
// happens next, because that is the question the owner is left with: is anything still
|
|
225
|
+
// being done about this, and is any of it mine to do?
|
|
226
|
+
const BLOCKERS_LINK = '<a href="/blockers">the owner’s blockers</a>';
|
|
227
|
+
function failureHtml(p, m, when, detail) {
|
|
228
|
+
const f = m.failure;
|
|
229
|
+
const cls = f && f.class;
|
|
230
|
+
const id = escapeHtml(String(p.instance_id));
|
|
231
|
+
const to = escapeHtml(String(m.target_version));
|
|
232
|
+
const still = `This project is still running core ${escapeHtml(String(p.served_version))}.`;
|
|
233
|
+
const card = (mod, head, rest, action = '') => `
|
|
234
|
+
<div class="dp-settled dp-settled--${mod}">
|
|
235
|
+
<p class="ov-row__sub"><strong>${head}</strong>${rest}</p>
|
|
236
|
+
${action}
|
|
237
|
+
${detail}
|
|
238
|
+
</div>`;
|
|
239
|
+
// A row from before the runner classified failures: the old wording, unchanged.
|
|
240
|
+
if (!cls) return card('failed', `The move to core ${to} did not land`, ` — attempted${when}. ${still}`);
|
|
241
|
+
const why = escapeHtml(reasonWords(f));
|
|
242
|
+
if (f.reason === 'rollback_failed') {
|
|
243
|
+
return card('failed', `The move to core ${to} failed, and the old core could not be put back`,
|
|
244
|
+
` — attempted${when}. This project may be down. A person needs to look; it has been filed in ${BLOCKERS_LINK}.`);
|
|
245
|
+
}
|
|
246
|
+
if (cls === 'needs_decision') {
|
|
247
|
+
let action = '';
|
|
248
|
+
if (f.reason === 'outside_channel' && p.update_channel !== 'minor') {
|
|
249
|
+
action = `<div class="ps-controls">
|
|
250
|
+
<button type="button" class="btn btn--primary" id="dp-allow-${id}">Allow new releases and move to ${to}</button>
|
|
251
|
+
</div>`;
|
|
252
|
+
}
|
|
253
|
+
const next = {
|
|
254
|
+
outside_channel: action ? ' Allowing new releases changes this project’s update rule, then makes the move.' : ' Change the update rule below, then move again.',
|
|
255
|
+
artist_gate: ' Approve the artist review, or change the artist gate in this project’s settings, then move again.',
|
|
256
|
+
downgrade: ' Nothing to do unless you meant to go back to an older core.',
|
|
257
|
+
module_incompatible: ' Turn that module off in this project’s settings and move again, or wait for a core it works with — the technical detail below names the module.',
|
|
258
|
+
shape_not_automated: ' Whoever runs this project’s machine moves its core there.',
|
|
259
|
+
}[f.reason] || '';
|
|
260
|
+
return card('decision', `Your call: the move to core ${to} is waiting on a decision`,
|
|
261
|
+
` — ${why}. Attempted${when}. ${still}${next}`, action);
|
|
262
|
+
}
|
|
263
|
+
if (cls === 'transient') {
|
|
264
|
+
const retry = String(m.target_version) === String(p.recommended_version)
|
|
265
|
+
? `<div class="ps-controls"><button type="button" class="btn" id="dp-again-${id}">Try again</button></div>` : '';
|
|
266
|
+
return card('failed', `The move to core ${to} kept stopping`,
|
|
267
|
+
` — ${why}. That is usually temporary, so it tried again on its own over several hours, then gave up${when}. ${still}`, retry);
|
|
268
|
+
}
|
|
269
|
+
// NO blockers link here, deliberately: move-escalation.js files one only for an unknown
|
|
270
|
+
// failure or a failed rollback, so pointing at the list would send the owner to look for
|
|
271
|
+
// a row that does not exist. The step is named instead.
|
|
272
|
+
if (cls === 'known_wedge') {
|
|
273
|
+
return card('failed', `The move to core ${to} hit a known snag it could not fix here`,
|
|
274
|
+
` — ${why}. Attempted${when}. ${still} The automatic fix could not be applied on this project’s
|
|
275
|
+
machine, so whoever runs that machine clears the snag; the technical detail below says what it is.
|
|
276
|
+
After that, the move can be pressed again.`);
|
|
277
|
+
}
|
|
278
|
+
return card('failed', `The move to core ${to} did not land, and nothing automatic can fix it`,
|
|
279
|
+
` — ${why}. Attempted${when}. ${still} It has been filed in ${BLOCKERS_LINK} for a person to look at.`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// The project's update rule (task 1004445): how far the runner may move its core. A
|
|
283
|
+
// RULE, never a move — saving one queues nothing and restarts nothing.
|
|
284
|
+
const RULES = [
|
|
285
|
+
['pinned', 'Stay put', 'no new versions are offered; this project stays on the core it runs'],
|
|
286
|
+
['patch', 'Fixes only', 'takes fixes within the release line it is on'],
|
|
287
|
+
['minor', 'Fixes and new releases', 'also takes the next release line when one comes out'],
|
|
288
|
+
];
|
|
289
|
+
function ruleHtml(p) {
|
|
290
|
+
const cur = RULES.find((r) => r[0] === p.update_channel);
|
|
291
|
+
if (!cur) return '';
|
|
292
|
+
const id = escapeHtml(String(p.instance_id));
|
|
293
|
+
const opts = RULES.map(([k, label]) => `<option value="${k}"${k === cur[0] ? ' selected' : ''}>${escapeHtml(label)}</option>`).join('');
|
|
294
|
+
return `
|
|
295
|
+
<div class="dp-rule">
|
|
296
|
+
<p class="ov-row__sub"><strong>Update rule: ${escapeHtml(cur[1])}</strong> — ${escapeHtml(cur[2])}.</p>
|
|
297
|
+
<div class="ps-controls">
|
|
298
|
+
<label class="dp-rule__label" for="dp-rule-sel-${id}">Change to</label>
|
|
299
|
+
<select id="dp-rule-sel-${id}" class="dp-rule__select">${opts}</select>
|
|
300
|
+
<button type="button" class="btn" id="dp-rule-save-${id}">Save rule</button>
|
|
301
|
+
</div>
|
|
302
|
+
</div>`;
|
|
303
|
+
}
|
|
304
|
+
|
|
133
305
|
// ---- the states ONE box can be in ----------------------------------------
|
|
134
306
|
|
|
135
307
|
// Something is already running. The press is not offered at all — offering a button
|
|
@@ -141,6 +313,7 @@
|
|
|
141
313
|
// reload, and neither is being watched — so it says what is true instead.
|
|
142
314
|
function busyHtml(p) {
|
|
143
315
|
const i = p.open_intent;
|
|
316
|
+
if (waitingOf(i)) return waitingHtml(p);
|
|
144
317
|
return `
|
|
145
318
|
<p class="ov-row__sub">A <strong>${escapeHtml(i.action)}</strong> is ${escapeHtml(i.state)} for this box${
|
|
146
319
|
i.target_version ? ` (to core ${escapeHtml(i.target_version)})` : ''}.
|
|
@@ -186,7 +359,7 @@
|
|
|
186
359
|
// verbatim and kept CLOSED: the owner cannot run `sudo -u`, so 30 lines of it (the
|
|
187
360
|
// dirty-tree refusal prints a whole `git status`) is not what a deploy page leads on.
|
|
188
361
|
const detail = m.error
|
|
189
|
-
? `<details class="dp-settled__detail"><summary>
|
|
362
|
+
? `<details class="dp-settled__detail"><summary>Technical detail — what the upgrade said</summary>
|
|
190
363
|
<pre class="dp-preflight__error">${escapeHtml(String(m.error))}</pre></details>`
|
|
191
364
|
: '';
|
|
192
365
|
if (m.state === 'error' && m.superseded) {
|
|
@@ -199,14 +372,7 @@
|
|
|
199
372
|
${detail}
|
|
200
373
|
</div>`;
|
|
201
374
|
}
|
|
202
|
-
if (m.state === 'error')
|
|
203
|
-
return `
|
|
204
|
-
<div class="dp-settled dp-settled--failed">
|
|
205
|
-
<p class="ov-row__sub"><strong>The move to core ${escapeHtml(String(m.target_version))} did not land</strong>
|
|
206
|
-
— attempted${when}. This project is still running core ${escapeHtml(String(p.served_version))}.</p>
|
|
207
|
-
${detail}
|
|
208
|
-
</div>`;
|
|
209
|
-
}
|
|
375
|
+
if (m.state === 'error') return failureHtml(p, m, when, detail);
|
|
210
376
|
// LANDED, BUT GITHUB DID NOT GET IT (task 1004065). The runner pushes a hosted
|
|
211
377
|
// project's new pin to its repo after the move; when it could not, the move is still
|
|
212
378
|
// done — the site runs the new core — and `warning` says why the repo was not updated.
|
|
@@ -439,6 +605,7 @@
|
|
|
439
605
|
: ''}
|
|
440
606
|
${schemaBehindHtml(p)}
|
|
441
607
|
${action}
|
|
608
|
+
${ruleHtml(p)}
|
|
442
609
|
</div></li>`;
|
|
443
610
|
}
|
|
444
611
|
|
|
@@ -463,6 +630,54 @@
|
|
|
463
630
|
if (cancel) cancel.addEventListener('click', () => { steps.set(p.instance_id, 'preview'); repaint(); });
|
|
464
631
|
const confirm = $(`dp-confirm-${id}`);
|
|
465
632
|
if (confirm) confirm.addEventListener('click', () => move(p));
|
|
633
|
+
// Task 1004450: the waiting move, sooner; a stopped temporary one, again; the one-click
|
|
634
|
+
// answer to an update-rule refusal; and the rule itself.
|
|
635
|
+
const retry = $(`dp-retry-${id}`);
|
|
636
|
+
if (retry && p.open_intent) retry.addEventListener('click', () => move(p, p.open_intent.target_version));
|
|
637
|
+
const again = $(`dp-again-${id}`);
|
|
638
|
+
if (again && p.last_move) again.addEventListener('click', () => move(p, p.last_move.target_version));
|
|
639
|
+
const allow = $(`dp-allow-${id}`);
|
|
640
|
+
if (allow && p.last_move) allow.addEventListener('click', () => allowAndMove(p, p.last_move.target_version, allow));
|
|
641
|
+
const save = $(`dp-rule-save-${id}`);
|
|
642
|
+
if (save) save.addEventListener('click', () => saveRule(p, save));
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Change the rule, then report the server's own sentence and re-read the row.
|
|
646
|
+
async function setRule(p, channel) {
|
|
647
|
+
const r = await sendJson('POST', `/provisioning/instances/${p.instance_id}/update-channel`, { channel });
|
|
648
|
+
toast(r.message || 'Update rule saved.');
|
|
649
|
+
return r;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function saveRule(p, btn) {
|
|
653
|
+
const sel = $(`dp-rule-sel-${p.instance_id}`);
|
|
654
|
+
const channel = sel && sel.value;
|
|
655
|
+
if (!channel) return;
|
|
656
|
+
btn.disabled = true;
|
|
657
|
+
try {
|
|
658
|
+
await setRule(p, channel);
|
|
659
|
+
} catch (e) {
|
|
660
|
+
if (e && e.kind === '401') return;
|
|
661
|
+
toast(e.message || 'Could not change the update rule.');
|
|
662
|
+
btn.disabled = false;
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
await refreshOne(p);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// "Allow new releases and move": the rule first, and only if it took, the move — a move
|
|
669
|
+
// queued under the old rule would be refused the same way again.
|
|
670
|
+
async function allowAndMove(p, to, btn) {
|
|
671
|
+
if (btn) { btn.disabled = true; btn.textContent = 'Changing the rule…'; }
|
|
672
|
+
try {
|
|
673
|
+
await setRule(p, 'minor');
|
|
674
|
+
} catch (e) {
|
|
675
|
+
if (e && e.kind === '401') return;
|
|
676
|
+
toast(e.message || 'Could not change the update rule.');
|
|
677
|
+
if (btn) { btn.disabled = false; btn.textContent = `Allow new releases and move to ${to}`; }
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
await move(p, to);
|
|
466
681
|
}
|
|
467
682
|
|
|
468
683
|
// What the page is currently showing: the ONE box this hall is about, whether that box
|
|
@@ -491,11 +706,12 @@
|
|
|
491
706
|
if (view.box) wire(view.box);
|
|
492
707
|
}
|
|
493
708
|
|
|
494
|
-
|
|
709
|
+
// `to` defaults to the version the owner was SHOWN; the retry buttons pass the move they
|
|
710
|
+
// are retrying, which is also a version on screen.
|
|
711
|
+
async function move(p, to = p.recommended_version) {
|
|
495
712
|
const btn = $(`dp-confirm-${p.instance_id}`);
|
|
496
713
|
if (btn) { btn.disabled = true; btn.textContent = 'Queueing…'; }
|
|
497
714
|
const id = String(p.instance_id);
|
|
498
|
-
const to = p.recommended_version;
|
|
499
715
|
try {
|
|
500
716
|
// `p.recommended_version` — the version this owner was SHOWN for THIS box, not
|
|
501
717
|
// whatever is newest at the moment of the click.
|
|
@@ -532,6 +748,10 @@
|
|
|
532
748
|
// that "done" would drop the row out of its in-flight state early.
|
|
533
749
|
if (!now.open_intent && now.served_version === to) { settled = true; break; }
|
|
534
750
|
if (!now.open_intent && now.last_move && now.last_move.state === 'error') { settled = true; break; }
|
|
751
|
+
// WAITING IS SETTLED TOO (task 1004450). A pending move whose next try is minutes or
|
|
752
|
+
// hours away is not in flight; the row says when it tries again, and watching it for
|
|
753
|
+
// five minutes would end in a "still working" toast about a move that is not running.
|
|
754
|
+
if (waitingOf(now.open_intent)) { settled = true; break; }
|
|
535
755
|
}
|
|
536
756
|
moving.delete(p.instance_id);
|
|
537
757
|
repaint();
|
|
@@ -186,3 +186,31 @@
|
|
|
186
186
|
padding: 0.35rem 0;
|
|
187
187
|
min-height: 24px;
|
|
188
188
|
}
|
|
189
|
+
|
|
190
|
+
/* Why a move stopped, by kind (task 1004450). WAITING is the accent, like a move in
|
|
191
|
+
flight — something is still being done, on its own clock. A DECISION is the warn mark:
|
|
192
|
+
nothing is broken, and the next step is the owner's. */
|
|
193
|
+
.dp-settled--waiting {
|
|
194
|
+
border-left: 3px solid var(--accent);
|
|
195
|
+
padding-left: 0.7rem;
|
|
196
|
+
}
|
|
197
|
+
.dp-settled--decision {
|
|
198
|
+
border-left: 3px solid var(--warn);
|
|
199
|
+
padding-left: 0.7rem;
|
|
200
|
+
}
|
|
201
|
+
.dp-settled .ps-controls { margin: 0.4rem 0 0.2rem; }
|
|
202
|
+
|
|
203
|
+
/* The project's update rule (task 1004445) — a quiet setting under the move, set apart by
|
|
204
|
+
a hairline so it does not read as part of whichever state sits above it. */
|
|
205
|
+
.dp-rule {
|
|
206
|
+
margin: 0.9rem 0 0;
|
|
207
|
+
padding-top: 0.7rem;
|
|
208
|
+
border-top: 1px solid var(--rule-soft);
|
|
209
|
+
}
|
|
210
|
+
.dp-rule .ps-controls { align-items: center; gap: 0.5rem; }
|
|
211
|
+
.dp-rule__label { font-size: 0.86rem; color: var(--ink-soft); }
|
|
212
|
+
.dp-rule__select { min-height: 32px; }
|
|
213
|
+
/* A link inside a stopped move's sentence ("the owner's blockers") is a control and owes
|
|
214
|
+
the 24px floor: the prose-link recipe buys the height with padding and hands it back
|
|
215
|
+
with an equal negative margin, so the line does not re-space. */
|
|
216
|
+
.dp-settled .ov-row__sub a { display: inline-block; min-height: 24px; padding: 3px 0; margin: -3px 0; }
|
|
@@ -348,7 +348,10 @@ function previewFromSnapshot(inst, snap, openIntent, lastMove) {
|
|
|
348
348
|
preflight_at: pf && recommended && pf.target === recommended ? (snap.preflight_at || null) : null,
|
|
349
349
|
// not_before: a failed move waiting out its retry backoff (task 1004447) — the page can
|
|
350
350
|
// say "trying again at …" instead of an unexplained "pending" for up to six hours.
|
|
351
|
-
open_intent: openIntent ? { action: openIntent.action, state: openIntent.state, target_version: openIntent.target_version || null, not_before: openIntent.not_before || null, last_error: openIntent.not_before && openIntent.last_error ? String(openIntent.last_error).slice(0, 600) : null
|
|
351
|
+
open_intent: openIntent ? { action: openIntent.action, state: openIntent.state, target_version: openIntent.target_version || null, not_before: openIntent.not_before || null, last_error: openIntent.not_before && openIntent.last_error ? String(openIntent.last_error).slice(0, 600) : null,
|
|
352
|
+
// WHY it is waiting (task 1004450): a transient retry and a known snag the runner just
|
|
353
|
+
// fixed wait the same way, and the page words them differently. Only on a waiting row.
|
|
354
|
+
failure: openIntent.not_before && openIntent.failure_class ? { class: openIntent.failure_class, reason: openIntent.failure_reason || null } : null } : null,
|
|
352
355
|
// THE LAST MOVE, WHATEVER BECAME OF IT (task 1004145).
|
|
353
356
|
//
|
|
354
357
|
// `open_intent` is `state IN ('pending','running')` — by the index's own definition.
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.36",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.20.
|
|
9
|
+
"version": "1.20.36",
|
|
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.20.
|
|
3
|
+
"version": "1.20.36",
|
|
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
|
@@ -8297,5 +8297,11 @@
|
|
|
8297
8297
|
"id": "1004457",
|
|
8298
8298
|
"text": "The project setup wizard now offers Render's Free plan for your app, with a plain note that a free app sleeps when idle and takes about a minute to wake. Starter stays the default. The build and start command boxes no longer l"
|
|
8299
8299
|
}
|
|
8300
|
+
],
|
|
8301
|
+
"1.20.36": [
|
|
8302
|
+
{
|
|
8303
|
+
"id": "1004450",
|
|
8304
|
+
"text": "The deploy page now explains why an update stopped in plain words, says when it will try again, and offers a one-click fix when the only thing in the way is your own update setting. You can also see and change how far a projec"
|
|
8305
|
+
}
|
|
8300
8306
|
]
|
|
8301
8307
|
}
|
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.20.
|
|
78
|
+
const CORE_VERSION = '1.20.36'; // 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');
|
|
@@ -431,7 +431,7 @@ await t('schema_pending passes through as a count, and UNKNOWN stays null rather
|
|
|
431
431
|
await t('an open intent is surfaced with its target, so the page can say what is already running', () => {
|
|
432
432
|
const p = CU.previewFromSnapshot(previewInst, { served_version: '1.19.758', available: [], recommended: null },
|
|
433
433
|
{ action: 'core-upgrade', state: 'running', target_version: '1.19.762' });
|
|
434
|
-
assert.deepEqual(p.open_intent, { action: 'core-upgrade', state: 'running', target_version: '1.19.762', not_before: null, last_error: null });
|
|
434
|
+
assert.deepEqual(p.open_intent, { action: 'core-upgrade', state: 'running', target_version: '1.19.762', not_before: null, last_error: null, failure: null });
|
|
435
435
|
});
|
|
436
436
|
|
|
437
437
|
console.log('\nthe deploy page is gated on the same atom as its routes:');
|
|
@@ -1007,5 +1007,12 @@ await t('the preview says when a waiting move will try again, and why it is wait
|
|
|
1007
1007
|
'an ordinary pending intent shows no error');
|
|
1008
1008
|
});
|
|
1009
1009
|
|
|
1010
|
+
await t('a waiting move says what KIND of failure it is waiting out; a plain pending one says none (task 1004450)', () => {
|
|
1011
|
+
const p = CU.previewFromSnapshot(ACTIVE, null, waiting({ failure_class: 'known_wedge', failure_reason: 'dirty_tree' }), null);
|
|
1012
|
+
assert.deepEqual(p.open_intent.failure, { class: 'known_wedge', reason: 'dirty_tree' });
|
|
1013
|
+
assert.equal(CU.previewFromSnapshot(ACTIVE, null, waiting({ not_before: null, failure_class: 'transient' }), null).open_intent.failure, null);
|
|
1014
|
+
assert.equal(CU.previewFromSnapshot(ACTIVE, null, waiting(), null).open_intent.failure, null, 'a row from before the columns');
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1010
1017
|
console.log(`\ncore_upgrade_door: ${passed} passed, ${failed} failed`);
|
|
1011
1018
|
if (failed) process.exit(1);
|
|
@@ -517,7 +517,7 @@ test('the runner’s shell output is folded behind a disclosure, never the headl
|
|
|
517
517
|
}) });
|
|
518
518
|
await b.settle();
|
|
519
519
|
const html = b.panel();
|
|
520
|
-
assert.match(html, /<details class="dp-settled__detail"><summary>
|
|
520
|
+
assert.match(html, /<details class="dp-settled__detail"><summary>Technical detail — what the upgrade said<\/summary>/, 'folded');
|
|
521
521
|
assert.match(html, /working tree not clean/, 'and the words are still all there');
|
|
522
522
|
assert.ok(html.indexOf('did not land') < html.indexOf('<details'), 'the plain sentence comes first');
|
|
523
523
|
});
|
|
@@ -645,3 +645,167 @@ test('the reconnect link returns to the page\u2019s full https address when ther
|
|
|
645
645
|
await b.settle();
|
|
646
646
|
assert.ok(b.panel().includes(`return=${encodeURIComponent(here)}`), b.panel());
|
|
647
647
|
});
|
|
648
|
+
|
|
649
|
+
// ── why a move stopped, by kind; the update rule; waiting is not moving (task 1004450) ──
|
|
650
|
+
|
|
651
|
+
// A body built inside the vm is another realm's object; compare its data, not its prototype.
|
|
652
|
+
const plain = (o) => JSON.parse(JSON.stringify(o));
|
|
653
|
+
const soon = (mins) => new Date(Date.now() + mins * 60000).toISOString();
|
|
654
|
+
const failedAs = (cls, reason, over = {}) => ({
|
|
655
|
+
target_version: '1.19.871', state: 'error', at: new Date().toISOString(), error: 'raw runner words', superseded: false,
|
|
656
|
+
failure: { class: cls, reason }, ...over,
|
|
657
|
+
});
|
|
658
|
+
const waitingIntent = (cls, reason, mins = 60) => ({
|
|
659
|
+
action: 'core-upgrade', state: 'pending', target_version: '1.19.871', not_before: soon(mins),
|
|
660
|
+
last_error: 'x', failure: cls ? { class: cls, reason } : null,
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test('a move waiting out a temporary failure says WHEN it tries again, and offers to try now', async () => {
|
|
664
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { open_intent: waitingIntent('transient', 'not_published') }) });
|
|
665
|
+
await b.settle();
|
|
666
|
+
const html = b.panel();
|
|
667
|
+
assert.match(html, /dp-settled--waiting/);
|
|
668
|
+
assert.match(html, /Waiting to try core 1\.19\.871 again at .*in about 60 min/);
|
|
669
|
+
assert.match(html, /not published yet/, 'the reason, in plain words');
|
|
670
|
+
assert.match(html, /usually temporary/);
|
|
671
|
+
assert.doesNotMatch(html, /is pending for this box/, 'not the generic busy line');
|
|
672
|
+
b.els.get('dp-retry-1')._on.click();
|
|
673
|
+
await b.settle();
|
|
674
|
+
const post = b.posts.find((p) => /core-upgrade$/.test(p.path));
|
|
675
|
+
assert.deepEqual(post && plain(post.body), { to: '1.19.871' }, 'Try now asks for the SAME move');
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
test('a known snag the runner just fixed says what it did, and that it tries once more', async () => {
|
|
679
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { open_intent: waitingIntent('known_wedge', 'dirty_tree', 2) }) });
|
|
680
|
+
await b.settle();
|
|
681
|
+
const html = b.panel();
|
|
682
|
+
assert.match(html, /A known snag stopped the move/);
|
|
683
|
+
assert.match(html, /unsaved file changes/);
|
|
684
|
+
assert.match(html, /saved those changes on its own/);
|
|
685
|
+
assert.match(html, /tries the move once more/);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test('a pending move with a PAST not_before is still a move under way, not a wait', async () => {
|
|
689
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { open_intent: waitingIntent('transient', 'not_published', -5) }) });
|
|
690
|
+
await b.settle();
|
|
691
|
+
assert.doesNotMatch(b.panel(), /dp-settled--waiting/);
|
|
692
|
+
assert.match(b.panel(), /is pending for this box/);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
test('a move that falls into a WAIT stops the poll at once, with no "still working" toast', async () => {
|
|
696
|
+
const b = boot({
|
|
697
|
+
scope: 'hoodify-finance', moveTries: 50,
|
|
698
|
+
instanceOver: (inst, n) => (n >= 2 ? { open_intent: waitingIntent('transient', 'registry_unreadable') } : {}),
|
|
699
|
+
});
|
|
700
|
+
await b.settle();
|
|
701
|
+
b.els.get('dp-preview-32')._on.click();
|
|
702
|
+
b.els.get('dp-confirm-32')._on.click();
|
|
703
|
+
for (let i = 0; i < 4; i++) await b.settle();
|
|
704
|
+
assert.match(b.panel(), /dp-settled--waiting/, 'the row shows the wait');
|
|
705
|
+
assert.doesNotMatch(b.panel(), /dp-moving/, 'and is no longer painted as moving');
|
|
706
|
+
assert.ok(!b.toasts.some((t) => /Still working/.test(t)), `no false "still working": ${b.toasts}`);
|
|
707
|
+
assert.ok(b.reads.get('32') < 10, `the poll stopped early (read ${b.reads.get('32')} times, bound 50)`);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
test('a refusal by the update rule offers ONE click: allow new releases, then move', async () => {
|
|
711
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { update_channel: 'patch', last_move: failedAs('needs_decision', 'outside_channel', { target_version: '1.20.1' }) }) });
|
|
712
|
+
await b.settle();
|
|
713
|
+
const html = b.panel();
|
|
714
|
+
assert.match(html, /dp-settled--decision/);
|
|
715
|
+
assert.match(html, /Your call/);
|
|
716
|
+
assert.match(html, /outside this project’s update rule/);
|
|
717
|
+
assert.match(html, /Allow new releases and move to 1\.20\.1/);
|
|
718
|
+
b.els.get('dp-allow-1')._on.click();
|
|
719
|
+
await b.settle();
|
|
720
|
+
const paths = b.posts.map((p) => p.path);
|
|
721
|
+
const rule = paths.indexOf('/provisioning/instances/1/update-channel');
|
|
722
|
+
const mv = paths.indexOf('/provisioning/instances/1/core-upgrade');
|
|
723
|
+
assert.ok(rule >= 0 && mv > rule, `the rule changes FIRST, then the move is queued: ${paths}`);
|
|
724
|
+
assert.deepEqual(plain(b.posts[rule].body), { channel: 'minor' });
|
|
725
|
+
assert.deepEqual(plain(b.posts[mv].body), { to: '1.20.1' });
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
test('under the widest rule already, the refusal offers no allow button', async () => {
|
|
729
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { update_channel: 'minor', last_move: failedAs('needs_decision', 'outside_channel') }) });
|
|
730
|
+
await b.settle();
|
|
731
|
+
assert.doesNotMatch(b.panel(), /dp-allow-1/);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
test('the other decisions say what the owner can do, with no button that cannot help', async () => {
|
|
735
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { last_move: failedAs('needs_decision', 'artist_gate') }) });
|
|
736
|
+
await b.settle();
|
|
737
|
+
assert.match(b.panel(), /Approve the artist review/);
|
|
738
|
+
assert.doesNotMatch(b.panel(), /dp-allow-1/);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test('a failure nothing automatic can fix points at the blocker filed for it', async () => {
|
|
742
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { last_move: failedAs('unknown', 'health') }) });
|
|
743
|
+
await b.settle();
|
|
744
|
+
const html = b.panel();
|
|
745
|
+
assert.match(html, /nothing automatic can fix it/);
|
|
746
|
+
assert.match(html, /did not answer after restarting/);
|
|
747
|
+
assert.match(html, /href="\/blockers"/);
|
|
748
|
+
assert.match(html, /<details class="dp-settled__detail">/, 'the runner’s words are still one click away');
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test('a failed ROLLBACK says the project may be down', async () => {
|
|
752
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { last_move: failedAs('unknown', 'rollback_failed') }) });
|
|
753
|
+
await b.settle();
|
|
754
|
+
assert.match(b.panel(), /may be down/);
|
|
755
|
+
assert.match(b.panel(), /href="\/blockers"/);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
test('a temporary failure that ran out of retries offers to try again — only for the move on offer', async () => {
|
|
759
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { last_move: failedAs('transient', 'npm_install') }) });
|
|
760
|
+
await b.settle();
|
|
761
|
+
assert.match(b.panel(), /kept stopping/);
|
|
762
|
+
assert.match(b.panel(), /dp-again-1/);
|
|
763
|
+
const other = boot({ controlPlane: preview('1', 'cloudbongos', { last_move: failedAs('transient', 'npm_install', { target_version: '1.19.850' }) }) });
|
|
764
|
+
await other.settle();
|
|
765
|
+
assert.doesNotMatch(other.panel(), /dp-again-1/, 'a try-again for a version no longer on offer would be a different move');
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
test('the update rule is shown in words and can be changed from the page', async () => {
|
|
769
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { update_channel: 'patch' }) });
|
|
770
|
+
await b.settle();
|
|
771
|
+
const html = b.panel();
|
|
772
|
+
assert.match(html, /Update rule: Fixes only/);
|
|
773
|
+
assert.match(html, /<option value="patch" selected>/);
|
|
774
|
+
b.els.set('dp-rule-sel-1', { value: 'minor' });
|
|
775
|
+
b.els.get('dp-rule-save-1')._on.click();
|
|
776
|
+
await b.settle();
|
|
777
|
+
const post = b.posts.find((p) => /update-channel$/.test(p.path));
|
|
778
|
+
assert.deepEqual(post && plain(post.body), { channel: 'minor' });
|
|
779
|
+
assert.ok(b.toasts.length, 'the server’s sentence is shown');
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
test('a preview with no rule reading shows no rule control, rather than a guessed one', async () => {
|
|
783
|
+
const b = boot();
|
|
784
|
+
await b.settle();
|
|
785
|
+
assert.doesNotMatch(b.panel(), /Update rule/);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
test('every reason the runner can give has owner words on this page — none falls to the fallback', async () => {
|
|
789
|
+
// upgrade-outcome.js's REASON_CLASS is the vocabulary; a reason added there with no
|
|
790
|
+
// sentence here would reach the owner as "no words for this yet".
|
|
791
|
+
const { createRequire } = await import('node:module');
|
|
792
|
+
const req = createRequire(import.meta.url);
|
|
793
|
+
const vocab = Object.keys(req(path.join(ROOT, 'scripts/gds/upgrade-outcome.js')).REASON_CLASS || {});
|
|
794
|
+
assert.ok(vocab.length > 20, 'the vocabulary was read');
|
|
795
|
+
const block = SRC.slice(SRC.indexOf('const REASON_WORDS = {'), SRC.indexOf('const reasonWords'));
|
|
796
|
+
const missing = vocab.filter((k) => !new RegExp(`\\b${k}:`).test(block));
|
|
797
|
+
assert.deepEqual(missing, [], `reasons with no owner words: ${missing}`);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
test('a module that is not ready says what the owner can do about it', async () => {
|
|
801
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { last_move: failedAs('needs_decision', 'module_incompatible') }) });
|
|
802
|
+
await b.settle();
|
|
803
|
+
assert.match(b.panel(), /Turn that module off/);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test('a known snag it could not fix names the next step, and does NOT point at a blocker that was never filed', async () => {
|
|
807
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { last_move: failedAs('known_wedge', 'disk_full') }) });
|
|
808
|
+
await b.settle();
|
|
809
|
+
assert.match(b.panel(), /whoever runs that machine clears the snag/);
|
|
810
|
+
assert.doesNotMatch(b.panel(), /href="\/blockers"/, 'move-escalation files no blocker for this class');
|
|
811
|
+
});
|