@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.
Files changed (77) hide show
  1. package/.bongos-core.json +116 -76
  2. package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
  3. package/docs/adr/0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md +1 -1
  4. package/docs/adr/0325-the-managed-fleet-control-plane-is-the-hub-we-already-run.md +1 -1
  5. package/docs/adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md +107 -0
  6. package/docs/adr/README.md +1 -0
  7. package/docs/api/openapi.json +1 -1
  8. package/docs/api-reference.md +1 -1
  9. package/docs/architecture.md +6 -6
  10. package/docs/branding-contract.md +1 -1
  11. package/docs/canonical-permissions.md +24 -24
  12. package/docs/copy-inventory.md +193 -187
  13. package/docs/copy-registry.json +320 -276
  14. package/docs/design/projects-hub-direction-v2.md +1 -1
  15. package/docs/design/vanilla-hall-ui-redesign-scope.md +2 -2
  16. package/docs/file-map.md +7 -7
  17. package/docs/handoff-template.md +1 -1
  18. package/docs/module-api-changelog.md +4 -0
  19. package/docs/modules-contract.md +1 -1
  20. package/docs/onboarding/drachmae-and-karma.md +2 -2
  21. package/docs/packs/engineer.md +5 -5
  22. package/docs/packs/ideator.md +1 -1
  23. package/docs/recipes/README.md +1 -1
  24. package/docs/recipes/audit-replay.md +2 -2
  25. package/docs/recipes/gds-db-backup.md +3 -3
  26. package/docs/recipes/managed-settings-remote-control.md +9 -9
  27. package/docs/recipes/memory-sync-conflicts.md +2 -2
  28. package/docs/recipes/ops-gotchas.md +24 -5
  29. package/docs/recipes/overnight-builder.md +3 -3
  30. package/docs/recipes/parallel-goal-waves.md +3 -3
  31. package/docs/recipes/reranking-a-goals-board.md +2 -2
  32. package/docs/recipes/search-index-ingestion.md +1 -1
  33. package/docs/recipes/ship-and-regrade.md +2 -2
  34. package/docs/recipes/staging-environment.md +1 -1
  35. package/docs/recipes/windows-builders.md +1 -1
  36. package/modules/hall-ui/public/deploy.html +10 -5
  37. package/modules/hall-ui/public/deploy.js +199 -110
  38. package/modules/hall-ui/public/goals-page.js +6 -0
  39. package/modules/hall-ui/public/goals-render.js +16 -0
  40. package/modules/hall-ui/public/mine-lens.js +172 -0
  41. package/modules/hall-ui/public/project-settings.css +36 -0
  42. package/modules/hall-ui/public/work-summary.js +90 -0
  43. package/modules/hall-ui/public/work.css +50 -0
  44. package/modules/hall-ui/public/work.html +24 -0
  45. package/modules/hall-ui/public/work.js +38 -51
  46. package/modules/lifecycle/db-goals.js +20 -3
  47. package/modules/lifecycle/routes/goals.js +2 -2
  48. package/modules/provisioning/core-upgrade.js +61 -2
  49. package/modules/public-landing/public/contact.html +1 -1
  50. package/modules/public-landing/public/index.html +1 -1
  51. package/modules/public-landing/public/privacy.html +1 -1
  52. package/modules/public-landing/public/projects.html +1 -1
  53. package/modules/public-landing/public/terms.html +1 -1
  54. package/package-lock.json +2 -2
  55. package/package.json +1 -1
  56. package/scripts/gds/api-alias-caller-scan.js +47 -9
  57. package/scripts/gds/cloudbongos-token-isolation.conf +33 -0
  58. package/scripts/gds/fitness.js +3 -0
  59. package/scripts/gds/provision-core-upgrade.js +132 -1
  60. package/scripts/gds/run-unit-tests.js +14 -9
  61. package/scripts/gds/scope-roster-guard.js +118 -0
  62. package/scripts/hall-preview/server.js +5 -0
  63. package/src/bongos/module-scope-map.js +1 -0
  64. package/src/module-api.js +1 -1
  65. package/tests/api_alias_caller_ratchet.mjs +68 -2
  66. package/tests/core_upgrade_door.mjs +81 -0
  67. package/tests/core_upgrade_runner.mjs +132 -0
  68. package/tests/deploy_page_projects.mjs +234 -79
  69. package/tests/fitness_scope_roster.mjs +117 -0
  70. package/tests/goal_mine_lens.mjs +233 -0
  71. package/tests/goal_tier.mjs +30 -4
  72. package/tests/hall_mine_lens.mjs +381 -0
  73. package/tests/idea_spark_hall.mjs +101 -12
  74. package/tests/known_failing_ratchet.mjs +19 -9
  75. package/tests/landing_legal_pages.mjs +5 -1
  76. package/tests/projects_hub_pre_uat.mjs +12 -6
  77. 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
- // WHY THE PROJECTS ARE HERE AND NOT IN THEIR OWN HALLS. The obvious shape — each owner
13
- // pressing Upgrade in their own project's hall — is not buildable: the upgrade route and
14
- // the intent queue belong to the PROVISIONING module, and a hosted project does not run
15
- // it. Probed 2026-09-18: /api/bongos/provisioning/health answers 200 here and 404 on
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
- // Nothing about the ENGINE had to change for this. UPGRADE_SHAPES already admitted
21
- // 'co-tenant' and 'standalone' alongside 'control-plane', coreUpgradeInstance already
22
- // resolved each shape's own checkout and built that box's own health and version URLs,
23
- // and the preview route already took any instance id. Only this page was single-row.
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. With several boxes on one page the step is held
38
- // PER BOX, and each button's handler CLOSES OVER the preview object it was built from —
39
- // so the version sent is the one that box displayed, and a repaint cannot swap it. A
40
- // page-wide "currently previewing" would be a wrong-box press waiting to happen.
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
- return `${Math.round(mins / 60)}h ago`;
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
- — this project was put back on ${escapeHtml(String(p.served_version))} and is running.</p>
158
- ${m.error ? `<pre class="dp-preflight__error">${escapeHtml(String(m.error))}</pre>` : ''}
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
- return `<p class="ov-row__sub dp-preflight dp-preflight--checking">Checking what this would do…</p>`;
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 = (view.self && String(view.self.instance_id) === id)
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
- // A project whose preview could not be read at all. It is LISTED rather than dropped:
319
- // a box silently missing from a deploy page reads as a box that does not need
320
- // deploying, which is the one wrong impression this page must never give.
321
- function unreadableHtml(inst, message) {
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(inst.slug))}</span>
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 self row (or null), the readable project
342
- // previews, and the ones that would not read. Held so a repaint after a click does not
343
- // have to re-fetch everything.
344
- let view = { self: null, boxes: [], unreadable: [], notEnrolled: null };
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
- const items = [];
348
- if (view.self) items.push(boxHtml(view.self, true));
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
- items.push(`<li class="ov-row"><div class="ov-row__main">
353
- <span class="ov-row__title">This platform</span>
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
- for (const p of view.boxes) items.push(boxHtml(p, false));
358
- for (const u of view.unreadable) items.push(unreadableHtml(u.inst, u.message));
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">${items.join('')}</ul>
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.self) wire(view.self);
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 = (view.self && String(view.self.instance_id) === id)
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 ONLY the box that just changed. The full load() fans out one probe per
423
- // hosted project, and firing all of them to refresh one row is cache invalidation
424
- // far wider than the change — it also re-probes boxes the owner never touched. A
425
- // failure here falls back to the full read, because a stale row on a deploy page is
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
- if (isSelf) view.self = d.preview;
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
- // The projects this control plane hosts. `/provisioning/fleet` is the whole roster and
446
- // needs its own atom; a holder of core.pin.move who lacks it still gets their OWN
447
- // projects rather than an error, because the pin move itself is gated server-side per
448
- // instance either way — this choice only decides what the page can LIST.
449
- async function hostedProjects() {
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
- const d = await fetchJson('/provisioning/fleet');
452
- return d.instances || [];
552
+ rows = (await fetchJson('/provisioning/fleet')).instances || [];
453
553
  } catch (e) {
454
- if (e && e.kind === '403') {
455
- const d = await fetchJson('/provisioning/instances');
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
- let self = null;
471
- let notEnrolled = null;
472
- try {
473
- const d = await fetchJson('/provisioning/control-plane');
474
- self = d.preview;
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
- const d = await fetchJson(`/provisioning/instances/${inst.id}/core-upgrade`);
490
- return { ok: true, preview: d.preview };
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 { ok: false, inst, message: 'not signed in' };
493
- return { ok: false, inst, message: (e && e.message) || 'unknown error' };
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
- view = {
498
- self,
499
- notEnrolled,
500
- boxes: settled.filter((s) => s.ok).map((s) => s.preview),
501
- unreadable: settled.filter((s) => !s.ok).map((s) => ({ inst: s.inst, message: s.message })),
502
- };
503
- const n = view.boxes.length + view.unreadable.length;
504
- $('dp-sub').textContent = n
505
- ? `This platform and ${n} hosted project${n === 1 ? '' : 's'}`
506
- : 'This platform';
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