@bongos/core 1.19.971 → 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 +116 -76
- 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/api/openapi.json +1 -1
- package/docs/api-reference.md +1 -1
- 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 +193 -187
- package/docs/copy-registry.json +320 -276
- 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 +4 -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/goals-page.js +6 -0
- package/modules/hall-ui/public/goals-render.js +16 -0
- package/modules/hall-ui/public/mine-lens.js +172 -0
- package/modules/hall-ui/public/project-settings.css +36 -0
- package/modules/hall-ui/public/work-summary.js +90 -0
- package/modules/hall-ui/public/work.css +50 -0
- package/modules/hall-ui/public/work.html +24 -0
- package/modules/hall-ui/public/work.js +38 -51
- package/modules/lifecycle/db-goals.js +20 -3
- package/modules/lifecycle/routes/goals.js +2 -2
- 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/fitness.js +3 -0
- package/scripts/gds/provision-core-upgrade.js +132 -1
- package/scripts/gds/run-unit-tests.js +14 -9
- package/scripts/gds/scope-roster-guard.js +118 -0
- package/scripts/hall-preview/server.js +5 -0
- package/src/bongos/module-scope-map.js +1 -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/fitness_scope_roster.mjs +117 -0
- package/tests/goal_mine_lens.mjs +233 -0
- package/tests/goal_tier.mjs +30 -4
- package/tests/hall_mine_lens.mjs +381 -0
- 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) {
|
|
@@ -1052,6 +1052,12 @@
|
|
|
1052
1052
|
// ADR 0174: the craft axis again, scoped to the tasks inside one goal.
|
|
1053
1053
|
{ id: 'f-goal-discipline', label: `Task ${window.OTB.roleAxisLabel().toLowerCase()}`, options: window.OTB.roleOptions({ unset: true }) }, // ADR 0174
|
|
1054
1054
|
{ id: 'f-goal-newcomer', label: 'Newcomer', options: [{ value: '', label: '— any —' }, { value: 'yes', label: 'newcomer-friendly only' }] },
|
|
1055
|
+
// The board's "Only mine" lens, scoped to one goal. Inside a goal the
|
|
1056
|
+
// membership half of that question is already answered (you are looking at
|
|
1057
|
+
// one goal, and the page says whether you are on it), so what is left is the
|
|
1058
|
+
// claim half: of the work queued here, what am I holding? Answered from
|
|
1059
|
+
// state.claimsByTask, which the detail already loads for the Release button.
|
|
1060
|
+
{ id: 'f-goal-claimed', label: 'Mine', options: [{ value: '', label: '— anyone\'s —' }, { value: 'mine', label: 'claimed by me' }] },
|
|
1055
1061
|
{ id: 'f-goal-fit', label: 'Run mode', options: [{ value: '', label: '— any —' },
|
|
1056
1062
|
{ value: 'parallel', label: 'parallel-safe only' },
|
|
1057
1063
|
{ value: 'conflict', label: 'has file conflicts' }] },
|
|
@@ -111,7 +111,14 @@
|
|
|
111
111
|
|| !!(state.disciplines && state.disciplines.get(String(g.id))?.has(role));
|
|
112
112
|
const matchesStale = (g) => !staleDays || isStale(goalActivityMs(g), staleDays);
|
|
113
113
|
const myId = selfId();
|
|
114
|
+
// GET /goals now answers this on the row itself (`viewer_is_member`, an EXISTS
|
|
115
|
+
// over goal_members for the AUTHENTICATED reader), which is what this lens
|
|
116
|
+
// reads first. The per-goal detail is kept as the fallback, and the order
|
|
117
|
+
// matters: the detail prefetch is a best-effort fan-out, so on a slow or
|
|
118
|
+
// partly-failed boot the lens used to return NOTHING and say, in effect, "you
|
|
119
|
+
// are on no goals" — a claim about the reader, made out of a failed fetch.
|
|
114
120
|
const iAmMember = (g) => {
|
|
121
|
+
if (typeof g.viewer_is_member === 'boolean') return g.viewer_is_member;
|
|
115
122
|
const d = state.details.get(String(g.id));
|
|
116
123
|
return d ? (d.members || []).some((m) => sameId(m.builder_id, myId)) : false;
|
|
117
124
|
};
|
|
@@ -505,12 +512,21 @@
|
|
|
505
512
|
const kFilter = $('#f-goal-kind')?.value || '';
|
|
506
513
|
const dFilter = $('#f-goal-discipline')?.value || '';
|
|
507
514
|
const nFilter = $('#f-goal-newcomer')?.value || '';
|
|
515
|
+
// "Mine" inside a goal = the claims this builder is holding here. Signed out
|
|
516
|
+
// there is no answer, so the lens is inert rather than emptying the list.
|
|
517
|
+
const myId = selfId();
|
|
518
|
+
const mineFilter = (myId != null && $('#f-goal-claimed')?.value === 'mine');
|
|
519
|
+
const claimedByMe = (t) => {
|
|
520
|
+
const c = state.claimsByTask.get(String(t.id));
|
|
521
|
+
return !!(c && sameId(c.builder_id, myId));
|
|
522
|
+
};
|
|
508
523
|
const sortBy = $('#f-goal-sort')?.value || 'cpm';
|
|
509
524
|
let list = state.work.todo.filter((t) => {
|
|
510
525
|
if (!matchesTodoSearch(t, sQuery)) return false;
|
|
511
526
|
if (kFilter && (t.kind || window.OTB.KIND_UNSET) !== kFilter) return false;
|
|
512
527
|
if (dFilter && (t.discipline || window.OTB.ROLE_UNSET) !== dFilter) return false;
|
|
513
528
|
if (nFilter === 'yes' && !t.newcomer_friendly) return false;
|
|
529
|
+
if (mineFilter && !claimedByMe(t)) return false;
|
|
514
530
|
return true;
|
|
515
531
|
});
|
|
516
532
|
// Run-mode filter: parallel-safe (touches[] disjoint from the rest of the
|