@tokenoftrust/storefront-runner 1.4.0 → 1.4.2-rc.0

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 (193) hide show
  1. package/apps/storefront/astro.config.mjs +17 -1
  2. package/apps/storefront/integrations/materialize-guard.mjs +56 -0
  3. package/apps/storefront/migrations-apps/0001_woozy_lyja.sql +22 -0
  4. package/apps/storefront/migrations-apps/0002_good_switch.sql +26 -0
  5. package/apps/storefront/migrations-apps/0003_mute_marauders.sql +12 -0
  6. package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
  7. package/apps/storefront/migrations-apps/meta/0002_snapshot.json +1160 -0
  8. package/apps/storefront/migrations-apps/meta/0003_snapshot.json +1235 -0
  9. package/apps/storefront/migrations-apps/meta/_journal.json +21 -0
  10. package/apps/storefront/package.json +0 -1
  11. package/apps/storefront/public/js/dashboard-team.js +38 -2
  12. package/apps/storefront/src/components/Seo.astro +65 -1
  13. package/apps/storefront/src/components/admin/AdminBlogDetailRail.astro +878 -0
  14. package/apps/storefront/src/components/admin/AdminBlogTab.astro +1432 -0
  15. package/apps/storefront/src/components/admin/AdminCustomersTab.astro +3 -7
  16. package/apps/storefront/src/components/admin/AdminPublishTab.astro +1756 -1382
  17. package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +1 -3
  18. package/apps/storefront/src/components/blog/BlogPaywall.astro +87 -0
  19. package/apps/storefront/src/components/blog/PostAccessBadge.astro +43 -0
  20. package/apps/storefront/src/components/blog/PostGrid.astro +57 -0
  21. package/apps/storefront/src/components/blog/ReactionBar.astro +214 -0
  22. package/apps/storefront/src/layouts/Layout.astro +48 -3
  23. package/apps/storefront/src/lib/activity/alerts.ts +426 -0
  24. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
  25. package/apps/storefront/src/lib/activity/deployVersion.ts +120 -0
  26. package/apps/storefront/src/lib/activity/ingest.ts +188 -0
  27. package/apps/storefront/src/lib/activity/ingestAuth.ts +105 -0
  28. package/apps/storefront/src/lib/activity/killSwitch.ts +80 -0
  29. package/apps/storefront/src/lib/activity/query.ts +403 -0
  30. package/apps/storefront/src/lib/activity/recordActivity.ts +101 -0
  31. package/apps/storefront/src/lib/activity/store.ts +120 -0
  32. package/apps/storefront/src/lib/activity/uiActor.ts +150 -0
  33. package/apps/storefront/src/lib/activity/workerCommit.ts +70 -0
  34. package/apps/storefront/src/lib/admin/adminShell.ts +290 -0
  35. package/apps/storefront/src/lib/admin/ai/approvalMode.ts +2 -3
  36. package/apps/storefront/src/lib/admin/ai/audit.ts +3 -5
  37. package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +7 -7
  38. package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +3 -3
  39. package/apps/storefront/src/lib/admin/orderClient.ts +3 -5
  40. package/apps/storefront/src/lib/admin/services/orders.ts +17 -19
  41. package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +4 -6
  42. package/apps/storefront/src/lib/admin/viewmodels/settings.ts +1 -1
  43. package/apps/storefront/src/lib/apps/adminService.ts +4 -4
  44. package/apps/storefront/src/lib/apps/adminSession.ts +53 -44
  45. package/apps/storefront/src/lib/apps/apiAuth.ts +2 -2
  46. package/apps/storefront/src/lib/apps/apiRoute.ts +2 -2
  47. package/apps/storefront/src/lib/apps/catalogMapper.ts +1 -1
  48. package/apps/storefront/src/lib/apps/credentials.ts +2 -2
  49. package/apps/storefront/src/lib/apps/gatewayKeys.ts +4 -4
  50. package/apps/storefront/src/lib/apps/healthAggregate.ts +5 -5
  51. package/apps/storefront/src/lib/apps/orders/attributionService.ts +2 -2
  52. package/apps/storefront/src/lib/apps/orders/customerHash.ts +1 -1
  53. package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +12 -12
  54. package/apps/storefront/src/lib/apps/orders/idempotency.ts +7 -7
  55. package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +1 -1
  56. package/apps/storefront/src/lib/apps/orders/orderMapper.ts +2 -2
  57. package/apps/storefront/src/lib/apps/orders/ordersStore.ts +6 -6
  58. package/apps/storefront/src/lib/apps/registryService.ts +6 -6
  59. package/apps/storefront/src/lib/apps/scopes.ts +2 -2
  60. package/apps/storefront/src/lib/apps/tokenIssuer.ts +4 -4
  61. package/apps/storefront/src/lib/apps/tokenVerifier.ts +6 -6
  62. package/apps/storefront/src/lib/apps/widgets/eligibility.ts +2 -2
  63. package/apps/storefront/src/lib/apps/widgets/frameProps.ts +3 -3
  64. package/apps/storefront/src/lib/apps/widgets/launchToken.ts +4 -4
  65. package/apps/storefront/src/lib/apps/widgets/placements.ts +2 -2
  66. package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +1 -1
  67. package/apps/storefront/src/lib/auth/adminEntry.ts +9 -9
  68. package/apps/storefront/src/lib/auth/identityToken.ts +6 -14
  69. package/apps/storefront/src/lib/auth/loginGate.ts +75 -13
  70. package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +6 -4
  71. package/apps/storefront/src/lib/auth/operatorSession.ts +113 -0
  72. package/apps/storefront/src/lib/auth/route.ts +44 -4
  73. package/apps/storefront/src/lib/auth/session.ts +7 -8
  74. package/apps/storefront/src/lib/auth/totAccessClient.ts +8 -18
  75. package/apps/storefront/src/lib/basePath.ts +9 -2
  76. package/apps/storefront/src/lib/blog/access.ts +29 -0
  77. package/apps/storefront/src/lib/blog/collection.ts +313 -0
  78. package/apps/storefront/src/lib/blog/markdown.ts +174 -0
  79. package/apps/storefront/src/lib/blog/provider.ts +5 -0
  80. package/apps/storefront/src/lib/blog/reactions.ts +87 -0
  81. package/apps/storefront/src/lib/blog/rss.ts +92 -0
  82. package/apps/storefront/src/lib/blog/teaser.ts +83 -0
  83. package/apps/storefront/src/lib/blog/types.ts +34 -0
  84. package/apps/storefront/src/lib/blog/viewCounts.ts +243 -0
  85. package/apps/storefront/src/lib/d1/catalog.ts +2 -2
  86. package/apps/storefront/src/lib/d1/schema-apps.ts +147 -14
  87. package/apps/storefront/src/lib/dev/activityIngestToken.ts +2 -4
  88. package/apps/storefront/src/lib/dev/activityStore.ts +3 -5
  89. package/apps/storefront/src/lib/dev/apiBase.ts +1 -4
  90. package/apps/storefront/src/lib/dev/cockpitStore.ts +1 -9
  91. package/apps/storefront/src/lib/dev/devDraft.ts +1 -1
  92. package/apps/storefront/src/lib/dev/previewStatus.ts +9 -21
  93. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +9 -24
  94. package/apps/storefront/src/lib/edgeCache.ts +17 -0
  95. package/apps/storefront/src/lib/env.ts +51 -0
  96. package/apps/storefront/src/lib/http/fetchTransport.ts +63 -0
  97. package/apps/storefront/src/lib/i18n.ts +25 -0
  98. package/apps/storefront/src/lib/jsonld.ts +118 -0
  99. package/apps/storefront/src/lib/monitoring/manifest.ts +11 -11
  100. package/apps/storefront/src/lib/newsletter/confirmationEmail.ts +30 -0
  101. package/apps/storefront/src/lib/newsletter/consent.ts +23 -0
  102. package/apps/storefront/src/lib/newsletter/digest.ts +117 -0
  103. package/apps/storefront/src/lib/newsletter/resendClient.ts +79 -0
  104. package/apps/storefront/src/lib/newsletter/send.ts +265 -0
  105. package/apps/storefront/src/lib/newsletter/subscribers.ts +156 -0
  106. package/apps/storefront/src/lib/publish/adminPublishTab.ts +3519 -0
  107. package/apps/storefront/src/lib/publish/apex-readiness.ts +41 -18
  108. package/apps/storefront/src/lib/publish/dispatchHealth.ts +5 -7
  109. package/apps/storefront/src/lib/publish/domainState.ts +22 -13
  110. package/apps/storefront/src/lib/publish/panelPolling.ts +78 -0
  111. package/apps/storefront/src/lib/publish/shipWorkspace.ts +100 -42
  112. package/apps/storefront/src/lib/publish/stageBundleUpload.ts +215 -0
  113. package/apps/storefront/src/lib/rate/kvWindow.ts +53 -0
  114. package/apps/storefront/src/lib/search/index.ts +2 -2
  115. package/apps/storefront/src/lib/seo/alternates.ts +42 -0
  116. package/apps/storefront/src/lib/seo/meta.ts +65 -0
  117. package/apps/storefront/src/lib/social/golive.ts +212 -0
  118. package/apps/storefront/src/lib/social/notify.ts +32 -0
  119. package/apps/storefront/src/lib/social/tokenStore.ts +171 -0
  120. package/apps/storefront/src/lib/the-build/provider.ts +27 -0
  121. package/apps/storefront/src/lib/the-build/readingTime.ts +35 -0
  122. package/apps/storefront/src/lib/the-build/toc.ts +95 -0
  123. package/apps/storefront/src/lib/tot/ToTClient.ts +1 -1
  124. package/apps/storefront/src/lib/tot/d1Client.ts +2 -2
  125. package/apps/storefront/src/lib/tot/totClientInterface.ts +3 -4
  126. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +16 -17
  127. package/apps/storefront/src/middleware/index.ts +130 -2
  128. package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
  129. package/apps/storefront/src/pages/admin.astro +52 -1206
  130. package/apps/storefront/src/pages/api/activity.ts +137 -0
  131. package/apps/storefront/src/pages/api/admin/activity-alerts.ts +73 -0
  132. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +27 -1
  133. package/apps/storefront/src/pages/api/apps/admin/install.ts +26 -1
  134. package/apps/storefront/src/pages/api/apps/admin/resume.ts +24 -1
  135. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +24 -1
  136. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +25 -1
  137. package/apps/storefront/src/pages/api/apps/admin/update.ts +24 -1
  138. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +11 -0
  139. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +11 -0
  140. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +20 -0
  141. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +12 -0
  142. package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +104 -0
  143. package/apps/storefront/src/pages/api/cache-purge.ts +11 -0
  144. package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -0
  145. package/apps/storefront/src/pages/api/newsletter/[collection]/subscribe.ts +146 -0
  146. package/apps/storefront/src/pages/api/newsletter/confirm.ts +75 -0
  147. package/apps/storefront/src/pages/api/newsletter/unsubscribe.ts +90 -0
  148. package/apps/storefront/src/pages/api/publish/stage-bundle.ts +70 -0
  149. package/apps/storefront/src/pages/api/rum/vitals.ts +21 -2
  150. package/apps/storefront/src/pages/auth/login.astro +5 -4
  151. package/apps/storefront/src/pages/blog/[slug].astro +96 -16
  152. package/apps/storefront/src/pages/blog/author/[author].astro +140 -0
  153. package/apps/storefront/src/pages/blog/category/[category].astro +67 -0
  154. package/apps/storefront/src/pages/blog/index.astro +2 -0
  155. package/apps/storefront/src/pages/blog/tag/[tag].astro +65 -0
  156. package/apps/storefront/src/pages/cockpit.astro +74 -12
  157. package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +15 -11
  158. package/apps/storefront/src/pages/search.astro +1 -1
  159. package/apps/storefront/src/pages/sitemap.xml.ts +20 -0
  160. package/apps/storefront/src/pages/the-build/[slug].astro +129 -0
  161. package/apps/storefront/src/pages/the-build/index.astro +146 -0
  162. package/apps/storefront/src/pages/the-build/rss.xml.ts +33 -0
  163. package/apps/storefront/src/styles/admin.css +1080 -0
  164. package/apps/storefront/tsconfig.json +5 -0
  165. package/package.json +1 -1
  166. package/packages/public-runtime/package.json +1 -0
  167. package/packages/public-runtime/src/activity/README.md +146 -0
  168. package/packages/public-runtime/src/activity/catalog.ts +553 -0
  169. package/packages/public-runtime/src/activity/event.ts +168 -0
  170. package/packages/public-runtime/src/activity/index.ts +16 -0
  171. package/packages/public-runtime/src/activity/redaction.ts +263 -0
  172. package/packages/public-runtime/src/candidate-index.ts +13 -0
  173. package/packages/public-runtime/src/catalog-d1.ts +1 -1
  174. package/packages/public-runtime/src/customization-preview.ts +25 -2
  175. package/packages/public-runtime/src/customization-runtime.ts +125 -3
  176. package/packages/public-runtime/src/customization-versioning.ts +10 -0
  177. package/packages/public-runtime/src/index.ts +7 -0
  178. package/packages/public-runtime/src/static-bundle.ts +357 -0
  179. package/packages/public-runtime/src/tenant.ts +36 -8
  180. package/scripts/build/copy-tenant-assets.mjs +14 -4
  181. package/scripts/dev/ai-edit.mjs +1 -1
  182. package/scripts/dev/checkout-watch.mjs +2 -2
  183. package/scripts/dev/file-browser.mjs +2 -4
  184. package/scripts/dev/file-writer.mjs +1 -1
  185. package/scripts/dev/git-status.mjs +2 -2
  186. package/scripts/dev/locate-handler.mjs +3 -3
  187. package/scripts/dev/port-check.mjs +3 -3
  188. package/scripts/dev/publish.mjs +5 -9
  189. package/scripts/dev/shot.mjs +1 -1
  190. package/scripts/dev/unified-diff.mjs +8 -3
  191. package/scripts/tot-dev.mjs +6 -9
  192. package/apps/storefront/src/lib/webhooks/signing.ts +0 -29
  193. package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +0 -146
@@ -0,0 +1,3519 @@
1
+ /**
2
+ * Client island for the admin Publish tab (`components/admin/AdminPublishTab.astro`).
3
+ *
4
+ * The tab's template exposes every runtime endpoint + capability flag as `data-*`
5
+ * attributes on `#publish`; this module reads them and wires the queue, aggregate,
6
+ * versions, branch-retention, and domain panels — the whole go-live surface. It runs
7
+ * from a PROCESSED `<script>` (Astro bundles + type-checks it), so the pure logic it
8
+ * used to hand-mirror inline is imported from its tested source of truth instead of
9
+ * duplicated (see docs/architecture/decisions/0013-client-island-pattern-and-inline-drift-guard.md).
10
+ */
11
+ import { createKeyedCache, createCoalescer } from "@/lib/publish/panelPolling";
12
+ import { candidatePrUrl } from "@/lib/preview/candidateDashboard";
13
+ import { classifyResolveResponse } from "@/lib/change/resolveConflict";
14
+
15
+ export function initAdminPublishTab(): void {
16
+ const root: any = document.getElementById("publish");
17
+ if (!root) return;
18
+ const ds = root.dataset;
19
+ const canShip = ds.canShip === "true";
20
+ const repo = ds.repo || "";
21
+ const appDomain = ds.appDomain || "";
22
+
23
+ const statusEl = root.querySelector("[data-publish-status]");
24
+ const queueBody = root.querySelector("[data-publish-queue-body]");
25
+ const queueChip = root.querySelector("[data-publish-queue-chip]");
26
+ const queueCount = root.querySelector("[data-publish-queue-count]");
27
+ const degradedStrip = root.querySelector("[data-publish-degraded]");
28
+
29
+ // rux7 — in-flight UI state the auto-refresh poll must preserve across a
30
+ // re-render. These are the AUTHORITATIVE source of truth (never scraped
31
+ // back out of the DOM): an action's async closure holds a reference to the
32
+ // OLD button, so after a poll swaps the row the completion signal lands on a
33
+ // detached node — reading `disabled`/`data-building` off the fresh DOM would
34
+ // wedge forever. buildPreview/integrate/reject/retire add on start and
35
+ // delete in `finally` (before their own loadQueue), so the next render
36
+ // reflects the true busy set. Open diff panes are the exception: the DOM IS
37
+ // their source of truth (toggleDiff owns open/close), so renderQueue detaches
38
+ // and re-attaches the actual pane node — see renderQueue.
39
+ const inflightBuilds = new Set(); // prNumber currently building
40
+ const busyChanges = new Set(); // changeId with an in-flight integrate/reject/retire
41
+ let aggregatePublishing = false; // the aggregate Publish POST is in flight
42
+ let aggregateRetrying = false; // u15: the aggregate Retry-build POST is in flight
43
+
44
+
45
+ // A fingerprint of the current publish state (candidate head shas + live
46
+ // version), updated on every /api/changes read. Heavy panels key their
47
+ // client cache on it: unchanged fingerprint ⇒ no re-fetch. See changesSignature.
48
+ let currentStateKey: string | null = null;
49
+
50
+ // Visibility state, driven by an IntersectionObserver (see Wiring). Panels
51
+ // don't fetch until first viewed, and the queue poll only runs while the tab
52
+ // is active — a backgrounded/inactive Publish tab must not fire heavy reads.
53
+ let tabActive = false;
54
+ let branchesVisible = false;
55
+
56
+ const setStatus = (el?: any, message?: any, state?: any) => {
57
+ if (!el) return;
58
+ el.textContent = message || "";
59
+ if (state) el.dataset.state = state;
60
+ else delete el.dataset.state;
61
+ };
62
+
63
+ const shortSha = (sha?: any) => (sha ? String(sha).slice(0, 8) : "—");
64
+
65
+ // Build a "+N −M" lines-changed node (added green, removed red) as ONE element,
66
+ // so a meta "·" separator sits before the pair rather than between the numbers.
67
+ // Either count may be absent.
68
+ const linesNode = (added?: any, removed?: any) => {
69
+ const wrap = document.createElement("span");
70
+ wrap.className = "agg-lines";
71
+ if (typeof added === "number") {
72
+ const a = document.createElement("span");
73
+ a.className = "agg-add";
74
+ a.textContent = "+" + added;
75
+ wrap.append(a);
76
+ }
77
+ if (typeof removed === "number") {
78
+ const d = document.createElement("span");
79
+ d.className = "agg-del";
80
+ d.textContent = (typeof added === "number" ? " " : "") + "−" + removed;
81
+ wrap.append(d);
82
+ }
83
+ return wrap;
84
+ };
85
+
86
+
87
+ const age = (iso?: any) => {
88
+ if (!iso) return "—";
89
+ const ms = Date.now() - new Date(iso).getTime();
90
+ if (!Number.isFinite(ms) || ms < 0) return "—";
91
+ const mins = Math.floor(ms / 60000);
92
+ if (mins < 1) return "just now";
93
+ if (mins < 60) return mins + "m ago";
94
+ const hours = Math.floor(mins / 60);
95
+ if (hours < 48) return hours + "h ago";
96
+ return Math.floor(hours / 24) + "d ago";
97
+ };
98
+
99
+ const STATUS_CHIP: Record<string, string> = {
100
+ queued: "",
101
+ reconciling: "blue",
102
+ ready: "green",
103
+ blocked: "red",
104
+ };
105
+
106
+ const chip = (text?: any, tone?: any) => {
107
+ const el = document.createElement("span");
108
+ el.className = "chip" + (tone ? " " + tone : "");
109
+ el.textContent = text;
110
+ return el;
111
+ };
112
+
113
+ const cell = (...children: any[]) => {
114
+ const td = document.createElement("td");
115
+ for (const child of children) {
116
+ if (child == null) continue;
117
+ td.append(typeof child === "string" ? document.createTextNode(child) : child);
118
+ }
119
+ return td;
120
+ };
121
+
122
+ const subtle = (text?: any) => {
123
+ const el = document.createElement("span");
124
+ el.className = "subtle";
125
+ el.textContent = text;
126
+ return el;
127
+ };
128
+
129
+ // One included-change line item (PR # + human title, a one-line description of
130
+ // what it does, and meta: sha / files / lines). Shared by the aggregate plan's
131
+ // "Included changes" and the published-versions "what shipped" list, so both
132
+ // read the same way. Every enrichment field is optional — a row degrades to
133
+ // just "PR #N · <sha>" when candidate_summary didn't cover it.
134
+ const changeSummaryItem = (ch?: any) => {
135
+ const li = document.createElement("li");
136
+ li.className = "agg-included-item";
137
+
138
+ const top = document.createElement("div");
139
+ top.className = "agg-included-top";
140
+ const prEl = document.createElement("span");
141
+ prEl.className = "agg-included-pr";
142
+ prEl.textContent = ch.prNumber != null ? "PR #" + ch.prNumber : ch.changeId;
143
+ top.append(prEl);
144
+ if (ch.title) {
145
+ const t = document.createElement("span");
146
+ t.className = "agg-included-title";
147
+ t.textContent = ch.title;
148
+ top.append(t);
149
+ }
150
+ li.append(top);
151
+
152
+ const description = ch.description || ch.summary;
153
+ if (description) {
154
+ const desc = document.createElement("p");
155
+ desc.className = "agg-included-desc";
156
+ desc.textContent = description;
157
+ li.append(desc);
158
+ }
159
+
160
+ const meta = document.createElement("div");
161
+ meta.className = "agg-included-meta";
162
+ if (ch.headSha) {
163
+ const sha = document.createElement("span");
164
+ sha.className = "publish-mono sha-token";
165
+ sha.textContent = shortSha(ch.headSha);
166
+ meta.append(sha);
167
+ }
168
+ const fileCount = ch.fileCount != null ? ch.fileCount : ch.filesChanged;
169
+ if (fileCount != null) {
170
+ const f = document.createElement("span");
171
+ f.textContent = fileCount + (fileCount === 1 ? " file" : " files");
172
+ meta.append(f);
173
+ }
174
+ if (typeof ch.linesAdded === "number" || typeof ch.linesRemoved === "number") {
175
+ meta.append(linesNode(ch.linesAdded, ch.linesRemoved));
176
+ }
177
+ if (meta.childNodes.length) li.append(meta);
178
+ return li;
179
+ };
180
+
181
+ // ---- ?pr=N deep link ---------------------------------------------------
182
+ // A deep link like /admin#publish?pr=42 asks us to surface a specific PR's
183
+ // row once the queue has loaded. The param rides in the hash fragment
184
+ // (after the tab anchor) because that's how the admin shell routes tabs;
185
+ // fall back to a real query string too. Resolve exactly once — so the
186
+ // scroll/highlight never re-fires on the reloads that follow every ship
187
+ // action — and until it resolves, keep an honest "not in the queue yet"
188
+ // callout up rather than silently doing nothing.
189
+
190
+ const readRequestedPr = () => {
191
+ const hash = window.location.hash || "";
192
+ const q = hash.indexOf("?");
193
+ let raw = new URLSearchParams(q >= 0 ? hash.slice(q + 1) : "").get("pr");
194
+ if (raw == null) raw = new URLSearchParams(window.location.search).get("pr");
195
+ if (raw == null) return null;
196
+ const n = Number.parseInt(raw, 10);
197
+ return Number.isInteger(n) && n > 0 ? n : null;
198
+ };
199
+
200
+ const requestedPr = readRequestedPr();
201
+ let prDeeplinkResolved = false;
202
+
203
+ const applyPrDeeplink = () => {
204
+ if (requestedPr == null || prDeeplinkResolved) return;
205
+ const callout = root.querySelector("[data-publish-pr-callout]");
206
+ const row = queueBody.querySelector('tr[data-pr-number="' + requestedPr + '"]');
207
+ if (!row) {
208
+ if (callout) {
209
+ callout.hidden = false;
210
+ callout.textContent = "PR #" + requestedPr + " isn't in the queue yet.";
211
+ }
212
+ return;
213
+ }
214
+ prDeeplinkResolved = true;
215
+ if (callout) {
216
+ callout.hidden = true;
217
+ callout.textContent = "";
218
+ }
219
+ row.scrollIntoView({ block: "center", behavior: "smooth" });
220
+ row.classList.remove("publish-row-highlight");
221
+ void row.offsetWidth; // restart the flash animation if re-applied
222
+ row.classList.add("publish-row-highlight");
223
+ window.setTimeout(() => row.classList.remove("publish-row-highlight"), 2600);
224
+ };
225
+
226
+ // ---- Open change queue -------------------------------------------------
227
+
228
+ const renderQueue = (payload?: any) => {
229
+ const changes = Array.isArray(payload.changes) ? payload.changes : [];
230
+ // rux1: surface a forge-listing failure as a visible strip — never let a
231
+ // degraded (short) list read as a genuine empty queue.
232
+ if (degradedStrip) degradedStrip.hidden = payload.degraded !== true;
233
+ queueBody.textContent = "";
234
+ if (queueCount) queueCount.textContent = String(changes.length);
235
+ if (queueChip) {
236
+ queueChip.textContent = changes.length === 1 ? "1 open" : changes.length + " open";
237
+ queueChip.className = "chip" + (changes.length ? " blue" : "");
238
+ }
239
+
240
+ if (!changes.length) {
241
+ const tr = document.createElement("tr");
242
+ const td = cell(
243
+ subtle(
244
+ "No open changes. New store changes appear here when a developer submits them for review.",
245
+ ),
246
+ );
247
+ td.colSpan = 6;
248
+ tr.append(td);
249
+ queueBody.append(tr);
250
+ return;
251
+ }
252
+
253
+ for (const change of changes) {
254
+ const tr = document.createElement("tr");
255
+ if (change.prNumber != null) tr.dataset.prNumber = String(change.prNumber);
256
+
257
+ // Change id + branch / PR + preview link.
258
+ const idCell = document.createElement("td");
259
+ const idStrong = document.createElement("strong");
260
+ idStrong.textContent = change.changeId || "—";
261
+ idCell.append(idStrong);
262
+ const meta = [];
263
+ if (change.branch) meta.push(change.branch);
264
+ if (change.prNumber != null) meta.push("PR #" + change.prNumber);
265
+ if (meta.length) idCell.append(subtle(meta.join(" · ")));
266
+ // Owner-facing "who did this" — accept takes priority over build (a
267
+ // candidate that's been accepted-to-preview is more meaningfully
268
+ // "acted on" than merely built). Reject never shows here: a rejected
269
+ // candidate is closed and has already left the open queue.
270
+ const actorRecord = change.actors && (change.actors.accept || change.actors.build);
271
+ if (actorRecord) {
272
+ const label = change.actors.accept ? "Accepted by " : "Built by ";
273
+ idCell.append(subtle(label + (actorRecord.name || actorRecord.email)));
274
+ }
275
+ // Prefer the synthesized friendly per-PR route (handles every state);
276
+ // fall back to any server-provided previewUrl for change-first rows
277
+ // that have no PR number yet.
278
+ const previewUrl = candidatePrUrl(appDomain, change.prNumber) || change.previewUrl;
279
+ if (previewUrl) {
280
+ const preview = document.createElement("a");
281
+ preview.href = previewUrl;
282
+ preview.target = "_blank";
283
+ preview.rel = "noopener";
284
+ preview.textContent = "Open preview";
285
+ const wrap = document.createElement("span");
286
+ wrap.className = "subtle";
287
+ wrap.append(preview);
288
+ idCell.append(wrap);
289
+ }
290
+ tr.append(idCell);
291
+
292
+ // Reconcile status. u13 DISPLAY HONESTY: when the server could not confirm
293
+ // this row's live state against the forge (liveStateConfirmed === false — the
294
+ // forge listing failed), its stored label may be stale (e.g. a merged PR still
295
+ // reads "ready"). Render "State unknown" instead of asserting the stale label
296
+ // as current fact; keep the last-known label as a subtle, clearly-hedged note.
297
+ let statusCell;
298
+ if (change.liveStateConfirmed === false) {
299
+ statusCell = cell(chip("State unknown", "amber"));
300
+ statusCell.append(
301
+ subtle("Forge unreachable — last known: " + (change.status || "unknown")),
302
+ );
303
+ } else {
304
+ statusCell = cell(chip(change.status || "unknown", STATUS_CHIP[change.status] ?? "amber"));
305
+ if (change.lastError) statusCell.append(subtle(change.lastError));
306
+ }
307
+ tr.append(statusCell);
308
+
309
+ // Evidence / compliance verdict.
310
+ const checks = (change.evidence && change.evidence.checks) || [];
311
+ const failing = checks.filter((c: any) => c.status === "fail");
312
+ const promotable = Boolean(change.evidence && change.evidence.promotable);
313
+ const checksCell = cell(
314
+ promotable ? chip("Checks passed", "green") : chip("Checks blocked", "amber"),
315
+ );
316
+ if (failing.length) {
317
+ checksCell.append(subtle("Failing: " + failing.map((c: any) => c.label || c.id).join(", ")));
318
+ } else if (!checks.length) {
319
+ checksCell.append(subtle("No checks reported yet"));
320
+ }
321
+ tr.append(checksCell);
322
+
323
+ // Head sha + age.
324
+ const headCell = cell(shortSha(change.headSha));
325
+ headCell.classList.add("publish-mono");
326
+ tr.append(headCell);
327
+ tr.append(cell(age(change.updatedAt)));
328
+
329
+ // Actions.
330
+ const actions = document.createElement("td");
331
+ actions.className = "action publish-actions";
332
+
333
+ // rux5 — a synthetic not-built forge PR row (built:false / status
334
+ // "not-built") has NO built candidate: changeId is null, so the built-only
335
+ // actions below (Diff/Accept/Reject/Retire, all keyed on changeId) do not
336
+ // apply. Its one action is Build preview — materialize the PR's hosted
337
+ // preview on demand. Capability-gated: the button is SHOWN only to a
338
+ // ship-capable operator, and the server (buildGate) re-authorizes — the UI
339
+ // check is UX, not the real gate.
340
+ if (change.built === false || change.status === "not-built") {
341
+ if (canShip) {
342
+ const build = document.createElement("button");
343
+ build.type = "button";
344
+ build.className = "btn primary";
345
+ build.title = "Materialize this PR's hosted preview on demand (does not go live)";
346
+ // rux7: if a build for this PR is mid-flight, a poll re-render must
347
+ // reflect the spinner/disabled state (not a fresh clickable button
348
+ // that would let the operator double-submit). The in-flight closure
349
+ // re-renders on completion, clearing this.
350
+ if (change.prNumber != null && inflightBuilds.has(change.prNumber)) {
351
+ build.textContent = "Building…";
352
+ build.disabled = true;
353
+ build.dataset.building = "true";
354
+ } else {
355
+ build.textContent = "Build preview";
356
+ }
357
+ build.addEventListener("click", () => buildPreview(change, build));
358
+ actions.append(build);
359
+ } else {
360
+ actions.append(subtle("Owner/admin sign-in required to build"));
361
+ }
362
+ tr.append(actions);
363
+ queueBody.append(tr);
364
+ continue;
365
+ }
366
+
367
+ // rux7: while an integrate/reject/retire for this change is in flight,
368
+ // keep its mutating actions disabled across a poll re-render so the
369
+ // operator can't double-submit against a freshly-minted button.
370
+ const changeBusy = busyChanges.has(change.changeId);
371
+
372
+ // rux6 Review: expand the row into a single "review moment" pane —
373
+ // preview iframe + evidence chips + diff + Accept/Reject — so a reviewer
374
+ // never leaves the queue. Supersedes the standalone Diff button (the dg7
375
+ // diff now lives inside the pane, reusing renderDiff). The row's own
376
+ // Accept/Reject/Retire buttons below are unchanged.
377
+ const reviewBtn = document.createElement("button");
378
+ reviewBtn.type = "button";
379
+ reviewBtn.className = "btn";
380
+ reviewBtn.textContent = "Review";
381
+ reviewBtn.setAttribute("aria-expanded", "false");
382
+ reviewBtn.title = "Open one pane to preview, diff, and act on this change";
383
+ reviewBtn.addEventListener("click", () => toggleReview(change, reviewBtn, tr));
384
+ actions.append(reviewBtn);
385
+ // b11 SPLIT: the candidate-queue primary action is now ACCEPT TO PREVIEW
386
+ // (b08 integrate = advance the shared preview aggregate), NOT go-live.
387
+ // Integrate flips NO live channel, so it has NO paywall (entitled) gate —
388
+ // it holds only the ship-on-behalf capability floor. Going live is the
389
+ // aggregate card's Publish action, once the aggregate is green.
390
+ const accept = document.createElement("button");
391
+ accept.type = "button";
392
+ accept.className = "btn primary";
393
+ // A ready candidate with passed evidence but `mergeable:false` is a
394
+ // forge-settled conflict with the current preview tip. Make the existing
395
+ // automatic rebuild/re-integrate recovery reachable from the queue rather
396
+ // than presenting a generic, disabled "not ready" action. Other false
397
+ // mergeability states remain disabled: a rebuild cannot fix an incomplete
398
+ // reconcile or failed evidence.
399
+ const needsPreviewUpdate =
400
+ change.status === "ready" && promotable && change.mergeable === false;
401
+ accept.textContent = needsPreviewUpdate ? "Update change" : "Accept to preview";
402
+ accept.disabled = !canShip || (!needsPreviewUpdate && !change.mergeable) || changeBusy;
403
+ accept.title = !canShip
404
+ ? "Requires a ship-on-behalf grant on your account — ask the store owner"
405
+ : needsPreviewUpdate
406
+ ? "Preview changed while you worked — update this change, then integrate it"
407
+ : !change.mergeable
408
+ ? "This change is not ready to integrate"
409
+ : "Integrate this change into the preview aggregate (does not go live)";
410
+ accept.addEventListener("click", () =>
411
+ needsPreviewUpdate ? openConflictRecovery(change, accept) : integrateCandidate(change, accept),
412
+ );
413
+ actions.append(accept);
414
+ const reject = document.createElement("button");
415
+ reject.type = "button";
416
+ reject.className = "btn";
417
+ reject.textContent = "Reject";
418
+ reject.disabled = !canShip || changeBusy;
419
+ reject.title = canShip
420
+ ? "Close this change without publishing"
421
+ : "Requires a ship-on-behalf grant on your account — ask the store owner";
422
+ reject.addEventListener("click", () => rejectChange(change, reject));
423
+ actions.append(reject);
424
+
425
+ // U7 Retire/GC: evict this candidate's preview environment + version to
426
+ // reclaim space. DISTINCT from Reject — retire is reversible-by-rebuild
427
+ // (U1 build-on-demand + the U2 fallback page rematerialize it), so a
428
+ // retired PR degrades to "not built yet", not a dead 404.
429
+ const retire = document.createElement("button");
430
+ retire.type = "button";
431
+ retire.className = "btn";
432
+ retire.textContent = "Retire";
433
+ retire.disabled = !canShip || changeBusy;
434
+ retire.title = canShip
435
+ ? "Evict this preview to reclaim space — rebuildable on demand (not the same as Reject)"
436
+ : "Requires an owner or admin sign-in";
437
+ retire.addEventListener("click", () => retireChange(change, retire));
438
+ actions.append(retire);
439
+ tr.append(actions);
440
+
441
+ queueBody.append(tr);
442
+
443
+ }
444
+ };
445
+
446
+ const renderCurrent = (current?: any, shippedBy?: any) => {
447
+ const versionEl = root.querySelector("[data-publish-live-version]");
448
+ const sourceEl = root.querySelector("[data-publish-live-source]");
449
+ const atEl = root.querySelector("[data-publish-live-at]");
450
+ const shippedByEl = root.querySelector("[data-publish-live-shipped-by]");
451
+ if (current && current.versionId) {
452
+ if (versionEl) versionEl.textContent = shortSha(current.versionId);
453
+ if (sourceEl) {
454
+ sourceEl.textContent =
455
+ (current.source === "commit" ? "Published from a change" : "Published version") +
456
+ (current.artifactCount != null ? " · " + current.artifactCount + " files" : "");
457
+ }
458
+ if (atEl) atEl.textContent = current.createdAt ? age(current.createdAt) : "—";
459
+ // Owner-facing "who did this" — see changeActorAttribution.ts. Absent for
460
+ // any version published before this attribution existed, which is honest,
461
+ // not a bug.
462
+ if (shippedByEl) {
463
+ shippedByEl.textContent = shippedBy ? "Shipped by " + (shippedBy.name || shippedBy.email) : "";
464
+ }
465
+ } else {
466
+ if (versionEl) versionEl.textContent = "Not live yet";
467
+ if (sourceEl) sourceEl.textContent = "Nothing has been published for this store";
468
+ if (atEl) atEl.textContent = "—";
469
+ if (shippedByEl) shippedByEl.textContent = "";
470
+ }
471
+ };
472
+
473
+ // ---- rux7: auto-refresh (poll GET /api/changes) ------------------------
474
+ // Keep the queue + aggregate live without a manual reload: poll on an
475
+ // interval that BACKS OFF while nothing changes (grow the delay up to a cap)
476
+ // and SNAPS BACK to the base the moment something does. Pause entirely while
477
+ // the tab is hidden (document.hidden) — a backgrounded admin tab must not
478
+ // hammer the endpoint — and refresh promptly on return. A poll re-render
479
+ // reuses renderQueue/renderAggregate, which already preserve open diff panes
480
+ // and in-flight actions (inflightBuilds / busyChanges / aggregatePublishing),
481
+ // and applyPrDeeplink stays a one-time effect (prDeeplinkResolved), so the
482
+ // ?pr scroll never re-fires on a tick.
483
+ const POLL_BASE_MS = 8000;
484
+ const POLL_MAX_MS = 60000;
485
+ const POLL_GROWTH = 1.6;
486
+ let pollDelay = POLL_BASE_MS;
487
+ let pollTimer: any = null;
488
+ let pollBusy = false;
489
+ let lastChangesSignature: string | null = null;
490
+
491
+ // A compact projection of everything the queue/aggregate render VISIBLY off,
492
+ // so a no-op poll (identical data) neither re-renders nor interrupts a text
493
+ // selection — it just backs off. Relative ages are intentionally excluded;
494
+ // they drift at most one backoff interval and refresh on any real change.
495
+ const changesSignature = (data?: any) => {
496
+ const cur = data.current || {};
497
+ const agg = data.aggregate || {};
498
+ const run = agg.activeRun || agg.lastRun || {};
499
+ const plan = data.plan || {};
500
+ const rows = (Array.isArray(data.changes) ? data.changes : []).map((c: any) => [
501
+ c.changeId,
502
+ c.prNumber,
503
+ c.status,
504
+ c.headSha,
505
+ c.mergeable,
506
+ c.built,
507
+ c.lastError || null,
508
+ !!(c.evidence && c.evidence.promotable),
509
+ c.actors ? Object.keys(c.actors).sort().join(",") : "",
510
+ ]);
511
+ const summary = data.releaseSummary || (agg.releaseSummary) || null;
512
+ return JSON.stringify({
513
+ degraded: data.degraded === true,
514
+ current: [cur.versionId || null, cur.createdAt || null, cur.artifactCount ?? null],
515
+ shippedBy: (data.shippedBy && data.shippedBy.email) || null,
516
+ aggregate: [
517
+ agg.queueState || null,
518
+ agg.aggregateSha || null,
519
+ run.state || null,
520
+ run.finishedAt || null,
521
+ // include per-PR identity + title + stats so enrichment (a title/scope
522
+ // arriving) re-renders
523
+ (agg.includedPrs || []).map((p: any) => [
524
+ p.prNumber ?? p.changeId ?? null, p.headSha || null, p.title || null,
525
+ p.fileCount ?? null, p.linesAdded ?? null, p.linesRemoved ?? null,
526
+ Array.isArray(p.diff) ? p.diff.length : 0,
527
+ ]),
528
+ ],
529
+ summary: summary ? [summary.lead || summary.text || null, (summary.highlights || []).length] : null,
530
+ scope: data.stagedScope
531
+ ? [data.stagedScope.files, data.stagedScope.added, data.stagedScope.removed]
532
+ : null,
533
+ plan: [plan.ok === true, plan.pinnedSha || null, plan.artifactDigest || null, plan.alreadyShipped === true],
534
+ rows,
535
+ });
536
+ };
537
+
538
+ const clearPoll = () => {
539
+ if (pollTimer) {
540
+ clearTimeout(pollTimer);
541
+ pollTimer = null;
542
+ }
543
+ };
544
+
545
+ const schedulePoll = () => {
546
+ clearPoll();
547
+ // Paused while the browser tab is hidden (visibilitychange resumes) OR
548
+ // while the Publish admin-tab is not the active one (the IntersectionObserver
549
+ // resumes it on activation) — an inactive tab must not poll heavy reads.
550
+ if (document.hidden || !tabActive) return;
551
+ pollTimer = setTimeout(runPoll, pollDelay);
552
+ };
553
+
554
+ // One poll tick: fetch, and only re-render when the projection actually
555
+ // changed. Returns an outcome the caller uses to size the next interval.
556
+ const pollChanges = async () => {
557
+ let res;
558
+ try {
559
+ res = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
560
+ } catch {
561
+ return "error"; // network blip — keep the last good view, just back off
562
+ }
563
+ if (res.status === 401 || res.status === 403) return "auth"; // session gone
564
+ const data = await res.json().catch(() => null);
565
+ if (!res.ok || !data) return "error";
566
+ const sig = changesSignature(data);
567
+ currentStateKey = sig; // heavy panels cache against this fingerprint
568
+ if (sig === lastChangesSignature) return "unchanged";
569
+ lastChangesSignature = sig;
570
+ renderCurrent(data.current, data.shippedBy);
571
+ renderQueue(data);
572
+ applyPrDeeplink();
573
+ renderAggregate(data);
574
+ return "changed";
575
+ };
576
+
577
+ const runPoll = async () => {
578
+ pollTimer = null;
579
+ if (document.hidden || !tabActive || pollBusy) {
580
+ schedulePoll();
581
+ return;
582
+ }
583
+ pollBusy = true;
584
+ let outcome;
585
+ try {
586
+ outcome = await pollChanges();
587
+ } finally {
588
+ pollBusy = false;
589
+ }
590
+ if (outcome === "auth") {
591
+ clearPoll(); // stop polling — a sign-in is required; a manual refresh retries
592
+ return;
593
+ }
594
+ // Branch retention rides the SAME cadence as the queue so its rows reflect
595
+ // live forge state, not a frozen page-load snapshot (b1: a stale "PR #11
596
+ // open / Keep" 30+ min after the PR closed) — but ONLY while the branches
597
+ // panel is actually on screen (lazy: don't fetch a panel the operator
598
+ // isn't looking at). loadBranches coalesces overlapping ticks and owns its
599
+ // own no-change guard and unreachable fallback.
600
+ if (branchesVisible) loadBranches();
601
+ pollDelay =
602
+ outcome === "changed"
603
+ ? POLL_BASE_MS
604
+ : Math.min(POLL_MAX_MS, Math.round(pollDelay * POLL_GROWTH));
605
+ schedulePoll();
606
+ };
607
+
608
+ const loadQueue = async () => {
609
+ try {
610
+ const res = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
611
+ if (res.status === 401 || res.status === 403) {
612
+ if (queueChip) queueChip.textContent = "Sign-in required";
613
+ if (degradedStrip) degradedStrip.hidden = true;
614
+ queueBody.textContent = "";
615
+ const tr = document.createElement("tr");
616
+ const td = cell(subtle("Sign in as a store owner or admin to view the publish queue."));
617
+ td.colSpan = 6;
618
+ tr.append(td);
619
+ queueBody.append(tr);
620
+ return;
621
+ }
622
+ const data = await res.json();
623
+ if (!res.ok) {
624
+ setStatus(statusEl, data.error || "Could not load the change queue (" + res.status + ")", "err");
625
+ return;
626
+ }
627
+ renderCurrent(data.current, data.shippedBy);
628
+ renderQueue(data);
629
+ applyPrDeeplink();
630
+ renderAggregate(data);
631
+ // rux7: this is fresh authoritative data — resync the poll clock so the
632
+ // next tick dedupes against it, and reset the cadence to responsive
633
+ // (a manual refresh / a just-completed action likely means more coming).
634
+ lastChangesSignature = changesSignature(data);
635
+ currentStateKey = lastChangesSignature; // heavy panels cache against this
636
+ pollDelay = POLL_BASE_MS;
637
+ schedulePoll();
638
+ } catch {
639
+ setStatus(statusEl, "Could not load the change queue — network error", "err");
640
+ }
641
+ };
642
+
643
+ // ---- b11: the aggregate card + exact-digest Publish ---------------------
644
+ // Reads the aggregate/runs/plan projection GET /api/changes now returns
645
+ // (AggregateCardView + AggregateShipPlanView, built by aggregateView.ts):
646
+ // - the aggregate's queueState + build sha + reviewed digest,
647
+ // - the COMBINED EVIDENCE = the last integration run's honest state +
648
+ // statusMessage (never re-derived),
649
+ // - the included-PR batch,
650
+ // - the b09 ship PLAN (pinned SHA/digest/PRs/rollback/paywall + run link),
651
+ // shown inline so the confirm never has to POST to preview it.
652
+
653
+ const aggPanel = root.querySelector("[data-publish-aggregate]");
654
+ const AGG_STATE_CHIP: Record<string, string> = {
655
+ empty: "",
656
+ queued: "amber",
657
+ integrating: "blue",
658
+ green: "green",
659
+ red: "red",
660
+ shipped: "green",
661
+ };
662
+ // The plan we last rendered — the exact-digest guard the Publish POST sends
663
+ // back so a stale plan (queue moved / artifact changed) is refused, never
664
+ // shipped blind.
665
+ let currentPlan: any = null;
666
+
667
+ const runLink = (revHref?: any, label?: any) => {
668
+ if (!revHref) return null;
669
+ const a = document.createElement("a");
670
+ a.href = revHref;
671
+ a.target = "_blank";
672
+ a.rel = "noopener";
673
+ a.textContent = label || "Open build";
674
+ return a;
675
+ };
676
+
677
+ const renderAggregate = (data?: any) => {
678
+ if (!aggPanel) return;
679
+ const agg = data.aggregate || null;
680
+ const plan = data.plan || null;
681
+ currentPlan = plan && plan.ok ? plan : null;
682
+
683
+ // AI-assembled release summary — plain-language "what am I publishing?".
684
+ // OPTIONAL server enrichment: data.releaseSummary { lead|text, highlights?:
685
+ // [{title, detail}] }. Absent → the whole card stays hidden and the panel
686
+ // degrades to the technical chain below (graceful fallback, never a stub).
687
+ const summaryWrap = aggPanel.querySelector("[data-aggregate-summary]");
688
+ if (summaryWrap) {
689
+ const summary = data.releaseSummary || (agg && agg.releaseSummary) || null;
690
+ const lead = summary && (summary.lead || summary.text);
691
+ if (!lead) {
692
+ summaryWrap.hidden = true;
693
+ } else {
694
+ summaryWrap.hidden = false;
695
+ const leadEl = summaryWrap.querySelector("[data-aggregate-summary-lead]");
696
+ const pointsEl = summaryWrap.querySelector("[data-aggregate-summary-points]");
697
+ if (leadEl) leadEl.textContent = lead;
698
+ if (pointsEl) {
699
+ pointsEl.textContent = "";
700
+ const points = Array.isArray(summary.highlights) ? summary.highlights : [];
701
+ for (const pt of points) {
702
+ const li = document.createElement("li");
703
+ const tick = document.createElement("span");
704
+ tick.className = "agg-tick";
705
+ tick.setAttribute("aria-hidden", "true");
706
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
707
+ const use = document.createElementNS("http://www.w3.org/2000/svg", "use");
708
+ use.setAttribute("href", "#i-check");
709
+ svg.append(use);
710
+ tick.append(svg);
711
+ const text = document.createElement("span");
712
+ if (pt && typeof pt === "object" && pt.title) {
713
+ const b = document.createElement("b");
714
+ b.textContent = pt.title;
715
+ text.append(b);
716
+ if (pt.detail) text.append(document.createTextNode(" — " + pt.detail));
717
+ } else {
718
+ text.textContent = typeof pt === "string" ? pt : (pt && pt.detail) || "";
719
+ }
720
+ li.append(tick, text);
721
+ pointsEl.append(li);
722
+ }
723
+ pointsEl.hidden = points.length === 0;
724
+ }
725
+ }
726
+ }
727
+
728
+ const chipEl = aggPanel.querySelector("[data-aggregate-chip]");
729
+ const shaEl = aggPanel.querySelector("[data-aggregate-sha]");
730
+ const runLinkEl = aggPanel.querySelector("[data-aggregate-run-link]");
731
+ const evEl = aggPanel.querySelector("[data-aggregate-evidence]");
732
+ const evDetailEl = aggPanel.querySelector("[data-aggregate-evidence-detail]");
733
+ const digestEl = aggPanel.querySelector("[data-aggregate-digest]");
734
+ const prsWrap = aggPanel.querySelector("[data-aggregate-prs-wrap]");
735
+ const prsTitle = aggPanel.querySelector("[data-aggregate-prs-title]");
736
+ const deltaNote = aggPanel.querySelector("[data-aggregate-delta-note]");
737
+ const scopeEl = aggPanel.querySelector("[data-aggregate-scope]");
738
+ const prsList = aggPanel.querySelector("[data-aggregate-prs]");
739
+ const publishBtn = aggPanel.querySelector("[data-aggregate-publish]");
740
+ const publishDetail = aggPanel.querySelector("[data-aggregate-publish-detail]");
741
+ const retryRow = aggPanel.querySelector("[data-aggregate-retry-row]");
742
+ const retryBtn = aggPanel.querySelector("[data-aggregate-retry-build]");
743
+
744
+ const state = (agg && agg.queueState) || "empty";
745
+ // u15 — only offer a retry while the aggregate is actually red; a poll
746
+ // refresh landing mid-retry must not re-enable a button whose click is
747
+ // already in flight (rux7's own guard, mirrored here).
748
+ if (retryRow) retryRow.hidden = state !== "red";
749
+ if (retryBtn) retryBtn.disabled = aggregateRetrying;
750
+ if (chipEl) {
751
+ chipEl.textContent = state;
752
+ chipEl.className = "chip " + (AGG_STATE_CHIP[state] || "");
753
+ }
754
+ if (shaEl) shaEl.textContent = agg && agg.aggregateSha ? shortSha(agg.aggregateSha) : "—";
755
+ if (runLinkEl) {
756
+ runLinkEl.textContent = "";
757
+ const link = agg && runLink(agg.revHref, "Open build");
758
+ if (link) runLinkEl.append(link);
759
+ else runLinkEl.append(document.createTextNode("The current green preview build for this store."));
760
+ }
761
+
762
+ // Combined evidence = the last (or in-flight) integration run's honest state.
763
+ const lastRun = agg && (agg.activeRun || agg.lastRun);
764
+ if (evEl) {
765
+ if (!lastRun) {
766
+ evEl.textContent = "No runs yet";
767
+ evEl.dataset.state = "none";
768
+ } else if (lastRun.state === "passed") {
769
+ evEl.textContent = "Passed";
770
+ evEl.dataset.state = "passed";
771
+ } else if (lastRun.state === "failed") {
772
+ evEl.textContent = "Failed";
773
+ evEl.dataset.state = "failed";
774
+ } else {
775
+ evEl.textContent = "Integrating…";
776
+ evEl.dataset.state = "running";
777
+ }
778
+ }
779
+ if (evDetailEl) {
780
+ evDetailEl.textContent = "";
781
+ if (lastRun) {
782
+ const parts = [];
783
+ if (lastRun.statusMessage) parts.push(lastRun.statusMessage);
784
+ else if (lastRun.state === "passed") parts.push("Combined evidence passed.");
785
+ else if (lastRun.state === "failed") parts.push("Combined evidence did not pass.");
786
+ if (lastRun.finishedAt) parts.push(age(lastRun.finishedAt));
787
+ evDetailEl.append(document.createTextNode(parts.join(" · ")));
788
+ const rl = runLink(lastRun.revHref, "Run build");
789
+ if (rl) {
790
+ evDetailEl.append(document.createTextNode(" · "));
791
+ evDetailEl.append(rl);
792
+ }
793
+ } else {
794
+ evDetailEl.append(document.createTextNode("The last integration run's verdict."));
795
+ }
796
+ }
797
+
798
+ // Reviewed digest: only known once the plan pins it (b09). Never fabricated.
799
+ if (digestEl) {
800
+ digestEl.textContent = currentPlan ? shortSha(currentPlan.artifactDigest.replace(/^sha256:/, "")) : "—";
801
+ }
802
+
803
+ // u14 — the STAGED delta vs live. The included-PR batch is the "missing
804
+ // middle": what's in the staged preview but NOT yet on the live site.
805
+ // `includedPrs` never clears on ship (a shipped aggregate still lists its
806
+ // PRs), so "ahead of live" MUST key on the shas — staged aggregateSha vs the
807
+ // live version id — not on the list length alone.
808
+ const prs = (agg && agg.includedPrs) || [];
809
+ const stagedSha = agg && agg.aggregateSha;
810
+ const liveId = data.current && data.current.versionId;
811
+ const matchesLive = Boolean(stagedSha && liveId && stagedSha === liveId);
812
+ const aheadCount = prs.length;
813
+
814
+ if (prsWrap) prsWrap.hidden = prs.length === 0;
815
+ if (prsTitle) {
816
+ prsTitle.textContent =
817
+ aheadCount && !matchesLive
818
+ ? "Staged changes — " + aheadCount + " ahead of live"
819
+ : "Included changes";
820
+ }
821
+ if (deltaNote) {
822
+ let msg = "";
823
+ if (matchesLive) {
824
+ msg = "This staged build is exactly what's live — nothing is waiting to publish.";
825
+ } else if (!liveId) {
826
+ msg = "Nothing is live yet — publishing makes this staged build your first version.";
827
+ } else if (aheadCount) {
828
+ msg =
829
+ "In the staged preview (" + shortSha(stagedSha) + ") but not on your live site (" +
830
+ shortSha(liveId) + ") until you publish.";
831
+ }
832
+ deltaNote.textContent = msg;
833
+ deltaNote.hidden = !msg;
834
+ }
835
+
836
+ // Staged → live delta strip: the staged aggregate sha ahead of the live
837
+ // pointer. Shown only when there IS a delta (ahead of a real live version).
838
+ const deltaStrip = aggPanel.querySelector("[data-aggregate-delta-strip]");
839
+ if (deltaStrip) {
840
+ const showStrip = Boolean(aheadCount && !matchesLive && stagedSha && liveId);
841
+ deltaStrip.hidden = !showStrip;
842
+ if (showStrip) {
843
+ const stagedEl = deltaStrip.querySelector("[data-agg-delta-staged]");
844
+ const liveEl = deltaStrip.querySelector("[data-agg-delta-live]");
845
+ const countEl = deltaStrip.querySelector("[data-agg-delta-count]");
846
+ if (stagedEl) stagedEl.textContent = shortSha(stagedSha);
847
+ if (liveEl) liveEl.textContent = shortSha(liveId);
848
+ if (countEl) countEl.textContent = aheadCount + " ahead";
849
+ }
850
+ }
851
+
852
+ // Top-level scope-of-change: "Scope · N files · +A −R" across the staged batch.
853
+ // A risk-at-a-glance measure; hidden when no staged change carries size stats.
854
+ if (scopeEl) {
855
+ const scope = data.stagedScope;
856
+ const hasScope = Boolean(scope && (scope.files > 0 || scope.added > 0 || scope.removed > 0));
857
+ scopeEl.textContent = "";
858
+ scopeEl.hidden = !hasScope;
859
+ if (hasScope) {
860
+ const label = document.createElement("span");
861
+ label.className = "agg-scope-label";
862
+ label.textContent = "Scope";
863
+ scopeEl.append(label);
864
+ const files = document.createElement("span");
865
+ files.textContent = scope.files + (scope.files === 1 ? " file" : " files");
866
+ scopeEl.append(files);
867
+ if (scope.added || scope.removed) {
868
+ scopeEl.append(linesNode(scope.added || 0, scope.removed || 0));
869
+ }
870
+ if (scope.partial) {
871
+ const partial = document.createElement("span");
872
+ partial.className = "agg-scope-partial";
873
+ partial.textContent = "partial";
874
+ partial.title = "Some staged changes had no size stats — the totals cover the rest.";
875
+ scopeEl.append(partial);
876
+ }
877
+ }
878
+ }
879
+
880
+ if (prsList) {
881
+ prsList.textContent = "";
882
+ for (const pr of prs) {
883
+ const li = document.createElement("li");
884
+ li.className = "agg-change";
885
+
886
+ const main = document.createElement("div");
887
+ main.className = "agg-change-main";
888
+
889
+ // Line 1: PR label + optional human title (what the change actually is).
890
+ const line1 = document.createElement("div");
891
+ line1.className = "agg-change-line";
892
+ const label = document.createElement("span");
893
+ label.className = "agg-change-pr";
894
+ label.textContent = pr.prNumber != null ? "PR #" + pr.prNumber : pr.changeId;
895
+ line1.append(label);
896
+ if (pr.title) {
897
+ const title = document.createElement("span");
898
+ title.className = "agg-change-title";
899
+ title.textContent = pr.title;
900
+ line1.append(title);
901
+ }
902
+ main.append(line1);
903
+
904
+ // Line 2: sha token + provenance (server enrichment): WHEN it entered the
905
+ // staged preview + WHO ran Integrate. Best-effort — absent when unattributed.
906
+ const line2 = document.createElement("div");
907
+ line2.className = "agg-change-line agg-change-sub";
908
+ const sha = document.createElement("span");
909
+ sha.className = "publish-mono sha-token";
910
+ sha.textContent = shortSha(pr.headSha);
911
+ line2.append(sha);
912
+ if (pr.integratedAt || (pr.integratedBy && pr.integratedBy.email)) {
913
+ const meta = document.createElement("span");
914
+ meta.className = "subtle aggregate-pr-meta";
915
+ const bits = [];
916
+ if (pr.integratedAt) bits.push("integrated " + age(pr.integratedAt));
917
+ if (pr.integratedBy && pr.integratedBy.email) bits.push("by " + pr.integratedBy.email);
918
+ meta.textContent = bits.join(" ");
919
+ line2.append(meta);
920
+ }
921
+ main.append(line2);
922
+ li.append(main);
923
+
924
+ // u14 Undo — per-change revert: roll THIS change OUT of the staged preview
925
+ // (a revert commit + rebuild); the live site is untouched. Only for a
926
+ // PR-numbered, still-ahead-of-live change; gated on ship capability.
927
+ if (canShip && pr.prNumber != null && !matchesLive) {
928
+ const undo = document.createElement("button");
929
+ undo.type = "button";
930
+ undo.className = "btn btn-compact";
931
+ undo.textContent = "Undo";
932
+ undo.title =
933
+ "Revert PR #" + pr.prNumber + " out of the staged preview (your live site is untouched)";
934
+ undo.addEventListener("click", () => revertStaged(pr, undo));
935
+ li.append(undo);
936
+ }
937
+ prsList.append(li);
938
+ }
939
+ }
940
+
941
+ renderPlan(data.plan);
942
+
943
+ // The Publish button: enabled only for a shippable, paywall-cleared plan and
944
+ // a viewer who can ship. A paywalled plan is SHOWN (upsell), never hidden.
945
+ if (publishBtn) {
946
+ const shippable = Boolean(currentPlan);
947
+ const paywalled = currentPlan && currentPlan.paywall && !currentPlan.paywall.allowed;
948
+ // rux7: never re-enable the Publish button while its POST is in flight —
949
+ // a poll refresh landing mid-publish must not open a double-submit.
950
+ publishBtn.disabled =
951
+ aggregatePublishing || !canShip || !shippable || paywalled || currentPlan.alreadyShipped;
952
+ publishBtn.title = !canShip
953
+ ? "Requires an owner or admin sign-in"
954
+ : !shippable
955
+ ? "The aggregate is not green / has no passed run to publish yet"
956
+ : paywalled
957
+ ? "Publishing to your live store is part of the paid plan"
958
+ : currentPlan.alreadyShipped
959
+ ? "This reviewed build is already live"
960
+ : "Publish the exact reviewed digest to your live store";
961
+ }
962
+ if (publishDetail) {
963
+ if (currentPlan && currentPlan.alreadyShipped) {
964
+ publishDetail.textContent =
965
+ "This reviewed build (" + shortSha(currentPlan.pinnedSha) + ") is already live. ";
966
+ const view = document.createElement("a");
967
+ view.href = ds.liveHref || "/";
968
+ view.target = "_blank";
969
+ view.rel = "noopener";
970
+ view.textContent = "View your live store →";
971
+ publishDetail.append(view);
972
+ } else if (currentPlan) {
973
+ publishDetail.textContent =
974
+ "Publishes the exact reviewed digest " + shortSha(currentPlan.artifactDigest.replace(/^sha256:/, "")) +
975
+ " (build " + shortSha(currentPlan.pinnedSha) + ") — no rebuild.";
976
+ } else {
977
+ publishDetail.textContent =
978
+ "Publishes the exact reviewed digest — no rebuild. Available once the aggregate is green and its integration run passed.";
979
+ }
980
+ }
981
+
982
+ // u14 — orient the owner between the staged aggregate (above) and the live
983
+ // versions list (below): how many staged changes are still unpublished and
984
+ // what Publish does to the versions timeline. Owned here (its OWN element) so
985
+ // this render never races renderVersions' own poll.
986
+ const versionsOrientation = root.querySelector("[data-versions-orientation]");
987
+ if (versionsOrientation) {
988
+ if (aheadCount && !matchesLive) {
989
+ versionsOrientation.textContent =
990
+ aheadCount + " staged change" + (aheadCount === 1 ? " is" : "s are") +
991
+ " not yet published — Publish live creates the next version here, and the current version becomes your rollback target.";
992
+ versionsOrientation.hidden = false;
993
+ } else {
994
+ versionsOrientation.hidden = true;
995
+ }
996
+ }
997
+ };
998
+
999
+ // Openable "Included changes" — each accepted PR as a line item with its
1000
+ // human title, a one-line description of what it does, and meta (sha / files /
1001
+ // who + when). title/description/fileCount are OPTIONAL server enrichment: a
1002
+ // row degrades to just "PR #N · <sha>" when they're absent, and the <details>
1003
+ // still lists every included change. The <details> element itself is static
1004
+ // (in the markup) so its open/closed state survives a poll re-render.
1005
+ const renderIncludedChanges = (planBox?: any, prs?: any) => {
1006
+ const wrap = planBox.querySelector("[data-aggregate-plan-included]");
1007
+ if (!wrap) return;
1008
+ if (!prs.length) {
1009
+ wrap.hidden = true;
1010
+ return;
1011
+ }
1012
+ wrap.hidden = false;
1013
+ const countEl = wrap.querySelector("[data-aggregate-plan-included-count]");
1014
+ const hintEl = wrap.querySelector("[data-aggregate-plan-included-hint]");
1015
+ const listEl = wrap.querySelector("[data-aggregate-plan-included-list]");
1016
+ if (countEl) countEl.textContent = String(prs.length);
1017
+ if (hintEl) {
1018
+ hintEl.textContent = prs
1019
+ .map((pr: any) => (pr.prNumber != null ? "PR #" + pr.prNumber : pr.changeId))
1020
+ .filter(Boolean)
1021
+ .join(", ");
1022
+ }
1023
+ if (!listEl) return;
1024
+ listEl.textContent = "";
1025
+ for (const pr of prs) {
1026
+ const li = document.createElement("li");
1027
+ li.className = "agg-included-item";
1028
+
1029
+ const top = document.createElement("div");
1030
+ top.className = "agg-included-top";
1031
+ const prEl = document.createElement("span");
1032
+ prEl.className = "agg-included-pr";
1033
+ prEl.textContent = pr.prNumber != null ? "PR #" + pr.prNumber : pr.changeId;
1034
+ top.append(prEl);
1035
+ if (pr.title) {
1036
+ const t = document.createElement("span");
1037
+ t.className = "agg-included-title";
1038
+ t.textContent = pr.title;
1039
+ top.append(t);
1040
+ }
1041
+ li.append(top);
1042
+
1043
+ const description = pr.description || pr.summary;
1044
+ if (description) {
1045
+ const desc = document.createElement("p");
1046
+ desc.className = "agg-included-desc";
1047
+ desc.textContent = description;
1048
+ li.append(desc);
1049
+ }
1050
+
1051
+ const meta = document.createElement("div");
1052
+ meta.className = "agg-included-meta";
1053
+ const sha = document.createElement("span");
1054
+ sha.className = "publish-mono sha-token";
1055
+ sha.textContent = shortSha(pr.headSha);
1056
+ meta.append(sha);
1057
+ const fileCount = pr.fileCount != null ? pr.fileCount : pr.filesChanged;
1058
+ if (fileCount != null) {
1059
+ const f = document.createElement("span");
1060
+ f.textContent = fileCount + (fileCount === 1 ? " file" : " files");
1061
+ meta.append(f);
1062
+ }
1063
+ // Lines added/removed (green/red) — one meta item so the "·" separator sits
1064
+ // before the pair, not between +N and −M.
1065
+ if (typeof pr.linesAdded === "number" || typeof pr.linesRemoved === "number") {
1066
+ meta.append(linesNode(pr.linesAdded, pr.linesRemoved));
1067
+ }
1068
+ const bits = [];
1069
+ if (pr.integratedAt) bits.push("integrated " + age(pr.integratedAt));
1070
+ if (pr.integratedBy && pr.integratedBy.email) bits.push("by " + pr.integratedBy.email);
1071
+ if (bits.length) {
1072
+ const when = document.createElement("span");
1073
+ when.textContent = bits.join(" ");
1074
+ meta.append(when);
1075
+ }
1076
+ li.append(meta);
1077
+
1078
+ // A trivial change carries its own bounded diff — offer it in a "View diff"
1079
+ // disclosure (for a tiny change the diff IS the summary). Lines are colored
1080
+ // by their leading +/- marker.
1081
+ if (Array.isArray(pr.diff) && pr.diff.length) {
1082
+ const details = document.createElement("details");
1083
+ details.className = "agg-diff";
1084
+ const summaryEl = document.createElement("summary");
1085
+ summaryEl.textContent = "View diff";
1086
+ details.append(summaryEl);
1087
+ for (const file of pr.diff) {
1088
+ const fileEl = document.createElement("div");
1089
+ fileEl.className = "agg-diff-file";
1090
+ const path = document.createElement("div");
1091
+ path.className = "agg-diff-path publish-mono";
1092
+ path.textContent = file.path;
1093
+ fileEl.append(path);
1094
+ if (file.patch) {
1095
+ const pre = document.createElement("pre");
1096
+ pre.className = "agg-diff-patch publish-mono";
1097
+ for (const line of String(file.patch).split("\n")) {
1098
+ const ln = document.createElement("span");
1099
+ ln.className =
1100
+ line.startsWith("+") && !line.startsWith("+++") ? "agg-diff-add"
1101
+ : line.startsWith("-") && !line.startsWith("---") ? "agg-diff-del"
1102
+ : line.startsWith("@@") ? "agg-diff-hunk" : "";
1103
+ ln.textContent = line + "\n";
1104
+ pre.append(ln);
1105
+ }
1106
+ fileEl.append(pre);
1107
+ } else {
1108
+ const binary = document.createElement("div");
1109
+ binary.className = "subtle";
1110
+ binary.textContent = "(no line-level diff available)";
1111
+ fileEl.append(binary);
1112
+ }
1113
+ details.append(fileEl);
1114
+ }
1115
+ li.append(details);
1116
+ }
1117
+
1118
+ listEl.append(li);
1119
+ }
1120
+ };
1121
+
1122
+ const renderPlan = (plan?: any) => {
1123
+ const planBox = aggPanel && aggPanel.querySelector("[data-aggregate-plan]");
1124
+ if (!planBox) return;
1125
+ const includedWrap = planBox.querySelector("[data-aggregate-plan-included]");
1126
+ const chipEl = planBox.querySelector("[data-aggregate-plan-chip]");
1127
+ const grid = planBox.querySelector("[data-aggregate-plan-grid]");
1128
+ const note = planBox.querySelector("[data-aggregate-plan-note]");
1129
+ if (grid) grid.textContent = "";
1130
+
1131
+ // No plan at all (degenerate: no store bound) — hide the box.
1132
+ if (!plan) {
1133
+ planBox.hidden = true;
1134
+ if (includedWrap) includedWrap.hidden = true;
1135
+ return;
1136
+ }
1137
+ planBox.hidden = false;
1138
+
1139
+ if (!plan.ok) {
1140
+ // An honest refusal — show WHY it isn't shippable, never a fake plan.
1141
+ if (chipEl) {
1142
+ chipEl.textContent = "Not shippable";
1143
+ chipEl.className = "chip amber";
1144
+ }
1145
+ if (note) note.textContent = plan.message || ("Not shippable (" + (plan.reason || "refused") + ").");
1146
+ if (includedWrap) includedWrap.hidden = true;
1147
+ return;
1148
+ }
1149
+
1150
+ const paywalled = plan.paywall && !plan.paywall.allowed;
1151
+ if (chipEl) {
1152
+ chipEl.textContent = plan.alreadyShipped ? "Already live" : paywalled ? "Upgrade required" : "Ready to publish";
1153
+ chipEl.className = "chip " + (plan.alreadyShipped ? "green" : paywalled ? "amber" : "green");
1154
+ }
1155
+
1156
+ const row = (term?: any, value?: any, mono?: any) => {
1157
+ const dt = document.createElement("dt");
1158
+ dt.textContent = term;
1159
+ const dd = document.createElement("dd");
1160
+ if (value instanceof Node) dd.append(value);
1161
+ else {
1162
+ dd.textContent = value;
1163
+ if (mono) dd.className = "publish-mono";
1164
+ }
1165
+ grid.append(dt, dd);
1166
+ };
1167
+
1168
+ row("Reviewed build", shortSha(plan.pinnedSha), true);
1169
+ row("Reviewed digest", shortSha(plan.artifactDigest.replace(/^sha256:/, "")), true);
1170
+ const runCell = document.createElement("span");
1171
+ runCell.append(document.createTextNode(plan.integrationRunId));
1172
+ const planRunLink = runLink(plan.pinnedRevHref, "Open build");
1173
+ if (planRunLink) {
1174
+ runCell.append(document.createTextNode(" · "));
1175
+ runCell.append(planRunLink);
1176
+ }
1177
+ row("Integration run", runCell);
1178
+ if (plan.rollbackTarget) {
1179
+ row("Rollback to", shortSha(plan.rollbackTarget.aggregateSha), true);
1180
+ } else {
1181
+ row("Rollback to", "None (first publish)");
1182
+ }
1183
+ row("Subscription", plan.paywall && plan.paywall.allowed ? "Enabled" : "Not enabled");
1184
+
1185
+ // The openable per-PR line items (replaces the bare "Included changes: N" row).
1186
+ renderIncludedChanges(planBox, plan.includedPrs || []);
1187
+
1188
+ if (note) {
1189
+ note.textContent = paywalled
1190
+ ? (plan.paywall.message || "Publishing to your live store requires the paid plan.")
1191
+ : plan.alreadyShipped
1192
+ ? "This reviewed build is already live — nothing to publish."
1193
+ : "Publish sends the exact reviewed digest above to your live store. No rebuild.";
1194
+ }
1195
+ };
1196
+
1197
+ // The exact-digest Publish (b10 ship = b09 orchestrator.ship). Confirms the
1198
+ // pinned plan, then POSTs { confirmGoLive, expectedAggregateSha,
1199
+ // expectedArtifactDigest } — the reviewed-digest guard: a stale plan (queue
1200
+ // moved / artifact changed) is refused by the server, never shipped blind.
1201
+ // ── Publish-live ceremony (shipoff) ──────────────────────────────────────
1202
+ // The go-live counterpart of the accept liftoff below (shares its shell,
1203
+ // styles, planRow + celebrate helpers — referenced at runtime, defined a few
1204
+ // sections down). One honest in-flight state: ship is a single POST that
1205
+ // promotes the pinned digest, so no staged theater — just plan → publishing →
1206
+ // outcome with a real "View your live store" link.
1207
+ const shipoff = root.querySelector("[data-shipoff]");
1208
+ const shipoffCard = shipoff && shipoff.querySelector(".liftoff-card");
1209
+ const shipoffEls: any = shipoff
1210
+ ? {
1211
+ plan: shipoff.querySelector("[data-shipoff-plan]"),
1212
+ flight: shipoff.querySelector("[data-shipoff-flight]"),
1213
+ outcome: shipoff.querySelector("[data-shipoff-outcome]"),
1214
+ grid: shipoff.querySelector("[data-shipoff-grid]"),
1215
+ proceed: shipoff.querySelector("[data-shipoff-proceed]"),
1216
+ cancel: shipoff.querySelector("[data-shipoff-cancel]"),
1217
+ live: shipoff.querySelector("[data-shipoff-live]"),
1218
+ elapsed: shipoff.querySelector("[data-shipoff-elapsed]"),
1219
+ seal: shipoff.querySelector("[data-shipoff-seal]"),
1220
+ outcomeTitle: shipoff.querySelector("[data-shipoff-outcome-title]"),
1221
+ outcomeDetail: shipoff.querySelector("[data-shipoff-outcome-detail]"),
1222
+ view: shipoff.querySelector("[data-shipoff-view]"),
1223
+ done: shipoff.querySelector("[data-shipoff-done]"),
1224
+ confetti: shipoff.querySelector("[data-shipoff-confetti]"),
1225
+ }
1226
+ : null;
1227
+ let shipoffTimer: any = null;
1228
+ const closeShipoff = () => {
1229
+ if (shipoffTimer) { clearInterval(shipoffTimer); shipoffTimer = null; }
1230
+ if (shipoff) shipoff.hidden = true;
1231
+ if (shipoffEls) shipoffEls.confetti.textContent = "";
1232
+ };
1233
+ const showShipoffPhase = (phase?: any) => {
1234
+ if (!shipoffEls) return;
1235
+ shipoffEls.plan.hidden = phase !== "plan";
1236
+ shipoffEls.flight.hidden = phase !== "flight";
1237
+ shipoffEls.outcome.hidden = phase !== "outcome";
1238
+ if (shipoffCard) shipoffCard.dataset.mode = phase;
1239
+ };
1240
+
1241
+ const buildPublishPlanText = (plan?: any) => {
1242
+ const lines = ["Publish plan (exact reviewed digest — no rebuild):"];
1243
+ lines.push(" tenant: " + (appDomain || repo));
1244
+ lines.push(" build: " + shortSha(plan.pinnedSha));
1245
+ lines.push(" digest: " + plan.artifactDigest);
1246
+ lines.push(" changes: " + (plan.includedPrs || []).length + " included");
1247
+ if (plan.rollbackTarget) lines.push(" rollback: " + shortSha(plan.rollbackTarget.aggregateSha));
1248
+ lines.push(" effect: publish this exact digest to your LIVE store.");
1249
+ return lines.join("\n");
1250
+ };
1251
+
1252
+ const shortDigest = (digest?: any) => {
1253
+ const d = String(digest || "").replace(/^sha256:/, "");
1254
+ return d ? "sha256:" + d.slice(0, 12) + "…" : "—";
1255
+ };
1256
+
1257
+ const publishAggregate = (button?: any) => {
1258
+ if (!currentPlan) return;
1259
+ const plan = currentPlan;
1260
+ if (!shipoff || !shipoffEls) {
1261
+ // No-markup fallback only — the ceremony IS the confirm now.
1262
+ if (!window.confirm(buildPublishPlanText(plan) + "\n\nPublish live?")) return;
1263
+ runShip(plan, button);
1264
+ return;
1265
+ }
1266
+ shipoffEls.grid.textContent = "";
1267
+ shipoffEls.grid.append(
1268
+ ...planRow("Store", appDomain || repo),
1269
+ ...planRow("Build", shortSha(plan.pinnedSha), true),
1270
+ ...planRow("Digest", shortDigest(plan.artifactDigest), true),
1271
+ ...planRow("Changes", (plan.includedPrs || []).length + " included"),
1272
+ ...(plan.rollbackTarget
1273
+ ? planRow("Rollback to", shortSha(plan.rollbackTarget.aggregateSha), true)
1274
+ : []),
1275
+ );
1276
+ showShipoffPhase("plan");
1277
+ shipoff.hidden = false;
1278
+ shipoffEls.proceed.focus();
1279
+ shipoffEls.cancel.onclick = () => closeShipoff();
1280
+ shipoffEls.done.onclick = () => closeShipoff();
1281
+ shipoffEls.proceed.onclick = () => runShip(plan, button);
1282
+ };
1283
+
1284
+ const runShip = async (plan?: any, button?: any) => {
1285
+ if (button) button.disabled = true;
1286
+ aggregatePublishing = true; // rux7: hold the button disabled across polls
1287
+ setStatus(aggStatusEl, "Publishing the reviewed build (" + shortSha(plan.pinnedSha) + ") live…");
1288
+ const startedAt = Date.now();
1289
+ if (shipoffEls) {
1290
+ showShipoffPhase("flight");
1291
+ shipoffTimer = setInterval(() => {
1292
+ const s = Math.floor((Date.now() - startedAt) / 1000);
1293
+ shipoffEls.elapsed.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
1294
+ }, 1000);
1295
+ }
1296
+ const outcome = (state?: any, title?: any, detail?: any, showView?: any) => {
1297
+ if (!shipoffEls) return;
1298
+ if (shipoffTimer) { clearInterval(shipoffTimer); shipoffTimer = null; }
1299
+ shipoffEls.seal.dataset.state = state;
1300
+ shipoffEls.outcomeTitle.textContent = title;
1301
+ shipoffEls.outcomeDetail.textContent = detail;
1302
+ shipoffEls.view.hidden = !showView;
1303
+ if (showView) shipoffEls.view.href = ds.liveHref || "/";
1304
+ showShipoffPhase("outcome");
1305
+ shipoffEls.done.focus();
1306
+ if (state === "ok") celebrate(shipoffEls.confetti);
1307
+ };
1308
+ try {
1309
+ const res = await fetch(ds.shipEndpoint, {
1310
+ method: "POST",
1311
+ headers: { "content-type": "application/json", "x-tot-capability": "ship-on-behalf" },
1312
+ body: JSON.stringify({
1313
+ aggregateId: plan.aggregateId,
1314
+ // The HUMAN go-live gate + the reviewed-digest guards (b09 ship input).
1315
+ confirmGoLive: true,
1316
+ expectedAggregateSha: plan.pinnedSha,
1317
+ expectedArtifactDigest: plan.artifactDigest,
1318
+ }),
1319
+ });
1320
+ const data = await res.json().catch(() => ({}));
1321
+ // b09 AggregateShipResult: the ONLY live claim is state === "shipped".
1322
+ if (data.state === "shipped") {
1323
+ setStatus(
1324
+ aggStatusEl,
1325
+ "Published live · build " + shortSha(data.pinnedSha) + " (receipt " + (data.receiptId || "—") + ").",
1326
+ "ok",
1327
+ );
1328
+ outcome(
1329
+ "ok",
1330
+ "Your store is live",
1331
+ "Build " + shortSha(data.pinnedSha) + " — the exact digest you reviewed — is now serving" +
1332
+ (data.receiptId ? " (receipt " + data.receiptId + ")." : "."),
1333
+ true,
1334
+ );
1335
+ } else {
1336
+ let message;
1337
+ if (data.state === "promote_failed" || data.state === "record_failed") {
1338
+ // Honest partial-failure truth — recoverable, never a false "shipped".
1339
+ message = data.message || "Publish did not complete (" + data.state + "). Retry.";
1340
+ } else if (data.reason === "paywall") {
1341
+ message = data.message || "Publishing to your live store is part of the paid plan.";
1342
+ } else {
1343
+ message = data.message || data.error || "Publish refused (" + (data.reason || res.status) + ")";
1344
+ }
1345
+ setStatus(aggStatusEl, message, "err");
1346
+ outcome("err", "Publish did not complete", message + " Your live store is unchanged.", false);
1347
+ }
1348
+ } catch {
1349
+ setStatus(aggStatusEl, "Publish failed — network error", "err");
1350
+ outcome(
1351
+ "err",
1352
+ "Publish did not complete",
1353
+ "Network error mid-publish — refresh to see the honest state; your live store serves the last published version.",
1354
+ false,
1355
+ );
1356
+ } finally {
1357
+ aggregatePublishing = false;
1358
+ if (button) button.disabled = false;
1359
+ loadQueue();
1360
+ loadVersions({ force: true }); // a publish changed the versions list
1361
+ }
1362
+ };
1363
+
1364
+ const aggStatusEl = aggPanel && aggPanel.querySelector("[data-aggregate-status]");
1365
+ aggPanel
1366
+ ?.querySelector("[data-aggregate-publish]")
1367
+ ?.addEventListener("click", (event: any) => publishAggregate(event.currentTarget));
1368
+ aggPanel
1369
+ ?.querySelector("[data-aggregate-retry-build]")
1370
+ ?.addEventListener("click", (event: any) => retryBuild(event.currentTarget));
1371
+
1372
+ // ── Accept-to-preview LIFTOFF ─────────────────────────────────────────────
1373
+ // The plan confirm + live integration flight + outcome ceremony (replaces
1374
+ // the old window.confirm plan popup). Same b11 contract as before: accept =
1375
+ // `POST /api/changes/integrate` (b08) → integrate into the protected
1376
+ // `preview` aggregate, rebuild + combined-evidence it (b05). Flips NO live
1377
+ // channel — going live is the aggregate card's Publish.
1378
+ //
1379
+ // HONESTY RULE for the stage rail: a stage only advances on a REAL signal —
1380
+ // the outcome POST resolving, or GET /api/changes polling showing the
1381
+ // candidate inside `aggregate.includedPrs` (merge landed) / an `activeRun`
1382
+ // (build+evidence running, statusMessage relayed verbatim). No timers ever
1383
+ // fake progress.
1384
+ const liftoff = root.querySelector("[data-liftoff]");
1385
+ const liftoffCard = liftoff && liftoff.querySelector(".liftoff-card");
1386
+ const liftoffEls: any = liftoff
1387
+ ? {
1388
+ plan: liftoff.querySelector("[data-liftoff-plan]"),
1389
+ flight: liftoff.querySelector("[data-liftoff-flight]"),
1390
+ outcome: liftoff.querySelector("[data-liftoff-outcome]"),
1391
+ title: liftoff.querySelector("[data-liftoff-title]"),
1392
+ grid: liftoff.querySelector("[data-liftoff-grid]"),
1393
+ proceed: liftoff.querySelector("[data-liftoff-proceed]"),
1394
+ cancel: liftoff.querySelector("[data-liftoff-cancel]"),
1395
+ flightTitle: liftoff.querySelector("[data-liftoff-flight-title]"),
1396
+ live: liftoff.querySelector("[data-liftoff-live]"),
1397
+ elapsed: liftoff.querySelector("[data-liftoff-elapsed]"),
1398
+ hide: liftoff.querySelector("[data-liftoff-hide]"),
1399
+ seal: liftoff.querySelector("[data-liftoff-seal]"),
1400
+ outcomeTitle: liftoff.querySelector("[data-liftoff-outcome-title]"),
1401
+ outcomeDetail: liftoff.querySelector("[data-liftoff-outcome-detail]"),
1402
+ review: liftoff.querySelector("[data-liftoff-review]"),
1403
+ done: liftoff.querySelector("[data-liftoff-done]"),
1404
+ confetti: liftoff.querySelector("[data-liftoff-confetti]"),
1405
+ conflict: liftoff.querySelector("[data-liftoff-conflict]"),
1406
+ conflictCommands: liftoff.querySelector("[data-conflict-commands]"),
1407
+ conflictCopy: liftoff.querySelector("[data-conflict-copy]"),
1408
+ conflictCopyPrompt: liftoff.querySelector("[data-conflict-copy-prompt]"),
1409
+ conflictRaw: liftoff.querySelector("[data-conflict-raw]"),
1410
+ // u4 — the automate-first recovery surface layered over the expert fallback.
1411
+ conflictSummary: liftoff.querySelector("[data-conflict-summary]"),
1412
+ conflictAuto: liftoff.querySelector("[data-conflict-auto]"),
1413
+ conflictPrimaryStatus: liftoff.querySelector("[data-conflict-primary-status]"),
1414
+ conflictCards: liftoff.querySelector("[data-conflict-cards]"),
1415
+ conflictFiles: liftoff.querySelector("[data-conflict-files]"),
1416
+ conflictCardsStatus: liftoff.querySelector("[data-conflict-cards-status]"),
1417
+ conflictCardButtons: liftoff.querySelectorAll("[data-conflict-card]"),
1418
+ conflictExpert: liftoff.querySelector("[data-conflict-expert]"),
1419
+ }
1420
+ : null;
1421
+
1422
+ // The terminal recovery for a merge-refused candidate: verify the checkout
1423
+ // actually backs THIS change (`tot pr` marks the current checkout's candidate
1424
+ // "(active)" — rebasing the wrong clone/branch is the classic footgun), then
1425
+ // rebase onto the moved preview and re-submit. The open PR updates in place
1426
+ // on re-push. DELIBERATELY comment-free: a live paste-through showed zsh
1427
+ // (no interactivecomments) chokes on `#` lines — every word of guidance
1428
+ // lives in the prose above the block, the block is pure paste-safe commands.
1429
+ const conflictCommandsFor = () => ["tot pr", "git fetch origin", "git rebase origin/preview", "tot preview"].join("\n");
1430
+
1431
+ // The same recovery shaped as a PROMPT for the operator's coding agent
1432
+ // (Claude Code, etc.) — the multi-step, judgment-required version. Every
1433
+ // step below earned its place in a live 2026-08-20 recovery run:
1434
+ // the git-status preflight (an ABANDONED half-rebase with committed
1435
+ // conflict markers was lurking and poisoned the first attempt), the
1436
+ // two-stage auth self-heal (tot login fixes the session, NOT the token
1437
+ // baked into the remote URL — u10 credential-helper removes this), the
1438
+ // conflict-intent framing, and the never-`--new` rule.
1439
+ const conflictPromptFor = (change?: any, rawMessage?: any) => {
1440
+ const tenant = appDomain || repo;
1441
+ const pr = change && change.prNumber != null ? "PR #" + change.prNumber : "my open candidate PR";
1442
+ return [
1443
+ "In my " + tenant + " store checkout on this machine, " + pr + " could not be integrated: " +
1444
+ "the shared `preview` branch changed after my change branched, and the two now conflict. " +
1445
+ "Bring my change up to date and re-submit it.",
1446
+ "",
1447
+ "1. Run `git status` first. If a rebase/merge is already in progress (a previous recovery attempt may have been abandoned), inspect it, then `git rebase --abort` to start clean — and check the tracked files for committed `<<<<<<<` conflict markers while you're there.",
1448
+ "2. Run `tot pr` and confirm " + pr + " is listed and marked (active) for this checkout — if it isn't, stop and tell me before touching anything.",
1449
+ "3. Run `git fetch origin`, then `git rebase origin/preview`. On an authentication failure: `tot login`, retry; if fetch STILL fails, the token embedded in the remote URL has rotated — replace the remote with a freshly minted one: `git remote set-url origin \"$(tot clone " + tenant + " --remote-only --print-remote)\"` (this matches how the checkout was originally configured; ask me first if you're unsure).",
1450
+ "4. Resolve any conflicts git flags, preserving the intent of my change on the lines it edited; keep preview's version everywhere else. Show me the resolution if the same lines were edited on both sides. Never commit a file still containing conflict markers; finish with `git rebase --continue` — a half-done rebase must never be left behind.",
1451
+ "5. Re-submit with `tot preview` (plain — never `--new`, which would fork a separate PR). " + pr + " updates in place.",
1452
+ "6. Confirm the push succeeded and the submit output reports the preview building. Then end by reminding me how to FINISH the merge once the build is ready: back in the store admin's Publish tab (" + adminPublishUrl() + "), " + pr + " → Integrate into preview" +
1453
+ (change && change.prNumber != null
1454
+ ? " — or from this terminal: `tot accept --tenant " + tenant + " --pr " + change.prNumber + "`."
1455
+ : "."),
1456
+ rawMessage ? "\nThe system's refusal, for reference: " + rawMessage : "",
1457
+ ].join("\n");
1458
+ };
1459
+
1460
+ // The Publish tab's own URL — where the operator copying this prompt is
1461
+ // standing right now — so the agent's closing hand-back can name it exactly.
1462
+ const adminPublishUrl = () => window.location.origin + window.location.pathname + "#publish";
1463
+
1464
+ // ── u4: automate-first conflict recovery ──────────────────────────────────
1465
+ // The per-change resolve route (u3): POST /api/changes/<changeId>/resolve.
1466
+ // Derived off changesEndpoint so it inherits the tenant surface's basePath.
1467
+ const resolveEndpointFor = (changeId?: any) =>
1468
+ ds.changesEndpoint + "/" + encodeURIComponent(changeId) + "/resolve";
1469
+
1470
+
1471
+ // Reveal the three option cards, naming the exact files diff3 could not
1472
+ // auto-combine (never a bare list with no next step).
1473
+ const revealConflictCards = (unresolved?: any) => {
1474
+ if (!liftoffEls.conflictCards) return;
1475
+ liftoffEls.conflictCards.hidden = false;
1476
+ if (liftoffEls.conflictFiles) {
1477
+ liftoffEls.conflictFiles.textContent = "";
1478
+ (unresolved || []).forEach((f: any) => {
1479
+ const li = document.createElement("li");
1480
+ li.textContent = f;
1481
+ liftoffEls.conflictFiles.append(li);
1482
+ });
1483
+ liftoffEls.conflictFiles.hidden = (unresolved || []).length === 0;
1484
+ }
1485
+ };
1486
+
1487
+ // Open the collapsed expert/terminal fallback and bring it into view — the
1488
+ // honest destination for a same-line overlap only a human/agent can combine,
1489
+ // and the graceful landing when automation isn't deployed (501).
1490
+ const revealExpertFallback = () => {
1491
+ if (liftoffEls.conflictExpert) {
1492
+ liftoffEls.conflictExpert.open = true;
1493
+ liftoffEls.conflictExpert.scrollIntoView({ block: "nearest" });
1494
+ }
1495
+ };
1496
+
1497
+ const setConflictStatus = (el?: any, message?: any, state?: any) => {
1498
+ if (!el) return;
1499
+ el.hidden = !message;
1500
+ el.textContent = message || "";
1501
+ if (state) el.dataset.state = state;
1502
+ else delete el.dataset.state;
1503
+ };
1504
+
1505
+ // POST /resolve with `strategy`, then act on the classified outcome. From the
1506
+ // primary action `source` is "primary" (a clean rebuild reveals cards on the
1507
+ // rare overlap); from a card it's "card" (a merge card that re-hits the overlap
1508
+ // escalates to the expert fallback rather than looping). A clean rebuild ALWAYS
1509
+ // auto-re-integrates exactly once via the existing runIntegration flow.
1510
+ const resolveThenReintegrate = async (change?: any, strategy?: any, button?: any, source?: any) => {
1511
+ const statusEl2 = source === "card" ? liftoffEls.conflictCardsStatus : liftoffEls.conflictPrimaryStatus;
1512
+ const controls = [liftoffEls.conflictAuto, ...Array.from(liftoffEls.conflictCardButtons || [])].filter(Boolean);
1513
+ controls.forEach((b) => { b.disabled = true; });
1514
+ setConflictStatus(statusEl2, "Updating your change…");
1515
+ try {
1516
+ const res = await fetch(resolveEndpointFor(change.changeId), {
1517
+ method: "POST",
1518
+ headers: { "content-type": "application/json", "x-tot-capability": "ship-on-behalf" },
1519
+ body: JSON.stringify({ repo, strategy }),
1520
+ });
1521
+ const body = await res.json().catch(() => ({}));
1522
+ const action = classifyResolveResponse(change, res.status, body);
1523
+ if (action.kind === "reintegrate") {
1524
+ setConflictStatus(statusEl2, "");
1525
+ // Auto-re-integrate ONCE with the refreshed change (new PR/head).
1526
+ runIntegration(action.change, button);
1527
+ } else if (action.kind === "cards") {
1528
+ if (source === "card") {
1529
+ // A merge card that STILL overlaps — no safe auto-combine exists; hand
1530
+ // the specific files to the expert/terminal path.
1531
+ setConflictStatus(
1532
+ statusEl2,
1533
+ "These files need a hand-merge — see “Do it manually instead” below.",
1534
+ "err",
1535
+ );
1536
+ revealConflictCards(action.unresolved);
1537
+ revealExpertFallback();
1538
+ } else {
1539
+ setConflictStatus(statusEl2, "");
1540
+ revealConflictCards(action.unresolved);
1541
+ }
1542
+ } else if (action.kind === "unavailable") {
1543
+ setConflictStatus(
1544
+ statusEl2,
1545
+ "Automatic update isn't available on this store yet — use the manual steps below.",
1546
+ "err",
1547
+ );
1548
+ revealExpertFallback();
1549
+ } else {
1550
+ setConflictStatus(statusEl2, action.message, "err");
1551
+ revealExpertFallback();
1552
+ }
1553
+ } catch {
1554
+ setConflictStatus(statusEl2, "Couldn't reach the server — try the manual steps below.", "err");
1555
+ revealExpertFallback();
1556
+ } finally {
1557
+ controls.forEach((b) => { b.disabled = false; });
1558
+ }
1559
+ };
1560
+
1561
+ // Show/clear the merge-conflict guidance inside the outcome phase. Every
1562
+ // outcome path calls this — visible ONLY for the merge_failed refusal, so a
1563
+ // conflict block from a previous run can never leak into an unrelated outcome.
1564
+ const setConflictGuidance = (visible?: any, rawMessage?: any, change?: any) => {
1565
+ if (!liftoffEls || !liftoffEls.conflict) return;
1566
+ liftoffEls.conflict.hidden = !visible;
1567
+ if (!visible) return;
1568
+ liftoffEls.conflictCommands.textContent = conflictCommandsFor();
1569
+ // The AI prompt is rebuilt per outcome (change + raw message vary) and
1570
+ // stashed on the element; the one-time click handler reads the stash.
1571
+ liftoffEls.conflict.dataset.prompt = conflictPromptFor(change, rawMessage || "");
1572
+ // The forge's own words, kept small for support/diagnosis — the headline
1573
+ // above already says what happened in product language.
1574
+ liftoffEls.conflictRaw.textContent = rawMessage || "";
1575
+ const wireCopy = (btn?: any, label?: any, getText?: any) => {
1576
+ if (!btn || btn.dataset.wired) return;
1577
+ btn.dataset.wired = "1";
1578
+ btn.addEventListener("click", () => {
1579
+ navigator.clipboard.writeText(getText()).then(
1580
+ () => {
1581
+ btn.textContent = "Copied";
1582
+ setTimeout(() => { btn.textContent = label; }, 1600);
1583
+ },
1584
+ () => { /* clipboard denied — the commands are selectable text */ },
1585
+ );
1586
+ });
1587
+ };
1588
+ wireCopy(liftoffEls.conflictCopy, "Copy commands", () => liftoffEls.conflictCommands.textContent);
1589
+ wireCopy(liftoffEls.conflictCopyPrompt, "Copy prompt for your AI agent", () => liftoffEls.conflict.dataset.prompt || "");
1590
+
1591
+ // u4 — reset the automate-first surface for THIS outcome so a prior run's
1592
+ // cards/status/expanded-expert never leak in, then bind the primary action +
1593
+ // option cards to the current change (`.onclick` overwrites, never stacks).
1594
+ if (liftoffEls.conflictCards) liftoffEls.conflictCards.hidden = true;
1595
+ if (liftoffEls.conflictFiles) liftoffEls.conflictFiles.textContent = "";
1596
+ if (liftoffEls.conflictExpert) liftoffEls.conflictExpert.open = false;
1597
+ setConflictStatus(liftoffEls.conflictPrimaryStatus, "");
1598
+ setConflictStatus(liftoffEls.conflictCardsStatus, "");
1599
+ if (liftoffEls.conflictSummary) {
1600
+ // The only structured collision data that survives to the client is the
1601
+ // forge's refusal text (integrate flattens u2's {files,competing} to a
1602
+ // string) + the diff3 `unresolved` list on the resolve response. Name the
1603
+ // competing PR when the refusal mentions one; else stay generic.
1604
+ const prRef = /pr[ #]*([0-9]+)/i.exec(rawMessage || "");
1605
+ liftoffEls.conflictSummary.textContent = prRef
1606
+ ? "Another change (PR #" + prRef[1] + ") reached the store first and touched the same content."
1607
+ : "";
1608
+ liftoffEls.conflictSummary.hidden = !prRef;
1609
+ }
1610
+ if (liftoffEls.conflictAuto) {
1611
+ liftoffEls.conflictAuto.onclick = () =>
1612
+ resolveThenReintegrate(change, "merge", liftoffEls.conflictAuto, "primary");
1613
+ }
1614
+ (liftoffEls.conflictCardButtons || []).forEach((card: any) => {
1615
+ card.onclick = () =>
1616
+ resolveThenReintegrate(change, card.dataset.strategy, card, "card");
1617
+ });
1618
+ };
1619
+
1620
+ // A settled conflict is visible in the queue before an integration POST.
1621
+ // Open the same recovery surface normally shown after `merge_failed`, without
1622
+ // claiming that an integration was attempted or changing the aggregate.
1623
+ const openConflictRecovery = (change?: any, button?: any) => {
1624
+ if (!liftoff || !liftoffEls) {
1625
+ setStatus(
1626
+ statusEl,
1627
+ "This change needs updating against the latest preview before it can integrate.",
1628
+ "err",
1629
+ );
1630
+ return;
1631
+ }
1632
+ liftoffOpenedFrom = button || null;
1633
+ const target = change.prNumber != null ? "PR #" + change.prNumber : "This change";
1634
+ liftoffEls.seal.dataset.state = "err";
1635
+ liftoffEls.outcomeTitle.textContent = "Preview changed while you worked";
1636
+ liftoffEls.outcomeDetail.textContent =
1637
+ target + " passed its checks, but preview changed after it was created and the two now " +
1638
+ "conflict. Nothing was integrated — update this change automatically, then review the result.";
1639
+ liftoffEls.review.hidden = true;
1640
+ setConflictGuidance(true, "", change);
1641
+ showPhase("outcome");
1642
+ liftoff.hidden = false;
1643
+ if (liftoffEls.conflictAuto) liftoffEls.conflictAuto.focus();
1644
+ };
1645
+
1646
+ const liftoffStage = (name?: any) =>
1647
+ liftoff && liftoff.querySelector('[data-liftoff-stage="' + name + '"]');
1648
+ const LIFTOFF_STAGES = ["merge", "build", "evidence", "green"];
1649
+ const setStage = (name?: any, state?: any) => {
1650
+ const el = liftoffStage(name);
1651
+ if (el) el.dataset.state = state; // pending | active | done | fail
1652
+ };
1653
+ // Advance the rail so everything before `name` is done and `name` is active.
1654
+ const railTo = (name?: any) => {
1655
+ const idx = LIFTOFF_STAGES.indexOf(name);
1656
+ LIFTOFF_STAGES.forEach((s, i) => {
1657
+ const el = liftoffStage(s);
1658
+ const cur = el && el.dataset.state;
1659
+ if (cur === "fail") return; // never un-fail
1660
+ setStage(s, i < idx ? "done" : i === idx ? "active" : "pending");
1661
+ });
1662
+ };
1663
+
1664
+ let liftoffTimer: any = null;
1665
+ let liftoffPoll: any = null;
1666
+ let liftoffOpenedFrom: any = null;
1667
+ const stopLiftoffLoops = () => {
1668
+ if (liftoffTimer) { clearInterval(liftoffTimer); liftoffTimer = null; }
1669
+ if (liftoffPoll) { clearInterval(liftoffPoll); liftoffPoll = null; }
1670
+ };
1671
+ const closeLiftoff = () => {
1672
+ stopLiftoffLoops();
1673
+ if (liftoff) liftoff.hidden = true;
1674
+ if (liftoffEls) liftoffEls.confetti.textContent = "";
1675
+ if (liftoffOpenedFrom && document.contains(liftoffOpenedFrom)) liftoffOpenedFrom.focus();
1676
+ liftoffOpenedFrom = null;
1677
+ };
1678
+ const showPhase = (phase?: any) => {
1679
+ if (!liftoffEls) return;
1680
+ liftoffEls.plan.hidden = phase !== "plan";
1681
+ liftoffEls.flight.hidden = phase !== "flight";
1682
+ liftoffEls.outcome.hidden = phase !== "outcome";
1683
+ if (liftoffCard) liftoffCard.dataset.mode = phase;
1684
+ };
1685
+
1686
+ const planRow = (dt?: any, dd?: any, mono?: any) => {
1687
+ const t = document.createElement("dt");
1688
+ t.textContent = dt;
1689
+ const d = document.createElement("dd");
1690
+ d.textContent = dd;
1691
+ if (mono) d.className = "publish-mono";
1692
+ return [t, d];
1693
+ };
1694
+
1695
+ const celebrate = (container?: any) => {
1696
+ if (!container) return;
1697
+ if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
1698
+ const colors = ["#0f9d8f", "#25b8a8", "#f4b942", "#5b8def", "#e2634f"];
1699
+ for (let i = 0; i < 42; i++) {
1700
+ const p = document.createElement("i");
1701
+ p.style.left = Math.random() * 100 + "%";
1702
+ p.style.background = colors[i % colors.length]!;
1703
+ p.style.animationDelay = Math.random() * 0.35 + "s";
1704
+ p.style.animationDuration = 0.9 + Math.random() * 0.9 + "s";
1705
+ p.style.setProperty("--liftoff-drift", (Math.random() * 160 - 80).toFixed(0) + "px");
1706
+ p.style.setProperty("--liftoff-spin", (Math.random() * 640 - 320).toFixed(0) + "deg");
1707
+ container.append(p);
1708
+ }
1709
+ setTimeout(() => { container.textContent = ""; }, 2400);
1710
+ };
1711
+
1712
+ // The live poll during flight: reflect the server's REAL progress.
1713
+ const startFlightPoll = (change?: any, preFlightSha?: any) => {
1714
+ let inAggregate = false;
1715
+ liftoffPoll = setInterval(async () => {
1716
+ try {
1717
+ const res = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
1718
+ if (!res.ok) return;
1719
+ const data = await res.json();
1720
+ const agg = data.aggregate || {};
1721
+ const included = (agg.includedPrs || []).some((p: any) => p.changeId === change.changeId);
1722
+ const shaMoved = agg.aggregateSha && agg.aggregateSha !== preFlightSha;
1723
+ const run = agg.activeRun || null;
1724
+ if (!inAggregate && (included || shaMoved)) {
1725
+ inAggregate = true;
1726
+ railTo("build");
1727
+ if (liftoffEls) liftoffEls.live.textContent =
1728
+ "Merged into preview — rebuilding the combined aggregate…";
1729
+ }
1730
+ if (run) {
1731
+ railTo("evidence");
1732
+ if (liftoffEls) liftoffEls.live.textContent =
1733
+ run.statusMessage || "Combined evidence is running…";
1734
+ }
1735
+ } catch { /* polling is best-effort; the POST outcome is authoritative */ }
1736
+ }, 2500);
1737
+ };
1738
+
1739
+ const openLiftoff = (change?: any, button?: any) => {
1740
+ if (!liftoff || !liftoffEls) return false;
1741
+ liftoffOpenedFrom = button || null;
1742
+ const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
1743
+ liftoffEls.title.textContent = "Integrate " + target + " into preview";
1744
+ liftoffEls.grid.textContent = "";
1745
+ liftoffEls.grid.append(
1746
+ ...planRow("Store", appDomain || repo),
1747
+ ...(change.prNumber != null ? planRow("Pull request", "#" + change.prNumber) : []),
1748
+ ...planRow("Change", change.changeId, true),
1749
+ ...(change.headSha ? planRow("Head", shortSha(change.headSha), true) : []),
1750
+ );
1751
+ LIFTOFF_STAGES.forEach((s) => setStage(s, "pending"));
1752
+ liftoffEls.live.textContent = "Working…";
1753
+ liftoffEls.elapsed.textContent = "0:00";
1754
+ showPhase("plan");
1755
+ liftoff.hidden = false;
1756
+ liftoffEls.proceed.focus();
1757
+
1758
+ liftoffEls.cancel.onclick = () => closeLiftoff();
1759
+ liftoffEls.done.onclick = () => closeLiftoff();
1760
+ liftoffEls.hide.onclick = () => { liftoff.hidden = true; }; // flight keeps running
1761
+ liftoffEls.proceed.onclick = () => runIntegration(change, button);
1762
+ return true;
1763
+ };
1764
+
1765
+ // Esc: close in plan/outcome; during flight just hide (the POST keeps going).
1766
+ // Handles BOTH ceremonies (accept liftoff + publish shipoff).
1767
+ document.addEventListener("keydown", (ev) => {
1768
+ if (ev.key !== "Escape") return;
1769
+ if (liftoff && !liftoff.hidden) {
1770
+ const mode = liftoffCard && liftoffCard.dataset.mode;
1771
+ if (mode === "flight") liftoff.hidden = true;
1772
+ else closeLiftoff();
1773
+ }
1774
+ if (shipoff && !shipoff.hidden) {
1775
+ const mode = shipoffCard && shipoffCard.dataset.mode;
1776
+ if (mode === "flight") shipoff.hidden = true;
1777
+ else closeShipoff();
1778
+ }
1779
+ });
1780
+
1781
+ // b08 integrate returns an IntegrateOutcome — { ok, queueState, runState,
1782
+ // aggregateSha, pointerMoved, statusMessage, reason? } — NEVER a bare
1783
+ // merged:true. Render it honestly: green advances the aggregate; red left it
1784
+ // exactly where it was and names the failing stage.
1785
+ const runIntegration = async (change?: any, button?: any) => {
1786
+ const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
1787
+ if (button) button.disabled = true;
1788
+ busyChanges.add(change.changeId); // rux7: survive a poll re-render as busy
1789
+ setStatus(statusEl, "Integrating " + change.changeId + " into preview…");
1790
+ const startedAt = Date.now();
1791
+ let preFlightSha: any = null;
1792
+ if (liftoffEls) {
1793
+ showPhase("flight");
1794
+ liftoffEls.flightTitle.textContent = "Integrating " + target + " into preview";
1795
+ railTo("merge");
1796
+ liftoffEls.live.textContent = "Squash-merging the candidate into preview…";
1797
+ liftoffTimer = setInterval(() => {
1798
+ const s = Math.floor((Date.now() - startedAt) / 1000);
1799
+ liftoffEls.elapsed.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
1800
+ }, 1000);
1801
+ }
1802
+ try {
1803
+ // Snapshot the pre-flight aggregate sha so the poll can detect the merge
1804
+ // landing (sha moved) even before includedPrs reflects it.
1805
+ try {
1806
+ const pre = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
1807
+ if (pre.ok) preFlightSha = ((await pre.json()).aggregate || {}).aggregateSha || null;
1808
+ } catch { /* best-effort */ }
1809
+ startFlightPoll(change, preFlightSha);
1810
+
1811
+ const res = await fetch(ds.integrateEndpoint, {
1812
+ method: "POST",
1813
+ headers: {
1814
+ "content-type": "application/json",
1815
+ // Mirror of the server-side capability model (UX): the server
1816
+ // re-derives capability and stays authoritative.
1817
+ "x-tot-capability": "ship-on-behalf",
1818
+ },
1819
+ body: JSON.stringify({
1820
+ repo,
1821
+ changeId: change.changeId,
1822
+ prNumber: change.prNumber ?? undefined,
1823
+ expectedHeadSha: change.headSha || undefined,
1824
+ }),
1825
+ });
1826
+ const data = await res.json().catch(() => ({}));
1827
+ stopLiftoffLoops();
1828
+ if (res.ok && data.ok) {
1829
+ setStatus(
1830
+ statusEl,
1831
+ "Integrated " + change.changeId + " — preview aggregate is green (" +
1832
+ shortSha(data.aggregateSha) + "). Review the aggregate, then Publish to go live.",
1833
+ "ok",
1834
+ );
1835
+ if (liftoffEls) {
1836
+ LIFTOFF_STAGES.forEach((s) => setStage(s, "done"));
1837
+ liftoffEls.seal.dataset.state = "ok";
1838
+ liftoffEls.outcomeTitle.textContent = "The preview aggregate is green";
1839
+ liftoffEls.outcomeDetail.textContent =
1840
+ target + " is in. Aggregate build " + shortSha(data.aggregateSha) +
1841
+ " passed its combined evidence — this exact build is what you review, and what " +
1842
+ "Publish would take live. Nothing is live yet.";
1843
+ if (data.aggregateSha) {
1844
+ liftoffEls.review.href =
1845
+ "/" + encodeURIComponent(appDomain) + "/preview/rev/" + data.aggregateSha + "/";
1846
+ liftoffEls.review.hidden = false;
1847
+ } else {
1848
+ liftoffEls.review.hidden = true;
1849
+ }
1850
+ setConflictGuidance(false);
1851
+ if (liftoff.hidden) liftoff.hidden = false; // surface the payoff even if backgrounded
1852
+ showPhase("outcome");
1853
+ liftoffEls.done.focus();
1854
+ celebrate(liftoffEls.confetti);
1855
+ }
1856
+ } else if (data.reason === "candidate_not_open") {
1857
+ // "No open candidate" = the PR already merged before this attempt —
1858
+ // usually a second click on a just-integrated candidate whose row went
1859
+ // stale (DZ, 2026-08-20). That is a SUCCESS the operator is re-hearing,
1860
+ // not a failure: green seal, happy copy, no fail stage, never conflict
1861
+ // guidance. The queue read now drops merged rows, so the row is gone on
1862
+ // the next refresh; the durable KV closeout is u12.
1863
+ const target = change.prNumber != null ? "PR #" + change.prNumber : "This change";
1864
+ setStatus(statusEl, target + " was already integrated — it's in the preview build.", "ok");
1865
+ if (liftoffEls) {
1866
+ LIFTOFF_STAGES.forEach((s) => setStage(s, "done"));
1867
+ liftoffEls.seal.dataset.state = "ok";
1868
+ liftoffEls.outcomeTitle.textContent = "Already integrated";
1869
+ liftoffEls.outcomeDetail.textContent =
1870
+ target + " landed in the preview build earlier — this attempt found " +
1871
+ "nothing left to do. It will drop off the queue on the next refresh; " +
1872
+ "the integration is recorded in the history below.";
1873
+ setConflictGuidance(false);
1874
+ liftoffEls.review.hidden = true;
1875
+ if (liftoff.hidden) liftoff.hidden = false;
1876
+ showPhase("outcome");
1877
+ liftoffEls.done.focus();
1878
+ }
1879
+ } else {
1880
+ let message;
1881
+ let failStage = "merge";
1882
+ // The merge-refused conflict is a FORK, not a failure of the change:
1883
+ // preview moved after the candidate branched and both edit the same
1884
+ // content. Render it as directions (rebase + re-submit), never as the
1885
+ // raw forge refusal — that dead-ended real operators (2026-08-20).
1886
+ const isMergeConflict = data.reason === "merge_failed";
1887
+ if (res.ok && data.queueState === "red") {
1888
+ // The queue ran but the aggregate did not go green — the candidate did
1889
+ // NOT advance the shippable set. Name the honest failing stage.
1890
+ failStage = "evidence";
1891
+ message =
1892
+ "Integration of " + change.changeId + " failed (" + (data.reason || "red") + "): " +
1893
+ (data.statusMessage || "the aggregate did not pass its combined evidence.") +
1894
+ " The aggregate is unchanged.";
1895
+ } else if (data.reason === "not_preview_base") {
1896
+ message =
1897
+ "Not integrated: " + change.changeId + " does not target the preview branch " +
1898
+ "(base is not \"preview\"). " + (data.statusMessage || "");
1899
+ } else {
1900
+ message = data.statusMessage || data.error || "Integrate failed (" + res.status + ")";
1901
+ }
1902
+ setStatus(statusEl, message, "err");
1903
+ if (liftoffEls) {
1904
+ setStage(failStage, "fail");
1905
+ liftoffEls.seal.dataset.state = "err";
1906
+ if (isMergeConflict) {
1907
+ const target = change.prNumber != null ? "PR #" + change.prNumber : "This change";
1908
+ liftoffEls.outcomeTitle.textContent = "Preview changed while you worked";
1909
+ liftoffEls.outcomeDetail.textContent =
1910
+ target + " edits content that also changed on preview after it was " +
1911
+ "created, so it couldn't merge as-is. Nothing was integrated — preview " +
1912
+ "is exactly as it was. You can update this change automatically below.";
1913
+ setConflictGuidance(true, data.statusMessage || "", change);
1914
+ } else {
1915
+ liftoffEls.outcomeTitle.textContent = "Integration did not land";
1916
+ liftoffEls.outcomeDetail.textContent = message;
1917
+ setConflictGuidance(false);
1918
+ }
1919
+ liftoffEls.review.hidden = true;
1920
+ if (liftoff.hidden) liftoff.hidden = false;
1921
+ showPhase("outcome");
1922
+ liftoffEls.done.focus();
1923
+ }
1924
+ }
1925
+ } catch {
1926
+ stopLiftoffLoops();
1927
+ setStatus(statusEl, "Integrate failed — network error", "err");
1928
+ if (liftoffEls) {
1929
+ liftoffEls.seal.dataset.state = "err";
1930
+ liftoffEls.outcomeTitle.textContent = "Integration did not land";
1931
+ liftoffEls.outcomeDetail.textContent =
1932
+ "Network error mid-flight — refresh the queue to see the aggregate's honest state.";
1933
+ setConflictGuidance(false);
1934
+ liftoffEls.review.hidden = true;
1935
+ if (liftoff.hidden) liftoff.hidden = false;
1936
+ showPhase("outcome");
1937
+ }
1938
+ } finally {
1939
+ busyChanges.delete(change.changeId);
1940
+ if (button) button.disabled = false;
1941
+ loadQueue();
1942
+ }
1943
+ };
1944
+
1945
+ const integrateCandidate = (change?: any, button?: any) => {
1946
+ // The liftoff dialog IS the plan confirm now; window.confirm stays only as
1947
+ // the no-dialog fallback (markup missing = fail open to the old popup).
1948
+ if (openLiftoff(change, button)) return;
1949
+ const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
1950
+ const plan =
1951
+ "Accept-to-preview plan:\n tenant: " + (appDomain || repo) +
1952
+ (change.prNumber != null ? "\n PR: #" + change.prNumber : "") +
1953
+ "\n change id: " + change.changeId +
1954
+ (change.headSha ? "\n head sha: " + shortSha(change.headSha) : "") +
1955
+ "\n effect: integrate " + target + " into the preview aggregate, then rebuild + " +
1956
+ "re-evidence it. Does NOT go live — publish the reviewed build separately.";
1957
+ if (!window.confirm(plan + "\n\nProceed?")) return;
1958
+ runIntegration(change, button);
1959
+ };
1960
+
1961
+ const rejectChange = async (change?: any, button?: any) => {
1962
+ const reason = window.prompt(
1963
+ "Reject " + change.changeId + " — add a short note for the submitter:",
1964
+ );
1965
+ if (reason == null) return;
1966
+ if (!reason.trim()) {
1967
+ setStatus(statusEl, "A reject note is required.", "err");
1968
+ return;
1969
+ }
1970
+ button.disabled = true;
1971
+ busyChanges.add(change.changeId); // rux7: survive a poll re-render as busy
1972
+ setStatus(statusEl, "Rejecting " + change.changeId + "…");
1973
+ try {
1974
+ const res = await fetch(
1975
+ ds.changesEndpoint + "/" + encodeURIComponent(change.changeId) + "/reject",
1976
+ {
1977
+ method: "POST",
1978
+ headers: {
1979
+ "content-type": "application/json",
1980
+ "x-tot-capability": "ship-on-behalf",
1981
+ },
1982
+ body: JSON.stringify({ repo, reason: reason.trim() }),
1983
+ },
1984
+ );
1985
+ const data = await res.json().catch(() => ({}));
1986
+ if (res.ok) {
1987
+ setStatus(statusEl, "Change " + change.changeId + " rejected.", "ok");
1988
+ } else {
1989
+ setStatus(statusEl, data.error || "Reject failed (" + res.status + ")", "err");
1990
+ }
1991
+ } catch {
1992
+ setStatus(statusEl, "Reject failed — network error", "err");
1993
+ } finally {
1994
+ busyChanges.delete(change.changeId);
1995
+ button.disabled = false;
1996
+ loadQueue();
1997
+ }
1998
+ };
1999
+
2000
+ // u14 — per-change REVERT (Undo): roll ONE change OUT of the shared staged
2001
+ // preview. Appends a revert commit + rebuilds the `preview` aggregate; it NEVER
2002
+ // touches the live channel (no paywall, `/api/changes/revert`). The outcome is
2003
+ // rendered HONESTLY per the queue's reason — `already_reverted` / `target_not_found`
2004
+ // are calm informational states (the change is simply already out of staging), not
2005
+ // errors, so they never flash red.
2006
+ const revertStaged = async (pr?: any, button?: any) => {
2007
+ const target = pr.prNumber != null ? "PR #" + pr.prNumber : pr.changeId;
2008
+ const plan =
2009
+ "Undo " + target + " from the staged preview?\n\n" +
2010
+ " tenant: " + (appDomain || repo) + "\n" +
2011
+ " target: " + target + "\n" +
2012
+ " effect: append a revert commit to the staged preview and rebuild — the change\n" +
2013
+ " comes OUT of staging. Your live site is untouched.\n\n" +
2014
+ "Proceed?";
2015
+ if (!window.confirm(plan)) return;
2016
+ button.disabled = true;
2017
+ setStatus(statusEl, "Reverting " + target + " out of the staged preview…");
2018
+ try {
2019
+ const res = await fetch(ds.changesEndpoint + "/revert", {
2020
+ method: "POST",
2021
+ headers: {
2022
+ "content-type": "application/json",
2023
+ "x-tot-capability": "ship-on-behalf",
2024
+ },
2025
+ body: JSON.stringify({ repo, prNumber: pr.prNumber }),
2026
+ });
2027
+ const data = await res.json().catch(() => ({}));
2028
+ if (res.ok && data.ok) {
2029
+ setStatus(statusEl, "Reverted " + target + " — the staged preview is rebuilding.", "ok");
2030
+ } else if (res.ok && (data.reason === "already_reverted" || data.reason === "target_not_found")) {
2031
+ // Not an error: the change is already out of staging (or was never in it).
2032
+ setStatus(
2033
+ statusEl,
2034
+ data.reason === "already_reverted"
2035
+ ? target + " was already removed from the staged preview."
2036
+ : target + " isn't in the staged preview — nothing to undo.",
2037
+ );
2038
+ } else {
2039
+ setStatus(statusEl, data.error || data.statusMessage || "Undo failed (" + res.status + ")", "err");
2040
+ }
2041
+ } catch {
2042
+ setStatus(statusEl, "Undo failed — network error", "err");
2043
+ } finally {
2044
+ button.disabled = false;
2045
+ loadQueue();
2046
+ }
2047
+ };
2048
+
2049
+ // u15 — retry a DEAD aggregate rebuild, no new merge: the last candidate's
2050
+ // PR already merged into preview, but the build/evidence step that follows
2051
+ // died (e.g. a transient MCP error). `POST /api/changes/rebuild` re-runs
2052
+ // ONLY that step against the sha the forge already carries — never a
2053
+ // second merge attempt, never a membership change.
2054
+ const retryBuild = async (button?: any) => {
2055
+ if (!window.confirm(
2056
+ "Retry the build for " + (appDomain || repo) + "?\n\n" +
2057
+ " effect: re-run the build + evidence check against the last merged\n" +
2058
+ " commit — no new merge, no membership change.\n\n" +
2059
+ "Proceed?",
2060
+ )) return;
2061
+ button.disabled = true;
2062
+ aggregateRetrying = true; // rux7-style: hold disabled across a poll landing mid-retry
2063
+ setStatus(aggStatusEl, "Retrying the build…");
2064
+ try {
2065
+ const res = await fetch(ds.changesEndpoint + "/rebuild", {
2066
+ method: "POST",
2067
+ headers: { "content-type": "application/json", "x-tot-capability": "ship-on-behalf" },
2068
+ body: JSON.stringify({ repo }),
2069
+ });
2070
+ const data = await res.json().catch(() => ({}));
2071
+ if (res.ok && data.ok) {
2072
+ setStatus(aggStatusEl, "Build retry passed — the aggregate is green.", "ok");
2073
+ } else if (res.ok && data.reason === "nothing_to_rebuild") {
2074
+ setStatus(aggStatusEl, "Nothing to rebuild — no prior integration attempt exists yet.");
2075
+ } else if (res.ok) {
2076
+ setStatus(
2077
+ aggStatusEl,
2078
+ "Build retry did not pass" +
2079
+ (data.statusMessage ? ": " + data.statusMessage : " (" + (data.reason || data.queueState) + ")"),
2080
+ "err",
2081
+ );
2082
+ } else {
2083
+ setStatus(aggStatusEl, data.error || "Retry failed (" + res.status + ")", "err");
2084
+ }
2085
+ } catch {
2086
+ setStatus(aggStatusEl, "Retry failed — network error", "err");
2087
+ } finally {
2088
+ aggregateRetrying = false;
2089
+ button.disabled = false;
2090
+ loadQueue();
2091
+ }
2092
+ };
2093
+
2094
+ // U7 — retire/GC. Mirror of the accept plan affordance (decision
2095
+ // operator-verb-and-hosting-model): state EXACTLY what retire does — evict the
2096
+ // candidate's preview environment + immutable version — and that it is
2097
+ // REBUILDABLE (degrades to "not built yet", not a 404), so an operator can't
2098
+ // confuse it with Reject. Same shape as `tot retire`'s CLI plan for parity.
2099
+ const buildRetirePlan = (change?: any) => {
2100
+ const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
2101
+ const lines = ["Retire plan:", " tenant: " + (appDomain || repo)];
2102
+ if (change.prNumber != null) lines.push(" PR: #" + change.prNumber);
2103
+ lines.push(" change id: " + change.changeId);
2104
+ if (change.headSha) lines.push(" head sha: " + shortSha(change.headSha));
2105
+ lines.push(
2106
+ " effect: evict " + target + "'s preview environment + version to reclaim space.",
2107
+ );
2108
+ lines.push(
2109
+ " rebuild: REVERSIBLE — rebuild it any time (this is NOT Reject; the change stays open).",
2110
+ );
2111
+ return lines.join("\n");
2112
+ };
2113
+
2114
+ const retireChange = async (change?: any, button?: any) => {
2115
+ if (!window.confirm(buildRetirePlan(change) + "\n\nProceed?")) return;
2116
+ button.disabled = true;
2117
+ busyChanges.add(change.changeId); // rux7: survive a poll re-render as busy
2118
+ setStatus(statusEl, "Retiring " + change.changeId + "…");
2119
+ try {
2120
+ const res = await fetch(ds.retireEndpoint, {
2121
+ method: "POST",
2122
+ headers: {
2123
+ "content-type": "application/json",
2124
+ // Mirror of the server-side capability model (UX); the server
2125
+ // re-derives capability and stays authoritative.
2126
+ "x-tot-capability": "ship-on-behalf",
2127
+ },
2128
+ body: JSON.stringify({
2129
+ changeId: change.changeId,
2130
+ pr: change.prNumber ?? undefined,
2131
+ }),
2132
+ });
2133
+ const data = await res.json().catch(() => ({}));
2134
+ if (res.ok && data.ok) {
2135
+ setStatus(
2136
+ statusEl,
2137
+ data.evicted
2138
+ ? "Retired " + change.changeId + " — the preview was evicted (rebuildable on demand)."
2139
+ : "Retire " + change.changeId + ": nothing to evict (already retired).",
2140
+ "ok",
2141
+ );
2142
+ } else {
2143
+ setStatus(statusEl, data.error || "Retire failed (" + res.status + ")", "err");
2144
+ }
2145
+ } catch {
2146
+ setStatus(statusEl, "Retire failed — network error", "err");
2147
+ } finally {
2148
+ busyChanges.delete(change.changeId);
2149
+ button.disabled = false;
2150
+ loadQueue();
2151
+ }
2152
+ };
2153
+
2154
+ // ---- rux5: build-on-demand for a synthetic not-built PR ------------------
2155
+ // Mirror of the integrate/retire plan affordances: state EXACTLY what build
2156
+ // does — materialize this PR's hosted preview at its forge head — and that it
2157
+ // is INERT (flips no live channel). POSTs { pr, headSha?, baseSha? }; the row
2158
+ // already carries the head sha GET /api/changes resolved via forge PR-read, and
2159
+ // the server re-resolves it authoritatively when the body omits it. Shows an
2160
+ // in-progress state (disabled + "Building…") while the build runs, then reloads
2161
+ // the queue so the freshly-built PR reappears as a built candidate.
2162
+
2163
+ const buildPreviewPlan = (change?: any) => {
2164
+ const lines = ["Build preview plan:", " tenant: " + (appDomain || repo)];
2165
+ lines.push(" PR: #" + change.prNumber);
2166
+ if (change.headSha) lines.push(" head sha: " + shortSha(change.headSha));
2167
+ lines.push(
2168
+ " effect: materialize this PR's hosted preview at its head — INERT " +
2169
+ "(does NOT go live).",
2170
+ );
2171
+ return lines.join("\n");
2172
+ };
2173
+
2174
+ const buildPreview = async (change?: any, button?: any) => {
2175
+ const label = "PR #" + change.prNumber;
2176
+ if (!window.confirm(buildPreviewPlan(change) + "\n\nBuild the preview now?")) return;
2177
+ button.disabled = true;
2178
+ const originalText = button.textContent;
2179
+ button.textContent = "Building…";
2180
+ button.dataset.building = "true";
2181
+ // rux7: record the in-flight build so a poll re-render preserves the
2182
+ // spinner/disabled state on the freshly-rendered button (cleared in finally).
2183
+ if (change.prNumber != null) inflightBuilds.add(change.prNumber);
2184
+ setStatus(statusEl, "Building preview for " + label + "…");
2185
+ try {
2186
+ const res = await fetch(ds.buildEndpoint, {
2187
+ method: "POST",
2188
+ headers: {
2189
+ "content-type": "application/json",
2190
+ // Mirror of the server-side capability model (UX); the server
2191
+ // re-derives capability (buildGate) and stays authoritative.
2192
+ "x-tot-capability": "ship-on-behalf",
2193
+ },
2194
+ body: JSON.stringify({
2195
+ pr: change.prNumber,
2196
+ headSha: change.headSha || undefined,
2197
+ baseSha: change.baseSha || undefined,
2198
+ }),
2199
+ });
2200
+ const data = await res.json().catch(() => ({}));
2201
+ if (res.ok && data.ok) {
2202
+ setStatus(
2203
+ statusEl,
2204
+ "Built preview for " + label +
2205
+ " — it now appears as a built change in the queue.",
2206
+ "ok",
2207
+ );
2208
+ } else if (data && data.ok === false) {
2209
+ // reconcileCandidate returned a blocked/failed candidate (e.g. a safety
2210
+ // finding with no override) — the honest reason, never a false success.
2211
+ setStatus(
2212
+ statusEl,
2213
+ "Build did not complete for " + label + ": " +
2214
+ ((data.errors && data.errors.join("; ")) || data.error || "blocked"),
2215
+ "err",
2216
+ );
2217
+ } else {
2218
+ setStatus(statusEl, data.error || "Build failed (" + res.status + ")", "err");
2219
+ }
2220
+ } catch {
2221
+ setStatus(statusEl, "Build failed — network error", "err");
2222
+ } finally {
2223
+ if (change.prNumber != null) inflightBuilds.delete(change.prNumber);
2224
+ button.disabled = false;
2225
+ button.textContent = originalText;
2226
+ delete button.dataset.building;
2227
+ loadQueue();
2228
+ }
2229
+ };
2230
+
2231
+ // ---- rux6: unified review pane -----------------------------------------
2232
+ // "One review moment": expanding a candidate row reveals a single pane with
2233
+ // the live preview (iframed SAME-ORIGIN — the /<tenant>/preview/pr/<N> route
2234
+ // serves `frame-ancestors 'self'` + `X-Frame-Options: SAMEORIGIN`, so a
2235
+ // same-origin admin embed is permitted; a cross-origin embed is refused,
2236
+ // which is why the pane also always offers an "Open in new tab" link), the
2237
+ // evidence chips the row already carries (change.evidence), the dg7 diff
2238
+ // (reusing renderDiff below — GET /api/changes/<id>/diff, which never invents
2239
+ // a diff for a non-renderable candidate), and the SAME Accept-to-preview /
2240
+ // Reject actions. The reviewer never leaves the queue to review and act; the
2241
+ // server stays authoritative for every action (this is UI consolidation only).
2242
+
2243
+ const diffBase = ds.diffEndpointBase;
2244
+
2245
+ const reviewRowFor = (tr?: any) => {
2246
+ const next = tr.nextElementSibling;
2247
+ return next && next.dataset && next.dataset.publishReviewRow === tr.dataset.changeRowId
2248
+ ? next
2249
+ : null;
2250
+ };
2251
+
2252
+ const closeReview = (tr?: any, button?: any) => {
2253
+ const existing = reviewRowFor(tr);
2254
+ if (existing) existing.remove();
2255
+ if (button) {
2256
+ button.textContent = "Review";
2257
+ button.setAttribute("aria-expanded", "false");
2258
+ }
2259
+ };
2260
+
2261
+ // Evidence chips from the data the row already carries (change.evidence):
2262
+ // an overall promotable verdict + one chip per reported check.
2263
+ const renderEvidenceChips = (container?: any, change?: any) => {
2264
+ const checks = (change.evidence && change.evidence.checks) || [];
2265
+ const promotable = Boolean(change.evidence && change.evidence.promotable);
2266
+ container.append(
2267
+ promotable ? chip("Checks passed", "green") : chip("Checks blocked", "amber"),
2268
+ );
2269
+ for (const c of checks) {
2270
+ const tone = c.status === "pass" ? "green" : c.status === "fail" ? "red" : "amber";
2271
+ const label = (c.label || c.id || "check") + (c.status && c.status !== "pass" ? " · " + c.status : "");
2272
+ const el = chip(label, tone);
2273
+ if (c.detail) el.title = c.detail;
2274
+ container.append(el);
2275
+ }
2276
+ if (!checks.length) container.append(subtle("No checks reported yet"));
2277
+ };
2278
+
2279
+ // Build the split pane. Returns the pane node + the diff container so the
2280
+ // caller can populate the diff asynchronously (reusing renderDiff).
2281
+ const buildReviewPane = (change?: any) => {
2282
+ const pane = document.createElement("div");
2283
+ pane.className = "review-pane";
2284
+
2285
+ // Preview column — same-origin iframe of the friendly per-PR route (it
2286
+ // handles every state: ready render, or the not-built page). Falls back to
2287
+ // any server-provided previewUrl for a change-first row with no PR number.
2288
+ const previewUrl = candidatePrUrl(appDomain, change.prNumber) || change.previewUrl;
2289
+ const previewCol = document.createElement("div");
2290
+ previewCol.className = "review-preview";
2291
+ const pHead = document.createElement("div");
2292
+ pHead.className = "review-pane-head";
2293
+ const pTitle = document.createElement("b");
2294
+ pTitle.textContent = "Preview";
2295
+ pHead.append(pTitle);
2296
+ if (previewUrl) {
2297
+ const openLink = document.createElement("a");
2298
+ openLink.href = previewUrl;
2299
+ openLink.target = "_blank";
2300
+ openLink.rel = "noopener";
2301
+ openLink.textContent = "Open in new tab ↗";
2302
+ pHead.append(openLink);
2303
+ }
2304
+ previewCol.append(pHead);
2305
+ if (previewUrl) {
2306
+ const frame = document.createElement("iframe");
2307
+ frame.className = "review-frame";
2308
+ frame.src = previewUrl;
2309
+ frame.loading = "lazy";
2310
+ frame.title =
2311
+ change.prNumber != null
2312
+ ? "Preview of PR #" + change.prNumber
2313
+ : "Preview of " + change.changeId;
2314
+ previewCol.append(frame);
2315
+ } else {
2316
+ previewCol.append(subtle("No preview is available for this change yet."));
2317
+ }
2318
+
2319
+ // Detail column — evidence chips + diff + the same Accept/Reject actions.
2320
+ const detailCol = document.createElement("div");
2321
+ detailCol.className = "review-detail";
2322
+
2323
+ const evSection = document.createElement("div");
2324
+ evSection.className = "review-section";
2325
+ const evTitle = document.createElement("b");
2326
+ evTitle.textContent = "Checks";
2327
+ const evChips = document.createElement("div");
2328
+ evChips.className = "review-chips";
2329
+ renderEvidenceChips(evChips, change);
2330
+ evSection.append(evTitle, evChips);
2331
+ detailCol.append(evSection);
2332
+
2333
+ const diffSection = document.createElement("div");
2334
+ diffSection.className = "review-section";
2335
+ const diffTitle = document.createElement("b");
2336
+ diffTitle.textContent = "Diff";
2337
+ const diffTarget = document.createElement("div");
2338
+ diffTarget.className = "review-diff-target";
2339
+ diffTarget.append(subtle("Loading the diff…"));
2340
+ diffSection.append(diffTitle, diffTarget);
2341
+ detailCol.append(diffSection);
2342
+
2343
+ // The SAME actions as the row — same handlers, same gates. Not a new authz
2344
+ // path: integrateCandidate/rejectChange POST to the same endpoints, and the
2345
+ // server re-authorizes.
2346
+ const actionRow = document.createElement("div");
2347
+ actionRow.className = "review-actions";
2348
+ const accept = document.createElement("button");
2349
+ accept.type = "button";
2350
+ accept.className = "btn primary";
2351
+ const needsPreviewUpdate =
2352
+ change.status === "ready" &&
2353
+ Boolean(change.evidence && change.evidence.promotable) &&
2354
+ change.mergeable === false;
2355
+ accept.textContent = needsPreviewUpdate ? "Update change" : "Accept to preview";
2356
+ accept.disabled = !canShip || (!needsPreviewUpdate && !change.mergeable);
2357
+ accept.title = !canShip
2358
+ ? "Requires a ship-on-behalf grant on your account — ask the store owner"
2359
+ : needsPreviewUpdate
2360
+ ? "Preview changed while you worked — update this change, then integrate it"
2361
+ : !change.mergeable
2362
+ ? "This change is not ready to integrate"
2363
+ : "Integrate this change into the preview aggregate (does not go live)";
2364
+ accept.addEventListener("click", () =>
2365
+ needsPreviewUpdate ? openConflictRecovery(change, accept) : integrateCandidate(change, accept),
2366
+ );
2367
+ const reject = document.createElement("button");
2368
+ reject.type = "button";
2369
+ reject.className = "btn";
2370
+ reject.textContent = "Reject";
2371
+ reject.disabled = !canShip;
2372
+ reject.title = canShip
2373
+ ? "Close this change without publishing"
2374
+ : "Requires an owner or admin sign-in";
2375
+ reject.addEventListener("click", () => rejectChange(change, reject));
2376
+ actionRow.append(accept, reject);
2377
+ detailCol.append(actionRow);
2378
+
2379
+ pane.append(previewCol, detailCol);
2380
+ return { pane, diffTarget };
2381
+ };
2382
+
2383
+ // A candidate's diff at a fixed head sha is immutable, so re-opening a review
2384
+ // pane for the same changeId@headSha serves from cache without re-fetching.
2385
+ const diffCache = createKeyedCache();
2386
+ const loadReviewDiff = async (change?: any, diffTarget?: any) => {
2387
+ const cacheKey = change.changeId + "@" + (change.headSha || "");
2388
+ const cached = diffCache.get(cacheKey);
2389
+ if (cached) {
2390
+ diffTarget.textContent = "";
2391
+ renderDiff(diffTarget, cached);
2392
+ return;
2393
+ }
2394
+ try {
2395
+ const res = await fetch(diffBase + "/" + encodeURIComponent(change.changeId) + "/diff", {
2396
+ headers: { accept: "application/json" },
2397
+ });
2398
+ const data = await res.json().catch(() => ({}));
2399
+ diffTarget.textContent = "";
2400
+ if (!res.ok) {
2401
+ diffTarget.append(subtle(data.error || "Could not load the diff (" + res.status + ")"));
2402
+ } else {
2403
+ const diff = data.diff || {};
2404
+ diffCache.set(cacheKey, diff);
2405
+ renderDiff(diffTarget, diff);
2406
+ }
2407
+ } catch {
2408
+ diffTarget.textContent = "";
2409
+ diffTarget.append(subtle("Could not load the diff — network error"));
2410
+ }
2411
+ };
2412
+
2413
+ const toggleReview = (change?: any, button?: any, tr?: any) => {
2414
+ if (!tr.dataset.changeRowId) tr.dataset.changeRowId = change.changeId;
2415
+ if (reviewRowFor(tr)) {
2416
+ closeReview(tr, button);
2417
+ return;
2418
+ }
2419
+ const detailTr = document.createElement("tr");
2420
+ detailTr.dataset.publishReviewRow = tr.dataset.changeRowId;
2421
+ const td = document.createElement("td");
2422
+ td.colSpan = 6;
2423
+ td.className = "publish-review-cell";
2424
+ const { pane, diffTarget } = buildReviewPane(change);
2425
+ td.append(pane);
2426
+ detailTr.append(td);
2427
+ tr.after(detailTr);
2428
+ button.textContent = "Hide review";
2429
+ button.setAttribute("aria-expanded", "true");
2430
+ loadReviewDiff(change, diffTarget);
2431
+ };
2432
+
2433
+ const CHANGE_MARK: Record<string, string> = { added: "+", changed: "~", removed: "−" };
2434
+
2435
+ const renderDiff = (td?: any, diff?: any) => {
2436
+ td.textContent = "";
2437
+ const head = document.createElement("div");
2438
+ head.className = "publish-diff-head";
2439
+
2440
+ // Authority: honest label of whether the diff describes the exact head.
2441
+ const authority = diff.authority || {};
2442
+ const authChip = chip(
2443
+ authority.authoritative
2444
+ ? "Reviewed at head"
2445
+ : authority.stale
2446
+ ? "Last-good preview (stale vs head)"
2447
+ : authority.missing
2448
+ ? "No evidence yet"
2449
+ : "Head",
2450
+ authority.authoritative ? "green" : authority.stale ? "amber" : "",
2451
+ );
2452
+ head.append(authChip);
2453
+
2454
+ if (diff.headSha) {
2455
+ const sha = document.createElement("span");
2456
+ sha.className = "publish-mono subtle";
2457
+ sha.textContent = "head " + shortSha(diff.headSha);
2458
+ head.append(sha);
2459
+ }
2460
+ if (diff.revHref) {
2461
+ const rev = document.createElement("a");
2462
+ rev.href = diff.revHref;
2463
+ rev.target = "_blank";
2464
+ rev.rel = "noopener";
2465
+ rev.textContent = "Open reviewed revision";
2466
+ head.append(rev);
2467
+ }
2468
+ td.append(head);
2469
+
2470
+ if (!diff.renderable) {
2471
+ // Honest: a candidate that can't render has NO diff — say why (dg3), no fake.
2472
+ const reason = document.createElement("p");
2473
+ reason.className = "note publish-diff-blocked";
2474
+ reason.textContent =
2475
+ diff.blockedReason || "This candidate has no renderable version to diff yet.";
2476
+ td.append(reason);
2477
+ return;
2478
+ }
2479
+
2480
+ const files = Array.isArray(diff.files) ? diff.files : [];
2481
+ if (!files.length) {
2482
+ td.append(subtle("No file changes vs the current live version."));
2483
+ return;
2484
+ }
2485
+ const list = document.createElement("ul");
2486
+ list.className = "publish-diff-list";
2487
+ for (const f of files) {
2488
+ const li = document.createElement("li");
2489
+ li.className = "publish-diff-" + f.change;
2490
+ const mark = document.createElement("span");
2491
+ mark.className = "publish-diff-mark";
2492
+ mark.textContent = CHANGE_MARK[f.change] || "•";
2493
+ const path = document.createElement("code");
2494
+ path.textContent = f.path;
2495
+ li.append(mark, path);
2496
+ list.append(li);
2497
+ }
2498
+ td.append(list);
2499
+ };
2500
+
2501
+ // ---- dg7: versions / promotion history + rollback ----------------------
2502
+ // The promotion history (u2/u3 promotion_status) with the tenant Gitea SHA,
2503
+ // its /rev link (u5), and the current-live marker; a prior version rolls back
2504
+ // THROUGH the one orchestrator (/api/changes/rollback → u8), rendering only the
2505
+ // HONEST terminal state (rolled_back only when verified).
2506
+
2507
+ const versionsPanel = root.querySelector("[data-publish-versions]");
2508
+ const versionsBody = versionsPanel?.querySelector("[data-versions-body]");
2509
+ const versionsChip = versionsPanel?.querySelector("[data-versions-chip]");
2510
+ const versionsStatus = versionsPanel?.querySelector("[data-versions-status]");
2511
+ const versionsRollbackNote = versionsPanel?.querySelector("[data-versions-rollback-note]");
2512
+ // Hosted and static-publish stores both render the same instant "Roll back" UX
2513
+ // below — every rollback is an instant KV pointer flip.
2514
+
2515
+ const renderVersions = (view?: any) => {
2516
+ if (!versionsBody) return;
2517
+ const versions = Array.isArray(view.versions) ? view.versions : [];
2518
+ versionsBody.textContent = "";
2519
+
2520
+ if (versionsChip) {
2521
+ versionsChip.textContent = versions.length === 1 ? "1 version" : versions.length + " versions";
2522
+ versionsChip.className = "chip" + (versions.length ? " blue" : "");
2523
+ }
2524
+
2525
+ // u14 — orient a store with exactly ONE live version: rollback needs a PRIOR
2526
+ // target, which only exists once a SECOND version is published. Only fill the
2527
+ // status line if nothing else has claimed it (an action/error message wins).
2528
+ if (versionsStatus && versions.length === 1 && !versionsStatus.textContent) {
2529
+ versionsStatus.textContent = "Rollback targets appear here once a second version is published.";
2530
+ }
2531
+
2532
+ // Every rollback is an instant KV pointer flip, so there is no CI-latency
2533
+ // note to show.
2534
+ if (versionsRollbackNote) versionsRollbackNote.hidden = true;
2535
+
2536
+ if (!versions.length) {
2537
+ const tr = document.createElement("tr");
2538
+ const td = cell(subtle("No versions have been promoted to live yet."));
2539
+ td.colSpan = 6;
2540
+ tr.append(td);
2541
+ versionsBody.append(tr);
2542
+ return;
2543
+ }
2544
+
2545
+ for (const v of versions) {
2546
+ const tr = document.createElement("tr");
2547
+
2548
+ // Version identity: the tenant Gitea SHA (content authority) + source.
2549
+ const idCell = document.createElement("td");
2550
+ const strong = document.createElement("strong");
2551
+ strong.className = "publish-mono";
2552
+ strong.textContent = shortSha(v.versionId);
2553
+ idCell.append(strong);
2554
+ if (v.source) idCell.append(subtle(v.source === "commit" ? "From a commit" : "Content hash"));
2555
+
2556
+ // "What shipped" — the SAME human summary the staged Publish console shows,
2557
+ // now describing the RELEASE: a plain-language lead + an openable list of the
2558
+ // PRs this version shipped (from its release receipt). OPTIONAL server
2559
+ // enrichment: absent (an older version with no linked receipt) → the cell
2560
+ // stays just the sha + source, exactly as before (graceful fallback).
2561
+ const summary = v.releaseSummary || null;
2562
+ if (summary && (summary.lead || summary.text)) {
2563
+ const lead = document.createElement("p");
2564
+ lead.className = "ver-summary";
2565
+ lead.textContent = summary.lead || summary.text;
2566
+ idCell.append(lead);
2567
+ }
2568
+ const changes = Array.isArray(v.changes) ? v.changes : [];
2569
+ if (changes.length) {
2570
+ const details = document.createElement("details");
2571
+ details.className = "agg-included-details ver-changes";
2572
+ const sm = document.createElement("summary");
2573
+ const smLead = document.createElement("span");
2574
+ smLead.className = "agg-included-lead";
2575
+ smLead.textContent = "What shipped";
2576
+ const badge = document.createElement("span");
2577
+ badge.className = "agg-included-badge";
2578
+ badge.textContent = String(changes.length);
2579
+ const chev = document.createElement("span");
2580
+ chev.className = "agg-chev";
2581
+ chev.setAttribute("aria-hidden", "true");
2582
+ const chevSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
2583
+ const chevPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
2584
+ chevPath.setAttribute("d", "M9 6l6 6-6 6");
2585
+ chevSvg.setAttribute("viewBox", "0 0 24 24");
2586
+ chevSvg.setAttribute("fill", "none");
2587
+ chevSvg.setAttribute("stroke", "currentColor");
2588
+ chevSvg.setAttribute("stroke-width", "2.2");
2589
+ chevSvg.append(chevPath);
2590
+ chev.append(chevSvg);
2591
+ sm.append(smLead, badge, chev);
2592
+ details.append(sm);
2593
+ const ul = document.createElement("ul");
2594
+ ul.className = "agg-included-list";
2595
+ for (const ch of changes) ul.append(changeSummaryItem(ch));
2596
+ details.append(ul);
2597
+ idCell.append(details);
2598
+ }
2599
+ tr.append(idCell);
2600
+
2601
+ // Digest (static current only — never fabricated for priors).
2602
+ const digestCell = cell(v.digest ? shortSha(v.digest) : "—");
2603
+ digestCell.classList.add("publish-mono");
2604
+ if (!v.digest) digestCell.classList.add("subtle");
2605
+ tr.append(digestCell);
2606
+
2607
+ tr.append(cell(age(v.promotedAt)));
2608
+
2609
+ // Preview: the u5 immutable /rev/<sha> link.
2610
+ const revCell = document.createElement("td");
2611
+ if (v.revHref) {
2612
+ const a = document.createElement("a");
2613
+ a.href = v.revHref;
2614
+ a.target = "_blank";
2615
+ a.rel = "noopener";
2616
+ a.textContent = "Revision";
2617
+ revCell.append(a);
2618
+ } else {
2619
+ revCell.append(subtle("—"));
2620
+ }
2621
+ tr.append(revCell);
2622
+
2623
+ // State: current live vs prior.
2624
+ tr.append(cell(v.isCurrent ? chip("Current live", "green") : chip("Prior", "")));
2625
+
2626
+ // Action: roll back to a PRIOR version (never the current one).
2627
+ const act = document.createElement("td");
2628
+ act.className = "action publish-actions";
2629
+ if (!v.isCurrent) {
2630
+ const rollbackBtn = document.createElement("button");
2631
+ rollbackBtn.type = "button";
2632
+ rollbackBtn.className = "btn";
2633
+ rollbackBtn.textContent = "Roll back";
2634
+ rollbackBtn.disabled = !canShip;
2635
+ rollbackBtn.title = !canShip
2636
+ ? "Requires an owner or ship-on-behalf sign-in"
2637
+ : "Roll the live site back to this version";
2638
+ rollbackBtn.addEventListener("click", () => rollbackVersion(v, rollbackBtn));
2639
+ act.append(rollbackBtn);
2640
+ }
2641
+ tr.append(act);
2642
+
2643
+ versionsBody.append(tr);
2644
+ }
2645
+ };
2646
+
2647
+ // Published versions only change when something is published, which moves the
2648
+ // change-queue fingerprint (currentStateKey). Cache the view against that key
2649
+ // so re-viewing the panel or a re-render doesn't re-fire the read; refetch
2650
+ // only when the fingerprint changes or on an explicit refresh (force).
2651
+ const versionsCache = createKeyedCache();
2652
+ const loadVersionsImpl = async () => {
2653
+ if (!versionsBody) return;
2654
+ const key = currentStateKey;
2655
+ if (key != null && versionsCache.has(key)) {
2656
+ renderVersions(versionsCache.get(key));
2657
+ return;
2658
+ }
2659
+ try {
2660
+ const res = await fetch(ds.versionsEndpoint, { headers: { accept: "application/json" } });
2661
+ if (res.status === 401 || res.status === 403) {
2662
+ if (versionsChip) versionsChip.textContent = "Sign-in required";
2663
+ versionsBody.textContent = "";
2664
+ const tr = document.createElement("tr");
2665
+ const td = cell(subtle("Sign in as a store owner or admin to view published versions."));
2666
+ td.colSpan = 6;
2667
+ tr.append(td);
2668
+ versionsBody.append(tr);
2669
+ return;
2670
+ }
2671
+ const data = await res.json().catch(() => ({}));
2672
+ if (!res.ok) {
2673
+ setStatus(versionsStatus, data.error || "Could not load versions (" + res.status + ")", "err");
2674
+ return;
2675
+ }
2676
+ const view = data.view || {};
2677
+ if (key != null) versionsCache.set(key, view);
2678
+ renderVersions(view);
2679
+ } catch {
2680
+ setStatus(versionsStatus, "Could not load versions — network error", "err");
2681
+ }
2682
+ };
2683
+ const versionsCoalescer = createCoalescer(loadVersionsImpl);
2684
+ const loadVersions = (opts?: any) => {
2685
+ // force (explicit refresh / post-action) drops the cached entry so the
2686
+ // next run re-fetches even if the fingerprint hasn't caught up yet.
2687
+ if (opts && opts.force && currentStateKey != null) versionsCache.delete(currentStateKey);
2688
+ return versionsCoalescer.trigger();
2689
+ };
2690
+
2691
+ // Interpret the rollback response into ONE honest terminal state. Kept local
2692
+ // (NOT imported from lib/publish/shipWorkspace's interpretRollbackResponse):
2693
+ // that module pulls lib/preview value dependencies, which must not be dragged
2694
+ // into this client bundle. `rolledBack` is
2695
+ // true ONLY for a verified `rolled_back` — a `publish_pending` is NEVER a
2696
+ // rolled-back claim.
2697
+ const interpretRollback = (httpOk?: any, body?: any) => {
2698
+ const toVersionId = typeof body.toVersionId === "string" ? body.toVersionId : null;
2699
+ if (typeof body.state === "string") {
2700
+ const state = ["rolled_back", "publish_pending", "publish_failed", "blocked"].includes(body.state)
2701
+ ? body.state
2702
+ : "error";
2703
+ return {
2704
+ state,
2705
+ rolledBack: state === "rolled_back",
2706
+ message: body.message || body.error || "Rollback returned state " + state + ".",
2707
+ };
2708
+ }
2709
+ if (httpOk && body.rolledBack === true) {
2710
+ return {
2711
+ state: "rolled_back",
2712
+ rolledBack: true,
2713
+ message: toVersionId ? "Rolled back live to " + shortSha(toVersionId) + "." : "Rolled back live.",
2714
+ };
2715
+ }
2716
+ return {
2717
+ state: "blocked",
2718
+ rolledBack: false,
2719
+ message: (body.error || "Rollback failed") + (body.reason ? " (" + body.reason + ")" : ""),
2720
+ };
2721
+ };
2722
+
2723
+ const rollbackVersion = async (version?: any, button?: any) => {
2724
+ // Every rollback is an instant KV pointer flip — the confirm/status copy is
2725
+ // the same for a hosted and a static-publish store.
2726
+ const confirmMsg =
2727
+ "Roll the live store back to version " + shortSha(version.versionId) +
2728
+ "? This publishes that prior version to the live site.";
2729
+ if (!window.confirm(confirmMsg)) return;
2730
+ button.disabled = true;
2731
+ setStatus(versionsStatus, "Rolling back to " + shortSha(version.versionId) + "…");
2732
+ try {
2733
+ // The console dispatches by version id only; a static prior version's
2734
+ // archive digest lives only in S3 (CI resolves it). Pass a digest ONLY when
2735
+ // we honestly have one (never for a static prior — its digest is always null).
2736
+ const payload: any = { targetVersionId: version.versionId };
2737
+ if (version.digest) payload.targetDigest = version.digest;
2738
+ const res = await fetch(ds.rollbackEndpoint, {
2739
+ method: "POST",
2740
+ headers: { "content-type": "application/json", "x-tot-capability": "ship-on-behalf" },
2741
+ body: JSON.stringify(payload),
2742
+ });
2743
+ const data = await res.json().catch(() => ({}));
2744
+ const outcome = interpretRollback(res.ok, data);
2745
+ setStatus(versionsStatus, outcome.message, outcome.rolledBack ? "ok" : outcome.state === "publish_pending" ? "ok" : "err");
2746
+ } catch {
2747
+ setStatus(versionsStatus, "Rollback failed — network error", "err");
2748
+ } finally {
2749
+ button.disabled = false;
2750
+ loadVersions({ force: true }); // a rollback changed the live version
2751
+ loadQueue();
2752
+ }
2753
+ };
2754
+
2755
+ // ---- Domain section ----------------------------------------------------
2756
+
2757
+ const domainPanel = root.querySelector("[data-publish-domain]");
2758
+ let domainPollTimer: any = null;
2759
+
2760
+ const renderDomain = (payload?: any) => {
2761
+ if (!domainPanel) return;
2762
+ const domain = payload.domain || {};
2763
+ const stateEl = domainPanel.querySelector("[data-domain-state]");
2764
+ const stateChip = domainPanel.querySelector("[data-domain-state-chip]");
2765
+ const sinceEl = domainPanel.querySelector("[data-domain-since]");
2766
+ const captureEl = domainPanel.querySelector("[data-domain-capture]");
2767
+ const runEl = domainPanel.querySelector("[data-domain-run]");
2768
+ const runDetailEl = domainPanel.querySelector("[data-domain-run-detail]");
2769
+ const rollbackBtn = domainPanel.querySelector("[data-domain-rollback]");
2770
+ const rollbackDetail = domainPanel.querySelector("[data-domain-rollback-detail]");
2771
+ const connected = domain.state === "cloudfront";
2772
+
2773
+ if (stateEl) {
2774
+ stateEl.textContent = connected
2775
+ ? "Storefront CDN (cloudfront)"
2776
+ : "Previous host (pantheon)";
2777
+ }
2778
+ if (stateChip) {
2779
+ stateChip.textContent = connected ? "Connected" : "Not connected";
2780
+ stateChip.className = "chip " + (connected ? "green" : "amber");
2781
+ }
2782
+ if (sinceEl) {
2783
+ sinceEl.textContent = domain.since
2784
+ ? "Since " + new Date(domain.since).toLocaleString()
2785
+ : "Current host for " + (payload.appDomain || ds.appDomain);
2786
+ }
2787
+ if (captureEl) {
2788
+ captureEl.textContent = domain.priorCapturedAt
2789
+ ? "Captured " + new Date(domain.priorCapturedAt).toLocaleString()
2790
+ : "None captured";
2791
+ }
2792
+
2793
+ const run = domain.lastRun;
2794
+ // "dispatched" = awaiting the CI completion callback (terminal-only wire).
2795
+ const inFlight = run && run.status === "dispatched";
2796
+ if (runEl) {
2797
+ runEl.textContent = run
2798
+ ? run.action + (run.rehearsal ? " (rehearsal)" : "") + " · " + run.status
2799
+ : "None";
2800
+ }
2801
+ if (runDetailEl) {
2802
+ runDetailEl.textContent = "";
2803
+ if (run) {
2804
+ const parts = [];
2805
+ if (run.actor) parts.push("by " + run.actor);
2806
+ if (run.startedAt) parts.push(age(run.startedAt));
2807
+ runDetailEl.append(document.createTextNode(parts.join(" · ")));
2808
+ if (run.runUrl) {
2809
+ const link = document.createElement("a");
2810
+ link.href = run.runUrl;
2811
+ link.target = "_blank";
2812
+ link.rel = "noopener";
2813
+ link.textContent = "View run";
2814
+ runDetailEl.append(document.createTextNode(" · "));
2815
+ runDetailEl.append(link);
2816
+ }
2817
+ if (run.status === "failed" && run.error) {
2818
+ const err = document.createElement("span");
2819
+ err.className = "publish-error-detail";
2820
+ err.textContent = run.error;
2821
+ runDetailEl.append(document.createElement("br"));
2822
+ runDetailEl.append(err);
2823
+ }
2824
+ } else {
2825
+ runDetailEl.textContent = "Connect and rollback runs appear here.";
2826
+ }
2827
+ }
2828
+ if (rollbackBtn) {
2829
+ rollbackBtn.disabled =
2830
+ !canShip || inFlight || !domain.priorRecordsKey;
2831
+ }
2832
+ if (rollbackDetail && domain.priorCapturedAt) {
2833
+ rollbackDetail.textContent =
2834
+ "Restores the DNS records captured " +
2835
+ new Date(domain.priorCapturedAt).toLocaleString() + ".";
2836
+ }
2837
+
2838
+ // Poll while a run is in flight so completion lands without a manual refresh.
2839
+ if (domainPollTimer) {
2840
+ clearTimeout(domainPollTimer);
2841
+ domainPollTimer = null;
2842
+ }
2843
+ if (inFlight) domainPollTimer = setTimeout(loadDomain, 15000);
2844
+ };
2845
+
2846
+ const loadDomain = async () => {
2847
+ if (!domainPanel) return;
2848
+ try {
2849
+ const res = await fetch(ds.domainStatusEndpoint, {
2850
+ headers: { accept: "application/json" },
2851
+ });
2852
+ if (!res.ok) return;
2853
+ renderDomain(await res.json());
2854
+ } catch {
2855
+ /* leave the last rendered state */
2856
+ }
2857
+ // u9: refresh the readiness itemization alongside the status.
2858
+ loadReadiness();
2859
+ };
2860
+
2861
+ // ---- Cutover readiness (u9) --------------------------------------------
2862
+ // Reads the SAME server gate `tot go-live` reads (/api/domain/readiness) and
2863
+ // itemizes the preconditions. The Connect button additionally requires the
2864
+ // server to report ready — the display and the enforcement can't diverge.
2865
+ const readinessBox = domainPanel?.querySelector("[data-domain-readiness]");
2866
+ const connectBtn = domainPanel?.querySelector("[data-domain-connect]");
2867
+ let domainReady = false;
2868
+
2869
+ const renderReadiness = (payload?: any) => {
2870
+ if (!readinessBox) return;
2871
+ const readiness = (payload && payload.readiness) || {};
2872
+ const checks = Array.isArray(readiness.checks) ? readiness.checks : [];
2873
+ domainReady = readiness.ready === true;
2874
+ readinessBox.hidden = false;
2875
+
2876
+ const chip = readinessBox.querySelector("[data-domain-readiness-chip]");
2877
+ if (chip) {
2878
+ chip.textContent = domainReady ? "Ready" : "Not ready";
2879
+ chip.className = "chip " + (domainReady ? "green" : "amber");
2880
+ }
2881
+ const list = readinessBox.querySelector("[data-domain-readiness-list]");
2882
+ if (list) {
2883
+ list.textContent = "";
2884
+ for (const c of checks) {
2885
+ const li = document.createElement("li");
2886
+ li.className = c.ok ? "ok" : "blocked";
2887
+ // Header row: mark + bold label. The detail and remedy render on their
2888
+ // OWN lines below (not as inline siblings of the label) so the label and
2889
+ // its description never run together as one string.
2890
+ const head = document.createElement("div");
2891
+ head.className = "domain-readiness-item-head";
2892
+ const mark = document.createElement("span");
2893
+ mark.className = "domain-readiness-mark";
2894
+ mark.textContent = c.ok ? "✓" : "✗";
2895
+ const label = document.createElement("b");
2896
+ label.textContent = c.label || c.id || "";
2897
+ head.append(mark, label);
2898
+ li.append(head);
2899
+ if (c.detail) {
2900
+ const detail = document.createElement("p");
2901
+ detail.className = "domain-readiness-detail";
2902
+ detail.textContent = c.detail;
2903
+ li.append(detail);
2904
+ }
2905
+ // Blocked checks carry a "how to clear this" remedy from the SAME gate
2906
+ // `tot go-live` reads — the guide lives with the enforcement, never a
2907
+ // divergent side note.
2908
+ if (!c.ok && c.remedy) {
2909
+ const remedy = document.createElement("p");
2910
+ remedy.className = "domain-readiness-remedy";
2911
+ remedy.textContent = "→ " + c.remedy;
2912
+ li.append(remedy);
2913
+ }
2914
+ list.append(li);
2915
+ }
2916
+ }
2917
+ const note = readinessBox.querySelector("[data-domain-readiness-note]");
2918
+ if (note) {
2919
+ note.textContent = payload && payload.ownerCapable === false
2920
+ ? "Apex cutover is owner-only — sign in as the store owner to connect the domain."
2921
+ : domainReady
2922
+ ? "All preconditions are green — the cutover is permitted."
2923
+ : "Clear the failing preconditions above before connecting the apex.";
2924
+ }
2925
+ // Gate the Connect button on server readiness in ADDITION to owner (the
2926
+ // markup already disables it for non-owners); never enable it when blocked.
2927
+ if (connectBtn && !connectBtn.dataset.ownerLocked) {
2928
+ connectBtn.disabled = !domainReady;
2929
+ }
2930
+ };
2931
+
2932
+ const loadReadiness = async () => {
2933
+ if (!readinessBox || !ds.domainReadinessEndpoint) return;
2934
+ try {
2935
+ const res = await fetch(ds.domainReadinessEndpoint, {
2936
+ headers: { accept: "application/json", "x-tot-capability": "ship-on-behalf" },
2937
+ });
2938
+ if (!res.ok) return;
2939
+ renderReadiness(await res.json());
2940
+ } catch {
2941
+ /* leave the last rendered readiness */
2942
+ }
2943
+ };
2944
+
2945
+ const domainStatusEl = domainPanel?.querySelector("[data-domain-status]");
2946
+
2947
+ const domainAction = async (body?: any, button?: any) => {
2948
+ button.disabled = true;
2949
+ setStatus(domainStatusEl, (body.action === "connect" ? "Connecting" : "Rolling back") + "…");
2950
+ try {
2951
+ const res = await fetch(ds.domainDispatchEndpoint, {
2952
+ method: "POST",
2953
+ headers: {
2954
+ "content-type": "application/json",
2955
+ "x-tot-capability": "ship-on-behalf",
2956
+ },
2957
+ body: JSON.stringify(body),
2958
+ });
2959
+ const data = await res.json().catch(() => ({}));
2960
+ if (res.ok && data.dispatched) {
2961
+ setStatus(
2962
+ domainStatusEl,
2963
+ (body.action === "connect" ? "Connect" : "Rollback") +
2964
+ (body.rehearsal ? " rehearsal" : "") +
2965
+ " dispatched — the run status updates below as it progresses.",
2966
+ "ok",
2967
+ );
2968
+ } else {
2969
+ setStatus(domainStatusEl, data.error || "Request failed (" + res.status + ")", "err");
2970
+ }
2971
+ } catch {
2972
+ setStatus(domainStatusEl, "Request failed — network error", "err");
2973
+ } finally {
2974
+ button.disabled = false;
2975
+ loadDomain();
2976
+ }
2977
+ };
2978
+
2979
+ domainPanel?.querySelector("[data-domain-connect]")?.addEventListener("click", (event: any) => {
2980
+ const confirmInput = domainPanel.querySelector("[data-domain-confirm]");
2981
+ const rehearsal = Boolean(domainPanel.querySelector("[data-domain-rehearsal]")?.checked);
2982
+ domainAction(
2983
+ {
2984
+ action: "connect",
2985
+ confirmDomain: (confirmInput?.value || "").trim(),
2986
+ rehearsal,
2987
+ },
2988
+ event.currentTarget,
2989
+ );
2990
+ });
2991
+
2992
+ domainPanel?.querySelector("[data-domain-rollback]")?.addEventListener("click", (event: any) => {
2993
+ if (
2994
+ !window.confirm(
2995
+ "Roll back " + ds.appDomain + " to the previous host? DNS returns to the captured records in about a minute.",
2996
+ )
2997
+ ) {
2998
+ return;
2999
+ }
3000
+ domainAction({ action: "rollback" }, event.currentTarget);
3001
+ });
3002
+
3003
+ // ---- b17: branch retention ----------------------------------------------
3004
+ // Renders the GET /api/changes/branches dry-run classification (already
3005
+ // projected server-side by branchRetentionView.ts — this script only
3006
+ // paints it, same division of labor as renderQueue/renderVersions above)
3007
+ // and drives the guarded delete. The server (candidate_delete, b13)
3008
+ // re-classifies fresh on every delete, so the confirm below is the HUMAN
3009
+ // gate, not the safety guarantee — the safety guarantee is server-side.
3010
+
3011
+ const branchesPanel = root.querySelector("[data-publish-branches]");
3012
+ const branchesBody = branchesPanel?.querySelector("[data-branches-body]");
3013
+ const branchesChip = branchesPanel?.querySelector("[data-branches-chip]");
3014
+ const branchesStatus = branchesPanel?.querySelector("[data-branches-status]");
3015
+ const branchesActions = branchesPanel?.querySelector("[data-branches-actions]");
3016
+ const branchesSweepBtn = branchesPanel?.querySelector("[data-branches-sweep]");
3017
+
3018
+ // Kept local (NOT imported from branchRetentionView): that module pulls a
3019
+ // lib/forge value dependency, which must not be dragged into this client bundle.
3020
+ // Shown when a live classification fetch fails — the table must NEVER keep the
3021
+ // last-known chips (a confident stale label is exactly the b1 bug).
3022
+ const FORGE_UNREACHABLE_LABEL = "State unknown (forge unreachable)";
3023
+
3024
+ // The last classification the panel rendered from a SUCCESSFUL fetch. Kept so a
3025
+ // later fetch failure can re-stamp those same branches as "state unknown" (never
3026
+ // leave the stale confident chips), and so an unchanged poll can skip re-render.
3027
+ let lastBranchesRows: any[] = [];
3028
+ let lastBranchesSignature: string | null = null;
3029
+ // Refs the "Sweep dead branches" action deletes, as pre-computed server-side
3030
+ // (view.sweepableRefs): Dead + delete-eligible + reachable. The UI never re-derives
3031
+ // the delete set — it confirms these, then loops the guarded delete.
3032
+ let lastSweepableRefs: any[] = [];
3033
+
3034
+ const buildCleanupPlan = (row?: any) => {
3035
+ return [
3036
+ "Delete branch plan:",
3037
+ " repo: " + (appDomain || repo),
3038
+ " branch: " + row.ref,
3039
+ " tip sha: " + row.shortSha,
3040
+ " classification: " + row.classificationLabel,
3041
+ " reason: " + row.reason,
3042
+ "",
3043
+ "This deletes the Git BRANCH only — it never evicts a hosted preview build",
3044
+ "(that is Retire, a separate action above). The server re-classifies this",
3045
+ "branch fresh before deleting; a moved tip or a branch that is no longer",
3046
+ "integrated/closed-or-rejected is refused, not silently skipped.",
3047
+ ].join("\n");
3048
+ };
3049
+
3050
+ // One guarded delete round-trip. Shared by the per-row "Clean up", the bulk
3051
+ // "Sweep dead branches", and the per-row orphan "Delete this branch" — the server
3052
+ // re-checks eligibility fresh on every ref. `opts` opts into the explicit HUMAN
3053
+ // delete (explicitConfirm + expectedClassification) for an orphan the default
3054
+ // eligibility path refuses; omitted, this is the default guarded delete.
3055
+ const postDeleteRef = async (ref?: any, expectedSha?: any, opts: any = {}) => {
3056
+ const body: any = { ref, expectedSha };
3057
+ if (opts.explicitConfirm) body.explicitConfirm = true;
3058
+ if (opts.expectedClassification) body.expectedClassification = opts.expectedClassification;
3059
+ const res = await fetch(ds.branchesEndpoint, {
3060
+ method: "POST",
3061
+ headers: { "content-type": "application/json", "x-tot-capability": "ship-on-behalf" },
3062
+ body: JSON.stringify(body),
3063
+ });
3064
+ const data = await res.json().catch(() => ({}));
3065
+ return { ok: res.ok && data.ok, status: res.status, data };
3066
+ };
3067
+
3068
+ const cleanupBranch = async (row?: any, button?: any) => {
3069
+ if (!window.confirm(buildCleanupPlan(row) + "\n\nDelete this branch?")) return;
3070
+ button.disabled = true;
3071
+ setStatus(branchesStatus, "Deleting " + row.shortRef + "…");
3072
+ try {
3073
+ const { ok, status, data } = await postDeleteRef(row.ref, row.sha);
3074
+ if (ok) {
3075
+ setStatus(
3076
+ branchesStatus,
3077
+ data.alreadyAbsent
3078
+ ? row.shortRef + " was already gone (nothing to delete)."
3079
+ : "Deleted " + row.shortRef + ".",
3080
+ "ok",
3081
+ );
3082
+ } else {
3083
+ setStatus(branchesStatus, data.error || "Delete refused (" + status + ")", "err");
3084
+ }
3085
+ } catch {
3086
+ setStatus(branchesStatus, "Delete failed — network error", "err");
3087
+ } finally {
3088
+ button.disabled = false;
3089
+ loadBranches();
3090
+ }
3091
+ };
3092
+
3093
+ // Per-row "Delete this branch": the deliberate, one-at-a-time HUMAN delete of an
3094
+ // ORPHAN — a branch the auto/bulk "Sweep" and one-click "Clean up" both refuse
3095
+ // (it is quarantined out of the sweep, and never eligibleForDelete). This is the
3096
+ // ONLY path that removes one. It routes through candidate_delete's guarded explicit
3097
+ // path (explicitConfirm + expectedClassification), which re-verifies the ref is
3098
+ // still an orphan at the same tip server-side and refuses (classification_changed /
3099
+ // sha_mismatch) if it drifted since this row was read — so the confirm below is the
3100
+ // human gate, the server re-check is the safety guarantee.
3101
+ const buildOrphanDeletePlan = (row?: any) => {
3102
+ return [
3103
+ "Delete ORPHANED branch:",
3104
+ " repo: " + (appDomain || repo),
3105
+ " branch: " + row.ref,
3106
+ " tip sha: " + row.shortSha,
3107
+ " classification: orphan",
3108
+ " reason: " + row.reason,
3109
+ "",
3110
+ "An orphan has no open PR and no integration record — nothing else will ever",
3111
+ "clean it up (the scheduled sweep and one-click cleanup both skip it). This",
3112
+ "deletes the Git BRANCH only — it never evicts a hosted preview build. The",
3113
+ "server re-checks this branch fresh before deleting; if it is no longer an",
3114
+ "orphan, or its tip has moved, the delete is refused — not silently skipped.",
3115
+ ].join("\n");
3116
+ };
3117
+
3118
+ const deleteOrphanBranch = async (row?: any, button?: any) => {
3119
+ if (!window.confirm(buildOrphanDeletePlan(row) + "\n\nPermanently delete this orphaned branch?")) return;
3120
+ button.disabled = true;
3121
+ setStatus(branchesStatus, "Deleting orphan " + row.shortRef + "…");
3122
+ try {
3123
+ const { ok, status, data } = await postDeleteRef(row.ref, row.sha, {
3124
+ explicitConfirm: true,
3125
+ expectedClassification: row.classification,
3126
+ });
3127
+ if (ok) {
3128
+ setStatus(
3129
+ branchesStatus,
3130
+ data.alreadyAbsent
3131
+ ? row.shortRef + " was already gone (nothing to delete)."
3132
+ : "Deleted orphaned branch " + row.shortRef + ".",
3133
+ "ok",
3134
+ );
3135
+ } else {
3136
+ setStatus(branchesStatus, data.error || "Delete refused (" + status + ")", "err");
3137
+ }
3138
+ } catch {
3139
+ setStatus(branchesStatus, "Delete failed — network error", "err");
3140
+ } finally {
3141
+ button.disabled = false;
3142
+ loadBranches();
3143
+ }
3144
+ };
3145
+
3146
+ // Bulk "Sweep dead branches": one confirm showing EXACTLY the refs it will delete,
3147
+ // then a guarded delete per ref (each re-checked fresh server-side — a ref that has
3148
+ // moved or is no longer eligible is refused, never silently skipped).
3149
+ const sweepDeadBranches = async () => {
3150
+ const rowsByRef = new Map<string, any>(lastBranchesRows.map((r: any): [string, any] => [r.ref, r]));
3151
+ const targets = lastSweepableRefs.map((ref) => rowsByRef.get(ref)).filter(Boolean);
3152
+ if (!targets.length) return;
3153
+ const preview = [
3154
+ "Sweep dead branches — this deletes " + targets.length + " branch(es):",
3155
+ "",
3156
+ ...targets.map((r: any) => " • " + r.ref + " @ " + r.shortSha + " (" + r.classificationLabel + ")"),
3157
+ "",
3158
+ "Each deletes the Git BRANCH only (never a hosted preview build). The server",
3159
+ "re-checks every ref fresh before deleting; anything no longer eligible is",
3160
+ "refused, not silently skipped.",
3161
+ ].join("\n");
3162
+ if (!window.confirm(preview + "\n\nDelete these branches?")) return;
3163
+ branchesSweepBtn.disabled = true;
3164
+ let deleted = 0;
3165
+ let refused = 0;
3166
+ for (const r of targets) {
3167
+ setStatus(branchesStatus, "Sweeping " + r.shortRef + "…");
3168
+ try {
3169
+ const { ok } = await postDeleteRef(r.ref, r.sha);
3170
+ if (ok) deleted++;
3171
+ else refused++;
3172
+ } catch {
3173
+ refused++;
3174
+ }
3175
+ }
3176
+ setStatus(
3177
+ branchesStatus,
3178
+ "Swept " + deleted + " branch(es)" + (refused ? ", " + refused + " refused" : "") + ".",
3179
+ refused ? "err" : "ok",
3180
+ );
3181
+ branchesSweepBtn.disabled = false;
3182
+ loadBranches();
3183
+ };
3184
+
3185
+ // Show/enable the sweep button only when there is a Dead+eligible set to sweep.
3186
+ const updateSweepButton = () => {
3187
+ if (!branchesActions || !branchesSweepBtn) return;
3188
+ const n = lastSweepableRefs.length;
3189
+ branchesActions.hidden = n === 0;
3190
+ branchesSweepBtn.disabled = !canShip || n === 0;
3191
+ branchesSweepBtn.title = canShip ? "" : "Requires an owner/admin sign-in.";
3192
+ branchesSweepBtn.textContent = n ? "Sweep dead branches (" + n + ")" : "Sweep dead branches";
3193
+ };
3194
+ branchesSweepBtn?.addEventListener("click", sweepDeadBranches);
3195
+
3196
+ // Paint the table body from a set of rows. `unreachable` re-stamps every row
3197
+ // as "state unknown" and withdraws its action — used when a live fetch fails,
3198
+ // so the operator never sees a confident classification the forge can no
3199
+ // longer confirm (the b1 bug: a stale "PR #11 open / Keep" long after close).
3200
+ const renderBranchRows = (rows?: any, { unreachable = false }: any = {}) => {
3201
+ branchesBody.textContent = "";
3202
+ if (!rows.length) {
3203
+ const tr = document.createElement("tr");
3204
+ const td = cell(
3205
+ subtle(
3206
+ unreachable
3207
+ ? "Branch state unknown — the forge could not be reached."
3208
+ : "No candidate branches found for this repo.",
3209
+ ),
3210
+ );
3211
+ td.colSpan = 7;
3212
+ tr.append(td);
3213
+ branchesBody.append(tr);
3214
+ return;
3215
+ }
3216
+
3217
+ for (const row of rows) {
3218
+ const rowUnknown = unreachable || row.forgeUnreachable === true;
3219
+ const tr = document.createElement("tr");
3220
+
3221
+ const branchCell = document.createElement("td");
3222
+ const branchStrong = document.createElement("strong");
3223
+ branchStrong.textContent = row.shortRef;
3224
+ branchCell.append(branchStrong, subtle(row.ref + " @ " + row.shortSha));
3225
+
3226
+ // Describe the branch by WHAT it changes — the SAME candidate_summary the
3227
+ // staged Publish console + versions list read. OPTIONAL server enrichment:
3228
+ // absent (no PR, or the summary read missed it) → the cell stays just the
3229
+ // ref, exactly as before (graceful fallback). Never shown on an unreachable
3230
+ // row: its classification is precisely what couldn't be confirmed.
3231
+ if (!rowUnknown && (row.prTitle || row.prDescription)) {
3232
+ if (row.prTitle) {
3233
+ const t = document.createElement("p");
3234
+ t.className = "branch-pr-title";
3235
+ t.textContent = row.prTitle;
3236
+ branchCell.append(t);
3237
+ }
3238
+ if (row.prDescription) {
3239
+ const d = document.createElement("p");
3240
+ d.className = "branch-pr-desc";
3241
+ d.textContent = row.prDescription;
3242
+ branchCell.append(d);
3243
+ }
3244
+ const fileCount = typeof row.fileCount === "number" ? row.fileCount : null;
3245
+ const hasLines = typeof row.linesAdded === "number" || typeof row.linesRemoved === "number";
3246
+ if (fileCount != null || hasLines || row.aiDrafted) {
3247
+ const meta = document.createElement("div");
3248
+ meta.className = "agg-included-meta branch-pr-meta";
3249
+ if (fileCount != null) {
3250
+ const f = document.createElement("span");
3251
+ f.textContent = fileCount + (fileCount === 1 ? " file" : " files");
3252
+ meta.append(f);
3253
+ }
3254
+ if (hasLines) meta.append(linesNode(row.linesAdded, row.linesRemoved));
3255
+ if (row.aiDrafted) {
3256
+ const ai = document.createElement("span");
3257
+ ai.className = "branch-pr-ai";
3258
+ ai.textContent = "AI summary";
3259
+ meta.append(ai);
3260
+ }
3261
+ branchCell.append(meta);
3262
+ }
3263
+ }
3264
+ tr.append(branchCell);
3265
+
3266
+ // Protected rows collapse to one line — branch + classification + a single
3267
+ // status. Nobody acts on them, so the PR/tip/age/evidence detail is just noise.
3268
+ if (row.compact && !rowUnknown) {
3269
+ tr.append(cell(chip(row.classificationLabel, row.tone || "")));
3270
+ const statusTd = cell(subtle(row.reason || "Protected — permanent"));
3271
+ statusTd.colSpan = 5;
3272
+ tr.append(statusTd);
3273
+ branchesBody.append(tr);
3274
+ continue;
3275
+ }
3276
+
3277
+ tr.append(
3278
+ cell(
3279
+ rowUnknown
3280
+ ? chip(FORGE_UNREACHABLE_LABEL, "amber")
3281
+ : chip(row.classificationLabel, row.tone || ""),
3282
+ ),
3283
+ );
3284
+
3285
+ const prCell = document.createElement("td");
3286
+ if (row.prNumber != null) {
3287
+ // Link to the INTERNAL friendly PR route (/<tenant>/preview/pr/<N>), never
3288
+ // the server-provided `row.prUrl` — that is the raw Gitea forge html_url the
3289
+ // MCP candidate_list projection emits (<forge-host>/<org>/<repo>/pulls/N),
3290
+ // and surfacing a raw forge link to an owner is exactly the boundary
3291
+ // noGiteaLinks.test.ts guards (the source scan can't catch a host that
3292
+ // arrives in data, so we refuse it here at render). Mirrors the candidate
3293
+ // queue, which already builds its preview link this way.
3294
+ const prHref = candidatePrUrl(appDomain, row.prNumber);
3295
+ if (prHref) {
3296
+ const a = document.createElement("a");
3297
+ a.href = prHref;
3298
+ a.target = "_blank";
3299
+ a.rel = "noopener";
3300
+ a.textContent = "PR #" + row.prNumber;
3301
+ prCell.append(a);
3302
+ } else {
3303
+ prCell.append(document.createTextNode("PR #" + row.prNumber));
3304
+ }
3305
+ prCell.append(subtle((row.prState || "") + (row.integratedIntoAggregate ? " · in aggregate" : "")));
3306
+ } else {
3307
+ prCell.append(subtle("No PR record"));
3308
+ }
3309
+ tr.append(prCell);
3310
+
3311
+ tr.append(cell(row.commitTimestampDisplay));
3312
+ tr.append(cell(row.ageLabel));
3313
+ // Evidence cell shows a summary only when the row actually HAS evidence —
3314
+ // never a "no evidence reported" placeholder in an otherwise empty column.
3315
+ tr.append(row.hasEvidence ? cell(row.evidenceSummary) : cell());
3316
+
3317
+ const actionsCell = document.createElement("td");
3318
+ actionsCell.className = "action";
3319
+ if (rowUnknown) {
3320
+ // No trustworthy classification → offer no delete; the row's own eligibility
3321
+ // is exactly what we could not confirm. A cleanup still re-checks server-side,
3322
+ // but we must not INVITE one off a state we know we could not read.
3323
+ actionsCell.append(subtle("—"));
3324
+ } else if (row.classification === "orphan") {
3325
+ // Orphan (human quarantined OR machine "Dead"): the sweep and one-click "Clean
3326
+ // up" both refuse it, so this deliberate per-row HUMAN delete is its only path
3327
+ // out. Distinct from "Clean up" (styled danger) and re-verified fresh server-side.
3328
+ const btn = document.createElement("button");
3329
+ btn.type = "button";
3330
+ btn.className = "btn branch-delete-orphan";
3331
+ btn.dataset.deleteOrphan = "1";
3332
+ btn.disabled = !canShip;
3333
+ btn.title = canShip ? "" : "Requires an owner/admin sign-in.";
3334
+ btn.textContent = "Delete this branch";
3335
+ btn.addEventListener("click", () => deleteOrphanBranch(row, btn));
3336
+ actionsCell.append(btn);
3337
+ } else if (row.eligibleForDelete) {
3338
+ const btn = document.createElement("button");
3339
+ btn.type = "button";
3340
+ btn.className = "btn";
3341
+ btn.disabled = !canShip;
3342
+ btn.title = canShip ? "" : "Requires an owner/admin sign-in.";
3343
+ btn.textContent = "Clean up";
3344
+ btn.addEventListener("click", () => cleanupBranch(row, btn));
3345
+ actionsCell.append(btn);
3346
+ } else {
3347
+ actionsCell.append(subtle("Keep"));
3348
+ }
3349
+ tr.append(actionsCell);
3350
+
3351
+ branchesBody.append(tr);
3352
+ }
3353
+ };
3354
+
3355
+ // Render a SUCCESSFUL live fetch: cache the rows (so a later failure can stamp
3356
+ // them unknown), skip re-render when nothing changed, and set the summary chip.
3357
+ const renderBranches = (view?: any) => {
3358
+ if (!branchesBody) return;
3359
+ const rows = Array.isArray(view.rows) ? view.rows : [];
3360
+ const sig = JSON.stringify(rows);
3361
+ lastBranchesRows = rows;
3362
+ lastSweepableRefs = Array.isArray(view.sweepableRefs) ? view.sweepableRefs : [];
3363
+ updateSweepButton();
3364
+ if (sig === lastBranchesSignature) return; // no visible change — don't churn the DOM
3365
+ lastBranchesSignature = sig;
3366
+ if (branchesChip) {
3367
+ const needsReview = rows.filter((r: any) => r.quarantine || r.eligibleForDelete).length;
3368
+ branchesChip.textContent = needsReview
3369
+ ? needsReview + " to review"
3370
+ : rows.length
3371
+ ? rows.length + " branches"
3372
+ : "0 branches";
3373
+ branchesChip.className = "chip" + (needsReview ? " amber" : "");
3374
+ }
3375
+ renderBranchRows(rows);
3376
+ };
3377
+
3378
+ // Render the forge-unreachable state: re-stamp the last-known branches as
3379
+ // "state unknown" (or a single notice if none were ever loaded), never leaving
3380
+ // the stale confident chips a fetch failure would otherwise freeze on screen.
3381
+ const renderBranchesUnreachable = () => {
3382
+ if (!branchesBody) return;
3383
+ lastBranchesSignature = null; // force a real re-render when the forge returns
3384
+ lastSweepableRefs = []; // can't confirm eligibility → nothing is sweepable
3385
+ updateSweepButton();
3386
+ if (branchesChip) {
3387
+ branchesChip.textContent = "State unknown";
3388
+ branchesChip.className = "chip amber";
3389
+ }
3390
+ renderBranchRows(lastBranchesRows, { unreachable: true });
3391
+ };
3392
+
3393
+ const loadBranchesImpl = async () => {
3394
+ if (!branchesBody) return;
3395
+ try {
3396
+ const res = await fetch(ds.branchesEndpoint, { headers: { accept: "application/json" } });
3397
+ if (res.status === 401 || res.status === 403) {
3398
+ if (branchesChip) branchesChip.textContent = "Sign-in required";
3399
+ lastSweepableRefs = [];
3400
+ updateSweepButton();
3401
+ branchesBody.textContent = "";
3402
+ const tr = document.createElement("tr");
3403
+ const td = cell(subtle("Sign in as a store owner or admin to view branch retention."));
3404
+ td.colSpan = 7;
3405
+ tr.append(td);
3406
+ branchesBody.append(tr);
3407
+ return;
3408
+ }
3409
+ const data = await res.json().catch(() => ({}));
3410
+ if (!res.ok) {
3411
+ // A non-2xx means the live classification is UNKNOWN — never keep the last
3412
+ // chips on screen (they may now be lies). Stamp the branches "state unknown".
3413
+ setStatus(branchesStatus, data.error || "Could not load branch classification (" + res.status + ")", "err");
3414
+ renderBranchesUnreachable();
3415
+ return;
3416
+ }
3417
+ renderBranches(data.report || {});
3418
+ } catch {
3419
+ setStatus(branchesStatus, "Could not load branch classification — network error", "err");
3420
+ renderBranchesUnreachable();
3421
+ }
3422
+ };
3423
+ // Coalesce overlapping branch reads: a poll tick, a reveal, and a post-action
3424
+ // refresh can all fire at once — only one runs, with at most one trailing run.
3425
+ const branchesCoalescer = createCoalescer(loadBranchesImpl);
3426
+ const loadBranches = () => branchesCoalescer.trigger();
3427
+
3428
+ // ---- Wiring ------------------------------------------------------------
3429
+
3430
+ // Progressive + lazy: the tab shell is already server-rendered; each heavy
3431
+ // panel fires its first read only when it (or the tab) actually becomes
3432
+ // visible, and the queue poll only runs while the tab is active. An
3433
+ // IntersectionObserver is the single visibility signal — a panel inside a
3434
+ // hidden admin-tab (display:none) never intersects, so nothing fetches until
3435
+ // the operator opens the Publish tab and scrolls the panel into view.
3436
+ const revealDomainOnce = (() => {
3437
+ let done = false;
3438
+ return () => {
3439
+ if (done) return;
3440
+ done = true;
3441
+ loadDomain();
3442
+ };
3443
+ })();
3444
+
3445
+ const onTabActivate = () => {
3446
+ if (tabActive) return;
3447
+ tabActive = true;
3448
+ // loadQueue renders + resyncs the poll clock + (re)starts the cadence.
3449
+ loadQueue();
3450
+ };
3451
+ const onTabDeactivate = () => {
3452
+ if (!tabActive) return;
3453
+ tabActive = false;
3454
+ clearPoll(); // an inactive tab must not keep polling heavy reads
3455
+ };
3456
+
3457
+ const observeVisibility = () => {
3458
+ if (typeof IntersectionObserver !== "function") {
3459
+ // No IO (very old/headless env): fall back to eager load — correctness
3460
+ // over efficiency. Behaves like the pre-progressive-render tab.
3461
+ tabActive = true;
3462
+ branchesVisible = true;
3463
+ loadQueue();
3464
+ loadVersions();
3465
+ revealDomainOnce();
3466
+ loadBranches();
3467
+ return;
3468
+ }
3469
+ const io = new IntersectionObserver(
3470
+ (entries) => {
3471
+ for (const e of entries) {
3472
+ const visible = e.isIntersecting;
3473
+ if (e.target === root) {
3474
+ if (visible) onTabActivate();
3475
+ else onTabDeactivate();
3476
+ } else if (e.target === versionsPanel) {
3477
+ if (visible) loadVersions();
3478
+ } else if (e.target === branchesPanel) {
3479
+ branchesVisible = visible;
3480
+ if (visible) loadBranches();
3481
+ } else if (e.target === domainPanel) {
3482
+ if (visible) revealDomainOnce();
3483
+ }
3484
+ }
3485
+ },
3486
+ { rootMargin: "200px" },
3487
+ );
3488
+ io.observe(root);
3489
+ if (versionsPanel) io.observe(versionsPanel);
3490
+ if (branchesPanel) io.observe(branchesPanel);
3491
+ if (domainPanel) io.observe(domainPanel);
3492
+ };
3493
+
3494
+ root.querySelector("[data-publish-refresh]")?.addEventListener("click", () => {
3495
+ setStatus(statusEl, "");
3496
+ // Explicit refresh: force past every client cache and re-read what's on screen.
3497
+ diffCache.clear();
3498
+ versionsCache.clear();
3499
+ loadQueue();
3500
+ loadVersions({ force: true });
3501
+ loadDomain();
3502
+ loadBranches();
3503
+ });
3504
+
3505
+ // rux7: pause polling while hidden; on return, refresh immediately (loadQueue
3506
+ // re-renders + resyncs the poll clock) rather than waiting out the interval.
3507
+ document.addEventListener("visibilitychange", () => {
3508
+ if (document.hidden) {
3509
+ clearPoll();
3510
+ } else if (tabActive) {
3511
+ // Only resume when the Publish tab is the active one — a background
3512
+ // browser-tab return must not wake a Publish tab the operator left.
3513
+ loadQueue();
3514
+ if (branchesVisible) loadBranches(); // refresh branch truth on return
3515
+ }
3516
+ });
3517
+
3518
+ observeVisibility();
3519
+ }