@tokenoftrust/storefront-runner 1.4.0 → 1.4.1

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 (51) hide show
  1. package/apps/storefront/migrations-apps/0001_woozy_lyja.sql +22 -0
  2. package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
  3. package/apps/storefront/migrations-apps/meta/_journal.json +7 -0
  4. package/apps/storefront/package.json +0 -1
  5. package/apps/storefront/src/components/admin/AdminPublishTab.astro +1735 -132
  6. package/apps/storefront/src/lib/activity/alerts.ts +428 -0
  7. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
  8. package/apps/storefront/src/lib/activity/deployVersion.ts +127 -0
  9. package/apps/storefront/src/lib/activity/ingest.ts +188 -0
  10. package/apps/storefront/src/lib/activity/ingestAuth.ts +105 -0
  11. package/apps/storefront/src/lib/activity/killSwitch.ts +80 -0
  12. package/apps/storefront/src/lib/activity/query.ts +403 -0
  13. package/apps/storefront/src/lib/activity/recordActivity.ts +105 -0
  14. package/apps/storefront/src/lib/activity/store.ts +122 -0
  15. package/apps/storefront/src/lib/activity/uiActor.ts +150 -0
  16. package/apps/storefront/src/lib/activity/workerCommit.ts +70 -0
  17. package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +4 -0
  18. package/apps/storefront/src/lib/auth/route.ts +44 -4
  19. package/apps/storefront/src/lib/d1/schema-apps.ts +54 -0
  20. package/apps/storefront/src/lib/dev/vcBinding.ts +80 -0
  21. package/apps/storefront/src/lib/env.ts +51 -0
  22. package/apps/storefront/src/lib/publish/shipWorkspace.ts +15 -8
  23. package/apps/storefront/src/middleware/index.ts +17 -0
  24. package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
  25. package/apps/storefront/src/pages/admin.astro +154 -6
  26. package/apps/storefront/src/pages/api/activity.ts +137 -0
  27. package/apps/storefront/src/pages/api/admin/activity-alerts.ts +73 -0
  28. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +27 -1
  29. package/apps/storefront/src/pages/api/apps/admin/install.ts +26 -1
  30. package/apps/storefront/src/pages/api/apps/admin/resume.ts +24 -1
  31. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +24 -1
  32. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +25 -1
  33. package/apps/storefront/src/pages/api/apps/admin/update.ts +24 -1
  34. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +11 -0
  35. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +11 -0
  36. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +20 -0
  37. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +12 -0
  38. package/apps/storefront/src/pages/api/cache-purge.ts +11 -0
  39. package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -0
  40. package/apps/storefront/src/pages/auth/login.astro +5 -4
  41. package/apps/storefront/src/pages/cockpit.astro +40 -3
  42. package/package.json +1 -1
  43. package/packages/public-runtime/src/activity/README.md +146 -0
  44. package/packages/public-runtime/src/activity/catalog.ts +501 -0
  45. package/packages/public-runtime/src/activity/event.ts +168 -0
  46. package/packages/public-runtime/src/activity/index.ts +16 -0
  47. package/packages/public-runtime/src/activity/redaction.ts +263 -0
  48. package/packages/public-runtime/src/candidate-index.ts +13 -0
  49. package/packages/public-runtime/src/index.ts +6 -0
  50. package/apps/storefront/src/lib/webhooks/signing.ts +0 -29
  51. package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +0 -146
@@ -6,9 +6,10 @@
6
6
  *
7
7
  * - OPEN CHANGE QUEUE from GET /api/changes ({ current, changes }) with
8
8
  * per-change reconcile/compliance state, preview link, head sha, and age.
9
- * - GO LIVE (POST /api/changes/accept with expectedHeadSha the stale-head
10
- * "superseded" refusal and the audit record are surfaced legibly) and
11
- * REJECT (with a note) per change.
9
+ * - ACCEPT TO PREVIEW (POST /api/changes/integrateb11's accept-to-preview
10
+ * split; advances the shared `preview` aggregate, does NOT go live) and
11
+ * REJECT (with a note) per change. Going live is the aggregate card's
12
+ * exact-digest PUBLISH (POST /api/changes/ship), never a per-change accept.
12
13
  * - LIVE STATE: the current live version (live-channel pointer) + the
13
14
  * static-republish dispatch status for static-publish tenants (fired /
14
15
  * skipped / sink-unbound notice).
@@ -78,7 +79,6 @@ const staticPublishEnabled = Boolean(
78
79
  const dispatchConfigured = Boolean((await readEnv("WWW_PUBLISH_DISPATCH_TOKEN"))?.trim());
79
80
 
80
81
  const changesEndpoint = withBase(basePath || "", "/api/changes");
81
- const acceptEndpoint = withBase(basePath || "", "/api/changes/accept");
82
82
  // b11 — the branch-integration lifecycle SPLIT of accept from publish:
83
83
  // - integrateEndpoint = b08's "accept = integrate into the protected preview
84
84
  // aggregate" (POST /api/changes/integrate → TenantIntegrationQueue.enqueue).
@@ -97,13 +97,25 @@ const shipEndpoint = withBase(basePath || "", "/api/changes/ship");
97
97
  // U7 — retire/GC: evict a candidate's ReviewEnvironment + version (rebuildable),
98
98
  // DISTINCT from reject (which closes the change). Gated server-side by retireGate.
99
99
  const retireEndpoint = withBase(basePath || "", "/api/preview/retire");
100
+ // rux5 — build-on-demand for a synthetic not-built PR row: materialize the PR's
101
+ // hosted preview (INERT — flips no live channel). Gated server-side by buildGate
102
+ // (the ship-on-behalf floor); the endpoint resolves the head sha via forge PR-read
103
+ // when the row supplies only the PR number.
104
+ const buildEndpoint = withBase(basePath || "", "/api/preview/build");
100
105
  // dg7 — the ship-operations workspace woven into this tab: versions/promotion
101
106
  // history, per-candidate diff, and rollback (through the ONE rollback
102
107
  // orchestrator). Read endpoints mirror the change-queue's auth/scope.
103
108
  const versionsEndpoint = withBase(basePath || "", "/api/changes/versions");
104
109
  const rollbackEndpoint = withBase(basePath || "", "/api/changes/rollback");
110
+ // b17 — branch retention: GET dry-run classification / POST guarded delete.
111
+ // DISTINCT endpoint from retireEndpoint above (branch delete vs artifact evict).
112
+ const branchesEndpoint = withBase(basePath || "", "/api/changes/branches");
105
113
  // The client builds `${diffEndpointBase}/<changeId>/diff` per candidate.
106
114
  const diffEndpointBase = changesEndpoint;
115
+ // The live store's root on THIS surface (basePath-aware): where the published
116
+ // digest actually serves. Rendered into "already live" copy + the publish
117
+ // ceremony's "View your live store" CTA.
118
+ const liveHref = withBase(basePath || "", "/");
107
119
  const domainStatusEndpoint = withBase(basePath || "", "/api/domain/status");
108
120
  const domainDispatchEndpoint = withBase(basePath || "", "/api/domain/dispatch");
109
121
  // u9: the machine-verifiable apex-cutover readiness gate — the SAME server gate the
@@ -120,18 +132,20 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
120
132
  data-agent-scope="publish"
121
133
  role="tabpanel"
122
134
  data-changes-endpoint={changesEndpoint}
123
- data-accept-endpoint={acceptEndpoint}
124
135
  data-integrate-endpoint={integrateEndpoint}
125
136
  data-ship-endpoint={shipEndpoint}
126
137
  data-retire-endpoint={retireEndpoint}
138
+ data-build-endpoint={buildEndpoint}
127
139
  data-versions-endpoint={versionsEndpoint}
128
140
  data-rollback-endpoint={rollbackEndpoint}
141
+ data-branches-endpoint={branchesEndpoint}
129
142
  data-diff-endpoint-base={diffEndpointBase}
130
143
  data-domain-status-endpoint={domainStatusEndpoint}
131
144
  data-domain-dispatch-endpoint={domainDispatchEndpoint}
132
145
  data-domain-readiness-endpoint={domainReadinessEndpoint}
133
146
  data-repo={tenant.appDomain}
134
147
  data-app-domain={tenant.appDomain}
148
+ data-live-href={liveHref}
135
149
  data-can-ship={viewerCanShip ? "true" : "false"}
136
150
  data-is-owner={viewerIsOwner ? "true" : "false"}
137
151
  data-entitled={goLiveEntitled ? "true" : "false"}
@@ -202,6 +216,7 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
202
216
  <span>Published</span>
203
217
  <strong data-publish-live-at>—</strong>
204
218
  <small>When the live version last changed</small>
219
+ <small data-publish-live-shipped-by></small>
205
220
  </div>
206
221
  <div class="metric">
207
222
  <span>Open changes</span>
@@ -232,6 +247,23 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
232
247
  </div>
233
248
  <div class="panel-body grid">
234
249
  <p class="note publish-status" data-publish-status role="status" aria-live="polite"></p>
250
+ <!-- rux1: forge-listing degraded strip. Shown ONLY when GET /api/changes
251
+ reports `degraded` (the open-PR listing failed) — distinct from the
252
+ genuine empty queue, so a forge blip never reads as "No open changes". -->
253
+ <div class="risk-alert" data-publish-degraded hidden role="alert">
254
+ <div class="risk-alert-main">
255
+ <svg><use href="#i-alert"></use></svg>
256
+ <div>
257
+ <b>Some open changes may not be shown</b>
258
+ <span>
259
+ We couldn't reach the pull-request list, so open changes that haven't been
260
+ built yet may be missing from the queue below. Built candidates are
261
+ unaffected. Refresh to try again.
262
+ </span>
263
+ </div>
264
+ </div>
265
+ </div>
266
+ <p class="note publish-pr-callout" data-publish-pr-callout hidden role="status" aria-live="polite"></p>
235
267
  <div class="table-wrap">
236
268
  <table class="publish-table">
237
269
  <thead>
@@ -357,6 +389,56 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
357
389
  </div>
358
390
  </div>
359
391
 
392
+ <!-- b17: branch retention — Admin visibility into the branch-lifecycle
393
+ contract's "Candidate branch retirement" table (P1 items 8/9/11). Reads
394
+ the guarded `candidate_list` classification (protected/active-open/
395
+ stale-open/integrated/closed-or-rejected/orphan) and, for a branch that
396
+ classifies as ready, runs the guarded `candidate_delete` — which
397
+ re-classifies fresh server-side and refuses anything not integrated or
398
+ closed-or-rejected, no matter what this panel last rendered. DISTINCT
399
+ from the "Retire" action in the candidate queue above: retire evicts a
400
+ hosted PREVIEW ARTIFACT (rebuildable); this panel deletes a Git BRANCH
401
+ (transport only, never the durable record — see the note below). An
402
+ orphan is always shown quarantined for review, never one-click deletable. -->
403
+ <div class="panel" data-publish-branches>
404
+ <div class="panel-head">
405
+ <h3><svg><use href="#i-workflows"></use></svg>Branch retention</h3>
406
+ <span class="chip" data-branches-chip>Loading</span>
407
+ </div>
408
+ <div class="panel-body grid">
409
+ <p class="note">
410
+ Cleans up candidate <b>branches</b> once their pull request is merged or
411
+ closed — a different action on a different object than <b>Retire</b> in
412
+ the queue above, which only evicts a hosted preview build (rebuildable
413
+ on demand, never a branch). Every classification below is a fresh
414
+ dry-run; deleting always re-checks eligibility on the server and asks
415
+ you to confirm before removing anything. Orphan branches (no PR record
416
+ at all) are quarantined for your review, never deleted automatically.
417
+ </p>
418
+ <p class="note publish-status" data-branches-status role="status" aria-live="polite"></p>
419
+ <div class="table-wrap">
420
+ <table class="publish-table">
421
+ <thead>
422
+ <tr>
423
+ <th>Branch</th>
424
+ <th>Classification</th>
425
+ <th>PR</th>
426
+ <th>Tip commit</th>
427
+ <th>Age</th>
428
+ <th>Evidence</th>
429
+ <th class="action">Actions</th>
430
+ </tr>
431
+ </thead>
432
+ <tbody data-branches-body>
433
+ <tr data-branches-placeholder>
434
+ <td colspan="7"><span class="subtle">Loading branch classification…</span></td>
435
+ </tr>
436
+ </tbody>
437
+ </table>
438
+ </div>
439
+ </div>
440
+ </div>
441
+
360
442
  {staticPublishEnabled && (
361
443
  <div class="panel" data-publish-domain>
362
444
  <div class="panel-head">
@@ -449,6 +531,121 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
449
531
  </div>
450
532
  </div>
451
533
  )}
534
+
535
+ <!-- Accept-to-preview LIFTOFF — the plan confirm + live integration flight +
536
+ outcome ceremony. One dialog, three phases (plan → flight → outcome).
537
+ The flight's stage rail advances ONLY on real signals (the in-flight POST
538
+ plus GET /api/changes polling — includedPrs / activeRun / aggregateSha);
539
+ nothing is simulated. Replaces the old window.confirm() plan popup. -->
540
+ <div class="liftoff-backdrop" data-liftoff hidden>
541
+ <div class="liftoff-card" role="dialog" aria-modal="true" aria-labelledby="liftoff-title" tabindex="-1">
542
+ <div class="liftoff-phase" data-liftoff-plan>
543
+ <p class="liftoff-kicker">Accept to preview</p>
544
+ <h3 class="liftoff-title" id="liftoff-title" data-liftoff-title>Integrate into preview</h3>
545
+ <dl class="liftoff-grid" data-liftoff-grid></dl>
546
+ <p class="note">
547
+ Integrates this candidate into the protected <code>preview</code> aggregate, then
548
+ rebuilds it and re-runs its combined evidence. <b>Nothing goes live</b> —
549
+ publishing the reviewed build stays a separate, explicit step.
550
+ </p>
551
+ <div class="liftoff-actions">
552
+ <button class="btn" type="button" data-liftoff-cancel>Cancel</button>
553
+ <button class="btn primary" type="button" data-liftoff-proceed>
554
+ <svg><use href="#i-check"></use></svg>Integrate into preview
555
+ </button>
556
+ </div>
557
+ </div>
558
+
559
+ <div class="liftoff-phase" data-liftoff-flight hidden>
560
+ <p class="liftoff-kicker">Integration in flight</p>
561
+ <h3 class="liftoff-title" data-liftoff-flight-title>Integrating…</h3>
562
+ <ol class="liftoff-stages">
563
+ <li data-liftoff-stage="merge">
564
+ <span class="liftoff-dot"></span>
565
+ <div><b>Merge</b><span>squash-merge the candidate into <code>preview</code></span></div>
566
+ </li>
567
+ <li data-liftoff-stage="build">
568
+ <span class="liftoff-dot"></span>
569
+ <div><b>Rebuild</b><span>build the combined preview aggregate</span></div>
570
+ </li>
571
+ <li data-liftoff-stage="evidence">
572
+ <span class="liftoff-dot"></span>
573
+ <div><b>Evidence</b><span>re-run the aggregate's combined checks</span></div>
574
+ </li>
575
+ <li data-liftoff-stage="green">
576
+ <span class="liftoff-dot"></span>
577
+ <div><b>Green</b><span>the aggregate becomes the reviewable, shippable build</span></div>
578
+ </li>
579
+ </ol>
580
+ <p class="liftoff-live" data-liftoff-live role="status" aria-live="polite">Working…</p>
581
+ <div class="liftoff-meta">
582
+ <span class="publish-mono" data-liftoff-elapsed>0:00</span>
583
+ <button class="btn" type="button" data-liftoff-hide>Continue in background</button>
584
+ </div>
585
+ </div>
586
+
587
+ <div class="liftoff-phase liftoff-outcome-phase" data-liftoff-outcome hidden>
588
+ <div class="liftoff-seal" data-liftoff-seal aria-hidden="true"></div>
589
+ <h3 class="liftoff-title" data-liftoff-outcome-title></h3>
590
+ <p class="note" data-liftoff-outcome-detail></p>
591
+ <div class="liftoff-actions">
592
+ <a class="btn" data-liftoff-review target="_blank" rel="noopener" hidden>Review the preview build</a>
593
+ <button class="btn primary" type="button" data-liftoff-done>Done</button>
594
+ </div>
595
+ </div>
596
+
597
+ <div class="liftoff-confetti" data-liftoff-confetti aria-hidden="true"></div>
598
+ </div>
599
+ </div>
600
+
601
+ <!-- Publish-live ceremony — the go-live counterpart of the accept liftoff
602
+ (same shell/styles). Plan states the exact digest + no-rebuild contract;
603
+ flight is a single honest in-flight state (one POST, no staged theater);
604
+ outcome celebrates with a real "View your live store" link. Replaces the
605
+ raw window.confirm() publish popup. -->
606
+ <div class="liftoff-backdrop" data-shipoff hidden>
607
+ <div class="liftoff-card" role="dialog" aria-modal="true" aria-labelledby="shipoff-title" tabindex="-1">
608
+ <div class="liftoff-phase" data-shipoff-plan>
609
+ <p class="liftoff-kicker">Publish live</p>
610
+ <h3 class="liftoff-title" id="shipoff-title">Publish the reviewed build to your live store</h3>
611
+ <dl class="liftoff-grid" data-shipoff-grid></dl>
612
+ <p class="note">
613
+ Publishes this <b>exact reviewed digest</b> — no rebuild, nothing rides along.
614
+ Your live store updates in about a second; rolling back re-publishes the prior
615
+ version from the archive.
616
+ </p>
617
+ <div class="liftoff-actions">
618
+ <button class="btn" type="button" data-shipoff-cancel>Cancel</button>
619
+ <button class="btn primary" type="button" data-shipoff-proceed>
620
+ <svg><use href="#i-export"></use></svg>Publish live
621
+ </button>
622
+ </div>
623
+ </div>
624
+
625
+ <div class="liftoff-phase" data-shipoff-flight hidden>
626
+ <p class="liftoff-kicker">Publishing</p>
627
+ <h3 class="liftoff-title">Promoting the reviewed digest…</h3>
628
+ <p class="liftoff-live" data-shipoff-live role="status" aria-live="polite">
629
+ Pinning the exact reviewed build to your live channel…
630
+ </p>
631
+ <div class="liftoff-meta">
632
+ <span class="publish-mono" data-shipoff-elapsed>0:00</span>
633
+ </div>
634
+ </div>
635
+
636
+ <div class="liftoff-phase liftoff-outcome-phase" data-shipoff-outcome hidden>
637
+ <div class="liftoff-seal" data-shipoff-seal aria-hidden="true"></div>
638
+ <h3 class="liftoff-title" data-shipoff-outcome-title></h3>
639
+ <p class="note" data-shipoff-outcome-detail></p>
640
+ <div class="liftoff-actions">
641
+ <a class="btn primary" data-shipoff-view target="_blank" rel="noopener" hidden>View your live store</a>
642
+ <button class="btn" type="button" data-shipoff-done>Done</button>
643
+ </div>
644
+ </div>
645
+
646
+ <div class="liftoff-confetti" data-shipoff-confetti aria-hidden="true"></div>
647
+ </div>
648
+ </div>
452
649
  </section>
453
650
 
454
651
  <script is:inline nonce={cspNonce}>
@@ -464,6 +661,21 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
464
661
  const queueBody = root.querySelector("[data-publish-queue-body]");
465
662
  const queueChip = root.querySelector("[data-publish-queue-chip]");
466
663
  const queueCount = root.querySelector("[data-publish-queue-count]");
664
+ const degradedStrip = root.querySelector("[data-publish-degraded]");
665
+
666
+ // rux7 — in-flight UI state the auto-refresh poll must preserve across a
667
+ // re-render. These are the AUTHORITATIVE source of truth (never scraped
668
+ // back out of the DOM): an action's async closure holds a reference to the
669
+ // OLD button, so after a poll swaps the row the completion signal lands on a
670
+ // detached node — reading `disabled`/`data-building` off the fresh DOM would
671
+ // wedge forever. buildPreview/integrate/reject/retire add on start and
672
+ // delete in `finally` (before their own loadQueue), so the next render
673
+ // reflects the true busy set. Open diff panes are the exception: the DOM IS
674
+ // their source of truth (toggleDiff owns open/close), so renderQueue detaches
675
+ // and re-attaches the actual pane node — see renderQueue.
676
+ const inflightBuilds = new Set(); // prNumber currently building
677
+ const busyChanges = new Set(); // changeId with an in-flight integrate/reject/retire
678
+ let aggregatePublishing = false; // the aggregate Publish POST is in flight
467
679
 
468
680
  const setStatus = (el, message, state) => {
469
681
  if (!el) return;
@@ -474,6 +686,14 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
474
686
 
475
687
  const shortSha = (sha) => (sha ? String(sha).slice(0, 8) : "—");
476
688
 
689
+ // Inline mirror of `candidatePrUrl` (lib/preview/candidateDashboard.ts) — an
690
+ // is:inline script can't import a module, so the pure URL shape is mirrored
691
+ // here (same pattern as `shortSha`). The friendly `/preview/<tenant>/pr/<N>`
692
+ // route handles every state (ready, not-built, …), so a row with a PR number
693
+ // always has a working preview link even when `previewUrl` is still null.
694
+ const candidatePrUrl = (prNumber) =>
695
+ prNumber != null ? "/preview/" + encodeURIComponent(appDomain) + "/pr/" + prNumber : null;
696
+
477
697
  const age = (iso) => {
478
698
  if (!iso) return "—";
479
699
  const ms = Date.now() - new Date(iso).getTime();
@@ -516,10 +736,67 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
516
736
  return el;
517
737
  };
518
738
 
739
+ // ---- ?pr=N deep link ---------------------------------------------------
740
+ // A deep link like /admin#publish?pr=42 asks us to surface a specific PR's
741
+ // row once the queue has loaded. The param rides in the hash fragment
742
+ // (after the tab anchor) because that's how the admin shell routes tabs;
743
+ // fall back to a real query string too. Resolve exactly once — so the
744
+ // scroll/highlight never re-fires on the reloads that follow every ship
745
+ // action — and until it resolves, keep an honest "not in the queue yet"
746
+ // callout up rather than silently doing nothing.
747
+
748
+ const readRequestedPr = () => {
749
+ const hash = window.location.hash || "";
750
+ const q = hash.indexOf("?");
751
+ let raw = new URLSearchParams(q >= 0 ? hash.slice(q + 1) : "").get("pr");
752
+ if (raw == null) raw = new URLSearchParams(window.location.search).get("pr");
753
+ if (raw == null) return null;
754
+ const n = Number.parseInt(raw, 10);
755
+ return Number.isInteger(n) && n > 0 ? n : null;
756
+ };
757
+
758
+ const requestedPr = readRequestedPr();
759
+ let prDeeplinkResolved = false;
760
+
761
+ const applyPrDeeplink = () => {
762
+ if (requestedPr == null || prDeeplinkResolved) return;
763
+ const callout = root.querySelector("[data-publish-pr-callout]");
764
+ const row = queueBody.querySelector('tr[data-pr-number="' + requestedPr + '"]');
765
+ if (!row) {
766
+ if (callout) {
767
+ callout.hidden = false;
768
+ callout.textContent = "PR #" + requestedPr + " isn't in the queue yet.";
769
+ }
770
+ return;
771
+ }
772
+ prDeeplinkResolved = true;
773
+ if (callout) {
774
+ callout.hidden = true;
775
+ callout.textContent = "";
776
+ }
777
+ row.scrollIntoView({ block: "center", behavior: "smooth" });
778
+ row.classList.remove("publish-row-highlight");
779
+ void row.offsetWidth; // restart the flash animation if re-applied
780
+ row.classList.add("publish-row-highlight");
781
+ window.setTimeout(() => row.classList.remove("publish-row-highlight"), 2600);
782
+ };
783
+
519
784
  // ---- Open change queue -------------------------------------------------
520
785
 
521
786
  const renderQueue = (payload) => {
522
787
  const changes = Array.isArray(payload.changes) ? payload.changes : [];
788
+ // rux1: surface a forge-listing failure as a visible strip — never let a
789
+ // degraded (short) list read as a genuine empty queue.
790
+ if (degradedStrip) degradedStrip.hidden = payload.degraded !== true;
791
+ // rux7: capture any open diff panes BEFORE wiping so a poll re-render
792
+ // re-attaches the exact node (with its already-fetched content) under the
793
+ // matching new row, instead of blowing the expanded pane away. Keyed by
794
+ // changeId (== the diff row's data-publish-diff-row). Wiping detaches them
795
+ // from the DOM but the Map keeps them alive for re-attachment.
796
+ const openDiffNodes = new Map();
797
+ queueBody.querySelectorAll("tr[data-publish-diff-row]").forEach((node) => {
798
+ openDiffNodes.set(node.dataset.publishDiffRow, node);
799
+ });
523
800
  queueBody.textContent = "";
524
801
  if (queueCount) queueCount.textContent = String(changes.length);
525
802
  if (queueChip) {
@@ -542,6 +819,7 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
542
819
 
543
820
  for (const change of changes) {
544
821
  const tr = document.createElement("tr");
822
+ if (change.prNumber != null) tr.dataset.prNumber = String(change.prNumber);
545
823
 
546
824
  // Change id + branch / PR + preview link.
547
825
  const idCell = document.createElement("td");
@@ -552,9 +830,22 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
552
830
  if (change.branch) meta.push(change.branch);
553
831
  if (change.prNumber != null) meta.push("PR #" + change.prNumber);
554
832
  if (meta.length) idCell.append(subtle(meta.join(" · ")));
555
- if (change.previewUrl) {
833
+ // Owner-facing "who did this" — accept takes priority over build (a
834
+ // candidate that's been accepted-to-preview is more meaningfully
835
+ // "acted on" than merely built). Reject never shows here: a rejected
836
+ // candidate is closed and has already left the open queue.
837
+ const actorRecord = change.actors && (change.actors.accept || change.actors.build);
838
+ if (actorRecord) {
839
+ const label = change.actors.accept ? "Accepted by " : "Built by ";
840
+ idCell.append(subtle(label + (actorRecord.name || actorRecord.email)));
841
+ }
842
+ // Prefer the synthesized friendly per-PR route (handles every state);
843
+ // fall back to any server-provided previewUrl for change-first rows
844
+ // that have no PR number yet.
845
+ const previewUrl = candidatePrUrl(change.prNumber) || change.previewUrl;
846
+ if (previewUrl) {
556
847
  const preview = document.createElement("a");
557
- preview.href = change.previewUrl;
848
+ preview.href = previewUrl;
558
849
  preview.target = "_blank";
559
850
  preview.rel = "noopener";
560
851
  preview.textContent = "Open preview";
@@ -595,14 +886,59 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
595
886
  // Actions.
596
887
  const actions = document.createElement("td");
597
888
  actions.className = "action publish-actions";
598
- // dg7 Diff: read-only, available to any viewer who can see the queue.
599
- const diffBtn = document.createElement("button");
600
- diffBtn.type = "button";
601
- diffBtn.className = "btn";
602
- diffBtn.textContent = "Diff";
603
- diffBtn.title = "Show what changed vs the live version";
604
- diffBtn.addEventListener("click", () => toggleDiff(change, diffBtn, tr));
605
- actions.append(diffBtn);
889
+
890
+ // rux5 a synthetic not-built forge PR row (built:false / status
891
+ // "not-built") has NO built candidate: changeId is null, so the built-only
892
+ // actions below (Diff/Accept/Reject/Retire, all keyed on changeId) do not
893
+ // apply. Its one action is Build preview — materialize the PR's hosted
894
+ // preview on demand. Capability-gated: the button is SHOWN only to a
895
+ // ship-capable operator, and the server (buildGate) re-authorizes the UI
896
+ // check is UX, not the real gate.
897
+ if (change.built === false || change.status === "not-built") {
898
+ if (canShip) {
899
+ const build = document.createElement("button");
900
+ build.type = "button";
901
+ build.className = "btn primary";
902
+ build.title = "Materialize this PR's hosted preview on demand (does not go live)";
903
+ // rux7: if a build for this PR is mid-flight, a poll re-render must
904
+ // reflect the spinner/disabled state (not a fresh clickable button
905
+ // that would let the operator double-submit). The in-flight closure
906
+ // re-renders on completion, clearing this.
907
+ if (change.prNumber != null && inflightBuilds.has(change.prNumber)) {
908
+ build.textContent = "Building…";
909
+ build.disabled = true;
910
+ build.dataset.building = "true";
911
+ } else {
912
+ build.textContent = "Build preview";
913
+ }
914
+ build.addEventListener("click", () => buildPreview(change, build));
915
+ actions.append(build);
916
+ } else {
917
+ actions.append(subtle("Owner/admin sign-in required to build"));
918
+ }
919
+ tr.append(actions);
920
+ queueBody.append(tr);
921
+ continue;
922
+ }
923
+
924
+ // rux7: while an integrate/reject/retire for this change is in flight,
925
+ // keep its mutating actions disabled across a poll re-render so the
926
+ // operator can't double-submit against a freshly-minted button.
927
+ const changeBusy = busyChanges.has(change.changeId);
928
+
929
+ // rux6 Review: expand the row into a single "review moment" pane —
930
+ // preview iframe + evidence chips + diff + Accept/Reject — so a reviewer
931
+ // never leaves the queue. Supersedes the standalone Diff button (the dg7
932
+ // diff now lives inside the pane, reusing renderDiff). The row's own
933
+ // Accept/Reject/Retire buttons below are unchanged.
934
+ const reviewBtn = document.createElement("button");
935
+ reviewBtn.type = "button";
936
+ reviewBtn.className = "btn";
937
+ reviewBtn.textContent = "Review";
938
+ reviewBtn.setAttribute("aria-expanded", "false");
939
+ reviewBtn.title = "Open one pane to preview, diff, and act on this change";
940
+ reviewBtn.addEventListener("click", () => toggleReview(change, reviewBtn, tr));
941
+ actions.append(reviewBtn);
606
942
  // b11 SPLIT: the candidate-queue primary action is now ACCEPT TO PREVIEW
607
943
  // (b08 integrate = advance the shared preview aggregate), NOT go-live.
608
944
  // Integrate flips NO live channel, so it has NO paywall (entitled) gate —
@@ -612,7 +948,7 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
612
948
  accept.type = "button";
613
949
  accept.className = "btn primary";
614
950
  accept.textContent = "Accept to preview";
615
- accept.disabled = !canShip || !change.mergeable;
951
+ accept.disabled = !canShip || !change.mergeable || changeBusy;
616
952
  accept.title = !canShip
617
953
  ? "Requires an owner or admin sign-in"
618
954
  : !change.mergeable
@@ -624,7 +960,7 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
624
960
  reject.type = "button";
625
961
  reject.className = "btn";
626
962
  reject.textContent = "Reject";
627
- reject.disabled = !canShip;
963
+ reject.disabled = !canShip || changeBusy;
628
964
  reject.title = canShip ? "Close this change without publishing" : "Requires an owner or admin sign-in";
629
965
  reject.addEventListener("click", () => rejectChange(change, reject));
630
966
  actions.append(reject);
@@ -637,7 +973,7 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
637
973
  retire.type = "button";
638
974
  retire.className = "btn";
639
975
  retire.textContent = "Retire";
640
- retire.disabled = !canShip;
976
+ retire.disabled = !canShip || changeBusy;
641
977
  retire.title = canShip
642
978
  ? "Evict this preview to reclaim space — rebuildable on demand (not the same as Reject)"
643
979
  : "Requires an owner or admin sign-in";
@@ -646,13 +982,24 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
646
982
  tr.append(actions);
647
983
 
648
984
  queueBody.append(tr);
985
+
986
+ // rux7: re-attach a diff pane that was open before this re-render. The
987
+ // pane node carries its already-fetched content; restore the expanded
988
+ // state + the "Hide diff" toggle affordance so a poll never collapses it.
989
+ const openDiff = openDiffNodes.get(change.changeId);
990
+ if (openDiff) {
991
+ tr.dataset.changeRowId = change.changeId;
992
+ diffBtn.textContent = "Hide diff";
993
+ tr.after(openDiff);
994
+ }
649
995
  }
650
996
  };
651
997
 
652
- const renderCurrent = (current) => {
998
+ const renderCurrent = (current, shippedBy) => {
653
999
  const versionEl = root.querySelector("[data-publish-live-version]");
654
1000
  const sourceEl = root.querySelector("[data-publish-live-source]");
655
1001
  const atEl = root.querySelector("[data-publish-live-at]");
1002
+ const shippedByEl = root.querySelector("[data-publish-live-shipped-by]");
656
1003
  if (current && current.versionId) {
657
1004
  if (versionEl) versionEl.textContent = shortSha(current.versionId);
658
1005
  if (sourceEl) {
@@ -661,11 +1008,131 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
661
1008
  (current.artifactCount != null ? " · " + current.artifactCount + " files" : "");
662
1009
  }
663
1010
  if (atEl) atEl.textContent = current.createdAt ? age(current.createdAt) : "—";
1011
+ // Owner-facing "who did this" — see changeActorAttribution.ts. Absent for
1012
+ // any version published before this attribution existed, which is honest,
1013
+ // not a bug.
1014
+ if (shippedByEl) {
1015
+ shippedByEl.textContent = shippedBy ? "Shipped by " + (shippedBy.name || shippedBy.email) : "";
1016
+ }
664
1017
  } else {
665
1018
  if (versionEl) versionEl.textContent = "Not live yet";
666
1019
  if (sourceEl) sourceEl.textContent = "Nothing has been published for this store";
667
1020
  if (atEl) atEl.textContent = "—";
1021
+ if (shippedByEl) shippedByEl.textContent = "";
1022
+ }
1023
+ };
1024
+
1025
+ // ---- rux7: auto-refresh (poll GET /api/changes) ------------------------
1026
+ // Keep the queue + aggregate live without a manual reload: poll on an
1027
+ // interval that BACKS OFF while nothing changes (grow the delay up to a cap)
1028
+ // and SNAPS BACK to the base the moment something does. Pause entirely while
1029
+ // the tab is hidden (document.hidden) — a backgrounded admin tab must not
1030
+ // hammer the endpoint — and refresh promptly on return. A poll re-render
1031
+ // reuses renderQueue/renderAggregate, which already preserve open diff panes
1032
+ // and in-flight actions (inflightBuilds / busyChanges / aggregatePublishing),
1033
+ // and applyPrDeeplink stays a one-time effect (prDeeplinkResolved), so the
1034
+ // ?pr scroll never re-fires on a tick.
1035
+ const POLL_BASE_MS = 8000;
1036
+ const POLL_MAX_MS = 60000;
1037
+ const POLL_GROWTH = 1.6;
1038
+ let pollDelay = POLL_BASE_MS;
1039
+ let pollTimer = null;
1040
+ let pollBusy = false;
1041
+ let lastChangesSignature = null;
1042
+
1043
+ // A compact projection of everything the queue/aggregate render VISIBLY off,
1044
+ // so a no-op poll (identical data) neither re-renders nor interrupts a text
1045
+ // selection — it just backs off. Relative ages are intentionally excluded;
1046
+ // they drift at most one backoff interval and refresh on any real change.
1047
+ const changesSignature = (data) => {
1048
+ const cur = data.current || {};
1049
+ const agg = data.aggregate || {};
1050
+ const run = agg.activeRun || agg.lastRun || {};
1051
+ const plan = data.plan || {};
1052
+ const rows = (Array.isArray(data.changes) ? data.changes : []).map((c) => [
1053
+ c.changeId,
1054
+ c.prNumber,
1055
+ c.status,
1056
+ c.headSha,
1057
+ c.mergeable,
1058
+ c.built,
1059
+ c.lastError || null,
1060
+ !!(c.evidence && c.evidence.promotable),
1061
+ c.actors ? Object.keys(c.actors).sort().join(",") : "",
1062
+ ]);
1063
+ return JSON.stringify({
1064
+ degraded: data.degraded === true,
1065
+ current: [cur.versionId || null, cur.createdAt || null, cur.artifactCount ?? null],
1066
+ shippedBy: (data.shippedBy && data.shippedBy.email) || null,
1067
+ aggregate: [
1068
+ agg.queueState || null,
1069
+ agg.aggregateSha || null,
1070
+ run.state || null,
1071
+ run.finishedAt || null,
1072
+ (agg.includedPrs || []).length,
1073
+ ],
1074
+ plan: [plan.ok === true, plan.pinnedSha || null, plan.artifactDigest || null, plan.alreadyShipped === true],
1075
+ rows,
1076
+ });
1077
+ };
1078
+
1079
+ const clearPoll = () => {
1080
+ if (pollTimer) {
1081
+ clearTimeout(pollTimer);
1082
+ pollTimer = null;
1083
+ }
1084
+ };
1085
+
1086
+ const schedulePoll = () => {
1087
+ clearPoll();
1088
+ if (document.hidden) return; // paused while hidden; visibilitychange resumes
1089
+ pollTimer = setTimeout(runPoll, pollDelay);
1090
+ };
1091
+
1092
+ // One poll tick: fetch, and only re-render when the projection actually
1093
+ // changed. Returns an outcome the caller uses to size the next interval.
1094
+ const pollChanges = async () => {
1095
+ let res;
1096
+ try {
1097
+ res = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
1098
+ } catch {
1099
+ return "error"; // network blip — keep the last good view, just back off
1100
+ }
1101
+ if (res.status === 401 || res.status === 403) return "auth"; // session gone
1102
+ const data = await res.json().catch(() => null);
1103
+ if (!res.ok || !data) return "error";
1104
+ const sig = changesSignature(data);
1105
+ if (sig === lastChangesSignature) return "unchanged";
1106
+ lastChangesSignature = sig;
1107
+ renderCurrent(data.current, data.shippedBy);
1108
+ renderQueue(data);
1109
+ applyPrDeeplink();
1110
+ renderAggregate(data);
1111
+ return "changed";
1112
+ };
1113
+
1114
+ const runPoll = async () => {
1115
+ pollTimer = null;
1116
+ if (document.hidden || pollBusy) {
1117
+ schedulePoll();
1118
+ return;
1119
+ }
1120
+ pollBusy = true;
1121
+ let outcome;
1122
+ try {
1123
+ outcome = await pollChanges();
1124
+ } finally {
1125
+ pollBusy = false;
1126
+ }
1127
+ if (outcome === "auth") {
1128
+ clearPoll(); // stop polling — a sign-in is required; a manual refresh retries
1129
+ return;
668
1130
  }
1131
+ pollDelay =
1132
+ outcome === "changed"
1133
+ ? POLL_BASE_MS
1134
+ : Math.min(POLL_MAX_MS, Math.round(pollDelay * POLL_GROWTH));
1135
+ schedulePoll();
669
1136
  };
670
1137
 
671
1138
  const loadQueue = async () => {
@@ -673,6 +1140,7 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
673
1140
  const res = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
674
1141
  if (res.status === 401 || res.status === 403) {
675
1142
  if (queueChip) queueChip.textContent = "Sign-in required";
1143
+ if (degradedStrip) degradedStrip.hidden = true;
676
1144
  queueBody.textContent = "";
677
1145
  const tr = document.createElement("tr");
678
1146
  const td = cell(subtle("Sign in as a store owner or admin to view the publish queue."));
@@ -686,9 +1154,16 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
686
1154
  setStatus(statusEl, data.error || "Could not load the change queue (" + res.status + ")", "err");
687
1155
  return;
688
1156
  }
689
- renderCurrent(data.current);
1157
+ renderCurrent(data.current, data.shippedBy);
690
1158
  renderQueue(data);
1159
+ applyPrDeeplink();
691
1160
  renderAggregate(data);
1161
+ // rux7: this is fresh authoritative data — resync the poll clock so the
1162
+ // next tick dedupes against it, and reset the cadence to responsive
1163
+ // (a manual refresh / a just-completed action likely means more coming).
1164
+ lastChangesSignature = changesSignature(data);
1165
+ pollDelay = POLL_BASE_MS;
1166
+ schedulePoll();
692
1167
  } catch {
693
1168
  setStatus(statusEl, "Could not load the change queue — network error", "err");
694
1169
  }
@@ -819,7 +1294,10 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
819
1294
  if (publishBtn) {
820
1295
  const shippable = Boolean(currentPlan);
821
1296
  const paywalled = currentPlan && currentPlan.paywall && !currentPlan.paywall.allowed;
822
- publishBtn.disabled = !canShip || !shippable || paywalled || currentPlan.alreadyShipped;
1297
+ // rux7: never re-enable the Publish button while its POST is in flight —
1298
+ // a poll refresh landing mid-publish must not open a double-submit.
1299
+ publishBtn.disabled =
1300
+ aggregatePublishing || !canShip || !shippable || paywalled || currentPlan.alreadyShipped;
823
1301
  publishBtn.title = !canShip
824
1302
  ? "Requires an owner or admin sign-in"
825
1303
  : !shippable
@@ -832,7 +1310,14 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
832
1310
  }
833
1311
  if (publishDetail) {
834
1312
  if (currentPlan && currentPlan.alreadyShipped) {
835
- publishDetail.textContent = "This reviewed build (" + shortSha(currentPlan.pinnedSha) + ") is already live.";
1313
+ publishDetail.textContent =
1314
+ "This reviewed build (" + shortSha(currentPlan.pinnedSha) + ") is already live. ";
1315
+ const view = document.createElement("a");
1316
+ view.href = ds.liveHref || "/";
1317
+ view.target = "_blank";
1318
+ view.rel = "noopener";
1319
+ view.textContent = "View your live store →";
1320
+ publishDetail.append(view);
836
1321
  } else if (currentPlan) {
837
1322
  publishDetail.textContent =
838
1323
  "Publishes the exact reviewed digest " + shortSha(currentPlan.artifactDigest.replace(/^sha256:/, "")) +
@@ -918,6 +1403,46 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
918
1403
  // pinned plan, then POSTs { confirmGoLive, expectedAggregateSha,
919
1404
  // expectedArtifactDigest } — the reviewed-digest guard: a stale plan (queue
920
1405
  // moved / artifact changed) is refused by the server, never shipped blind.
1406
+ // ── Publish-live ceremony (shipoff) ──────────────────────────────────────
1407
+ // The go-live counterpart of the accept liftoff below (shares its shell,
1408
+ // styles, planRow + celebrate helpers — referenced at runtime, defined a few
1409
+ // sections down). One honest in-flight state: ship is a single POST that
1410
+ // promotes the pinned digest, so no staged theater — just plan → publishing →
1411
+ // outcome with a real "View your live store" link.
1412
+ const shipoff = root.querySelector("[data-shipoff]");
1413
+ const shipoffCard = shipoff && shipoff.querySelector(".liftoff-card");
1414
+ const shipoffEls = shipoff
1415
+ ? {
1416
+ plan: shipoff.querySelector("[data-shipoff-plan]"),
1417
+ flight: shipoff.querySelector("[data-shipoff-flight]"),
1418
+ outcome: shipoff.querySelector("[data-shipoff-outcome]"),
1419
+ grid: shipoff.querySelector("[data-shipoff-grid]"),
1420
+ proceed: shipoff.querySelector("[data-shipoff-proceed]"),
1421
+ cancel: shipoff.querySelector("[data-shipoff-cancel]"),
1422
+ live: shipoff.querySelector("[data-shipoff-live]"),
1423
+ elapsed: shipoff.querySelector("[data-shipoff-elapsed]"),
1424
+ seal: shipoff.querySelector("[data-shipoff-seal]"),
1425
+ outcomeTitle: shipoff.querySelector("[data-shipoff-outcome-title]"),
1426
+ outcomeDetail: shipoff.querySelector("[data-shipoff-outcome-detail]"),
1427
+ view: shipoff.querySelector("[data-shipoff-view]"),
1428
+ done: shipoff.querySelector("[data-shipoff-done]"),
1429
+ confetti: shipoff.querySelector("[data-shipoff-confetti]"),
1430
+ }
1431
+ : null;
1432
+ let shipoffTimer = null;
1433
+ const closeShipoff = () => {
1434
+ if (shipoffTimer) { clearInterval(shipoffTimer); shipoffTimer = null; }
1435
+ if (shipoff) shipoff.hidden = true;
1436
+ if (shipoffEls) shipoffEls.confetti.textContent = "";
1437
+ };
1438
+ const showShipoffPhase = (phase) => {
1439
+ if (!shipoffEls) return;
1440
+ shipoffEls.plan.hidden = phase !== "plan";
1441
+ shipoffEls.flight.hidden = phase !== "flight";
1442
+ shipoffEls.outcome.hidden = phase !== "outcome";
1443
+ if (shipoffCard) shipoffCard.dataset.mode = phase;
1444
+ };
1445
+
921
1446
  const buildPublishPlanText = (plan) => {
922
1447
  const lines = ["Publish plan (exact reviewed digest — no rebuild):"];
923
1448
  lines.push(" tenant: " + (appDomain || repo));
@@ -929,12 +1454,62 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
929
1454
  return lines.join("\n");
930
1455
  };
931
1456
 
932
- const publishAggregate = async (button) => {
1457
+ const shortDigest = (digest) => {
1458
+ const d = String(digest || "").replace(/^sha256:/, "");
1459
+ return d ? "sha256:" + d.slice(0, 12) + "…" : "—";
1460
+ };
1461
+
1462
+ const publishAggregate = (button) => {
933
1463
  if (!currentPlan) return;
934
1464
  const plan = currentPlan;
935
- if (!window.confirm(buildPublishPlanText(plan) + "\n\nPublish live?")) return;
936
- button.disabled = true;
1465
+ if (!shipoff || !shipoffEls) {
1466
+ // No-markup fallback only — the ceremony IS the confirm now.
1467
+ if (!window.confirm(buildPublishPlanText(plan) + "\n\nPublish live?")) return;
1468
+ runShip(plan, button);
1469
+ return;
1470
+ }
1471
+ shipoffEls.grid.textContent = "";
1472
+ shipoffEls.grid.append(
1473
+ ...planRow("Store", appDomain || repo),
1474
+ ...planRow("Build", shortSha(plan.pinnedSha), true),
1475
+ ...planRow("Digest", shortDigest(plan.artifactDigest), true),
1476
+ ...planRow("Changes", (plan.includedPrs || []).length + " included"),
1477
+ ...(plan.rollbackTarget
1478
+ ? planRow("Rollback to", shortSha(plan.rollbackTarget.aggregateSha), true)
1479
+ : []),
1480
+ );
1481
+ showShipoffPhase("plan");
1482
+ shipoff.hidden = false;
1483
+ shipoffEls.proceed.focus();
1484
+ shipoffEls.cancel.onclick = () => closeShipoff();
1485
+ shipoffEls.done.onclick = () => closeShipoff();
1486
+ shipoffEls.proceed.onclick = () => runShip(plan, button);
1487
+ };
1488
+
1489
+ const runShip = async (plan, button) => {
1490
+ if (button) button.disabled = true;
1491
+ aggregatePublishing = true; // rux7: hold the button disabled across polls
937
1492
  setStatus(aggStatusEl, "Publishing the reviewed build (" + shortSha(plan.pinnedSha) + ") live…");
1493
+ const startedAt = Date.now();
1494
+ if (shipoffEls) {
1495
+ showShipoffPhase("flight");
1496
+ shipoffTimer = setInterval(() => {
1497
+ const s = Math.floor((Date.now() - startedAt) / 1000);
1498
+ shipoffEls.elapsed.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
1499
+ }, 1000);
1500
+ }
1501
+ const outcome = (state, title, detail, showView) => {
1502
+ if (!shipoffEls) return;
1503
+ if (shipoffTimer) { clearInterval(shipoffTimer); shipoffTimer = null; }
1504
+ shipoffEls.seal.dataset.state = state;
1505
+ shipoffEls.outcomeTitle.textContent = title;
1506
+ shipoffEls.outcomeDetail.textContent = detail;
1507
+ shipoffEls.view.hidden = !showView;
1508
+ if (showView) shipoffEls.view.href = ds.liveHref || "/";
1509
+ showShipoffPhase("outcome");
1510
+ shipoffEls.done.focus();
1511
+ if (state === "ok") celebrate(shipoffEls.confetti);
1512
+ };
938
1513
  try {
939
1514
  const res = await fetch(ds.shipEndpoint, {
940
1515
  method: "POST",
@@ -955,22 +1530,37 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
955
1530
  "Published live · build " + shortSha(data.pinnedSha) + " (receipt " + (data.receiptId || "—") + ").",
956
1531
  "ok",
957
1532
  );
958
- } else if (data.state === "promote_failed" || data.state === "record_failed") {
959
- // Honest partial-failure truth — recoverable, never a false "shipped".
960
- setStatus(aggStatusEl, data.message || "Publish did not complete (" + data.state + "). Retry.", "err");
961
- } else if (data.reason === "paywall") {
962
- setStatus(aggStatusEl, data.message || "Publishing to your live store is part of the paid plan.", "err");
963
- } else {
964
- setStatus(
965
- aggStatusEl,
966
- data.message || data.error || "Publish refused (" + (data.reason || res.status) + ")",
967
- "err",
1533
+ outcome(
1534
+ "ok",
1535
+ "Your store is live",
1536
+ "Build " + shortSha(data.pinnedSha) + " — the exact digest you reviewed — is now serving" +
1537
+ (data.receiptId ? " (receipt " + data.receiptId + ")." : "."),
1538
+ true,
968
1539
  );
1540
+ } else {
1541
+ let message;
1542
+ if (data.state === "promote_failed" || data.state === "record_failed") {
1543
+ // Honest partial-failure truth — recoverable, never a false "shipped".
1544
+ message = data.message || "Publish did not complete (" + data.state + "). Retry.";
1545
+ } else if (data.reason === "paywall") {
1546
+ message = data.message || "Publishing to your live store is part of the paid plan.";
1547
+ } else {
1548
+ message = data.message || data.error || "Publish refused (" + (data.reason || res.status) + ")";
1549
+ }
1550
+ setStatus(aggStatusEl, message, "err");
1551
+ outcome("err", "Publish did not complete", message + " Your live store is unchanged.", false);
969
1552
  }
970
1553
  } catch {
971
1554
  setStatus(aggStatusEl, "Publish failed — network error", "err");
1555
+ outcome(
1556
+ "err",
1557
+ "Publish did not complete",
1558
+ "Network error mid-publish — refresh to see the honest state; your live store serves the last published version.",
1559
+ false,
1560
+ );
972
1561
  } finally {
973
- button.disabled = false;
1562
+ aggregatePublishing = false;
1563
+ if (button) button.disabled = false;
974
1564
  loadQueue();
975
1565
  loadVersions();
976
1566
  }
@@ -981,35 +1571,207 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
981
1571
  ?.querySelector("[data-aggregate-publish]")
982
1572
  ?.addEventListener("click", (event) => publishAggregate(event.currentTarget));
983
1573
 
984
- // b11 — the ACCEPT-TO-PREVIEW plan affordance (decision
985
- // operator-verb-and-hosting-model): state EXACTLY what accept does now.
986
- // `POST /api/changes/integrate` (b08) = integrate this candidate into the
987
- // protected `preview` aggregate (b07's TenantIntegrationQueue.enqueue), then
988
- // rebuild + combined-evidence the aggregate (b05). It flips NO live channel —
989
- // going live is the aggregate card's Publish. So the plan says "integrate into
990
- // preview", never "merge into main / deploy live".
991
- const buildIntegratePlan = (change) => {
1574
+ // ── Accept-to-preview LIFTOFF ─────────────────────────────────────────────
1575
+ // The plan confirm + live integration flight + outcome ceremony (replaces
1576
+ // the old window.confirm plan popup). Same b11 contract as before: accept =
1577
+ // `POST /api/changes/integrate` (b08) → integrate into the protected
1578
+ // `preview` aggregate, rebuild + combined-evidence it (b05). Flips NO live
1579
+ // channel — going live is the aggregate card's Publish.
1580
+ //
1581
+ // HONESTY RULE for the stage rail: a stage only advances on a REAL signal —
1582
+ // the outcome POST resolving, or GET /api/changes polling showing the
1583
+ // candidate inside `aggregate.includedPrs` (merge landed) / an `activeRun`
1584
+ // (build+evidence running, statusMessage relayed verbatim). No timers ever
1585
+ // fake progress.
1586
+ const liftoff = root.querySelector("[data-liftoff]");
1587
+ const liftoffCard = liftoff && liftoff.querySelector(".liftoff-card");
1588
+ const liftoffEls = liftoff
1589
+ ? {
1590
+ plan: liftoff.querySelector("[data-liftoff-plan]"),
1591
+ flight: liftoff.querySelector("[data-liftoff-flight]"),
1592
+ outcome: liftoff.querySelector("[data-liftoff-outcome]"),
1593
+ title: liftoff.querySelector("[data-liftoff-title]"),
1594
+ grid: liftoff.querySelector("[data-liftoff-grid]"),
1595
+ proceed: liftoff.querySelector("[data-liftoff-proceed]"),
1596
+ cancel: liftoff.querySelector("[data-liftoff-cancel]"),
1597
+ flightTitle: liftoff.querySelector("[data-liftoff-flight-title]"),
1598
+ live: liftoff.querySelector("[data-liftoff-live]"),
1599
+ elapsed: liftoff.querySelector("[data-liftoff-elapsed]"),
1600
+ hide: liftoff.querySelector("[data-liftoff-hide]"),
1601
+ seal: liftoff.querySelector("[data-liftoff-seal]"),
1602
+ outcomeTitle: liftoff.querySelector("[data-liftoff-outcome-title]"),
1603
+ outcomeDetail: liftoff.querySelector("[data-liftoff-outcome-detail]"),
1604
+ review: liftoff.querySelector("[data-liftoff-review]"),
1605
+ done: liftoff.querySelector("[data-liftoff-done]"),
1606
+ confetti: liftoff.querySelector("[data-liftoff-confetti]"),
1607
+ }
1608
+ : null;
1609
+
1610
+ const liftoffStage = (name) =>
1611
+ liftoff && liftoff.querySelector('[data-liftoff-stage="' + name + '"]');
1612
+ const LIFTOFF_STAGES = ["merge", "build", "evidence", "green"];
1613
+ const setStage = (name, state) => {
1614
+ const el = liftoffStage(name);
1615
+ if (el) el.dataset.state = state; // pending | active | done | fail
1616
+ };
1617
+ // Advance the rail so everything before `name` is done and `name` is active.
1618
+ const railTo = (name) => {
1619
+ const idx = LIFTOFF_STAGES.indexOf(name);
1620
+ LIFTOFF_STAGES.forEach((s, i) => {
1621
+ const el = liftoffStage(s);
1622
+ const cur = el && el.dataset.state;
1623
+ if (cur === "fail") return; // never un-fail
1624
+ setStage(s, i < idx ? "done" : i === idx ? "active" : "pending");
1625
+ });
1626
+ };
1627
+
1628
+ let liftoffTimer = null;
1629
+ let liftoffPoll = null;
1630
+ let liftoffOpenedFrom = null;
1631
+ const stopLiftoffLoops = () => {
1632
+ if (liftoffTimer) { clearInterval(liftoffTimer); liftoffTimer = null; }
1633
+ if (liftoffPoll) { clearInterval(liftoffPoll); liftoffPoll = null; }
1634
+ };
1635
+ const closeLiftoff = () => {
1636
+ stopLiftoffLoops();
1637
+ if (liftoff) liftoff.hidden = true;
1638
+ if (liftoffEls) liftoffEls.confetti.textContent = "";
1639
+ if (liftoffOpenedFrom && document.contains(liftoffOpenedFrom)) liftoffOpenedFrom.focus();
1640
+ liftoffOpenedFrom = null;
1641
+ };
1642
+ const showPhase = (phase) => {
1643
+ if (!liftoffEls) return;
1644
+ liftoffEls.plan.hidden = phase !== "plan";
1645
+ liftoffEls.flight.hidden = phase !== "flight";
1646
+ liftoffEls.outcome.hidden = phase !== "outcome";
1647
+ if (liftoffCard) liftoffCard.dataset.mode = phase;
1648
+ };
1649
+
1650
+ const planRow = (dt, dd, mono) => {
1651
+ const t = document.createElement("dt");
1652
+ t.textContent = dt;
1653
+ const d = document.createElement("dd");
1654
+ d.textContent = dd;
1655
+ if (mono) d.className = "publish-mono";
1656
+ return [t, d];
1657
+ };
1658
+
1659
+ const celebrate = (container) => {
1660
+ if (!container) return;
1661
+ if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
1662
+ const colors = ["#0f9d8f", "#25b8a8", "#f4b942", "#5b8def", "#e2634f"];
1663
+ for (let i = 0; i < 42; i++) {
1664
+ const p = document.createElement("i");
1665
+ p.style.left = Math.random() * 100 + "%";
1666
+ p.style.background = colors[i % colors.length];
1667
+ p.style.animationDelay = Math.random() * 0.35 + "s";
1668
+ p.style.animationDuration = 0.9 + Math.random() * 0.9 + "s";
1669
+ p.style.setProperty("--liftoff-drift", (Math.random() * 160 - 80).toFixed(0) + "px");
1670
+ p.style.setProperty("--liftoff-spin", (Math.random() * 640 - 320).toFixed(0) + "deg");
1671
+ container.append(p);
1672
+ }
1673
+ setTimeout(() => { container.textContent = ""; }, 2400);
1674
+ };
1675
+
1676
+ // The live poll during flight: reflect the server's REAL progress.
1677
+ const startFlightPoll = (change, preFlightSha) => {
1678
+ let inAggregate = false;
1679
+ liftoffPoll = setInterval(async () => {
1680
+ try {
1681
+ const res = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
1682
+ if (!res.ok) return;
1683
+ const data = await res.json();
1684
+ const agg = data.aggregate || {};
1685
+ const included = (agg.includedPrs || []).some((p) => p.changeId === change.changeId);
1686
+ const shaMoved = agg.aggregateSha && agg.aggregateSha !== preFlightSha;
1687
+ const run = agg.activeRun || null;
1688
+ if (!inAggregate && (included || shaMoved)) {
1689
+ inAggregate = true;
1690
+ railTo("build");
1691
+ if (liftoffEls) liftoffEls.live.textContent =
1692
+ "Merged into preview — rebuilding the combined aggregate…";
1693
+ }
1694
+ if (run) {
1695
+ railTo("evidence");
1696
+ if (liftoffEls) liftoffEls.live.textContent =
1697
+ run.statusMessage || "Combined evidence is running…";
1698
+ }
1699
+ } catch { /* polling is best-effort; the POST outcome is authoritative */ }
1700
+ }, 2500);
1701
+ };
1702
+
1703
+ const openLiftoff = (change, button) => {
1704
+ if (!liftoff || !liftoffEls) return false;
1705
+ liftoffOpenedFrom = button || null;
992
1706
  const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
993
- const lines = ["Accept-to-preview plan:", " tenant: " + (appDomain || repo)];
994
- if (change.prNumber != null) lines.push(" PR: #" + change.prNumber);
995
- lines.push(" change id: " + change.changeId);
996
- if (change.headSha) lines.push(" head sha: " + shortSha(change.headSha));
997
- lines.push(
998
- " effect: integrate " + target + " into the preview aggregate, then rebuild + " +
999
- "re-evidence it. Does NOT go live publish the reviewed build separately.",
1707
+ liftoffEls.title.textContent = "Integrate " + target + " into preview";
1708
+ liftoffEls.grid.textContent = "";
1709
+ liftoffEls.grid.append(
1710
+ ...planRow("Store", appDomain || repo),
1711
+ ...(change.prNumber != null ? planRow("Pull request", "#" + change.prNumber) : []),
1712
+ ...planRow("Change", change.changeId, true),
1713
+ ...(change.headSha ? planRow("Head", shortSha(change.headSha), true) : []),
1000
1714
  );
1001
- return lines.join("\n");
1715
+ LIFTOFF_STAGES.forEach((s) => setStage(s, "pending"));
1716
+ liftoffEls.live.textContent = "Working…";
1717
+ liftoffEls.elapsed.textContent = "0:00";
1718
+ showPhase("plan");
1719
+ liftoff.hidden = false;
1720
+ liftoffEls.proceed.focus();
1721
+
1722
+ liftoffEls.cancel.onclick = () => closeLiftoff();
1723
+ liftoffEls.done.onclick = () => closeLiftoff();
1724
+ liftoffEls.hide.onclick = () => { liftoff.hidden = true; }; // flight keeps running
1725
+ liftoffEls.proceed.onclick = () => runIntegration(change, button);
1726
+ return true;
1002
1727
  };
1003
1728
 
1729
+ // Esc: close in plan/outcome; during flight just hide (the POST keeps going).
1730
+ // Handles BOTH ceremonies (accept liftoff + publish shipoff).
1731
+ document.addEventListener("keydown", (ev) => {
1732
+ if (ev.key !== "Escape") return;
1733
+ if (liftoff && !liftoff.hidden) {
1734
+ const mode = liftoffCard && liftoffCard.dataset.mode;
1735
+ if (mode === "flight") liftoff.hidden = true;
1736
+ else closeLiftoff();
1737
+ }
1738
+ if (shipoff && !shipoff.hidden) {
1739
+ const mode = shipoffCard && shipoffCard.dataset.mode;
1740
+ if (mode === "flight") shipoff.hidden = true;
1741
+ else closeShipoff();
1742
+ }
1743
+ });
1744
+
1004
1745
  // b08 integrate returns an IntegrateOutcome — { ok, queueState, runState,
1005
1746
  // aggregateSha, pointerMoved, statusMessage, reason? } — NEVER a bare
1006
1747
  // merged:true. Render it honestly: green advances the aggregate; red left it
1007
1748
  // exactly where it was and names the failing stage.
1008
- const integrateCandidate = async (change, button) => {
1009
- if (!window.confirm(buildIntegratePlan(change) + "\n\nProceed?")) return;
1010
- button.disabled = true;
1749
+ const runIntegration = async (change, button) => {
1750
+ const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
1751
+ if (button) button.disabled = true;
1752
+ busyChanges.add(change.changeId); // rux7: survive a poll re-render as busy
1011
1753
  setStatus(statusEl, "Integrating " + change.changeId + " into preview…");
1754
+ const startedAt = Date.now();
1755
+ let preFlightSha = null;
1756
+ if (liftoffEls) {
1757
+ showPhase("flight");
1758
+ liftoffEls.flightTitle.textContent = "Integrating " + target + " into preview";
1759
+ railTo("merge");
1760
+ liftoffEls.live.textContent = "Squash-merging the candidate into preview…";
1761
+ liftoffTimer = setInterval(() => {
1762
+ const s = Math.floor((Date.now() - startedAt) / 1000);
1763
+ liftoffEls.elapsed.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
1764
+ }, 1000);
1765
+ }
1012
1766
  try {
1767
+ // Snapshot the pre-flight aggregate sha so the poll can detect the merge
1768
+ // landing (sha moved) even before includedPrs reflects it.
1769
+ try {
1770
+ const pre = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
1771
+ if (pre.ok) preFlightSha = ((await pre.json()).aggregate || {}).aggregateSha || null;
1772
+ } catch { /* best-effort */ }
1773
+ startFlightPoll(change, preFlightSha);
1774
+
1013
1775
  const res = await fetch(ds.integrateEndpoint, {
1014
1776
  method: "POST",
1015
1777
  headers: {
@@ -1026,6 +1788,7 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
1026
1788
  }),
1027
1789
  });
1028
1790
  const data = await res.json().catch(() => ({}));
1791
+ stopLiftoffLoops();
1029
1792
  if (res.ok && data.ok) {
1030
1793
  setStatus(
1031
1794
  statusEl,
@@ -1033,38 +1796,91 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
1033
1796
  shortSha(data.aggregateSha) + "). Review the aggregate, then Publish to go live.",
1034
1797
  "ok",
1035
1798
  );
1036
- } else if (res.ok && data.queueState === "red") {
1037
- // The queue ran but the aggregate did not go green — the candidate did
1038
- // NOT advance the shippable set. Name the honest failing stage.
1039
- setStatus(
1040
- statusEl,
1041
- "Integration of " + change.changeId + " failed (" + (data.reason || "red") + "): " +
1042
- (data.statusMessage || "the aggregate did not pass its combined evidence.") +
1043
- " The aggregate is unchanged.",
1044
- "err",
1045
- );
1046
- } else if (data.reason === "not_preview_base") {
1047
- setStatus(
1048
- statusEl,
1049
- "Not integrated: " + change.changeId + " does not target the preview branch " +
1050
- "(base is not \"preview\"). " + (data.statusMessage || ""),
1051
- "err",
1052
- );
1799
+ if (liftoffEls) {
1800
+ LIFTOFF_STAGES.forEach((s) => setStage(s, "done"));
1801
+ liftoffEls.seal.dataset.state = "ok";
1802
+ liftoffEls.outcomeTitle.textContent = "The preview aggregate is green";
1803
+ liftoffEls.outcomeDetail.textContent =
1804
+ target + " is in. Aggregate build " + shortSha(data.aggregateSha) +
1805
+ " passed its combined evidence this exact build is what you review, and what " +
1806
+ "Publish would take live. Nothing is live yet.";
1807
+ if (data.aggregateSha) {
1808
+ liftoffEls.review.href =
1809
+ "/preview/" + encodeURIComponent(appDomain) + "/rev/" + data.aggregateSha + "/";
1810
+ liftoffEls.review.hidden = false;
1811
+ } else {
1812
+ liftoffEls.review.hidden = true;
1813
+ }
1814
+ if (liftoff.hidden) liftoff.hidden = false; // surface the payoff even if backgrounded
1815
+ showPhase("outcome");
1816
+ liftoffEls.done.focus();
1817
+ celebrate(liftoffEls.confetti);
1818
+ }
1053
1819
  } else {
1054
- setStatus(
1055
- statusEl,
1056
- data.statusMessage || data.error || "Integrate failed (" + res.status + ")",
1057
- "err",
1058
- );
1820
+ let message;
1821
+ let failStage = "merge";
1822
+ if (res.ok && data.queueState === "red") {
1823
+ // The queue ran but the aggregate did not go green — the candidate did
1824
+ // NOT advance the shippable set. Name the honest failing stage.
1825
+ failStage = "evidence";
1826
+ message =
1827
+ "Integration of " + change.changeId + " failed (" + (data.reason || "red") + "): " +
1828
+ (data.statusMessage || "the aggregate did not pass its combined evidence.") +
1829
+ " The aggregate is unchanged.";
1830
+ } else if (data.reason === "not_preview_base") {
1831
+ message =
1832
+ "Not integrated: " + change.changeId + " does not target the preview branch " +
1833
+ "(base is not \"preview\"). " + (data.statusMessage || "");
1834
+ } else {
1835
+ message = data.statusMessage || data.error || "Integrate failed (" + res.status + ")";
1836
+ }
1837
+ setStatus(statusEl, message, "err");
1838
+ if (liftoffEls) {
1839
+ setStage(failStage, "fail");
1840
+ liftoffEls.seal.dataset.state = "err";
1841
+ liftoffEls.outcomeTitle.textContent = "Integration did not land";
1842
+ liftoffEls.outcomeDetail.textContent = message;
1843
+ liftoffEls.review.hidden = true;
1844
+ if (liftoff.hidden) liftoff.hidden = false;
1845
+ showPhase("outcome");
1846
+ liftoffEls.done.focus();
1847
+ }
1059
1848
  }
1060
1849
  } catch {
1850
+ stopLiftoffLoops();
1061
1851
  setStatus(statusEl, "Integrate failed — network error", "err");
1852
+ if (liftoffEls) {
1853
+ liftoffEls.seal.dataset.state = "err";
1854
+ liftoffEls.outcomeTitle.textContent = "Integration did not land";
1855
+ liftoffEls.outcomeDetail.textContent =
1856
+ "Network error mid-flight — refresh the queue to see the aggregate's honest state.";
1857
+ liftoffEls.review.hidden = true;
1858
+ if (liftoff.hidden) liftoff.hidden = false;
1859
+ showPhase("outcome");
1860
+ }
1062
1861
  } finally {
1063
- button.disabled = false;
1862
+ busyChanges.delete(change.changeId);
1863
+ if (button) button.disabled = false;
1064
1864
  loadQueue();
1065
1865
  }
1066
1866
  };
1067
1867
 
1868
+ const integrateCandidate = (change, button) => {
1869
+ // The liftoff dialog IS the plan confirm now; window.confirm stays only as
1870
+ // the no-dialog fallback (markup missing = fail open to the old popup).
1871
+ if (openLiftoff(change, button)) return;
1872
+ const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
1873
+ const plan =
1874
+ "Accept-to-preview plan:\n tenant: " + (appDomain || repo) +
1875
+ (change.prNumber != null ? "\n PR: #" + change.prNumber : "") +
1876
+ "\n change id: " + change.changeId +
1877
+ (change.headSha ? "\n head sha: " + shortSha(change.headSha) : "") +
1878
+ "\n effect: integrate " + target + " into the preview aggregate, then rebuild + " +
1879
+ "re-evidence it. Does NOT go live — publish the reviewed build separately.";
1880
+ if (!window.confirm(plan + "\n\nProceed?")) return;
1881
+ runIntegration(change, button);
1882
+ };
1883
+
1068
1884
  const rejectChange = async (change, button) => {
1069
1885
  const reason = window.prompt(
1070
1886
  "Reject " + change.changeId + " — add a short note for the submitter:",
@@ -1075,6 +1891,7 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
1075
1891
  return;
1076
1892
  }
1077
1893
  button.disabled = true;
1894
+ busyChanges.add(change.changeId); // rux7: survive a poll re-render as busy
1078
1895
  setStatus(statusEl, "Rejecting " + change.changeId + "…");
1079
1896
  try {
1080
1897
  const res = await fetch(
@@ -1097,6 +1914,7 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
1097
1914
  } catch {
1098
1915
  setStatus(statusEl, "Reject failed — network error", "err");
1099
1916
  } finally {
1917
+ busyChanges.delete(change.changeId);
1100
1918
  button.disabled = false;
1101
1919
  loadQueue();
1102
1920
  }
@@ -1125,6 +1943,7 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
1125
1943
  const retireChange = async (change, button) => {
1126
1944
  if (!window.confirm(buildRetirePlan(change) + "\n\nProceed?")) return;
1127
1945
  button.disabled = true;
1946
+ busyChanges.add(change.changeId); // rux7: survive a poll re-render as busy
1128
1947
  setStatus(statusEl, "Retiring " + change.changeId + "…");
1129
1948
  try {
1130
1949
  const res = await fetch(ds.retireEndpoint, {
@@ -1155,67 +1974,271 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
1155
1974
  } catch {
1156
1975
  setStatus(statusEl, "Retire failed — network error", "err");
1157
1976
  } finally {
1977
+ busyChanges.delete(change.changeId);
1978
+ button.disabled = false;
1979
+ loadQueue();
1980
+ }
1981
+ };
1982
+
1983
+ // ---- rux5: build-on-demand for a synthetic not-built PR ------------------
1984
+ // Mirror of the integrate/retire plan affordances: state EXACTLY what build
1985
+ // does — materialize this PR's hosted preview at its forge head — and that it
1986
+ // is INERT (flips no live channel). POSTs { pr, headSha?, baseSha? }; the row
1987
+ // already carries the head sha GET /api/changes resolved via forge PR-read, and
1988
+ // the server re-resolves it authoritatively when the body omits it. Shows an
1989
+ // in-progress state (disabled + "Building…") while the build runs, then reloads
1990
+ // the queue so the freshly-built PR reappears as a built candidate.
1991
+
1992
+ const buildPreviewPlan = (change) => {
1993
+ const lines = ["Build preview plan:", " tenant: " + (appDomain || repo)];
1994
+ lines.push(" PR: #" + change.prNumber);
1995
+ if (change.headSha) lines.push(" head sha: " + shortSha(change.headSha));
1996
+ lines.push(
1997
+ " effect: materialize this PR's hosted preview at its head — INERT " +
1998
+ "(does NOT go live).",
1999
+ );
2000
+ return lines.join("\n");
2001
+ };
2002
+
2003
+ const buildPreview = async (change, button) => {
2004
+ const label = "PR #" + change.prNumber;
2005
+ if (!window.confirm(buildPreviewPlan(change) + "\n\nBuild the preview now?")) return;
2006
+ button.disabled = true;
2007
+ const originalText = button.textContent;
2008
+ button.textContent = "Building…";
2009
+ button.dataset.building = "true";
2010
+ // rux7: record the in-flight build so a poll re-render preserves the
2011
+ // spinner/disabled state on the freshly-rendered button (cleared in finally).
2012
+ if (change.prNumber != null) inflightBuilds.add(change.prNumber);
2013
+ setStatus(statusEl, "Building preview for " + label + "…");
2014
+ try {
2015
+ const res = await fetch(ds.buildEndpoint, {
2016
+ method: "POST",
2017
+ headers: {
2018
+ "content-type": "application/json",
2019
+ // Mirror of the server-side capability model (UX); the server
2020
+ // re-derives capability (buildGate) and stays authoritative.
2021
+ "x-tot-capability": "ship-on-behalf",
2022
+ },
2023
+ body: JSON.stringify({
2024
+ pr: change.prNumber,
2025
+ headSha: change.headSha || undefined,
2026
+ baseSha: change.baseSha || undefined,
2027
+ }),
2028
+ });
2029
+ const data = await res.json().catch(() => ({}));
2030
+ if (res.ok && data.ok) {
2031
+ setStatus(
2032
+ statusEl,
2033
+ "Built preview for " + label +
2034
+ " — it now appears as a built change in the queue.",
2035
+ "ok",
2036
+ );
2037
+ } else if (data && data.ok === false) {
2038
+ // reconcileCandidate returned a blocked/failed candidate (e.g. a safety
2039
+ // finding with no override) — the honest reason, never a false success.
2040
+ setStatus(
2041
+ statusEl,
2042
+ "Build did not complete for " + label + ": " +
2043
+ ((data.errors && data.errors.join("; ")) || data.error || "blocked"),
2044
+ "err",
2045
+ );
2046
+ } else {
2047
+ setStatus(statusEl, data.error || "Build failed (" + res.status + ")", "err");
2048
+ }
2049
+ } catch {
2050
+ setStatus(statusEl, "Build failed — network error", "err");
2051
+ } finally {
2052
+ if (change.prNumber != null) inflightBuilds.delete(change.prNumber);
1158
2053
  button.disabled = false;
2054
+ button.textContent = originalText;
2055
+ delete button.dataset.building;
1159
2056
  loadQueue();
1160
2057
  }
1161
2058
  };
1162
2059
 
1163
- // ---- dg7: diff view ----------------------------------------------------
1164
- // For a pending candidate, show what changed vs the current live version,
1165
- // tied to the exact tenant Gitea head SHA, with the u5 immutable /rev link
1166
- // and the dg3 evidence AUTHORITY (authoritative / stale / missing). Reads
1167
- // GET /api/changes/<id>/diff; never invents a diff for a non-renderable
1168
- // candidate it shows the honest blocked reason.
2060
+ // ---- rux6: unified review pane -----------------------------------------
2061
+ // "One review moment": expanding a candidate row reveals a single pane with
2062
+ // the live preview (iframed SAME-ORIGIN the /preview/<tenant>/pr/<N> route
2063
+ // serves `frame-ancestors 'self'` + `X-Frame-Options: SAMEORIGIN`, so a
2064
+ // same-origin admin embed is permitted; a cross-origin embed is refused,
2065
+ // which is why the pane also always offers an "Open in new tab" link), the
2066
+ // evidence chips the row already carries (change.evidence), the dg7 diff
2067
+ // (reusing renderDiff below — GET /api/changes/<id>/diff, which never invents
2068
+ // a diff for a non-renderable candidate), and the SAME Accept-to-preview /
2069
+ // Reject actions. The reviewer never leaves the queue to review and act; the
2070
+ // server stays authoritative for every action (this is UI consolidation only).
1169
2071
 
1170
2072
  const diffBase = ds.diffEndpointBase;
1171
2073
 
1172
- const diffRowFor = (tr) => {
2074
+ const reviewRowFor = (tr) => {
1173
2075
  const next = tr.nextElementSibling;
1174
- return next && next.dataset && next.dataset.publishDiffRow === tr.dataset.changeRowId
2076
+ return next && next.dataset && next.dataset.publishReviewRow === tr.dataset.changeRowId
1175
2077
  ? next
1176
2078
  : null;
1177
2079
  };
1178
2080
 
1179
- const closeDiff = (tr, button) => {
1180
- const existing = diffRowFor(tr);
2081
+ const closeReview = (tr, button) => {
2082
+ const existing = reviewRowFor(tr);
1181
2083
  if (existing) existing.remove();
1182
- if (button) button.textContent = "Diff";
2084
+ if (button) {
2085
+ button.textContent = "Review";
2086
+ button.setAttribute("aria-expanded", "false");
2087
+ }
1183
2088
  };
1184
2089
 
1185
- const toggleDiff = async (change, button, tr) => {
1186
- if (!tr.dataset.changeRowId) tr.dataset.changeRowId = change.changeId;
1187
- if (diffRowFor(tr)) {
1188
- closeDiff(tr, button);
1189
- return;
2090
+ // Evidence chips from the data the row already carries (change.evidence):
2091
+ // an overall promotable verdict + one chip per reported check.
2092
+ const renderEvidenceChips = (container, change) => {
2093
+ const checks = (change.evidence && change.evidence.checks) || [];
2094
+ const promotable = Boolean(change.evidence && change.evidence.promotable);
2095
+ container.append(
2096
+ promotable ? chip("Checks passed", "green") : chip("Checks blocked", "amber"),
2097
+ );
2098
+ for (const c of checks) {
2099
+ const tone = c.status === "pass" ? "green" : c.status === "fail" ? "red" : "amber";
2100
+ const label = (c.label || c.id || "check") + (c.status && c.status !== "pass" ? " · " + c.status : "");
2101
+ const el = chip(label, tone);
2102
+ if (c.detail) el.title = c.detail;
2103
+ container.append(el);
1190
2104
  }
1191
- button.disabled = true;
1192
- button.textContent = "Loading…";
1193
- const detailTr = document.createElement("tr");
1194
- detailTr.dataset.publishDiffRow = tr.dataset.changeRowId;
1195
- const td = document.createElement("td");
1196
- td.colSpan = 6;
1197
- td.className = "publish-diff-cell";
1198
- detailTr.append(td);
1199
- tr.after(detailTr);
2105
+ if (!checks.length) container.append(subtle("No checks reported yet"));
2106
+ };
2107
+
2108
+ // Build the split pane. Returns the pane node + the diff container so the
2109
+ // caller can populate the diff asynchronously (reusing renderDiff).
2110
+ const buildReviewPane = (change) => {
2111
+ const pane = document.createElement("div");
2112
+ pane.className = "review-pane";
2113
+
2114
+ // Preview column — same-origin iframe of the friendly per-PR route (it
2115
+ // handles every state: ready render, or the not-built page). Falls back to
2116
+ // any server-provided previewUrl for a change-first row with no PR number.
2117
+ const previewUrl = candidatePrUrl(change.prNumber) || change.previewUrl;
2118
+ const previewCol = document.createElement("div");
2119
+ previewCol.className = "review-preview";
2120
+ const pHead = document.createElement("div");
2121
+ pHead.className = "review-pane-head";
2122
+ const pTitle = document.createElement("b");
2123
+ pTitle.textContent = "Preview";
2124
+ pHead.append(pTitle);
2125
+ if (previewUrl) {
2126
+ const openLink = document.createElement("a");
2127
+ openLink.href = previewUrl;
2128
+ openLink.target = "_blank";
2129
+ openLink.rel = "noopener";
2130
+ openLink.textContent = "Open in new tab ↗";
2131
+ pHead.append(openLink);
2132
+ }
2133
+ previewCol.append(pHead);
2134
+ if (previewUrl) {
2135
+ const frame = document.createElement("iframe");
2136
+ frame.className = "review-frame";
2137
+ frame.src = previewUrl;
2138
+ frame.loading = "lazy";
2139
+ frame.title =
2140
+ change.prNumber != null
2141
+ ? "Preview of PR #" + change.prNumber
2142
+ : "Preview of " + change.changeId;
2143
+ previewCol.append(frame);
2144
+ } else {
2145
+ previewCol.append(subtle("No preview is available for this change yet."));
2146
+ }
2147
+
2148
+ // Detail column — evidence chips + diff + the same Accept/Reject actions.
2149
+ const detailCol = document.createElement("div");
2150
+ detailCol.className = "review-detail";
2151
+
2152
+ const evSection = document.createElement("div");
2153
+ evSection.className = "review-section";
2154
+ const evTitle = document.createElement("b");
2155
+ evTitle.textContent = "Checks";
2156
+ const evChips = document.createElement("div");
2157
+ evChips.className = "review-chips";
2158
+ renderEvidenceChips(evChips, change);
2159
+ evSection.append(evTitle, evChips);
2160
+ detailCol.append(evSection);
2161
+
2162
+ const diffSection = document.createElement("div");
2163
+ diffSection.className = "review-section";
2164
+ const diffTitle = document.createElement("b");
2165
+ diffTitle.textContent = "Diff";
2166
+ const diffTarget = document.createElement("div");
2167
+ diffTarget.className = "review-diff-target";
2168
+ diffTarget.append(subtle("Loading the diff…"));
2169
+ diffSection.append(diffTitle, diffTarget);
2170
+ detailCol.append(diffSection);
2171
+
2172
+ // The SAME actions as the row — same handlers, same gates. Not a new authz
2173
+ // path: integrateCandidate/rejectChange POST to the same endpoints, and the
2174
+ // server re-authorizes.
2175
+ const actionRow = document.createElement("div");
2176
+ actionRow.className = "review-actions";
2177
+ const accept = document.createElement("button");
2178
+ accept.type = "button";
2179
+ accept.className = "btn primary";
2180
+ accept.textContent = "Accept to preview";
2181
+ accept.disabled = !canShip || !change.mergeable;
2182
+ accept.title = !canShip
2183
+ ? "Requires an owner or admin sign-in"
2184
+ : !change.mergeable
2185
+ ? "This change is not ready to integrate"
2186
+ : "Integrate this change into the preview aggregate (does not go live)";
2187
+ accept.addEventListener("click", () => integrateCandidate(change, accept));
2188
+ const reject = document.createElement("button");
2189
+ reject.type = "button";
2190
+ reject.className = "btn";
2191
+ reject.textContent = "Reject";
2192
+ reject.disabled = !canShip;
2193
+ reject.title = canShip
2194
+ ? "Close this change without publishing"
2195
+ : "Requires an owner or admin sign-in";
2196
+ reject.addEventListener("click", () => rejectChange(change, reject));
2197
+ actionRow.append(accept, reject);
2198
+ detailCol.append(actionRow);
2199
+
2200
+ pane.append(previewCol, detailCol);
2201
+ return { pane, diffTarget };
2202
+ };
2203
+
2204
+ const loadReviewDiff = async (change, diffTarget) => {
1200
2205
  try {
1201
2206
  const res = await fetch(diffBase + "/" + encodeURIComponent(change.changeId) + "/diff", {
1202
2207
  headers: { accept: "application/json" },
1203
2208
  });
1204
2209
  const data = await res.json().catch(() => ({}));
2210
+ diffTarget.textContent = "";
1205
2211
  if (!res.ok) {
1206
- td.append(subtle(data.error || "Could not load the diff (" + res.status + ")"));
2212
+ diffTarget.append(subtle(data.error || "Could not load the diff (" + res.status + ")"));
1207
2213
  } else {
1208
- renderDiff(td, data.diff || {});
2214
+ renderDiff(diffTarget, data.diff || {});
1209
2215
  }
1210
- button.textContent = "Hide diff";
1211
2216
  } catch {
1212
- td.append(subtle("Could not load the diff — network error"));
1213
- button.textContent = "Hide diff";
1214
- } finally {
1215
- button.disabled = false;
2217
+ diffTarget.textContent = "";
2218
+ diffTarget.append(subtle("Could not load the diff — network error"));
1216
2219
  }
1217
2220
  };
1218
2221
 
2222
+ const toggleReview = (change, button, tr) => {
2223
+ if (!tr.dataset.changeRowId) tr.dataset.changeRowId = change.changeId;
2224
+ if (reviewRowFor(tr)) {
2225
+ closeReview(tr, button);
2226
+ return;
2227
+ }
2228
+ const detailTr = document.createElement("tr");
2229
+ detailTr.dataset.publishReviewRow = tr.dataset.changeRowId;
2230
+ const td = document.createElement("td");
2231
+ td.colSpan = 6;
2232
+ td.className = "publish-review-cell";
2233
+ const { pane, diffTarget } = buildReviewPane(change);
2234
+ td.append(pane);
2235
+ detailTr.append(td);
2236
+ tr.after(detailTr);
2237
+ button.textContent = "Hide review";
2238
+ button.setAttribute("aria-expanded", "true");
2239
+ loadReviewDiff(change, diffTarget);
2240
+ };
2241
+
1219
2242
  const CHANGE_MARK = { added: "+", changed: "~", removed: "−" };
1220
2243
 
1221
2244
  const renderDiff = (td, diff) => {
@@ -1308,15 +2331,17 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
1308
2331
  versionsChip.className = "chip" + (versions.length ? " blue" : "");
1309
2332
  }
1310
2333
 
1311
- // Static tenants: per-version rollback is honestly deferred (the console
1312
- // can't enumerate the S3 versions archive digest /api/changes/rollback needs).
2334
+ // Static tenants (rux13): per-version rollback is an HONEST ONE-DISPATCH.
2335
+ // The console can't enumerate the S3 versions archive digest, so it dispatches
2336
+ // by the tenant version id and CI resolves the digest + re-publishes — it
2337
+ // completes via CI, not instantly (never implied one-click).
1313
2338
  if (versionsRollbackNote) {
1314
2339
  if (rollbackMode === "static-archive") {
1315
2340
  versionsRollbackNote.hidden = false;
1316
2341
  versionsRollbackNote.textContent =
1317
- "This store publishes to a static public site. Rolling back to a prior version " +
1318
- "resolves the target from the versions archive — run the ship CLI (tot rollback), " +
1319
- "which enumerates it; the console can't list archived object digests.";
2342
+ "This store publishes to a static public site. Rolling back DISPATCHES a CI job that " +
2343
+ "re-publishes the prior version from the S3 versions archive — it completes via CI, not " +
2344
+ "instantly. The store keeps serving the current version until the re-publish is verified live.";
1320
2345
  } else {
1321
2346
  versionsRollbackNote.hidden = true;
1322
2347
  }
@@ -1375,13 +2400,14 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
1375
2400
  const rollbackBtn = document.createElement("button");
1376
2401
  rollbackBtn.type = "button";
1377
2402
  rollbackBtn.className = "btn";
1378
- rollbackBtn.textContent = "Roll back";
1379
- const staticDeferred = rollbackMode === "static-archive";
1380
- rollbackBtn.disabled = !canShip || staticDeferred;
2403
+ const isStatic = rollbackMode === "static-archive";
2404
+ // Honest label: a static rollback completes via CI, not instantly (rux13).
2405
+ rollbackBtn.textContent = isStatic ? "Roll back via CI" : "Roll back";
2406
+ rollbackBtn.disabled = !canShip;
1381
2407
  rollbackBtn.title = !canShip
1382
2408
  ? "Requires an owner or ship-on-behalf sign-in"
1383
- : staticDeferred
1384
- ? "Static rollback runs through the versions archive use the ship CLI (tot rollback)"
2409
+ : isStatic
2410
+ ? "Dispatch a rollback to this versioncompletes via CI (not instant)"
1385
2411
  : "Roll the live site back to this version";
1386
2412
  rollbackBtn.addEventListener("click", () => rollbackVersion(v, rollbackBtn));
1387
2413
  act.append(rollbackBtn);
@@ -1449,20 +2475,28 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
1449
2475
  };
1450
2476
 
1451
2477
  const rollbackVersion = async (version, button) => {
1452
- if (
1453
- !window.confirm(
1454
- "Roll the live store back to version " + shortSha(version.versionId) +
1455
- "? This publishes that prior version to the live site.",
1456
- )
1457
- ) {
1458
- return;
1459
- }
2478
+ // Static rollback (rux13) is an honest one-DISPATCH — completes via CI, not
2479
+ // instantly; the confirm/status copy must never imply an instant flip.
2480
+ const isStatic = rollbackMode === "static-archive";
2481
+ const confirmMsg = isStatic
2482
+ ? "Roll the static public site back to version " + shortSha(version.versionId) +
2483
+ "? This DISPATCHES a rollback that completes via CI (not instantly) — the store keeps " +
2484
+ "serving the current version until CI re-publishes the prior one."
2485
+ : "Roll the live store back to version " + shortSha(version.versionId) +
2486
+ "? This publishes that prior version to the live site.";
2487
+ if (!window.confirm(confirmMsg)) return;
1460
2488
  button.disabled = true;
1461
- setStatus(versionsStatus, "Rolling back to " + shortSha(version.versionId) + "…");
2489
+ setStatus(
2490
+ versionsStatus,
2491
+ isStatic
2492
+ ? "Dispatching rollback to " + shortSha(version.versionId) + " (completes via CI)…"
2493
+ : "Rolling back to " + shortSha(version.versionId) + "…",
2494
+ );
1462
2495
  try {
2496
+ // The console dispatches by version id only; a static prior version's
2497
+ // archive digest lives only in S3 (CI resolves it). Pass a digest ONLY when
2498
+ // we honestly have one (never for a static prior — its digest is always null).
1463
2499
  const payload = { targetVersionId: version.versionId };
1464
- // A static tenant additionally needs the archive digest — the button is
1465
- // disabled in that mode, but pass it through when we honestly have it.
1466
2500
  if (version.digest) payload.targetDigest = version.digest;
1467
2501
  const res = await fetch(ds.rollbackEndpoint, {
1468
2502
  method: "POST",
@@ -1714,6 +2748,184 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
1714
2748
  domainAction({ action: "rollback" }, event.currentTarget);
1715
2749
  });
1716
2750
 
2751
+ // ---- b17: branch retention ----------------------------------------------
2752
+ // Renders the GET /api/changes/branches dry-run classification (already
2753
+ // projected server-side by branchRetentionView.ts — this script only
2754
+ // paints it, same division of labor as renderQueue/renderVersions above)
2755
+ // and drives the guarded delete. The server (candidate_delete, b13)
2756
+ // re-classifies fresh on every delete, so the confirm below is the HUMAN
2757
+ // gate, not the safety guarantee — the safety guarantee is server-side.
2758
+
2759
+ const branchesPanel = root.querySelector("[data-publish-branches]");
2760
+ const branchesBody = branchesPanel?.querySelector("[data-branches-body]");
2761
+ const branchesChip = branchesPanel?.querySelector("[data-branches-chip]");
2762
+ const branchesStatus = branchesPanel?.querySelector("[data-branches-status]");
2763
+
2764
+ const BRANCH_TONE = {
2765
+ protected: "",
2766
+ "active-open": "green",
2767
+ "stale-open": "amber",
2768
+ integrated: "blue",
2769
+ "closed-or-rejected": "blue",
2770
+ orphan: "red",
2771
+ };
2772
+
2773
+ const buildCleanupPlan = (row) => {
2774
+ return [
2775
+ "Delete branch plan:",
2776
+ " repo: " + (appDomain || repo),
2777
+ " branch: " + row.ref,
2778
+ " tip sha: " + row.shortSha,
2779
+ " classification: " + row.classificationLabel,
2780
+ " reason: " + row.reason,
2781
+ "",
2782
+ "This deletes the Git BRANCH only — it never evicts a hosted preview build",
2783
+ "(that is Retire, a separate action above). The server re-classifies this",
2784
+ "branch fresh before deleting; a moved tip or a branch that is no longer",
2785
+ "integrated/closed-or-rejected is refused, not silently skipped.",
2786
+ ].join("\n");
2787
+ };
2788
+
2789
+ const cleanupBranch = async (row, button) => {
2790
+ if (!window.confirm(buildCleanupPlan(row) + "\n\nDelete this branch?")) return;
2791
+ button.disabled = true;
2792
+ setStatus(branchesStatus, "Deleting " + row.shortRef + "…");
2793
+ try {
2794
+ const res = await fetch(ds.branchesEndpoint, {
2795
+ method: "POST",
2796
+ headers: { "content-type": "application/json", "x-tot-capability": "ship-on-behalf" },
2797
+ body: JSON.stringify({ ref: row.ref, expectedSha: row.sha }),
2798
+ });
2799
+ const data = await res.json().catch(() => ({}));
2800
+ if (res.ok && data.ok) {
2801
+ setStatus(
2802
+ branchesStatus,
2803
+ data.alreadyAbsent
2804
+ ? row.shortRef + " was already gone (nothing to delete)."
2805
+ : "Deleted " + row.shortRef + ".",
2806
+ "ok",
2807
+ );
2808
+ } else {
2809
+ setStatus(branchesStatus, data.error || "Delete refused (" + res.status + ")", "err");
2810
+ }
2811
+ } catch {
2812
+ setStatus(branchesStatus, "Delete failed — network error", "err");
2813
+ } finally {
2814
+ button.disabled = false;
2815
+ loadBranches();
2816
+ }
2817
+ };
2818
+
2819
+ const renderBranches = (view) => {
2820
+ if (!branchesBody) return;
2821
+ const rows = Array.isArray(view.rows) ? view.rows : [];
2822
+ if (branchesChip) {
2823
+ const needsReview = rows.filter((r) => r.quarantine || r.eligibleForDelete).length;
2824
+ branchesChip.textContent = needsReview
2825
+ ? needsReview + " to review"
2826
+ : rows.length
2827
+ ? rows.length + " branches"
2828
+ : "0 branches";
2829
+ branchesChip.className = "chip" + (needsReview ? " amber" : "");
2830
+ }
2831
+ branchesBody.textContent = "";
2832
+ if (!rows.length) {
2833
+ const tr = document.createElement("tr");
2834
+ const td = cell(subtle("No candidate branches found for this repo."));
2835
+ td.colSpan = 7;
2836
+ tr.append(td);
2837
+ branchesBody.append(tr);
2838
+ return;
2839
+ }
2840
+
2841
+ for (const row of rows) {
2842
+ const tr = document.createElement("tr");
2843
+
2844
+ const branchCell = document.createElement("td");
2845
+ const branchStrong = document.createElement("strong");
2846
+ branchStrong.textContent = row.shortRef;
2847
+ branchCell.append(branchStrong, subtle(row.ref + " @ " + row.shortSha));
2848
+ tr.append(branchCell);
2849
+
2850
+ tr.append(cell(chip(row.classificationLabel, BRANCH_TONE[row.classification] || "")));
2851
+
2852
+ const prCell = document.createElement("td");
2853
+ if (row.prNumber != null) {
2854
+ // Link to the INTERNAL friendly PR route (/preview/<tenant>/pr/<N>), never
2855
+ // the server-provided `row.prUrl` — that is the raw Gitea forge html_url the
2856
+ // MCP candidate_list projection emits (<forge-host>/<org>/<repo>/pulls/N),
2857
+ // and surfacing a raw forge link to an owner is exactly the boundary
2858
+ // noGiteaLinks.test.ts guards (the source scan can't catch a host that
2859
+ // arrives in data, so we refuse it here at render). Mirrors the candidate
2860
+ // queue, which already builds its preview link this way.
2861
+ const prHref = candidatePrUrl(row.prNumber);
2862
+ if (prHref) {
2863
+ const a = document.createElement("a");
2864
+ a.href = prHref;
2865
+ a.target = "_blank";
2866
+ a.rel = "noopener";
2867
+ a.textContent = "PR #" + row.prNumber;
2868
+ prCell.append(a);
2869
+ } else {
2870
+ prCell.append(document.createTextNode("PR #" + row.prNumber));
2871
+ }
2872
+ prCell.append(subtle((row.prState || "") + (row.integratedIntoAggregate ? " · in aggregate" : "")));
2873
+ } else {
2874
+ prCell.append(subtle("No PR record"));
2875
+ }
2876
+ tr.append(prCell);
2877
+
2878
+ tr.append(cell(row.commitTimestampDisplay));
2879
+ tr.append(cell(row.ageLabel));
2880
+ tr.append(cell(row.evidenceSummary));
2881
+
2882
+ const actionsCell = document.createElement("td");
2883
+ actionsCell.className = "action";
2884
+ if (row.quarantine) {
2885
+ actionsCell.append(chip("Needs owner review", "red"));
2886
+ } else if (row.eligibleForDelete) {
2887
+ const btn = document.createElement("button");
2888
+ btn.type = "button";
2889
+ btn.className = "btn";
2890
+ btn.disabled = !canShip;
2891
+ btn.title = canShip ? "" : "Requires an owner/admin sign-in.";
2892
+ btn.textContent = "Clean up";
2893
+ btn.addEventListener("click", () => cleanupBranch(row, btn));
2894
+ actionsCell.append(btn);
2895
+ } else {
2896
+ actionsCell.append(subtle("Keep"));
2897
+ }
2898
+ tr.append(actionsCell);
2899
+
2900
+ branchesBody.append(tr);
2901
+ }
2902
+ };
2903
+
2904
+ const loadBranches = async () => {
2905
+ if (!branchesBody) return;
2906
+ try {
2907
+ const res = await fetch(ds.branchesEndpoint, { headers: { accept: "application/json" } });
2908
+ if (res.status === 401 || res.status === 403) {
2909
+ if (branchesChip) branchesChip.textContent = "Sign-in required";
2910
+ branchesBody.textContent = "";
2911
+ const tr = document.createElement("tr");
2912
+ const td = cell(subtle("Sign in as a store owner or admin to view branch retention."));
2913
+ td.colSpan = 7;
2914
+ tr.append(td);
2915
+ branchesBody.append(tr);
2916
+ return;
2917
+ }
2918
+ const data = await res.json().catch(() => ({}));
2919
+ if (!res.ok) {
2920
+ setStatus(branchesStatus, data.error || "Could not load branch classification (" + res.status + ")", "err");
2921
+ return;
2922
+ }
2923
+ renderBranches(data.report || {});
2924
+ } catch {
2925
+ setStatus(branchesStatus, "Could not load branch classification — network error", "err");
2926
+ }
2927
+ };
2928
+
1717
2929
  // ---- Wiring ------------------------------------------------------------
1718
2930
 
1719
2931
  root.querySelector("[data-publish-refresh]")?.addEventListener("click", () => {
@@ -1721,11 +2933,24 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
1721
2933
  loadQueue();
1722
2934
  loadVersions();
1723
2935
  loadDomain();
2936
+ loadBranches();
1724
2937
  });
1725
2938
 
2939
+ // rux7: pause polling while hidden; on return, refresh immediately (loadQueue
2940
+ // re-renders + resyncs the poll clock) rather than waiting out the interval.
2941
+ document.addEventListener("visibilitychange", () => {
2942
+ if (document.hidden) {
2943
+ clearPoll();
2944
+ } else {
2945
+ loadQueue();
2946
+ }
2947
+ });
2948
+
2949
+ // loadQueue starts the poll cadence via its tail (schedulePoll).
1726
2950
  loadQueue();
1727
2951
  loadVersions();
1728
2952
  loadDomain();
2953
+ loadBranches();
1729
2954
  })();
1730
2955
  </script>
1731
2956
 
@@ -1763,11 +2988,331 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
1763
2988
  color: var(--admin-red);
1764
2989
  }
1765
2990
 
2991
+ /* ?pr=N deep link: "not in the queue" callout + row flash */
2992
+ .publish-pr-callout {
2993
+ border: 1px solid var(--admin-line-strong);
2994
+ border-left: 3px solid var(--admin-amber);
2995
+ background: var(--admin-surface-soft);
2996
+ padding: 8px 12px;
2997
+ border-radius: var(--admin-radius-sm);
2998
+ color: var(--admin-ink);
2999
+ }
3000
+
3001
+ @keyframes publish-row-flash {
3002
+ 0%,
3003
+ 25% {
3004
+ background-color: var(--admin-teal-soft);
3005
+ }
3006
+ 100% {
3007
+ background-color: transparent;
3008
+ }
3009
+ }
3010
+
3011
+ .publish-row-highlight > td {
3012
+ animation: publish-row-flash 2.4s ease-out;
3013
+ }
3014
+
3015
+ @media (prefers-reduced-motion: reduce) {
3016
+ .publish-row-highlight > td {
3017
+ animation: none;
3018
+ background-color: var(--admin-teal-soft);
3019
+ }
3020
+ }
3021
+
1766
3022
  .publish-error-detail {
1767
3023
  color: var(--admin-red);
1768
3024
  font-size: 12px;
1769
3025
  }
1770
3026
 
3027
+ /* ── Accept-to-preview LIFTOFF ─────────────────────────────────────────── */
3028
+ .liftoff-backdrop {
3029
+ position: fixed;
3030
+ inset: 0;
3031
+ z-index: 60;
3032
+ display: grid;
3033
+ place-items: center;
3034
+ padding: 24px;
3035
+ background: color-mix(in srgb, var(--admin-ink) 32%, transparent);
3036
+ backdrop-filter: blur(6px);
3037
+ -webkit-backdrop-filter: blur(6px);
3038
+ }
3039
+
3040
+ /* `display: grid` above beats the UA's [hidden] rule — restate it, or the
3041
+ invisible backdrop click-blocks the entire page (caught by live test). */
3042
+ .liftoff-backdrop[hidden] {
3043
+ display: none;
3044
+ }
3045
+
3046
+ .liftoff-card {
3047
+ position: relative;
3048
+ width: min(520px, 100%);
3049
+ max-height: min(84vh, 640px);
3050
+ overflow: auto;
3051
+ border: 1px solid var(--admin-line-strong);
3052
+ border-radius: var(--admin-radius, 12px);
3053
+ background: var(--admin-surface);
3054
+ box-shadow: 0 24px 80px rgba(8, 32, 30, 0.35);
3055
+ padding: 24px 26px;
3056
+ animation: liftoff-in 0.22s ease-out;
3057
+ }
3058
+
3059
+ @keyframes liftoff-in {
3060
+ from { transform: translateY(10px) scale(0.985); opacity: 0; }
3061
+ to { transform: none; opacity: 1; }
3062
+ }
3063
+
3064
+ /* In-flight shimmer: a live gradient beam along the card's top edge. */
3065
+ .liftoff-card::before {
3066
+ content: "";
3067
+ position: absolute;
3068
+ inset: 0 0 auto 0;
3069
+ height: 3px;
3070
+ border-radius: inherit;
3071
+ opacity: 0;
3072
+ background: linear-gradient(90deg, transparent, var(--admin-teal, #0f9d8f), #f4b942, var(--admin-teal, #0f9d8f), transparent);
3073
+ background-size: 220% 100%;
3074
+ transition: opacity 0.3s ease;
3075
+ }
3076
+
3077
+ .liftoff-card[data-mode="flight"]::before {
3078
+ opacity: 1;
3079
+ animation: liftoff-beam 1.6s linear infinite;
3080
+ }
3081
+
3082
+ @keyframes liftoff-beam {
3083
+ from { background-position: 200% 0; }
3084
+ to { background-position: -20% 0; }
3085
+ }
3086
+
3087
+ .liftoff-kicker {
3088
+ margin: 0 0 2px;
3089
+ font-size: 11px;
3090
+ font-weight: 700;
3091
+ letter-spacing: 0.08em;
3092
+ text-transform: uppercase;
3093
+ color: var(--admin-teal, #0f9d8f);
3094
+ }
3095
+
3096
+ .liftoff-title {
3097
+ margin: 0 0 14px;
3098
+ font-size: 19px;
3099
+ line-height: 1.25;
3100
+ }
3101
+
3102
+ .liftoff-grid {
3103
+ display: grid;
3104
+ grid-template-columns: auto 1fr;
3105
+ gap: 6px 16px;
3106
+ margin: 0 0 12px;
3107
+ padding: 12px 14px;
3108
+ border: 1px solid var(--admin-line-strong);
3109
+ border-radius: var(--admin-radius-sm);
3110
+ background: var(--admin-surface-soft);
3111
+ }
3112
+
3113
+ .liftoff-grid dt {
3114
+ color: var(--admin-muted);
3115
+ font-size: 12px;
3116
+ }
3117
+
3118
+ .liftoff-grid dd {
3119
+ margin: 0;
3120
+ font-size: 13px;
3121
+ }
3122
+
3123
+ .liftoff-actions {
3124
+ display: flex;
3125
+ justify-content: flex-end;
3126
+ gap: 8px;
3127
+ margin-top: 16px;
3128
+ }
3129
+
3130
+ .liftoff-stages {
3131
+ list-style: none;
3132
+ margin: 6px 0 4px;
3133
+ padding: 0;
3134
+ display: grid;
3135
+ gap: 0;
3136
+ }
3137
+
3138
+ .liftoff-stages li {
3139
+ position: relative;
3140
+ display: flex;
3141
+ gap: 12px;
3142
+ align-items: flex-start;
3143
+ padding: 9px 0;
3144
+ }
3145
+
3146
+ /* Connecting rail between the dots. */
3147
+ .liftoff-stages li + li::before {
3148
+ content: "";
3149
+ position: absolute;
3150
+ left: 10px;
3151
+ top: -8px;
3152
+ height: 16px;
3153
+ width: 2px;
3154
+ background: var(--admin-line-strong);
3155
+ }
3156
+
3157
+ .liftoff-stages li > div {
3158
+ display: grid;
3159
+ gap: 1px;
3160
+ }
3161
+
3162
+ .liftoff-stages b {
3163
+ font-size: 13px;
3164
+ }
3165
+
3166
+ .liftoff-stages span:not(.liftoff-dot) {
3167
+ font-size: 12px;
3168
+ color: var(--admin-muted);
3169
+ }
3170
+
3171
+ .liftoff-dot {
3172
+ flex: none;
3173
+ width: 21px;
3174
+ height: 21px;
3175
+ margin-top: 1px;
3176
+ border-radius: 50%;
3177
+ border: 2px solid var(--admin-line-strong);
3178
+ display: grid;
3179
+ place-items: center;
3180
+ font-size: 12px;
3181
+ font-weight: 700;
3182
+ color: #fff;
3183
+ background: transparent;
3184
+ transition: all 0.25s ease;
3185
+ }
3186
+
3187
+ .liftoff-stages li[data-state="active"] .liftoff-dot {
3188
+ border-color: var(--admin-teal, #0f9d8f);
3189
+ box-shadow: 0 0 0 0 color-mix(in srgb, var(--admin-teal, #0f9d8f) 45%, transparent);
3190
+ animation: liftoff-pulse 1.3s ease-out infinite;
3191
+ }
3192
+
3193
+ .liftoff-stages li[data-state="active"] b {
3194
+ color: var(--admin-teal, #0f9d8f);
3195
+ }
3196
+
3197
+ @keyframes liftoff-pulse {
3198
+ from { box-shadow: 0 0 0 0 color-mix(in srgb, var(--admin-teal, #0f9d8f) 45%, transparent); }
3199
+ to { box-shadow: 0 0 0 10px transparent; }
3200
+ }
3201
+
3202
+ .liftoff-stages li[data-state="done"] .liftoff-dot {
3203
+ border-color: var(--admin-green);
3204
+ background: var(--admin-green);
3205
+ }
3206
+
3207
+ .liftoff-stages li[data-state="done"] .liftoff-dot::after {
3208
+ content: "✓";
3209
+ }
3210
+
3211
+ .liftoff-stages li[data-state="fail"] .liftoff-dot {
3212
+ border-color: var(--admin-red);
3213
+ background: var(--admin-red);
3214
+ }
3215
+
3216
+ .liftoff-stages li[data-state="fail"] .liftoff-dot::after {
3217
+ content: "✕";
3218
+ }
3219
+
3220
+ .liftoff-live {
3221
+ margin: 8px 0 0;
3222
+ min-height: 18px;
3223
+ font-size: 13px;
3224
+ color: var(--admin-ink);
3225
+ }
3226
+
3227
+ .liftoff-meta {
3228
+ display: flex;
3229
+ justify-content: space-between;
3230
+ align-items: center;
3231
+ margin-top: 14px;
3232
+ color: var(--admin-muted);
3233
+ }
3234
+
3235
+ .liftoff-outcome-phase {
3236
+ text-align: center;
3237
+ padding-top: 6px;
3238
+ }
3239
+
3240
+ .liftoff-outcome-phase .liftoff-actions {
3241
+ justify-content: center;
3242
+ }
3243
+
3244
+ .liftoff-seal {
3245
+ width: 56px;
3246
+ height: 56px;
3247
+ margin: 4px auto 12px;
3248
+ border-radius: 50%;
3249
+ display: grid;
3250
+ place-items: center;
3251
+ font-size: 28px;
3252
+ font-weight: 700;
3253
+ color: #fff;
3254
+ }
3255
+
3256
+ .liftoff-seal[data-state="ok"] {
3257
+ background: var(--admin-green);
3258
+ animation: liftoff-seal-pop 0.45s cubic-bezier(0.2, 1.6, 0.4, 1);
3259
+ }
3260
+
3261
+ .liftoff-seal[data-state="ok"]::after {
3262
+ content: "✓";
3263
+ }
3264
+
3265
+ .liftoff-seal[data-state="err"] {
3266
+ background: var(--admin-red);
3267
+ }
3268
+
3269
+ .liftoff-seal[data-state="err"]::after {
3270
+ content: "✕";
3271
+ }
3272
+
3273
+ @keyframes liftoff-seal-pop {
3274
+ from { transform: scale(0.4); opacity: 0; }
3275
+ 60% { transform: scale(1.12); }
3276
+ to { transform: none; opacity: 1; }
3277
+ }
3278
+
3279
+ .liftoff-confetti {
3280
+ pointer-events: none;
3281
+ position: absolute;
3282
+ inset: 0;
3283
+ overflow: hidden;
3284
+ border-radius: inherit;
3285
+ }
3286
+
3287
+ .liftoff-confetti i {
3288
+ position: absolute;
3289
+ top: -12px;
3290
+ width: 7px;
3291
+ height: 11px;
3292
+ border-radius: 2px;
3293
+ opacity: 0;
3294
+ animation-name: liftoff-confetti-fall;
3295
+ animation-timing-function: ease-in;
3296
+ animation-fill-mode: forwards;
3297
+ }
3298
+
3299
+ @keyframes liftoff-confetti-fall {
3300
+ 0% { opacity: 1; transform: translate(0, 0) rotate(0deg); }
3301
+ 100% {
3302
+ opacity: 0.9;
3303
+ transform: translate(var(--liftoff-drift, 0px), 660px) rotate(var(--liftoff-spin, 240deg));
3304
+ }
3305
+ }
3306
+
3307
+ @media (prefers-reduced-motion: reduce) {
3308
+ .liftoff-card,
3309
+ .liftoff-card[data-mode="flight"]::before,
3310
+ .liftoff-stages li[data-state="active"] .liftoff-dot,
3311
+ .liftoff-seal[data-state="ok"] {
3312
+ animation: none;
3313
+ }
3314
+ }
3315
+
1771
3316
  .domain-connect-controls {
1772
3317
  display: grid;
1773
3318
  gap: 8px;
@@ -1901,11 +3446,69 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
1901
3446
  min-width: 780px;
1902
3447
  }
1903
3448
 
1904
- .publish-diff-cell {
3449
+ /* rux6: unified review pane */
3450
+ .publish-review-cell {
1905
3451
  padding: 12px 14px;
1906
3452
  background: var(--admin-surface-soft);
1907
3453
  }
1908
3454
 
3455
+ .review-pane {
3456
+ display: grid;
3457
+ grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr);
3458
+ gap: 16px;
3459
+ align-items: start;
3460
+ }
3461
+
3462
+ @media (max-width: 900px) {
3463
+ .review-pane {
3464
+ grid-template-columns: 1fr;
3465
+ }
3466
+ }
3467
+
3468
+ .review-preview {
3469
+ display: grid;
3470
+ gap: 8px;
3471
+ min-width: 0;
3472
+ }
3473
+
3474
+ .review-pane-head {
3475
+ display: flex;
3476
+ align-items: center;
3477
+ justify-content: space-between;
3478
+ gap: 10px;
3479
+ }
3480
+
3481
+ .review-frame {
3482
+ width: 100%;
3483
+ height: 420px;
3484
+ border: 1px solid var(--admin-line-strong);
3485
+ border-radius: var(--admin-radius-sm);
3486
+ background: var(--admin-surface);
3487
+ }
3488
+
3489
+ .review-detail {
3490
+ display: grid;
3491
+ gap: 14px;
3492
+ min-width: 0;
3493
+ }
3494
+
3495
+ .review-section {
3496
+ display: grid;
3497
+ gap: 6px;
3498
+ }
3499
+
3500
+ .review-chips {
3501
+ display: flex;
3502
+ flex-wrap: wrap;
3503
+ gap: 6px;
3504
+ }
3505
+
3506
+ .review-actions {
3507
+ display: flex;
3508
+ flex-wrap: wrap;
3509
+ gap: 8px;
3510
+ }
3511
+
1909
3512
  .publish-diff-head {
1910
3513
  display: flex;
1911
3514
  flex-wrap: wrap;