@bongos/core 1.19.972 → 1.19.973
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 +74 -64
- package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
- package/docs/adr/0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md +1 -1
- package/docs/adr/0325-the-managed-fleet-control-plane-is-the-hub-we-already-run.md +1 -1
- package/docs/adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md +107 -0
- package/docs/adr/README.md +1 -0
- package/docs/architecture.md +6 -6
- package/docs/branding-contract.md +1 -1
- package/docs/canonical-permissions.md +24 -24
- package/docs/copy-inventory.md +45 -38
- package/docs/copy-registry.json +130 -67
- package/docs/design/projects-hub-direction-v2.md +1 -1
- package/docs/design/vanilla-hall-ui-redesign-scope.md +2 -2
- package/docs/file-map.md +7 -7
- package/docs/handoff-template.md +1 -1
- package/docs/module-api-changelog.md +2 -0
- package/docs/modules-contract.md +1 -1
- package/docs/onboarding/drachmae-and-karma.md +2 -2
- package/docs/packs/engineer.md +5 -5
- package/docs/packs/ideator.md +1 -1
- package/docs/recipes/README.md +1 -1
- package/docs/recipes/audit-replay.md +2 -2
- package/docs/recipes/gds-db-backup.md +3 -3
- package/docs/recipes/managed-settings-remote-control.md +9 -9
- package/docs/recipes/memory-sync-conflicts.md +2 -2
- package/docs/recipes/ops-gotchas.md +24 -5
- package/docs/recipes/overnight-builder.md +3 -3
- package/docs/recipes/parallel-goal-waves.md +3 -3
- package/docs/recipes/reranking-a-goals-board.md +2 -2
- package/docs/recipes/search-index-ingestion.md +1 -1
- package/docs/recipes/ship-and-regrade.md +2 -2
- package/docs/recipes/staging-environment.md +1 -1
- package/docs/recipes/windows-builders.md +1 -1
- package/modules/hall-ui/public/deploy.html +10 -5
- package/modules/hall-ui/public/deploy.js +199 -110
- package/modules/hall-ui/public/project-settings.css +36 -0
- package/modules/provisioning/core-upgrade.js +61 -2
- package/modules/public-landing/public/contact.html +1 -1
- package/modules/public-landing/public/index.html +1 -1
- package/modules/public-landing/public/privacy.html +1 -1
- package/modules/public-landing/public/projects.html +1 -1
- package/modules/public-landing/public/terms.html +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/api-alias-caller-scan.js +47 -9
- package/scripts/gds/cloudbongos-token-isolation.conf +33 -0
- package/scripts/gds/provision-core-upgrade.js +132 -1
- package/scripts/gds/run-unit-tests.js +14 -9
- package/scripts/hall-preview/server.js +5 -0
- package/src/module-api.js +1 -1
- package/tests/api_alias_caller_ratchet.mjs +68 -2
- package/tests/core_upgrade_door.mjs +81 -0
- package/tests/core_upgrade_runner.mjs +132 -0
- package/tests/deploy_page_projects.mjs +234 -79
- package/tests/idea_spark_hall.mjs +101 -12
- package/tests/known_failing_ratchet.mjs +19 -9
- package/tests/landing_legal_pages.mjs +5 -1
- package/tests/projects_hub_pre_uat.mjs +12 -6
- package/tests/served_paths_shipped.mjs +36 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// deploy.js — the owner's deploy door (task 1003159, ADR 0293; widened to the
|
|
2
|
-
// hosted projects by task 1004046).
|
|
2
|
+
// hosted projects by task 1004046, narrowed back to ONE project by task 1004171).
|
|
3
3
|
//
|
|
4
4
|
// WHAT THIS PAGE IS FOR. The owner, twice in a month: "I don't think I can make anything
|
|
5
5
|
// live", then "this is above my paygrade." Moving a core pin is gate 3 of the release
|
|
@@ -9,18 +9,27 @@
|
|
|
9
9
|
// `sudo -u <app user>` over ssh by design. So the one gate that is the owner's to hold was the
|
|
10
10
|
// one gate with no instrument. This page is the instrument.
|
|
11
11
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// every tenant. The control plane is the only place that can act on them, so this is
|
|
17
|
-
// where the button lives. Their own halls get the banner instead (task 1004045), which
|
|
18
|
-
// each instance derives for itself from the registry and needs nothing from us.
|
|
12
|
+
// ONE PAGE, ONE PROJECT (task 1004171). The owner, 2026-09-22: "Should not show core pin
|
|
13
|
+
// upgrades for all of the projects", and then the rule — "it should only show the project
|
|
14
|
+
// of that builders hall", "every project has the option to upgrade its version of bongos
|
|
15
|
+
// including bongos itself."
|
|
19
16
|
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
17
|
+
// So the default view is the hall's OWN project and nothing else. On the control plane
|
|
18
|
+
// that is the self row. What this replaces is task 1004046's shape, which listed every
|
|
19
|
+
// live instance here because a hosted project cannot act on itself: the upgrade route and
|
|
20
|
+
// the intent queue belong to the PROVISIONING module, and a tenant does not run it
|
|
21
|
+
// (probed 2026-09-18: /api/bongos/provisioning/health answers 200 here, 404 on every
|
|
22
|
+
// tenant). That reasoning still holds — the ACT still has to happen on the control plane —
|
|
23
|
+
// but it does not license a page in one project's hall that offers to restart five other
|
|
24
|
+
// projects, two of them belonging to builders who never asked. On 2026-09-22 this page
|
|
25
|
+
// listed six boxes and the owner wanted one.
|
|
26
|
+
//
|
|
27
|
+
// WHICH IS WHY ?project= EXISTS, and it is not a fleet browser. A hosted project's hall
|
|
28
|
+
// hands its owner off to this page scoped to THAT project (task 1004174), because the
|
|
29
|
+
// hand-off is how a tenant reaches a working control without the tenant's web tier
|
|
30
|
+
// gaining any capability — the split ADR 0293 D1 and ADR 0043 both turn on. Scoped or
|
|
31
|
+
// not, the page renders exactly one row, and authority is still resolved server-side per
|
|
32
|
+
// request: a slug in a query string decides what is LOOKED at, never what is allowed.
|
|
24
33
|
//
|
|
25
34
|
// THE RULE THAT SHAPES EVERY LINE HERE: this page KNOWS NOTHING. Every version on it is a
|
|
26
35
|
// reading the control-plane runner took on the box, carried through the server with the
|
|
@@ -34,10 +43,11 @@
|
|
|
34
43
|
// already guards the bad release; this guards the wrong button. Step one shows what would
|
|
35
44
|
// move; step two names the version it saw and sends that. The confirm button is built
|
|
36
45
|
// FROM the previewed version, so a release that lands between the two steps cannot
|
|
37
|
-
// silently change what the press does.
|
|
38
|
-
//
|
|
39
|
-
// so the version sent is the one that
|
|
40
|
-
//
|
|
46
|
+
// silently change what the press does. The step is still keyed BY INSTANCE rather than
|
|
47
|
+
// held page-wide, and each button's handler still CLOSES OVER the preview object it was
|
|
48
|
+
// built from — so the version sent is the one that was displayed and a repaint cannot
|
|
49
|
+
// swap it. That survives the narrowing to one row on purpose: the identity being carried
|
|
50
|
+
// is the box's, and keying it to the box is what made a wrong-box press unrepresentable.
|
|
41
51
|
(() => {
|
|
42
52
|
'use strict';
|
|
43
53
|
|
|
@@ -106,7 +116,14 @@
|
|
|
106
116
|
if (secs < 90) return `${secs}s ago`;
|
|
107
117
|
const mins = Math.round(secs / 60);
|
|
108
118
|
if (mins < 90) return `${mins} min ago`;
|
|
109
|
-
|
|
119
|
+
const hours = Math.round(mins / 60);
|
|
120
|
+
// PAST TWO DAYS, HOURS STOP BEING A DURATION AND BECOME A NUMBER (task 1004172).
|
|
121
|
+
// The failure that motivated dating these at all rendered "55h ago", which is
|
|
122
|
+
// arithmetic the reader has to do before they can tell whether it is news. Days are
|
|
123
|
+
// the unit a two-day-old attempt is actually read in.
|
|
124
|
+
if (hours < 48) return `${hours}h ago`;
|
|
125
|
+
const days = Math.round(hours / 24);
|
|
126
|
+
return `${days} day${days === 1 ? '' : 's'} ago`;
|
|
110
127
|
}
|
|
111
128
|
|
|
112
129
|
function versionHtml(v) {
|
|
@@ -147,15 +164,47 @@
|
|
|
147
164
|
// AND WHAT IT SEES AFTERWARDS. Three outcomes, and the failure is the one that had no
|
|
148
165
|
// surface at all before this: an errored intent is not an OPEN intent, so the payload
|
|
149
166
|
// after a rolled-back upgrade used to be identical to the payload before it.
|
|
167
|
+
//
|
|
168
|
+
// A FAILURE IS DATED, AND STOPS BEING THE HEADLINE ONCE THE BOX HAS MOVED ON
|
|
169
|
+
// (task 1004172). What this block used to render, undated, at the top of the page:
|
|
170
|
+
// "The move to core 1.19.921 did not land — this project was put back on 1.19.969 and
|
|
171
|
+
// is running." The attempt was two days old, the box had reached 1.19.969 by another
|
|
172
|
+
// road, and the cause it named had been fixed and released in between. Three defects in
|
|
173
|
+
// one sentence: no date, a PAST-tense claim ("was put back on") built from a PRESENT
|
|
174
|
+
// reading, and no notion that a box can recover.
|
|
175
|
+
//
|
|
176
|
+
// The rollback clause is present-tense now, which is the whole fix for the false half:
|
|
177
|
+
// "is still running core X" is a statement about the reading in hand, and true whenever
|
|
178
|
+
// the reading is. The page does not decide whether a failure is history — `superseded`
|
|
179
|
+
// is the server's answer (modules/provisioning/core-upgrade.js), because this page
|
|
180
|
+
// compares no versions.
|
|
150
181
|
function settledHtml(p) {
|
|
151
182
|
const m = p.last_move;
|
|
152
183
|
if (!m) return '';
|
|
184
|
+
const when = m.at ? ` ${escapeHtml(ago(m.at))}` : '';
|
|
185
|
+
// The runner's own words, which name a fix addressed to someone holding a shell. Kept
|
|
186
|
+
// verbatim and kept CLOSED: the owner cannot run `sudo -u`, so 30 lines of it (the
|
|
187
|
+
// dirty-tree refusal prints a whole `git status`) is not what a deploy page leads on.
|
|
188
|
+
const detail = m.error
|
|
189
|
+
? `<details class="dp-settled__detail"><summary>What the upgrade said</summary>
|
|
190
|
+
<pre class="dp-preflight__error">${escapeHtml(String(m.error))}</pre></details>`
|
|
191
|
+
: '';
|
|
192
|
+
if (m.state === 'error' && m.superseded) {
|
|
193
|
+
return `
|
|
194
|
+
<div class="dp-settled dp-settled--past">
|
|
195
|
+
<p class="ov-row__sub"><strong>An earlier attempt did not land.</strong> A move to core
|
|
196
|
+
${escapeHtml(String(m.target_version))} failed${when} — this project has moved on since,
|
|
197
|
+
and is serving ${escapeHtml(String(p.served_version))} now. Whatever stopped that one
|
|
198
|
+
may or may not still apply; the check above is the live answer.</p>
|
|
199
|
+
${detail}
|
|
200
|
+
</div>`;
|
|
201
|
+
}
|
|
153
202
|
if (m.state === 'error') {
|
|
154
203
|
return `
|
|
155
204
|
<div class="dp-settled dp-settled--failed">
|
|
156
205
|
<p class="ov-row__sub"><strong>The move to core ${escapeHtml(String(m.target_version))} did not land</strong>
|
|
157
|
-
—
|
|
158
|
-
${
|
|
206
|
+
— attempted${when}. This project is still running core ${escapeHtml(String(p.served_version))}.</p>
|
|
207
|
+
${detail}
|
|
159
208
|
</div>`;
|
|
160
209
|
}
|
|
161
210
|
if (m.state === 'done' && m.target_version === p.served_version) {
|
|
@@ -193,7 +242,11 @@
|
|
|
193
242
|
function preflightHtml(p) {
|
|
194
243
|
const pf = p.preflight;
|
|
195
244
|
if (checking.has(String(p.instance_id)) && !(pf && pf.target === p.recommended_version)) {
|
|
196
|
-
|
|
245
|
+
// NAME WHAT THE WAIT IS FOR (task 1004173). The runner picks this up on its own
|
|
246
|
+
// ~2-minute tick and then reads the box, so a bare spinner leaves the owner watching
|
|
247
|
+
// an unexplained pause on the one page where a pause could mean a live box is busy.
|
|
248
|
+
return `<p class="ov-row__sub dp-preflight dp-preflight--checking">Checking what this would do, and what it would bring in.
|
|
249
|
+
The answer comes from the machine itself, so it can take a couple of minutes.</p>`;
|
|
197
250
|
}
|
|
198
251
|
// No verdict is its own state, and it is NOT "fine". A silent gap here is how the
|
|
199
252
|
// page used to promise a preview it never performed.
|
|
@@ -253,9 +306,7 @@
|
|
|
253
306
|
if (steps.get(p.instance_id) !== 'confirm') break; // the row was closed; stop.
|
|
254
307
|
try {
|
|
255
308
|
await refreshOne(p, { keepStep: true });
|
|
256
|
-
const now =
|
|
257
|
-
? view.self
|
|
258
|
-
: view.boxes.find((b) => String(b.instance_id) === id);
|
|
309
|
+
const now = view.box && String(view.box.instance_id) === id ? view.box : null;
|
|
259
310
|
if (now && now.preflight && now.preflight.target === now.recommended_version) break;
|
|
260
311
|
} catch (e) { break; }
|
|
261
312
|
}
|
|
@@ -264,10 +315,51 @@
|
|
|
264
315
|
repaint();
|
|
265
316
|
}
|
|
266
317
|
|
|
318
|
+
// WHAT THE MOVE WOULD CARRY (task 1004173). The owner: "I don't actually see what would
|
|
319
|
+
// change when I hit the button." The pre-flight below answers whether the machinery will
|
|
320
|
+
// allow the move; this answers what taking it buys, in the words the ships themselves
|
|
321
|
+
// wrote — which are already addressed to this reader, because ship.js demands a summary
|
|
322
|
+
// an owner can read and posts it to Discord.
|
|
323
|
+
//
|
|
324
|
+
// AN EMPTY LIST IS NEVER RENDERED AS "NOTHING CHANGED". A range the runner could not
|
|
325
|
+
// resolve — no core history on the machine, a release it cannot find — comes back as an
|
|
326
|
+
// error and says so. "Nothing changed" over ninety-one releases is the one wrong
|
|
327
|
+
// impression this section could give, and it is the shape a silent failure takes.
|
|
328
|
+
function changesHtml(p) {
|
|
329
|
+
const pf = p.preflight;
|
|
330
|
+
const c = pf && pf.changes;
|
|
331
|
+
if (!c) return '';
|
|
332
|
+
if (c.error) {
|
|
333
|
+
return `<div class="dp-changes dp-changes--unknown">
|
|
334
|
+
<p class="ov-row__sub">Could not read what this would bring in: ${escapeHtml(String(c.error))}.
|
|
335
|
+
The check below still applies.</p>
|
|
336
|
+
</div>`;
|
|
337
|
+
}
|
|
338
|
+
const entries = Array.isArray(c.entries) ? c.entries : [];
|
|
339
|
+
if (!entries.length) {
|
|
340
|
+
return `<div class="dp-changes dp-changes--unknown">
|
|
341
|
+
<p class="ov-row__sub">No shipped work is recorded between core ${escapeHtml(String(c.from))} and
|
|
342
|
+
${escapeHtml(String(c.to))}.</p>
|
|
343
|
+
</div>`;
|
|
344
|
+
}
|
|
345
|
+
const items = entries.map((e) => `<li>${escapeHtml(String(e.text))}</li>`).join('');
|
|
346
|
+
const more = c.more > 0
|
|
347
|
+
? `<p class="ov-row__sub dp-changes__more">…and ${escapeHtml(String(c.more))} more, not listed.</p>`
|
|
348
|
+
: '';
|
|
349
|
+
return `<div class="dp-changes">
|
|
350
|
+
<p class="ov-row__sub"><strong>What this brings in</strong> — ${escapeHtml(String(entries.length))}
|
|
351
|
+
${entries.length === 1 ? 'piece' : 'pieces'} of work landed between core ${escapeHtml(String(c.from))}
|
|
352
|
+
and ${escapeHtml(String(c.to))}${c.more > 0 ? ', newest first' : ''}:</p>
|
|
353
|
+
<ul class="dp-changes__list">${items}</ul>
|
|
354
|
+
${more}
|
|
355
|
+
</div>`;
|
|
356
|
+
}
|
|
357
|
+
|
|
267
358
|
function stepTwoHtml(p, isSelf) {
|
|
268
359
|
const id = escapeHtml(String(p.instance_id));
|
|
269
360
|
return `
|
|
270
361
|
<div class="ps-controls">
|
|
362
|
+
${changesHtml(p)}
|
|
271
363
|
${preflightHtml(p)}
|
|
272
364
|
<p class="ov-row__sub">This restarts ${isSelf ? 'the platform' : escapeHtml(String(p.slug))} onto core
|
|
273
365
|
<strong>${escapeHtml(String(p.recommended_version))}</strong>. It waits for the box to answer and
|
|
@@ -315,13 +407,13 @@
|
|
|
315
407
|
</div></li>`;
|
|
316
408
|
}
|
|
317
409
|
|
|
318
|
-
//
|
|
319
|
-
// a box
|
|
320
|
-
// deploying, which is the one wrong impression this page must never give.
|
|
321
|
-
function unreadableHtml(
|
|
410
|
+
// The one project's preview could not be read. It is still RENDERED, named, rather
|
|
411
|
+
// than leaving the page blank: a box missing from a deploy page reads as a box that
|
|
412
|
+
// does not need deploying, which is the one wrong impression this page must never give.
|
|
413
|
+
function unreadableHtml(name, message) {
|
|
322
414
|
return `
|
|
323
415
|
<li class="ov-row"><div class="ov-row__main">
|
|
324
|
-
<span class="ov-row__title">${escapeHtml(String(
|
|
416
|
+
<span class="ov-row__title">${escapeHtml(String(name))}</span>
|
|
325
417
|
<p class="ps-invalid">Could not read this project's deploy state: ${escapeHtml(message)}</p>
|
|
326
418
|
</div></li>`;
|
|
327
419
|
}
|
|
@@ -338,33 +430,30 @@
|
|
|
338
430
|
if (confirm) confirm.addEventListener('click', () => move(p));
|
|
339
431
|
}
|
|
340
432
|
|
|
341
|
-
// What the page is currently showing: the
|
|
342
|
-
//
|
|
343
|
-
// have to re-fetch
|
|
344
|
-
let view = {
|
|
433
|
+
// What the page is currently showing: the ONE box this hall is about, whether that box
|
|
434
|
+
// is this platform itself, and — instead of a box — the reason there isn't one. Held so
|
|
435
|
+
// a repaint after a click does not have to re-fetch.
|
|
436
|
+
let view = { box: null, isSelf: true, name: 'This platform', notEnrolled: null, unreadable: null };
|
|
345
437
|
|
|
346
438
|
function repaint() {
|
|
347
|
-
|
|
348
|
-
if (view.
|
|
439
|
+
let item;
|
|
440
|
+
if (view.box) item = boxHtml(view.box, view.isSelf);
|
|
349
441
|
else if (view.notEnrolled) {
|
|
350
442
|
// The server's own sentence names the one command that fixes it, which is why it
|
|
351
443
|
// is shown rather than a generic failure line.
|
|
352
|
-
|
|
353
|
-
<span class="ov-row__title"
|
|
444
|
+
item = `<li class="ov-row"><div class="ov-row__main">
|
|
445
|
+
<span class="ov-row__title">${escapeHtml(String(view.name))}</span>
|
|
354
446
|
<p class="ps-invalid">${escapeHtml(view.notEnrolled)}</p>
|
|
355
|
-
</div></li
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
if (!items.length) {
|
|
360
|
-
items.push(`<li class="ov-row"><div class="ov-row__main">
|
|
447
|
+
</div></li>`;
|
|
448
|
+
} else if (view.unreadable) item = unreadableHtml(view.name, view.unreadable);
|
|
449
|
+
else {
|
|
450
|
+
item = `<li class="ov-row"><div class="ov-row__main">
|
|
361
451
|
<p class="ov-row__sub">No running box to deploy.</p>
|
|
362
|
-
</div></li
|
|
452
|
+
</div></li>`;
|
|
363
453
|
}
|
|
364
|
-
$('dp-panel').innerHTML = `<ul class="ov-lines">${
|
|
454
|
+
$('dp-panel').innerHTML = `<ul class="ov-lines">${item}</ul>
|
|
365
455
|
<p class="ps-prov">reads the control plane's own observation · ADR 0293 · every move is recorded with who asked</p>`;
|
|
366
|
-
if (view.
|
|
367
|
-
for (const p of view.boxes) wire(p);
|
|
456
|
+
if (view.box) wire(view.box);
|
|
368
457
|
}
|
|
369
458
|
|
|
370
459
|
async function move(p) {
|
|
@@ -400,9 +489,7 @@
|
|
|
400
489
|
// keepStep, for the reason the pre-flight poll documents: a refresh that resets
|
|
401
490
|
// the row would fight the state this loop is painting, every interval.
|
|
402
491
|
await refreshOne(p, { keepStep: true });
|
|
403
|
-
now =
|
|
404
|
-
? view.self
|
|
405
|
-
: view.boxes.find((b) => String(b.instance_id) === id);
|
|
492
|
+
now = view.box && String(view.box.instance_id) === id ? view.box : null;
|
|
406
493
|
} catch (e) { break; }
|
|
407
494
|
if (!now) break;
|
|
408
495
|
// Settled two ways, and BOTH need the intent to be closed: served_version can
|
|
@@ -419,21 +506,18 @@
|
|
|
419
506
|
if (!settled) toast(`Still working on core ${to}. It keeps going without this page — reload to see where it got to.`);
|
|
420
507
|
}
|
|
421
508
|
|
|
422
|
-
// Re-read
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
// the one thing worse than an extra request.
|
|
509
|
+
// Re-read the box. Its own endpoint, never the full load(): the self row and a scoped
|
|
510
|
+
// project are read from different places, and load() also re-resolves the scope. A
|
|
511
|
+
// failure here falls back to the full read, because a stale row on a deploy page is the
|
|
512
|
+
// one thing worse than an extra request.
|
|
427
513
|
// `keepStep` is for the pre-flight poll (task 1004121): a refresh AFTER a move should
|
|
428
514
|
// return the row to step one, but a refresh DURING the wait must not — it would close
|
|
429
515
|
// the panel the owner is reading, under them, every two seconds.
|
|
430
516
|
async function refreshOne(p, { keepStep = false } = {}) {
|
|
431
517
|
const id = String(p.instance_id);
|
|
432
|
-
const isSelf = view.self && String(view.self.instance_id) === id;
|
|
433
518
|
try {
|
|
434
|
-
const d = await fetchJson(isSelf ? '/provisioning/control-plane' : `/provisioning/instances/${id}/core-upgrade`);
|
|
435
|
-
|
|
436
|
-
else view.boxes = view.boxes.map((b) => (String(b.instance_id) === id ? d.preview : b));
|
|
519
|
+
const d = await fetchJson(view.isSelf ? '/provisioning/control-plane' : `/provisioning/instances/${id}/core-upgrade`);
|
|
520
|
+
view.box = d.preview;
|
|
437
521
|
if (!keepStep) steps.set(p.instance_id, 'preview');
|
|
438
522
|
repaint();
|
|
439
523
|
} catch (e) {
|
|
@@ -442,68 +526,73 @@
|
|
|
442
526
|
}
|
|
443
527
|
}
|
|
444
528
|
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
|
|
529
|
+
// Which project is this page about? The hall's own unless a scope was asked for.
|
|
530
|
+
//
|
|
531
|
+
// READ OFF `location`, DEFENSIVELY. The page runs in a vm harness whose `window.location`
|
|
532
|
+
// is a stub with no `search`, and a page that threw there would be untestable — but the
|
|
533
|
+
// deeper reason is that a missing scope is the ORDINARY case and must cost nothing.
|
|
534
|
+
function scopeSlug() {
|
|
535
|
+
try {
|
|
536
|
+
const q = String((window.location && window.location.search) || '');
|
|
537
|
+
const m = /[?&]project=([^&#]+)/.exec(q);
|
|
538
|
+
return m ? decodeURIComponent(m[1]) : null;
|
|
539
|
+
} catch (e) { return null; }
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Resolve a scoped slug to the instance the control plane knows by that name.
|
|
543
|
+
//
|
|
544
|
+
// `/provisioning/fleet` is the whole roster and needs its own atom; a holder of
|
|
545
|
+
// core.pin.move who lacks it falls back to their OWN projects, which is the set a
|
|
546
|
+
// project's owner is arriving from anyway. This is a LOOKUP, not a listing: exactly one
|
|
547
|
+
// row survives it, and the pin move is gated server-side per instance regardless — the
|
|
548
|
+
// slug decides what is looked at, never what is allowed.
|
|
549
|
+
async function findBySlug(slug) {
|
|
550
|
+
let rows = [];
|
|
450
551
|
try {
|
|
451
|
-
|
|
452
|
-
return d.instances || [];
|
|
552
|
+
rows = (await fetchJson('/provisioning/fleet')).instances || [];
|
|
453
553
|
} catch (e) {
|
|
454
|
-
if (e && e.kind
|
|
455
|
-
|
|
456
|
-
return d.instances || [];
|
|
457
|
-
}
|
|
458
|
-
throw e;
|
|
554
|
+
if (e && e.kind !== '403') throw e;
|
|
555
|
+
rows = (await fetchJson('/provisioning/instances')).instances || [];
|
|
459
556
|
}
|
|
557
|
+
return rows.find((i) => i && String(i.slug) === slug) || null;
|
|
460
558
|
}
|
|
461
559
|
|
|
462
|
-
// Only a RUNNING box can have its pin moved, and the runner refuses every other shape.
|
|
463
|
-
// Filtering here keeps torn-down rows — there are far more of them than live ones — off
|
|
464
|
-
// a page whose whole job is naming what is live.
|
|
465
|
-
const UPGRADEABLE = new Set(['standalone', 'co-tenant']);
|
|
466
|
-
const isLive = (i) => i && i.status === 'active' && UPGRADEABLE.has(i.hosting_shape);
|
|
467
|
-
|
|
468
560
|
async function load() {
|
|
561
|
+
const slug = scopeSlug();
|
|
469
562
|
try {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
} catch (e) {
|
|
476
|
-
if (e && e.kind === '401') return;
|
|
477
|
-
if (e && e.kind === '403') throw e;
|
|
478
|
-
notEnrolled = e.message || 'This platform has no deploy door yet.';
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
const live = (await hostedProjects()).filter(isLive);
|
|
482
|
-
// One preview per project, in parallel and INDEPENDENTLY: one project that cannot
|
|
483
|
-
// be read must not blank the page for the others. The request count scales with the
|
|
484
|
-
// fleet, which is deliberate and bounded — each is a cached server-side reading, not
|
|
485
|
-
// a live probe of the box, and this page exists precisely to show every box's state
|
|
486
|
-
// at once. It is re-read on OPEN only; a move refreshes just its own row.
|
|
487
|
-
const settled = await Promise.all(live.map(async (inst) => {
|
|
563
|
+
if (!slug) {
|
|
564
|
+
// THE HALL'S OWN PROJECT. On the control plane that is the self row, which is why
|
|
565
|
+
// this endpoint exists: the row is created once at install time and its id differs
|
|
566
|
+
// per install, so the page cannot be expected to know it.
|
|
567
|
+
view = { box: null, isSelf: true, name: 'This platform', notEnrolled: null, unreadable: null };
|
|
488
568
|
try {
|
|
489
|
-
|
|
490
|
-
|
|
569
|
+
view.box = (await fetchJson('/provisioning/control-plane')).preview;
|
|
570
|
+
view.name = (view.box && view.box.slug) || 'This platform';
|
|
491
571
|
} catch (e) {
|
|
492
|
-
if (e && e.kind === '401') return
|
|
493
|
-
|
|
572
|
+
if (e && e.kind === '401') return;
|
|
573
|
+
if (e && e.kind === '403') throw e;
|
|
574
|
+
view.notEnrolled = e.message || 'This platform has no deploy door yet.';
|
|
494
575
|
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
576
|
+
$('dp-sub').textContent = (view.box && view.box.slug) || 'This platform';
|
|
577
|
+
} else {
|
|
578
|
+
view = { box: null, isSelf: false, name: slug, notEnrolled: null, unreadable: null };
|
|
579
|
+
const inst = await findBySlug(slug);
|
|
580
|
+
if (!inst) {
|
|
581
|
+
// NAMED, NOT BLANK. A project the control plane cannot find is a wrong link or a
|
|
582
|
+
// torn-down project, and both are worth saying out loud — a deploy page that
|
|
583
|
+
// renders nothing reads as a project with nothing to deploy.
|
|
584
|
+
view.unreadable = 'no project by that name is hosted here.';
|
|
585
|
+
} else {
|
|
586
|
+
try {
|
|
587
|
+
view.box = (await fetchJson(`/provisioning/instances/${inst.id}/core-upgrade`)).preview;
|
|
588
|
+
} catch (e) {
|
|
589
|
+
if (e && e.kind === '401') return;
|
|
590
|
+
if (e && e.kind === '403') throw e;
|
|
591
|
+
view.unreadable = (e && e.message) || 'unknown error';
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
$('dp-sub').textContent = slug;
|
|
595
|
+
}
|
|
507
596
|
$('dp-body').hidden = false;
|
|
508
597
|
repaint();
|
|
509
598
|
} catch (e) {
|
|
@@ -128,6 +128,23 @@
|
|
|
128
128
|
overflow-x: auto;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/* What the move would BRING IN (task 1004173) — the ships' own summaries between the two
|
|
132
|
+
releases. It sits above the pre-flight because it is the question the owner asked
|
|
133
|
+
("I don't actually see what would change"); the verdict below it is the machinery's
|
|
134
|
+
answer to a different one. No mark of its own: this is the body of step two, not a
|
|
135
|
+
state, and a coloured bar here would compete with the three that DO mean something. */
|
|
136
|
+
.dp-changes { margin: 0 0 0.7rem; }
|
|
137
|
+
.dp-changes__list {
|
|
138
|
+
margin: 0.35rem 0 0;
|
|
139
|
+
padding-left: 1.1rem;
|
|
140
|
+
color: var(--ink-soft);
|
|
141
|
+
font-size: 0.86rem;
|
|
142
|
+
line-height: 1.55;
|
|
143
|
+
}
|
|
144
|
+
.dp-changes__list li { margin-bottom: 0.35rem; }
|
|
145
|
+
.dp-changes__more { color: var(--ink-soft); font-style: italic; }
|
|
146
|
+
.dp-changes--unknown { color: var(--ink-soft); }
|
|
147
|
+
|
|
131
148
|
/* A move in flight, and how it ended (task 1004145). The owner pressed Upgrade and got
|
|
132
149
|
a toast and then silence; these are the two states that silence was hiding. */
|
|
133
150
|
.dp-moving {
|
|
@@ -143,3 +160,22 @@
|
|
|
143
160
|
border-left: 3px solid var(--danger);
|
|
144
161
|
padding-left: 0.7rem;
|
|
145
162
|
}
|
|
163
|
+
/* A failure the box has already moved past (task 1004172). It keeps a mark, because it
|
|
164
|
+
is still a thing that happened and worth reading — but not the danger mark, which on
|
|
165
|
+
this page means "your project is in this state now". A two-day-old refusal wearing the
|
|
166
|
+
same red as a live one is how the owner read a solved problem as the current one. */
|
|
167
|
+
.dp-settled--past {
|
|
168
|
+
border-left: 3px solid var(--rule);
|
|
169
|
+
padding-left: 0.7rem;
|
|
170
|
+
color: var(--ink-soft);
|
|
171
|
+
}
|
|
172
|
+
/* The runner's own output, folded. It runs to thirty lines when the refusal is a dirty
|
|
173
|
+
tree (it prints the whole `git status`) and it addresses whoever holds a shell, which
|
|
174
|
+
the owner does not — so the sentence above it leads and this opens on request. */
|
|
175
|
+
.dp-settled__detail > summary {
|
|
176
|
+
cursor: pointer;
|
|
177
|
+
font-size: 0.82rem;
|
|
178
|
+
color: var(--ink-soft);
|
|
179
|
+
padding: 0.35rem 0;
|
|
180
|
+
min-height: 24px;
|
|
181
|
+
}
|
|
@@ -225,6 +225,60 @@ function makeCoreUpgradeHandler({ provisioning, pool, parseId, validateOrRespond
|
|
|
225
225
|
};
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Has the box moved on from the last move this control would report? PURE (task 1004172).
|
|
230
|
+
*
|
|
231
|
+
* WHY THIS QUESTION EXISTS. On 2026-09-22 the deploy page's most prominent element was:
|
|
232
|
+
* "The move to core 1.19.921 did not land — this project was put back on 1.19.969 and is
|
|
233
|
+
* running", with no date on it. That move was attempted two days earlier, the box had
|
|
234
|
+
* since reached 1.19.969 by a different route, and the cause it named (`sudo` with no
|
|
235
|
+
* `-u`) had been fixed and released in 1.19.928. Every word of it was history rendered as
|
|
236
|
+
* the present, and the owner read it, correctly, as the page having no feedback to give.
|
|
237
|
+
*
|
|
238
|
+
* THE TEST IS THE ONE `preflight` ALREADY USES, and deliberately so: a verdict about
|
|
239
|
+
* 1.19.920 tells the owner nothing true about pressing a button that says 1.19.972, and
|
|
240
|
+
* neither does a failure about it. So a move is superseded when the box now SERVES what
|
|
241
|
+
* that move was reaching for — it got there, by whatever road — or when the move on offer
|
|
242
|
+
* is a different one entirely.
|
|
243
|
+
*
|
|
244
|
+
* NO VERSION ORDERING IS COMPUTED, here or anywhere else in this file. "Is 1.19.969 newer
|
|
245
|
+
* than 1.19.921" is a question this layer does not need to ask to answer "is this news",
|
|
246
|
+
* and not asking it is what keeps the one comparison in the system (the runner's channel
|
|
247
|
+
* resolution) the only one that can be wrong.
|
|
248
|
+
*
|
|
249
|
+
* An unknown `recommended` — the registry could not be read — leaves a failure STANDING.
|
|
250
|
+
* Not knowing what is on offer is not evidence that the box recovered, and the direction
|
|
251
|
+
* to fail in is showing an old failure, never hiding a live one.
|
|
252
|
+
*/
|
|
253
|
+
function lastMoveSuperseded(target, served, recommended) {
|
|
254
|
+
if (!target) return false;
|
|
255
|
+
if (served && String(served) === String(target)) return true;
|
|
256
|
+
return !!(recommended && String(recommended) !== String(target));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The cached dry-run verdict, IF it is about the move now on offer. PURE (task 1004173).
|
|
261
|
+
*
|
|
262
|
+
* Two independent staleness questions, and they have different answers:
|
|
263
|
+
*
|
|
264
|
+
* • the VERDICT is about a target — a pass taken against 1.19.920 says nothing true
|
|
265
|
+
* about a button that reads 1.19.972, and a stale pass is worse than no answer
|
|
266
|
+
* because it reads as a guarantee. Withheld whole.
|
|
267
|
+
* • the CHANGE LIST is about a RANGE, so it also depends on where the box is starting
|
|
268
|
+
* from. Its `from` is the version that was live when the dry run ran; if the box has
|
|
269
|
+
* moved since, the list still describes a real stretch of history but not the one
|
|
270
|
+
* this move covers. Only the list is dropped — the verdict beside it is still true.
|
|
271
|
+
*
|
|
272
|
+
* Collapsing the two would mean either throwing away a good verdict or showing a range
|
|
273
|
+
* that starts somewhere the box no longer is.
|
|
274
|
+
*/
|
|
275
|
+
function preflightForOffer(pf, served, recommended) {
|
|
276
|
+
if (!pf || !recommended || pf.target !== recommended) return null;
|
|
277
|
+
const ch = pf.changes;
|
|
278
|
+
if (!ch || !ch.from || String(ch.from) === String(served || '')) return pf;
|
|
279
|
+
return { ...pf, changes: null };
|
|
280
|
+
}
|
|
281
|
+
|
|
228
282
|
/**
|
|
229
283
|
* What the control shows BEFORE anything is pressed — step one of the two-step (D4).
|
|
230
284
|
*
|
|
@@ -266,7 +320,7 @@ function previewFromSnapshot(inst, snap, openIntent, lastMove) {
|
|
|
266
320
|
// Withheld unless it was taken against the move now on offer: a verdict about
|
|
267
321
|
// 1.19.920 tells the owner nothing true about pressing a button that says 1.19.921,
|
|
268
322
|
// and a stale pass is worse than no answer because it reads as a guarantee.
|
|
269
|
-
preflight: pf
|
|
323
|
+
preflight: preflightForOffer(pf, served, recommended),
|
|
270
324
|
preflight_at: pf && recommended && pf.target === recommended ? (snap.preflight_at || null) : null,
|
|
271
325
|
open_intent: openIntent ? { action: openIntent.action, state: openIntent.state, target_version: openIntent.target_version || null } : null,
|
|
272
326
|
// THE LAST MOVE, WHATEVER BECAME OF IT (task 1004145).
|
|
@@ -284,6 +338,11 @@ function previewFromSnapshot(inst, snap, openIntent, lastMove) {
|
|
|
284
338
|
target_version: lastMove.target_version || null,
|
|
285
339
|
state: lastMove.state,
|
|
286
340
|
at: lastMove.resolved_at || lastMove.updated_at || null,
|
|
341
|
+
// Whether this is still news about the box's CURRENT state, or history the box has
|
|
342
|
+
// already moved past (task 1004172 — lastMoveSuperseded says why at length). The
|
|
343
|
+
// CLIENT must not work this out: the page's standing rule is that it compares no
|
|
344
|
+
// versions, and this is a version question wearing a different hat.
|
|
345
|
+
superseded: lastMoveSuperseded(lastMove.target_version, served, recommended),
|
|
287
346
|
// Truncated because it reaches a DOM and upgrade.js's refusals can carry a whole
|
|
288
347
|
// `git status` (the dirty-tree one lists every file).
|
|
289
348
|
error: lastMove.last_error ? String(lastMove.last_error).slice(0, 600) : null,
|
|
@@ -356,5 +415,5 @@ function makeControlPlaneHandler({ provisioning, pool, log }) {
|
|
|
356
415
|
};
|
|
357
416
|
}
|
|
358
417
|
|
|
359
|
-
module.exports = { CORE_UPGRADE_FROM, lastCoreMove, UPGRADE_SHAPES, VERSION_RE, isCoreVersion, coreUpgradeFault, enqueueCoreUpgrade, makeCoreUpgradeHandler, previewFromSnapshot, makeCoreUpgradePreviewHandler, makeControlPlaneHandler };
|
|
418
|
+
module.exports = { CORE_UPGRADE_FROM, lastCoreMove, lastMoveSuperseded, preflightForOffer, UPGRADE_SHAPES, VERSION_RE, isCoreVersion, coreUpgradeFault, enqueueCoreUpgrade, makeCoreUpgradeHandler, previewFromSnapshot, makeCoreUpgradePreviewHandler, makeControlPlaneHandler };
|
|
360
419
|
|
|
@@ -68,6 +68,6 @@
|
|
|
68
68
|
</section>
|
|
69
69
|
</main>
|
|
70
70
|
|
|
71
|
-
<footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on Cloud Bongos. <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">
|
|
71
|
+
<footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on Cloud Bongos. <a href="https://www.npmjs.com/package/@bongos/core" target="_blank" rel="noopener">Open source</a> (<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0</a>).<nav class="footNav" aria-label="Site"><a href="/privacy">Privacy</a><a href="/terms">Terms</a><a href="/contact" aria-current="page">Contact</a></nav></footer>
|
|
72
72
|
</body>
|
|
73
73
|
</html>
|
|
@@ -897,7 +897,7 @@ composition A; landing composition v3, picked by the owner 2026-08-27. No roll,
|
|
|
897
897
|
|
|
898
898
|
<footer>
|
|
899
899
|
<div class="footrow">
|
|
900
|
-
<p><!--apex-only:start-->A non-profit, capture-resistant commons. <!--apex-only:end-->Built by agents, on Cloud Bongos. <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">
|
|
900
|
+
<p><!--apex-only:start-->A non-profit, capture-resistant commons. <!--apex-only:end-->Built by agents, on Cloud Bongos. <a href="https://www.npmjs.com/package/@bongos/core" target="_blank" rel="noopener">Open source</a> (<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0</a>).</p>
|
|
901
901
|
<a href="#about">About</a>
|
|
902
902
|
<a href="/docs">Docs</a>
|
|
903
903
|
<a href="/privacy">Privacy</a>
|
|
@@ -77,6 +77,6 @@
|
|
|
77
77
|
</section>
|
|
78
78
|
</main>
|
|
79
79
|
|
|
80
|
-
<footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on Cloud Bongos. <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">
|
|
80
|
+
<footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on Cloud Bongos. <a href="https://www.npmjs.com/package/@bongos/core" target="_blank" rel="noopener">Open source</a> (<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0</a>).<nav class="footNav" aria-label="Site"><a href="/privacy" aria-current="page">Privacy</a><a href="/terms">Terms</a><a href="/contact">Contact</a></nav></footer>
|
|
81
81
|
</body>
|
|
82
82
|
</html>
|
|
@@ -1407,7 +1407,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1407
1407
|
|
|
1408
1408
|
</main>
|
|
1409
1409
|
|
|
1410
|
-
<footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on <span data-brand-product-inline>Cloud Bongos</span>. <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">
|
|
1410
|
+
<footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on <span data-brand-product-inline>Cloud Bongos</span>. <a href="https://www.npmjs.com/package/@bongos/core" target="_blank" rel="noopener">Open source</a> (<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0</a>).<nav class="footNav" aria-label="Site"><a href="/#about">About</a><a href="/docs">Docs</a><a href="/privacy">Privacy</a><a href="/terms">Terms</a><a href="/contact">Contact</a><!--apex-only:start--><a data-link-hall href="{{buildersOrigin}}">Builders' hall</a><!--apex-only:end--></nav></footer>
|
|
1411
1411
|
|
|
1412
1412
|
<script>
|
|
1413
1413
|
/* Brand-pack fill (R57): the server injects window.__BRANDING__ ahead of this
|