@bongos/core 1.20.35 → 1.20.37
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 +33 -23
- package/docs/architecture.md +2 -0
- package/docs/copy-inventory.md +54 -43
- package/docs/copy-registry.json +189 -70
- package/docs/module-api-changelog.md +4 -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 +16 -0
- package/scripts/gds/autobongos-run.js +107 -12
- package/scripts/gds/module-assess-tests.js +248 -0
- package/src/module-api.js +1 -1
- package/tests/autobongos_loop.mjs +205 -0
- package/tests/core_upgrade_door.mjs +8 -1
- package/tests/deploy_page_projects.mjs +165 -1
- package/tests/module_assess_tests.mjs +232 -0
|
@@ -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.37",
|
|
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.37",
|
|
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.37",
|
|
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,21 @@
|
|
|
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
|
+
}
|
|
8306
|
+
],
|
|
8307
|
+
"1.20.37": [
|
|
8308
|
+
{
|
|
8309
|
+
"id": "1003791",
|
|
8310
|
+
"text": "A module's own tests can now be run and scored: the store records what share of them pass, and a module with no tests shows as 'no data yet' instead of failing."
|
|
8311
|
+
},
|
|
8312
|
+
{
|
|
8313
|
+
"id": "1004406",
|
|
8314
|
+
"text": "The unattended runner stops restarting every time anyone merges: it now updates its code in place and only restarts when its own code has changed."
|
|
8315
|
+
}
|
|
8300
8316
|
]
|
|
8301
8317
|
}
|
|
@@ -359,7 +359,7 @@ const MAX_CLAIM_ATTEMPTS = 5;
|
|
|
359
359
|
const LIMIT_UNKNOWN_RESET_MS = 30 * 60 * 1000;
|
|
360
360
|
|
|
361
361
|
function newRunnerState() {
|
|
362
|
-
return { setAside: new Map(), queueGate: null, limitUntil: null };
|
|
362
|
+
return { setAside: new Map(), queueGate: null, limitUntil: null, diskHead: null };
|
|
363
363
|
}
|
|
364
364
|
const RUNNER_STATE = newRunnerState();
|
|
365
365
|
|
|
@@ -666,13 +666,16 @@ function heartbeatPath() {
|
|
|
666
666
|
// output, reddening `unit` on main and blocking every PR. Use a regular FILE as the
|
|
667
667
|
// parent directory instead: mkdir under it is ENOTDIR immediately, everywhere, and it
|
|
668
668
|
// does not depend on the caller's permissions.
|
|
669
|
-
function writeHeartbeat(now = Date.now()) {
|
|
669
|
+
function writeHeartbeat(now = Date.now(), runnerState = RUNNER_STATE) {
|
|
670
670
|
try {
|
|
671
671
|
const p = heartbeatPath();
|
|
672
672
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
673
673
|
// `head` is what this process is RUNNING, not what is on disk (task 1004374).
|
|
674
674
|
// Alive and alive-on-stale-code look identical from a check-in age alone.
|
|
675
|
-
|
|
675
|
+
// `disk_head` appears only when the checkout was refreshed in place past what
|
|
676
|
+
// is running (task 1004406): "up to date on disk, old supervisor" is visible.
|
|
677
|
+
const disk = runnerState && runnerState.diskHead && runnerState.diskHead !== LOADED_HEAD ? runnerState.diskHead : undefined;
|
|
678
|
+
fs.writeFileSync(p, JSON.stringify({ pid: process.pid, at: now, head: LOADED_HEAD, ...(disk ? { disk_head: disk } : {}) }));
|
|
676
679
|
return true;
|
|
677
680
|
} catch (_) { return false; }
|
|
678
681
|
}
|
|
@@ -851,10 +854,79 @@ async function codeDrifted(deps = {}) {
|
|
|
851
854
|
// the comparison untestable in isolation.
|
|
852
855
|
const mine = deps.loadedHead ? await deps.loadedHead(deps) : await loadedHead(deps);
|
|
853
856
|
if (!mine) return null;
|
|
857
|
+
// Measured from what is on DISK once the checkout has been refreshed in place
|
|
858
|
+
// (task 1004406), or every loop after a refresh would see the same drift again.
|
|
859
|
+
const base = ((deps.state || RUNNER_STATE).diskHead) || mine;
|
|
854
860
|
let tip = null;
|
|
855
861
|
try { tip = await runFetchHead(deps); } catch (_) { return null; }
|
|
856
862
|
if (!tip) return null;
|
|
857
|
-
return tip !==
|
|
863
|
+
return tip !== base ? { from: base.slice(0, 8), to: String(tip).slice(0, 8) } : false;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// ── pull always, restart rarely (task 1004406) ──────────────────────────────
|
|
867
|
+
//
|
|
868
|
+
// Owner-approved 2026-09-30. Main moves every time ANY session lands, and a
|
|
869
|
+
// relaunch per merge meant a busy day of restarts (four in fourteen minutes on
|
|
870
|
+
// 2026-09-29). But a restart only refreshes code THIS process holds in memory:
|
|
871
|
+
// every worker, and every claim.js / worktree.js / release.js the loop shells
|
|
872
|
+
// out to, is a fresh process that runs whatever is on disk. So on drift the
|
|
873
|
+
// checkout is updated in place, and the process exits for the wrapper only when
|
|
874
|
+
// a change touches code it has actually loaded.
|
|
875
|
+
//
|
|
876
|
+
// SUPERVISOR_PATH_RES catch the supervisor's own files even before they are
|
|
877
|
+
// required (autobongos-verify.js is loaded lazily on the first ship), so a change
|
|
878
|
+
// to a file the loop is ABOUT to load cannot mix old callers with a new callee.
|
|
879
|
+
const SUPERVISOR_PATH_RES = [/^scripts\/gds\/autobongos-[^/]+$/, /^modules\/autonomy\//];
|
|
880
|
+
|
|
881
|
+
// The repo files this process has loaded, repo-relative with forward slashes.
|
|
882
|
+
function loadedFiles() {
|
|
883
|
+
const root = REPO_ROOT + path.sep;
|
|
884
|
+
const nm = path.sep + 'node_modules' + path.sep;
|
|
885
|
+
return Object.keys(require.cache)
|
|
886
|
+
.filter((p) => p.startsWith(root) && !p.includes(nm))
|
|
887
|
+
.map((p) => path.relative(REPO_ROOT, p).split(path.sep).join('/'));
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// The changed files that force a restart: ones this process holds, or ones under
|
|
891
|
+
// the supervisor's own paths. Everything else is picked up by the next child.
|
|
892
|
+
function supervisorTouched(changed = [], loaded = loadedFiles()) {
|
|
893
|
+
const held = new Set(loaded);
|
|
894
|
+
return changed.filter((f) => held.has(f) || SUPERVISOR_PATH_RES.some((re) => re.test(f)));
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// refreshCheckout — the wrapper's fetch + reset, done from inside the process.
|
|
898
|
+
//
|
|
899
|
+
// THE MAIN CHECKOUT ONLY. It runs `git reset --hard`, and a linked worktree is a
|
|
900
|
+
// claim somebody is building in; resetting one would destroy their work. The
|
|
901
|
+
// check compares git-dir to git-common-dir, which are equal only in the main
|
|
902
|
+
// checkout. Armed only by main() (`opts.refreshInPlace`), so a test driving
|
|
903
|
+
// forever() can never reach it — in CI that reset would wipe the code under test.
|
|
904
|
+
//
|
|
905
|
+
// The diff is from the LOADED commit, not the last refresh, so a supervisor
|
|
906
|
+
// change that landed several refreshes ago still forces the restart it needs.
|
|
907
|
+
async function refreshCheckout(deps = {}) {
|
|
908
|
+
const runCmd = deps.run || run;
|
|
909
|
+
const git = (args) => runCmd('git', args, { cwd: REPO_ROOT });
|
|
910
|
+
try {
|
|
911
|
+
const [gd, cd] = await Promise.all([git(['rev-parse', '--git-dir']), git(['rev-parse', '--git-common-dir'])]);
|
|
912
|
+
if (!gd.ok || !cd.ok) return { ok: false, reason: 'could not tell which checkout this is' };
|
|
913
|
+
if (path.resolve(REPO_ROOT, String(gd.stdout).trim()) !== path.resolve(REPO_ROOT, String(cd.stdout).trim())) {
|
|
914
|
+
return { ok: false, reason: 'not the main checkout — refusing to reset a linked worktree' };
|
|
915
|
+
}
|
|
916
|
+
const loaded = deps.loadedHead ? await deps.loadedHead(deps) : await loadedHead(deps);
|
|
917
|
+
if (!loaded) return { ok: false, reason: 'the running commit is unknown' };
|
|
918
|
+
if (!(await git(['fetch', '--quiet', 'origin', 'main'])).ok) return { ok: false, reason: 'fetch failed' };
|
|
919
|
+
const fh = await git(['rev-parse', 'FETCH_HEAD']);
|
|
920
|
+
const to = fh.ok ? String(fh.stdout).trim() : '';
|
|
921
|
+
if (!/^[0-9a-f]{40}$/.test(to)) return { ok: false, reason: 'could not read the fetched commit' };
|
|
922
|
+
const diff = await git(['diff', '--name-only', loaded, to]);
|
|
923
|
+
if (!diff.ok) return { ok: false, reason: 'could not diff the running commit against main' };
|
|
924
|
+
if (!(await git(['reset', '--quiet', '--hard', to])).ok) return { ok: false, reason: 'reset failed' };
|
|
925
|
+
const changed = String(diff.stdout).split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
926
|
+
return { ok: true, to, changed };
|
|
927
|
+
} catch (e) {
|
|
928
|
+
return { ok: false, reason: e && e.message ? e.message : String(e) };
|
|
929
|
+
}
|
|
858
930
|
}
|
|
859
931
|
|
|
860
932
|
// The head of origin/main, or null. `git ls-remote` rather than `fetch`: it moves
|
|
@@ -932,7 +1004,9 @@ async function forever(opts, deps = {}) {
|
|
|
932
1004
|
// reports itself anyway: the hall reads the AGE of the last check-in, so a
|
|
933
1005
|
// failed beat shows up as silence, which is the signal.
|
|
934
1006
|
const beat = async (fields) => {
|
|
935
|
-
|
|
1007
|
+
// The runner state is passed, not defaulted: the file names `disk_head` from
|
|
1008
|
+
// it (task 1004406), and an injected state must reach the beat that reports it.
|
|
1009
|
+
try { beatFile(Date.now(), deps.state || RUNNER_STATE); } catch (_) { /* a runner that cannot write its file still runs */ }
|
|
936
1010
|
try { await beatServer(fields, deps); } catch (_) { /* and one that cannot reach the instance still runs */ }
|
|
937
1011
|
};
|
|
938
1012
|
// The running commit, read ONCE, BEFORE the first beat (task 1004397). It used
|
|
@@ -993,14 +1067,35 @@ async function forever(opts, deps = {}) {
|
|
|
993
1067
|
// spends nearly all of its time and an upgrade should not sit behind it.
|
|
994
1068
|
const upMs = (deps.now ? deps.now() : Date.now()) - Date.parse(RUNNER_STARTED_AT);
|
|
995
1069
|
const mayUpgrade = upMs >= MIN_UPTIME_BEFORE_UPGRADE_MS;
|
|
996
|
-
|
|
1070
|
+
let drift = mayUpgrade
|
|
997
1071
|
? (deps.codeDrifted ? await deps.codeDrifted(deps) : await codeDrifted(deps))
|
|
998
1072
|
: false;
|
|
999
1073
|
if (drift) {
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1074
|
+
// Pull always, restart rarely (task 1004406). Only main() arms the refresh.
|
|
1075
|
+
let why = 'exiting so the wrapper can relaunch on the new code';
|
|
1076
|
+
if (opts.refreshInPlace) {
|
|
1077
|
+
const refreshed = await (deps.refreshCheckout || refreshCheckout)(deps);
|
|
1078
|
+
if (refreshed && refreshed.ok) {
|
|
1079
|
+
const touched = supervisorTouched(refreshed.changed, deps.loadedFiles ? deps.loadedFiles() : loadedFiles());
|
|
1080
|
+
if (!touched.length) {
|
|
1081
|
+
(deps.state || RUNNER_STATE).diskHead = refreshed.to;
|
|
1082
|
+
log({ event: 'code_refreshed', from: drift.from, to: refreshed.to.slice(0, 8), changed: refreshed.changed.length,
|
|
1083
|
+
reason: `main moved from ${drift.from} to ${refreshed.to.slice(0, 8)} — checkout updated in place; none of the ${refreshed.changed.length} changed file(s) is code this process runs, so no restart` });
|
|
1084
|
+
await beat({ mode: state.mode, last_event: 'code_refreshed' });
|
|
1085
|
+
drift = null;
|
|
1086
|
+
} else {
|
|
1087
|
+
const more = touched.length > 5 ? `, +${touched.length - 5} more` : '';
|
|
1088
|
+
why = `it changed code this process runs (${touched.slice(0, 5).join(', ')}${more}) — exiting so the wrapper can relaunch on it`;
|
|
1089
|
+
}
|
|
1090
|
+
} else {
|
|
1091
|
+
why = `the in-place refresh did not run (${(refreshed && refreshed.reason) || 'no result'}) — exiting so the wrapper's own refresh can`;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
if (drift) {
|
|
1095
|
+
log({ event: 'upgrade_exit', from: drift.from, to: drift.to, reason: `main moved from ${drift.from} to ${drift.to} — ${why}` });
|
|
1096
|
+
await beat({ mode: state.mode, last_event: 'upgrade_exit' });
|
|
1097
|
+
return UPGRADE_EXIT_CODE;
|
|
1098
|
+
}
|
|
1004
1099
|
}
|
|
1005
1100
|
|
|
1006
1101
|
const { waited, jumped } = await wait(state.waitS, { ...deps, beat }, opts.cadence);
|
|
@@ -1015,7 +1110,7 @@ async function main(argv) {
|
|
|
1015
1110
|
// it lived on the machine being fenced; the database owns that now, and an empty
|
|
1016
1111
|
// `--goal` means "every allowlisted goal" rather than "anything claimable". The
|
|
1017
1112
|
// refusal did not disappear — it moved somewhere the runner host cannot edit.
|
|
1018
|
-
if (opts.forever) return forever(opts);
|
|
1113
|
+
if (opts.forever) return forever({ ...opts, refreshInPlace: true });
|
|
1019
1114
|
|
|
1020
1115
|
// The one-shot path, kept for an operator running a single task by hand. It
|
|
1021
1116
|
// still reconciles first: a leftover claim is a leftover claim whoever is
|
|
@@ -1043,5 +1138,5 @@ module.exports = {
|
|
|
1043
1138
|
worktreeName, readFence, reconcileLeftoverClaims, pollSignals, waitOrJump, forever, CLAIM_PREFIX, codeDrifted, loadedHead, UPGRADE_EXIT_CODE, MIN_UPTIME_BEFORE_UPGRADE_MS,
|
|
1044
1139
|
heartbeatPath, writeHeartbeat, readHeartbeat, pidAlive, anotherRunnerIsAlive, HEARTBEAT_STALE_MS,
|
|
1045
1140
|
isRunnerTree, RUNNER_TREE_RE, postHeartbeat, RUNNER_STARTED_AT, failureReason,
|
|
1046
|
-
newRunnerState, LIMIT_UNKNOWN_RESET_MS, QUEUE_GATED_EXIT, CLAIM_SET_ASIDE_MS, MAX_CLAIM_ATTEMPTS, owedTaskIds,
|
|
1141
|
+
newRunnerState, loadedFiles, supervisorTouched, refreshCheckout, LIMIT_UNKNOWN_RESET_MS, QUEUE_GATED_EXIT, CLAIM_SET_ASIDE_MS, MAX_CLAIM_ATTEMPTS, owedTaskIds,
|
|
1047
1142
|
};
|