@tokenoftrust/storefront-runner 1.4.2-rc.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/storefront/astro.config.mjs +3 -3
- package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +8 -3
- package/apps/storefront/env.d.ts +54 -5
- package/apps/storefront/integrations/materialize-guard.mjs +12 -14
- package/apps/storefront/migrations-apps/0004_deep_morph.sql +36 -0
- package/apps/storefront/migrations-apps/0005_common_mystique.sql +23 -0
- package/apps/storefront/migrations-apps/0006_broad_microchip.sql +16 -0
- package/apps/storefront/migrations-apps/0007_brave_outlaw_kid.sql +15 -0
- package/apps/storefront/migrations-apps/0008_blue_dazzler.sql +11 -0
- package/apps/storefront/migrations-apps/0009_bored_shinobi_shaw.sql +15 -0
- package/apps/storefront/migrations-apps/0010_ws4_verified_provider_measurement.sql +35 -0
- package/apps/storefront/migrations-apps/meta/0004_snapshot.json +1474 -0
- package/apps/storefront/migrations-apps/meta/0005_snapshot.json +1617 -0
- package/apps/storefront/migrations-apps/meta/0006_snapshot.json +1716 -0
- package/apps/storefront/migrations-apps/meta/0007_snapshot.json +1812 -0
- package/apps/storefront/migrations-apps/meta/0008_snapshot.json +1881 -0
- package/apps/storefront/migrations-apps/meta/0009_snapshot.json +1979 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +42 -0
- package/apps/storefront/public/apps/tot.demo.analytics/0.1.0/entry.js +42 -0
- package/apps/storefront/public/platform/amplitude-http/v1/entry.mjs +19 -0
- package/apps/storefront/public/platform/google-measurement/v1/entry.mjs +19 -0
- package/apps/storefront/public/shared/commerce-chrome.css +20 -2
- package/apps/storefront/scripts/build-v84-clean-clone-standalone.mjs +68 -0
- package/apps/storefront/scripts/deny-v84-clean-clone-standalone-capabilities.mjs +111 -0
- package/apps/storefront/scripts/deny-v84-dogfood-capabilities.mjs +2 -0
- package/apps/storefront/scripts/deny-v84-lifecycle-capabilities.mjs +247 -0
- package/apps/storefront/scripts/dogfood-v84-nonportable-routes.ts +73 -0
- package/apps/storefront/scripts/fixed-v84-native-install-producer.ts +79 -0
- package/apps/storefront/scripts/generated/v84-clean-clone-replay.bundle.mjs +10982 -0
- package/apps/storefront/scripts/import-v84-through-ws2.ts +38 -0
- package/apps/storefront/scripts/initialize-v84-lifecycle-tooling-dependency.ts +2 -0
- package/apps/storefront/scripts/initialize-v84-lifecycle-tooling.ts +4 -0
- package/apps/storefront/scripts/install-v84-native-through-ws1.ts +185 -0
- package/apps/storefront/scripts/prove-clone-isolation.ts +506 -0
- package/apps/storefront/scripts/replay-v84-clean-clone-pass.ts +148 -0
- package/apps/storefront/scripts/replay-v84-clean-clone-standalone.ts +35 -0
- package/apps/storefront/scripts/replay-v84-clean-clone.ts +214 -0
- package/apps/storefront/scripts/run-fixed-v84-native-install-producer.mjs +18 -0
- package/apps/storefront/scripts/run-v84-clone-isolation-proof.mjs +18 -0
- package/apps/storefront/scripts/run-v84-clone-lifecycle-proof.mjs +18 -0
- package/apps/storefront/scripts/run-v84-import-through-ws2-proof.mjs +17 -0
- package/apps/storefront/scripts/run-v84-nonportable-route-dogfood.mjs +15 -0
- package/apps/storefront/scripts/sanitize-v84-proof-environment.mjs +21 -0
- package/apps/storefront/scripts/v84CleanCloneGraphPin.ts +42 -0
- package/apps/storefront/scripts/v84CleanCloneSourceGraph.ts +145 -0
- package/apps/storefront/scripts/validate-v84-clone-lifecycle.ts +18 -0
- package/apps/storefront/src/components/Img.astro +1 -1
- package/apps/storefront/src/components/admin/AdminBlogTab.astro +19 -16
- package/apps/storefront/src/components/admin/AdminIntegrationsTab.astro +105 -0
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +1057 -412
- package/apps/storefront/src/components/admin/AdminSettingsTab.astro +2 -2
- package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +5 -5
- package/apps/storefront/src/components/chrome/NavDropdown.astro +12 -0
- package/apps/storefront/src/components/chrome/SiteFooter.astro +7 -2
- package/apps/storefront/src/components/chrome/SiteHeader.astro +46 -10
- package/apps/storefront/src/components/compliance/AdultSignatureNotice.astro +1 -1
- package/apps/storefront/src/components/compliance/ComplianceNotice.astro +1 -2
- package/apps/storefront/src/components/compliance/ExciseTaxNotice.astro +1 -2
- package/apps/storefront/src/components/compliance/PactActNotice.astro +1 -1
- package/apps/storefront/src/components/compliance/Prop65Warning.astro +1 -2
- package/apps/storefront/src/components/compliance/PurchaseLimitNotice.astro +2 -3
- package/apps/storefront/src/components/compliance/ShippingRestrictionNotice.astro +2 -2
- package/apps/storefront/src/components/compliance/StateEligibilityNotice.astro +2 -3
- package/apps/storefront/src/components/content/TrustStrip.astro +4 -4
- package/apps/storefront/src/components/islands/MobileNav.tsx +26 -1
- package/apps/storefront/src/components/islands/VariantSelector.tsx +27 -2
- package/apps/storefront/src/components/islands/useCheckoutHandoffCorrelation.ts +75 -0
- package/apps/storefront/src/components/membership/IllustrativeLabel.astro +1 -2
- package/apps/storefront/src/components/membership/TierCards.astro +2 -2
- package/apps/storefront/src/components/membership/TierComparisonTable.astro +2 -2
- package/apps/storefront/src/components/subscription/SavingsExplainer.astro +2 -2
- package/apps/storefront/src/config/adminTenantLookup.ts +35 -0
- package/apps/storefront/src/config/devTenantSeed.ts +9 -9
- package/apps/storefront/src/config/resolver.ts +18 -10
- package/apps/storefront/src/layouts/Layout.astro +100 -20
- package/apps/storefront/src/lib/activity/candidateSubmitLease.ts +87 -0
- package/apps/storefront/src/lib/activity/changeActorAttribution.ts +86 -6
- package/apps/storefront/src/lib/activity/deployVersion.ts +1 -1
- package/apps/storefront/src/lib/admin/adminShell.ts +2 -0
- package/apps/storefront/src/lib/admin/envelope.ts +1 -2
- package/apps/storefront/src/lib/apps/adminService.ts +10 -0
- package/apps/storefront/src/lib/apps/adminSession.ts +1 -1
- package/apps/storefront/src/lib/apps/appVersionStore.ts +281 -0
- package/apps/storefront/src/lib/apps/consentConfigStore.ts +228 -0
- package/apps/storefront/src/lib/apps/gtmMigrationInstall.ts +447 -0
- package/apps/storefront/src/lib/apps/gtmMigrationReport.ts +367 -0
- package/apps/storefront/src/lib/apps/gtmMigrationTab.ts +315 -0
- package/apps/storefront/src/lib/apps/integrationLifecycle.ts +614 -0
- package/apps/storefront/src/lib/apps/integrationLifecycleController.ts +125 -0
- package/apps/storefront/src/lib/apps/integrationReadModel.ts +563 -0
- package/apps/storefront/src/lib/apps/integrationsTab.ts +547 -0
- package/apps/storefront/src/lib/apps/integrationsView.ts +106 -0
- package/apps/storefront/src/lib/apps/managedAnalyticsService.ts +599 -0
- package/apps/storefront/src/lib/apps/orders/orderConversionD1.ts +150 -0
- package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +8 -0
- package/apps/storefront/src/lib/apps/orders/ordersStore.ts +1 -1
- package/apps/storefront/src/lib/apps/orders/verifiedProviderMeasurement.ts +117 -0
- package/apps/storefront/src/lib/apps/registryService.ts +264 -10
- package/apps/storefront/src/lib/apps/tenantIntegrationMaterialization.ts +170 -0
- package/apps/storefront/src/lib/apps/v84CleanCloneReplayContext.ts +16 -0
- package/apps/storefront/src/lib/apps/v84CloneLifecycleCacheEvidence.ts +105 -0
- package/apps/storefront/src/lib/apps/v84CloneLifecycleEvidence.ts +177 -0
- package/apps/storefront/src/lib/apps/v84CloneLifecycleMaterialization.ts +251 -0
- package/apps/storefront/src/lib/apps/v84CloneLifecycleSemantics.ts +241 -0
- package/apps/storefront/src/lib/apps/v84CloneMigrationEvidence.ts +211 -0
- package/apps/storefront/src/lib/apps/v84CloneNativeInstallEvidence.ts +409 -0
- package/apps/storefront/src/lib/apps/v84NonportableCodeCoaching.ts +368 -0
- package/apps/storefront/src/lib/apps/v84NonportableRouteContract.ts +188 -0
- package/apps/storefront/src/lib/apps/v84NonportableRouteDogfood.ts +291 -0
- package/apps/storefront/src/lib/apps/ws1ManagedAnalyticsClient.ts +95 -0
- package/apps/storefront/src/lib/auth/adminApiGuard.ts +66 -0
- package/apps/storefront/src/lib/auth/adminEntry.ts +2 -2
- package/apps/storefront/src/lib/auth/adminLanding.ts +88 -0
- package/apps/storefront/src/lib/blog/collection.ts +114 -17
- package/apps/storefront/src/lib/blog/markdown.ts +1 -1
- package/apps/storefront/src/lib/blog/provider.ts +76 -9
- package/apps/storefront/src/lib/blog/types.ts +9 -9
- package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome-assignments.example.json +6 -0
- package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome.example.json +79 -0
- package/apps/storefront/src/lib/chrome/model.ts +48 -253
- package/apps/storefront/src/lib/chrome/navCurrent.ts +29 -0
- package/apps/storefront/src/lib/chrome/parseConfig.ts +18 -32
- package/apps/storefront/src/lib/chrome/variant.ts +39 -0
- package/apps/storefront/src/lib/commerce/checkoutHandoffCorrelation.ts +105 -0
- package/apps/storefront/src/lib/compliance/tamper-sentinel-boot.ts +152 -0
- package/apps/storefront/src/lib/compliance/tamper-sentinel.ts +327 -0
- package/apps/storefront/src/lib/content/index.ts +5 -5
- package/apps/storefront/src/lib/content-edit/applyDiff.ts +1 -1
- package/apps/storefront/src/lib/content-edit/route.ts +1 -1
- package/apps/storefront/src/lib/d1/catalog.ts +12 -0
- package/apps/storefront/src/lib/d1/schema-apps.ts +276 -0
- package/apps/storefront/src/lib/dev/cockpit/ask.ts +49 -0
- package/apps/storefront/src/lib/dev/cockpit/clickToSource.ts +71 -0
- package/apps/storefront/src/lib/dev/cockpit/copyCommand.ts +227 -0
- package/apps/storefront/src/lib/dev/cockpit/files.ts +249 -0
- package/apps/storefront/src/lib/dev/cockpit/hostedActivity.ts +500 -0
- package/apps/storefront/src/lib/dev/cockpit/infoPopover.ts +12 -0
- package/apps/storefront/src/lib/dev/cockpit/mcpCopy.ts +34 -0
- package/apps/storefront/src/lib/dev/cockpit/monitor.ts +107 -0
- package/apps/storefront/src/lib/dev/cockpit/stepper.ts +116 -0
- package/apps/storefront/src/lib/dev/cockpit/themeToggle.ts +23 -0
- package/apps/storefront/src/lib/dev/runtimeStore.ts +1 -1
- package/apps/storefront/src/lib/edgeCache.ts +18 -6
- package/apps/storefront/src/lib/email/candidateConflictEmail.ts +80 -0
- package/apps/storefront/src/lib/integration/browserEnvironment.ts +137 -0
- package/apps/storefront/src/lib/integration/managedInstallDeclarations.ts +72 -0
- package/apps/storefront/src/lib/integration/nonceStamp.ts +49 -0
- package/apps/storefront/src/lib/integration/tenantDeclarations.ts +116 -0
- package/apps/storefront/src/lib/membership/eligibility.ts +1 -1
- package/apps/storefront/src/lib/membership/model.ts +1 -2
- package/apps/storefront/src/lib/newsletter/confirmationEmail.ts +2 -2
- package/apps/storefront/src/lib/newsletter/consent.ts +2 -2
- package/apps/storefront/src/lib/newsletter/send.ts +3 -3
- package/apps/storefront/src/lib/previewReload.ts +57 -1
- package/apps/storefront/src/lib/publish/adminPublishTab.ts +1775 -669
- package/apps/storefront/src/lib/publish/buildStream.ts +269 -0
- package/apps/storefront/src/lib/publish/collapsePersistence.ts +72 -0
- package/apps/storefront/src/lib/publish/lifecycleState.ts +369 -0
- package/apps/storefront/src/lib/publish/liveReleaseSummary.ts +29 -0
- package/apps/storefront/src/lib/publish/mergeDoctorOnFailure.ts +78 -0
- package/apps/storefront/src/lib/publish/mergeHealthBanner.ts +62 -0
- package/apps/storefront/src/lib/publish/mergeRowFindings.ts +124 -0
- package/apps/storefront/src/lib/publish/mergeSupportEscalation.ts +75 -0
- package/apps/storefront/src/lib/publish/publish-guard.ts +88 -0
- package/apps/storefront/src/lib/publish/publishFormat.ts +165 -0
- package/apps/storefront/src/lib/publish/publishHero.ts +177 -0
- package/apps/storefront/src/lib/publish/reviewRelease/changeState.ts +66 -0
- package/apps/storefront/src/lib/publish/reviewRelease/copy.ts +141 -0
- package/apps/storefront/src/lib/publish/reviewRelease/index.ts +41 -0
- package/apps/storefront/src/lib/publish/reviewRelease/permissions.ts +47 -0
- package/apps/storefront/src/lib/publish/reviewRelease/releaseState.ts +42 -0
- package/apps/storefront/src/lib/publish/reviewRelease/types.ts +59 -0
- package/apps/storefront/src/lib/publish/reviewRelease/viewModel.ts +440 -0
- package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/actions.ts +161 -0
- package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/controller.ts +325 -0
- package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/render.ts +241 -0
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +22 -3
- package/apps/storefront/src/lib/publish/sseRoute.ts +119 -0
- package/apps/storefront/src/lib/publish/streamLogConsole.ts +142 -0
- package/apps/storefront/src/lib/publish/versionNumber.ts +52 -0
- package/apps/storefront/src/lib/rawChrome.ts +125 -47
- package/apps/storefront/src/lib/rawMarketingHtml.ts +74 -3
- package/apps/storefront/src/lib/seo/alternates.ts +1 -1
- package/apps/storefront/src/lib/social/notify.ts +4 -4
- package/apps/storefront/src/lib/storyblok/content-model.ts +7 -33
- package/apps/storefront/src/lib/storyblok/provider.ts +203 -69
- package/apps/storefront/src/lib/subscription/model.ts +4 -4
- package/apps/storefront/src/lib/the-build/provider.ts +9 -4
- package/apps/storefront/src/middleware/index.ts +273 -51
- package/apps/storefront/src/pages/[...slug].astro +9 -4
- package/apps/storefront/src/pages/admin/review-release.astro +265 -0
- package/apps/storefront/src/pages/admin/select.astro +96 -0
- package/apps/storefront/src/pages/admin.astro +16 -2
- package/apps/storefront/src/pages/api/activity.ts +8 -8
- package/apps/storefront/src/pages/api/admin/activity-alerts.ts +5 -20
- package/apps/storefront/src/pages/api/admin/orders/[id].ts +7 -2
- package/apps/storefront/src/pages/api/admin/orders.ts +8 -2
- package/apps/storefront/src/pages/api/apps/admin/gtm-migration-install.ts +174 -0
- package/apps/storefront/src/pages/api/apps/admin/gtm-migration.ts +96 -0
- package/apps/storefront/src/pages/api/apps/admin/integration-lifecycle.ts +118 -0
- package/apps/storefront/src/pages/api/apps/admin/integrations.ts +89 -0
- package/apps/storefront/src/pages/api/apps/admin/platform-adapters.ts +85 -0
- package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +74 -2
- package/apps/storefront/src/pages/api/commerce/checkout-handoff.ts +77 -0
- package/apps/storefront/src/pages/api/compliance/tamper.ts +88 -0
- package/apps/storefront/src/pages/api/integrations/v1/[operation].ts +108 -0
- package/apps/storefront/src/pages/api/rum/vitals.ts +1 -2
- package/apps/storefront/src/pages/blog/[slug].astro +5 -5
- package/apps/storefront/src/pages/blog/author/[author].astro +3 -3
- package/apps/storefront/src/pages/blog/category/[category].astro +2 -2
- package/apps/storefront/src/pages/blog/index.astro +2 -2
- package/apps/storefront/src/pages/blog/tag/[tag].astro +2 -2
- package/apps/storefront/src/pages/cockpit.astro +61 -1362
- package/apps/storefront/src/pages/index.astro +10 -5
- package/apps/storefront/src/pages/llms.txt.ts +1 -1
- package/apps/storefront/src/pages/products/[handle].astro +7 -6
- package/apps/storefront/src/pages/sitemap.xml.ts +2 -2
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +42 -31
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +8 -3
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +6 -2
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +6 -2
- package/apps/storefront/src/pages/style-guide/index.astro +19 -5
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +38 -2
- package/apps/storefront/src/pages/the-build/[slug].astro +4 -4
- package/apps/storefront/src/pages/the-build/index.astro +2 -2
- package/apps/storefront/src/pages/the-build/rss.xml.ts +2 -2
- package/apps/storefront/src/styles/admin.css +27 -1
- package/apps/storefront/src/themes/tenants/index.ts +19 -7
- package/apps/storefront/tsconfig.json +1 -1
- package/package.json +1 -1
- package/packages/public-runtime/package.json +2 -1
- package/packages/public-runtime/schemas/managed-analytics/v1/adapter-definition.schema.json +427 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/create-integration-install-request.schema.json +161 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/create-migration-draft-request.schema.json +157 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/delete-integration-install-request.schema.json +84 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/delete-migration-draft-request.schema.json +84 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/error-envelope.schema.json +180 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/get-adapter-definition-request.schema.json +52 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/get-integration-install-request.schema.json +52 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/get-integration-revision-request.schema.json +79 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/get-migration-draft-request.schema.json +52 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/integration-install.schema.json +167 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/integration-revision.schema.json +208 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/list-adapter-definitions-request.schema.json +53 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/list-integration-installs-request.schema.json +53 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/list-integration-revisions-request.schema.json +82 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/migration-draft.schema.json +218 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/mutation-request.schema.json +52 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/mutation-success-envelope.schema.json +49 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/read-request.schema.json +20 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/read-success-envelope.schema.json +45 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/revise-integration-install-request.schema.json +156 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/set-integration-install-status-request.schema.json +92 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/update-migration-draft-request.schema.json +184 -0
- package/packages/public-runtime/scripts/generate-managed-analytics-api-v1-schemas.mjs +13 -0
- package/packages/public-runtime/src/activity/catalog.ts +41 -37
- package/packages/public-runtime/src/activity/event.ts +6 -6
- package/packages/public-runtime/src/activity/index.ts +2 -2
- package/packages/public-runtime/src/activity/redaction.ts +4 -4
- package/packages/public-runtime/src/candidate-index.ts +42 -8
- package/packages/public-runtime/src/checkout-orders.ts +2 -2
- package/packages/public-runtime/src/checkout-style-publish.ts +2 -2
- package/packages/public-runtime/src/checkout-style.ts +7 -7
- package/packages/public-runtime/src/checkout.ts +3 -3
- package/packages/public-runtime/src/chrome.ts +777 -0
- package/packages/public-runtime/src/compliance-obligations.ts +310 -0
- package/packages/public-runtime/src/csp.ts +99 -0
- package/packages/public-runtime/src/customization-runtime.ts +32 -1
- package/packages/public-runtime/src/destination-webhook/contract.ts +353 -0
- package/packages/public-runtime/src/embed-catalog.d.mts +9 -0
- package/packages/public-runtime/src/embed-catalog.mjs +40 -0
- package/packages/public-runtime/src/evidence.ts +288 -0
- package/packages/public-runtime/src/gtm-migration/bounds.ts +138 -0
- package/packages/public-runtime/src/gtm-migration/disposition-rules.ts +361 -0
- package/packages/public-runtime/src/gtm-migration/dispositions.ts +181 -0
- package/packages/public-runtime/src/gtm-migration/draft-generator.ts +400 -0
- package/packages/public-runtime/src/gtm-migration/graph.ts +468 -0
- package/packages/public-runtime/src/gtm-migration/index.ts +95 -0
- package/packages/public-runtime/src/gtm-migration/parse.ts +410 -0
- package/packages/public-runtime/src/gtm-migration/references.ts +266 -0
- package/packages/public-runtime/src/gtm-migration/types.ts +86 -0
- package/packages/public-runtime/src/index.ts +16 -1
- package/packages/public-runtime/src/integration/assurance-decision.ts +259 -0
- package/packages/public-runtime/src/integration/assurance.ts +228 -0
- package/packages/public-runtime/src/integration/candidate-evidence.ts +377 -0
- package/packages/public-runtime/src/integration/commerce-broker.ts +497 -0
- package/packages/public-runtime/src/integration/consent-broker.ts +337 -0
- package/packages/public-runtime/src/integration/consent-cmp-demand.ts +115 -0
- package/packages/public-runtime/src/integration/consent-config.ts +260 -0
- package/packages/public-runtime/src/integration/consent-preview.ts +125 -0
- package/packages/public-runtime/src/integration/correlation-token.ts +444 -0
- package/packages/public-runtime/src/integration/csp-compiler.ts +267 -0
- package/packages/public-runtime/src/integration/csp.ts +105 -0
- package/packages/public-runtime/src/integration/digest.ts +43 -0
- package/packages/public-runtime/src/integration/environment.ts +228 -0
- package/packages/public-runtime/src/integration/events-catalog.ts +186 -0
- package/packages/public-runtime/src/integration/events.ts +701 -0
- package/packages/public-runtime/src/integration/g2-action-boundary.ts +106 -0
- package/packages/public-runtime/src/integration/index.ts +90 -0
- package/packages/public-runtime/src/integration/interim-bridge.ts +138 -0
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +317 -0
- package/packages/public-runtime/src/integration/lifecycle-runtime.ts +393 -0
- package/packages/public-runtime/src/integration/lifecycle.ts +94 -0
- package/packages/public-runtime/src/integration/managed-analytics-api-v1-safety.d.mts +2 -0
- package/packages/public-runtime/src/integration/managed-analytics-api-v1-safety.mjs +10 -0
- package/packages/public-runtime/src/integration/managed-analytics-api-v1.schemas.ts +382 -0
- package/packages/public-runtime/src/integration/managed-analytics-api-v1.ts +684 -0
- package/packages/public-runtime/src/integration/managed-analytics-bridge.ts +383 -0
- package/packages/public-runtime/src/integration/managed-analytics-value-safety.ts +14 -0
- package/packages/public-runtime/src/integration/managed-analytics.ts +277 -0
- package/packages/public-runtime/src/integration/manifest.ts +470 -0
- package/packages/public-runtime/src/integration/native-amplitude.ts +362 -0
- package/packages/public-runtime/src/integration/native-ga4.ts +352 -0
- package/packages/public-runtime/src/integration/observability.ts +451 -0
- package/packages/public-runtime/src/integration/order-conversion.ts +0 -0
- package/packages/public-runtime/src/integration/phases.ts +182 -0
- package/packages/public-runtime/src/integration/plan.ts +112 -0
- package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +146 -0
- package/packages/public-runtime/src/integration/platform-adapters.ts +671 -0
- package/packages/public-runtime/src/integration/promotion-gate.ts +451 -0
- package/packages/public-runtime/src/integration/provider-checkout-extension.ts +425 -0
- package/packages/public-runtime/src/integration/receipt-measurement.ts +370 -0
- package/packages/public-runtime/src/integration/regulated-parent-cert.ts +311 -0
- package/packages/public-runtime/src/integration/render-phases.ts +267 -0
- package/packages/public-runtime/src/integration/resolve.ts +465 -0
- package/packages/public-runtime/src/integration/result.ts +239 -0
- package/packages/public-runtime/src/integration/secrets.ts +92 -0
- package/packages/public-runtime/src/integration/tamper-evidence.ts +205 -0
- package/packages/public-runtime/src/integration/tamper-recovery.ts +127 -0
- package/packages/public-runtime/src/integration/version.ts +92 -0
- package/packages/public-runtime/src/integration-capabilities.ts +520 -0
- package/packages/public-runtime/src/membership.ts +2 -2
- package/packages/public-runtime/src/raw-html-policy.ts +2 -4
- package/packages/public-runtime/src/regulated-commerce/broker/consent-dedupe.ts +144 -0
- package/packages/public-runtime/src/regulated-commerce/correlation/contract.ts +177 -0
- package/packages/public-runtime/src/regulated-commerce/diagnostics/local-dispatch.ts +24 -0
- package/packages/public-runtime/src/regulated-commerce/diagnostics/read-model.ts +69 -0
- package/packages/public-runtime/src/regulated-commerce/dispatch/core.ts +134 -0
- package/packages/public-runtime/src/regulated-commerce/events/contract.ts +113 -0
- package/packages/public-runtime/src/regulated-commerce/ledger/core.ts +81 -0
- package/packages/public-runtime/src/regulated-commerce/mappings/ga4-ads.ts +87 -0
- package/packages/public-runtime/src/regulated-commerce/provider-order/bridge.ts +52 -0
- package/packages/public-runtime/src/regulated-commerce/purchase-truth/contract.ts +252 -0
- package/packages/public-runtime/src/regulated-commerce/sources/accepted-browser-operation.ts +67 -0
- package/packages/public-runtime/src/regulated-commerce/testing/fake-destination.ts +93 -0
- package/packages/public-runtime/src/static-bundle.ts +5 -5
- package/packages/public-runtime/src/tenant-assets.ts +143 -4
- package/packages/public-runtime/src/tenant.ts +136 -9
- package/packages/public-runtime/tests/fixtures/integration/browser-environment.example.json +8 -0
- package/packages/public-runtime/tests/fixtures/integration/commerce-event.example.json +44 -0
- package/packages/public-runtime/tests/fixtures/integration/commerce-order-event.example.json +44 -0
- package/packages/public-runtime/tests/fixtures/integration/compliance-assurance-record.example.json +50 -0
- package/packages/public-runtime/tests/fixtures/integration/integration-manifest-v2.example.json +120 -0
- package/packages/public-runtime/tests/fixtures/integration/managed-analytics-v1/fake-adapter-catalog.json +35 -0
- package/packages/public-runtime/tests/fixtures/integration/tenant-action-registry.example.json +11 -0
- package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/README.md +22 -0
- package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/entry.js +30 -0
- package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/manifest.json +75 -0
- package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/public-config.json +3 -0
- package/packages/public-runtime/tests/fixtures/regulated-commerce/fake-destinations.ts +38 -0
- package/packages/public-runtime/tests/gtm-migration/fixtures/gtm-sample-container-v84.provenance.json +29 -0
- package/packages/public-runtime/tests/gtm-migration/fixtures/gtm-sample-container-v84.redacted.json +3397 -0
- package/scripts/build/copy-tenant-assets.mjs +12 -5
- package/scripts/dev/ai-edit.mjs +2 -1
- package/scripts/dev/file-browser.mjs +2 -2
- package/scripts/dev/file-writer.mjs +2 -2
- package/scripts/dev/integration-guardrails.mjs +498 -0
- package/scripts/dev/safe-path.mjs +2 -2
- package/scripts/dev/tenant-source-root.mjs +3 -2
- package/scripts/tot-dev.mjs +5 -4
|
@@ -9,8 +9,65 @@
|
|
|
9
9
|
* duplicated (see docs/architecture/decisions/0013-client-island-pattern-and-inline-drift-guard.md).
|
|
10
10
|
*/
|
|
11
11
|
import { createKeyedCache, createCoalescer } from "@/lib/publish/panelPolling";
|
|
12
|
+
import {
|
|
13
|
+
shortSha,
|
|
14
|
+
shortDigest,
|
|
15
|
+
age,
|
|
16
|
+
linesNode,
|
|
17
|
+
chip,
|
|
18
|
+
cell,
|
|
19
|
+
subtle,
|
|
20
|
+
changeSummaryItem,
|
|
21
|
+
} from "@/lib/publish/publishFormat";
|
|
12
22
|
import { candidatePrUrl } from "@/lib/preview/candidateDashboard";
|
|
13
|
-
import {
|
|
23
|
+
import { currentPreviewUrl } from "@/lib/preview/tenantPreview";
|
|
24
|
+
import { classifyResolveResponse } from "@/lib/change/views/resolve-conflict";
|
|
25
|
+
import { mergeHealthBanner } from "@/lib/publish/mergeHealthBanner";
|
|
26
|
+
import { mergeRowFindings, mergeRowKey } from "@/lib/publish/mergeRowFindings";
|
|
27
|
+
import { mergeSupportEscalation } from "@/lib/publish/mergeSupportEscalation";
|
|
28
|
+
// C5 — the doctor PUSH: on a failed accept/integrate or a refused ship, reduce the
|
|
29
|
+
// SAME /api/changes payload to the one-line diagnosis and append it where the failure
|
|
30
|
+
// already surfaced (so the developer doesn't have to go read the banner/chips).
|
|
31
|
+
import { integrateFailureDoctor, shipFailureDoctor } from "@/lib/publish/mergeDoctorOnFailure";
|
|
32
|
+
// Real-time build/check streaming: pure SSE parsing + progress reduction + copy text.
|
|
33
|
+
import {
|
|
34
|
+
BUILD_STAGES,
|
|
35
|
+
parseSseChunk,
|
|
36
|
+
reduceBuildProgress,
|
|
37
|
+
initialBuildProgress,
|
|
38
|
+
buildFailureDetail,
|
|
39
|
+
assembleBuildFailureCopy,
|
|
40
|
+
type BuildProgress,
|
|
41
|
+
type BuildResult,
|
|
42
|
+
} from "@/lib/publish/buildStream";
|
|
43
|
+
// The SHARED scrolling live-log console reused across build/integrate/rebuild/ship:
|
|
44
|
+
// pure phase→line formatting + terminal-line extraction (DOM glue verified live).
|
|
45
|
+
import {
|
|
46
|
+
logLineForPhase,
|
|
47
|
+
terminalLogLine,
|
|
48
|
+
type StreamTerminalResult,
|
|
49
|
+
} from "@/lib/publish/streamLogConsole";
|
|
50
|
+
// Fail-visible, never fail-silent: pure state-derivation for the publish lifecycle.
|
|
51
|
+
// classifyIntegrateOutcome derives an integrate attempt's HONEST result from the
|
|
52
|
+
// actual merge/build signals (killing the false "Integration did not land");
|
|
53
|
+
// classifyBuildLink withholds a rev deep-link that would 404 on an unbuilt sha.
|
|
54
|
+
import {
|
|
55
|
+
classifyIntegrateOutcome,
|
|
56
|
+
classifyBuildLink,
|
|
57
|
+
type IntegrateOutcomeView,
|
|
58
|
+
} from "@/lib/publish/lifecycleState";
|
|
59
|
+
// r01 — the SINGLE "next action" hero state machine. Reduces the same
|
|
60
|
+
// /api/changes read (queue count + merge health B1 + escalation B3 + ship plan)
|
|
61
|
+
// into ONE sentence + ONE CTA; folds the two former standalone banners in.
|
|
62
|
+
import { publishHeroState } from "@/lib/publish/publishHero";
|
|
63
|
+
import { computePublishGuard } from "@/lib/publish/publish-guard";
|
|
64
|
+
import { liveReleaseSummaryState } from "@/lib/publish/liveReleaseSummary";
|
|
65
|
+
// r05 — per-store persistence of each collapsible section's open/closed state.
|
|
66
|
+
import {
|
|
67
|
+
readPersistedOpen,
|
|
68
|
+
writePersistedOpen,
|
|
69
|
+
type CollapseSectionId,
|
|
70
|
+
} from "@/lib/publish/collapsePersistence";
|
|
14
71
|
|
|
15
72
|
export function initAdminPublishTab(): void {
|
|
16
73
|
const root: any = document.getElementById("publish");
|
|
@@ -19,25 +76,85 @@ export function initAdminPublishTab(): void {
|
|
|
19
76
|
const canShip = ds.canShip === "true";
|
|
20
77
|
const repo = ds.repo || "";
|
|
21
78
|
const appDomain = ds.appDomain || "";
|
|
79
|
+
const publicSiteHost = ds.publicSiteHost || "";
|
|
80
|
+
|
|
81
|
+
// r08 — shareable, view-only `?__preview` capability links an owner can hand to
|
|
82
|
+
// a non-technical stakeholder (no sign-in). The token is minted server-side
|
|
83
|
+
// (see AdminPublishTab.astro) and rides in on `data-preview-share-token`; empty
|
|
84
|
+
// when signing isn't configured, in which case the copy affordances stay hidden.
|
|
85
|
+
// ONE token authorizes both the shared next-release preview and per-candidate
|
|
86
|
+
// links (it claims the tenant_id both verifiers accept).
|
|
87
|
+
const previewShareToken = ds.previewShareToken || "";
|
|
88
|
+
const canonicalDomain = ds.canonicalDomain || "";
|
|
89
|
+
// The shared preview channel = the next-release aggregate build.
|
|
90
|
+
const sharedPreviewUrl = (): string | null =>
|
|
91
|
+
previewShareToken && canonicalDomain
|
|
92
|
+
? currentPreviewUrl(window.location.origin, canonicalDomain, previewShareToken)
|
|
93
|
+
: null;
|
|
94
|
+
// A single candidate PR's preview (the friendly `/preview/pr/<N>` alias, which
|
|
95
|
+
// accepts the same tenant-scoped token for a session-less viewer).
|
|
96
|
+
const candidatePreviewShareUrl = (prNumber?: any): string | null => {
|
|
97
|
+
if (!previewShareToken || prNumber == null) return null;
|
|
98
|
+
const path = candidatePrUrl(appDomain, prNumber);
|
|
99
|
+
return path
|
|
100
|
+
? window.location.origin + path + "?__preview=" + encodeURIComponent(previewShareToken)
|
|
101
|
+
: null;
|
|
102
|
+
};
|
|
103
|
+
// Copy `url` and flash a brief "Copied" on the button's label element (icon+text
|
|
104
|
+
// stays intact — accessible, never color-only). Clipboard denial is silent (the
|
|
105
|
+
// link is selectable text on failure).
|
|
106
|
+
const copyPreviewLink = (url: string | null, labelEl?: any, restore = "Copy preview link") => {
|
|
107
|
+
if (!url || !labelEl) return;
|
|
108
|
+
navigator.clipboard.writeText(url).then(
|
|
109
|
+
() => {
|
|
110
|
+
labelEl.textContent = "Copied";
|
|
111
|
+
setTimeout(() => { labelEl.textContent = restore; }, 1600);
|
|
112
|
+
},
|
|
113
|
+
() => { /* clipboard denied — the link is selectable text */ },
|
|
114
|
+
);
|
|
115
|
+
};
|
|
116
|
+
// Build an inline `#i-copy` icon (matches the SSR sprite-`use` convention).
|
|
117
|
+
const copyIcon = (): SVGSVGElement => {
|
|
118
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
119
|
+
const use = document.createElementNS("http://www.w3.org/2000/svg", "use");
|
|
120
|
+
use.setAttribute("href", "#i-copy");
|
|
121
|
+
svg.append(use);
|
|
122
|
+
return svg;
|
|
123
|
+
};
|
|
22
124
|
|
|
23
125
|
const statusEl = root.querySelector("[data-publish-status]");
|
|
24
126
|
const queueBody = root.querySelector("[data-publish-queue-body]");
|
|
25
127
|
const queueChip = root.querySelector("[data-publish-queue-chip]");
|
|
26
128
|
const queueCount = root.querySelector("[data-publish-queue-count]");
|
|
129
|
+
// "See all" closed/rejected history (§ see-all-rejected).
|
|
130
|
+
const closedPanel = root.querySelector("[data-publish-closed-panel]");
|
|
131
|
+
const closedBody = root.querySelector("[data-publish-closed-body]");
|
|
132
|
+
const closedChip = root.querySelector("[data-publish-closed-chip]");
|
|
27
133
|
const degradedStrip = root.querySelector("[data-publish-degraded]");
|
|
134
|
+
// r01: the SINGLE state hero. Folds in what used to be two standalone banners
|
|
135
|
+
// — the merge-doctor health summary (B1) and the platform-fault escalation
|
|
136
|
+
// (B3) — plus the queue count + ship plan, into one sentence + one CTA.
|
|
137
|
+
// Toggled by renderHero() off the same /api/changes read.
|
|
138
|
+
const heroEl = root.querySelector("[data-publish-hero]");
|
|
139
|
+
const heroIconEl = root.querySelector("[data-publish-hero-icon]");
|
|
140
|
+
const heroKickerEl = root.querySelector("[data-publish-hero-kicker]");
|
|
141
|
+
const heroHeadlineEl = root.querySelector("[data-publish-hero-headline]");
|
|
142
|
+
const heroDetailEl = root.querySelector("[data-publish-hero-detail]");
|
|
143
|
+
const heroCtaEl = root.querySelector("[data-publish-hero-cta]");
|
|
144
|
+
const heroCtaLinkEl = root.querySelector("[data-publish-hero-cta-link]");
|
|
28
145
|
|
|
29
146
|
// rux7 — in-flight UI state the auto-refresh poll must preserve across a
|
|
30
147
|
// re-render. These are the AUTHORITATIVE source of truth (never scraped
|
|
31
148
|
// back out of the DOM): an action's async closure holds a reference to the
|
|
32
149
|
// OLD button, so after a poll swaps the row the completion signal lands on a
|
|
33
150
|
// detached node — reading `disabled`/`data-building` off the fresh DOM would
|
|
34
|
-
// wedge forever. buildPreview/integrate/reject
|
|
151
|
+
// wedge forever. buildPreview/integrate/reject add on start and
|
|
35
152
|
// delete in `finally` (before their own loadQueue), so the next render
|
|
36
153
|
// reflects the true busy set. Open diff panes are the exception: the DOM IS
|
|
37
154
|
// their source of truth (toggleDiff owns open/close), so renderQueue detaches
|
|
38
155
|
// and re-attaches the actual pane node — see renderQueue.
|
|
39
156
|
const inflightBuilds = new Set(); // prNumber currently building
|
|
40
|
-
const busyChanges = new Set(); // changeId with an in-flight integrate/reject
|
|
157
|
+
const busyChanges = new Set(); // changeId with an in-flight integrate/reject
|
|
41
158
|
let aggregatePublishing = false; // the aggregate Publish POST is in flight
|
|
42
159
|
let aggregateRetrying = false; // u15: the aggregate Retry-build POST is in flight
|
|
43
160
|
|
|
@@ -60,122 +177,153 @@ export function initAdminPublishTab(): void {
|
|
|
60
177
|
else delete el.dataset.state;
|
|
61
178
|
};
|
|
62
179
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
180
|
+
// r12 — a status chip must read as icon + TEXT, never colour alone. The leading
|
|
181
|
+
// glyph is keyed by tone so every chip across the console speaks one visual
|
|
182
|
+
// language: ✓ good, ⚠ needs-attention, ✕ failed, • neutral/in-progress.
|
|
183
|
+
const CHIP_GLYPH: Record<string, string> = {
|
|
184
|
+
green: "✓",
|
|
185
|
+
teal: "✓",
|
|
186
|
+
amber: "⚠",
|
|
187
|
+
red: "✕",
|
|
188
|
+
blue: "•",
|
|
189
|
+
"": "•",
|
|
190
|
+
};
|
|
191
|
+
// Repaint an EXISTING chip element (SSR-rendered, re-used across polls) with a
|
|
192
|
+
// state word + tone + a leading glyph — the in-place analogue of publishFormat's
|
|
193
|
+
// `chip()` node builder, for the chips that live in the template rather than being
|
|
194
|
+
// created per render.
|
|
195
|
+
const paintChip = (el?: any, text?: string, tone?: string, glyph?: string) => {
|
|
196
|
+
if (!el) return;
|
|
197
|
+
el.className = "chip" + (tone ? " " + tone : "");
|
|
198
|
+
el.textContent = "";
|
|
199
|
+
if (glyph) {
|
|
200
|
+
const g = document.createElement("span");
|
|
201
|
+
g.className = "chip-glyph";
|
|
202
|
+
g.setAttribute("aria-hidden", "true");
|
|
203
|
+
g.textContent = glyph;
|
|
204
|
+
el.append(g);
|
|
82
205
|
}
|
|
83
|
-
|
|
206
|
+
el.append(document.createTextNode(text ?? ""));
|
|
84
207
|
};
|
|
85
208
|
|
|
209
|
+
// C5 — the doctor PUSH (admin side). On a failed accept/integrate or a refused
|
|
210
|
+
// ship, fetch the freshest /api/changes and, IF the merge-doctor has an opinion on
|
|
211
|
+
// this failure, append its one-line diagnosis to the failure's own outcome copy —
|
|
212
|
+
// so the developer sees WHY right where it failed, not only in the top-of-tab
|
|
213
|
+
// banner / row chips. Best-effort throughout: it never throws and never alters the
|
|
214
|
+
// action's outcome/status/exit path. `hintFor` runs the SAME pure analyzer the
|
|
215
|
+
// banner + row chips use (no new endpoint). `sinks` are elements to append to (the
|
|
216
|
+
// liftoff/shipoff outcome detail — stable, unlike statusEl which loadQueue rewrites).
|
|
217
|
+
const appendDoctorText = (el?: any, text?: string) => {
|
|
218
|
+
if (!el || !text) return;
|
|
219
|
+
const base = (el.textContent || "").trim();
|
|
220
|
+
el.textContent = base ? base + " " + text : text;
|
|
221
|
+
};
|
|
222
|
+
const surfaceDoctorHint = (
|
|
223
|
+
hintFor: (payload: any) => { show: boolean; text: string },
|
|
224
|
+
sinks: any[],
|
|
225
|
+
) => {
|
|
226
|
+
void (async () => {
|
|
227
|
+
try {
|
|
228
|
+
const res = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
|
|
229
|
+
if (!res.ok) return;
|
|
230
|
+
const hint = hintFor(await res.json());
|
|
231
|
+
if (!hint.show) return;
|
|
232
|
+
for (const el of sinks) appendDoctorText(el, hint.text);
|
|
233
|
+
} catch { /* best-effort — the failure's own copy already stands */ }
|
|
234
|
+
})();
|
|
235
|
+
};
|
|
86
236
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
237
|
+
// ── The SHARED scrolling live-log console ─────────────────────────────────
|
|
238
|
+
// ONE "CI build log" panel, reused across all four long-running flows
|
|
239
|
+
// (build/integrate/rebuild/ship). `makeLogConsole` binds it to a scrollable
|
|
240
|
+
// `<div data-*-log>`; `reset()` clears + stamps the clock start, `pushEvent`
|
|
241
|
+
// appends one timestamped human line per server `phase` event (auto-scrolled
|
|
242
|
+
// to the bottom), and `finish` appends the terminal ✓/✗ line. Pure formatting
|
|
243
|
+
// lives in the tested streamLogConsole.ts; this is the DOM glue only.
|
|
244
|
+
const makeLogConsole = (logEl?: any) => {
|
|
245
|
+
let startedAt = Date.now();
|
|
246
|
+
const append = (line?: string | null) => {
|
|
247
|
+
if (!logEl || !line) return;
|
|
248
|
+
const div = document.createElement("div");
|
|
249
|
+
div.className = "stream-log-line";
|
|
250
|
+
div.textContent = line;
|
|
251
|
+
logEl.appendChild(div);
|
|
252
|
+
logEl.scrollTop = logEl.scrollHeight; // keep the newest line in view
|
|
253
|
+
};
|
|
254
|
+
return {
|
|
255
|
+
reset() {
|
|
256
|
+
startedAt = Date.now();
|
|
257
|
+
if (logEl) logEl.textContent = "";
|
|
258
|
+
},
|
|
259
|
+
pushEvent(ev: { event: string; data: string }) {
|
|
260
|
+
append(logLineForPhase(ev, Date.now() - startedAt));
|
|
261
|
+
},
|
|
262
|
+
finish(result: StreamTerminalResult) {
|
|
263
|
+
append(terminalLogLine(result, Date.now() - startedAt).line);
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
};
|
|
267
|
+
type LogConsole = ReturnType<typeof makeLogConsole>;
|
|
268
|
+
|
|
269
|
+
// Consume a streamed (SSE) or buffered (JSON) response uniformly. In stream
|
|
270
|
+
// mode each `phase` event feeds the shared log console + the optional `onPhase`
|
|
271
|
+
// (a flow's own stage-rail), and the terminal `result` event's payload is
|
|
272
|
+
// returned; in JSON mode (a buffering proxy, or a non-streaming server) the whole
|
|
273
|
+
// body is read once and treated as that terminal payload — so every flow completes
|
|
274
|
+
// honestly either way. Never throws for a well-formed response; the caller keeps
|
|
275
|
+
// its own try/catch for network faults. `res.ok`/`res.status` stay authoritative
|
|
276
|
+
// for the caller (a stream is always HTTP 200; the route front-loads its non-2xx
|
|
277
|
+
// auth/validation refusals as plain JSON BEFORE any stream).
|
|
278
|
+
const streamWithLog = async (
|
|
279
|
+
res: any,
|
|
280
|
+
logc?: LogConsole | null,
|
|
281
|
+
onPhase?: (ev: { event: string; data: string }) => void,
|
|
282
|
+
): Promise<{ data: any }> => {
|
|
283
|
+
const ct = res.headers.get("content-type") || "";
|
|
284
|
+
if (res.body && ct.indexOf("text/event-stream") !== -1) {
|
|
285
|
+
const reader = res.body.getReader();
|
|
286
|
+
const decoder = new TextDecoder();
|
|
287
|
+
let buf = "";
|
|
288
|
+
let terminal: any = undefined;
|
|
289
|
+
for (;;) {
|
|
290
|
+
const { value, done } = await reader.read();
|
|
291
|
+
if (done) break;
|
|
292
|
+
buf += decoder.decode(value, { stream: true });
|
|
293
|
+
const parsed = parseSseChunk(buf);
|
|
294
|
+
buf = parsed.rest;
|
|
295
|
+
for (const ev of parsed.events) {
|
|
296
|
+
if (ev.event === "phase") {
|
|
297
|
+
if (logc) logc.pushEvent(ev);
|
|
298
|
+
if (onPhase) onPhase(ev);
|
|
299
|
+
} else if (ev.event === "result") {
|
|
300
|
+
try { terminal = JSON.parse(ev.data); } catch { terminal = {}; }
|
|
301
|
+
if (logc) logc.finish(terminal);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return { data: terminal ?? {} };
|
|
306
|
+
}
|
|
307
|
+
const data = await res.json().catch(() => ({}));
|
|
308
|
+
if (logc) logc.finish(data);
|
|
309
|
+
return { data };
|
|
97
310
|
};
|
|
98
311
|
|
|
312
|
+
|
|
99
313
|
const STATUS_CHIP: Record<string, string> = {
|
|
100
314
|
queued: "",
|
|
101
315
|
reconciling: "blue",
|
|
102
316
|
ready: "green",
|
|
103
317
|
blocked: "red",
|
|
104
318
|
};
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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;
|
|
319
|
+
// r04 — humanize the raw candidate-status word for the queue chip. The raw
|
|
320
|
+
// status still drives all logic; only the displayed label is merchant-facing
|
|
321
|
+
// (dev words like "reconciling" never reach the operator).
|
|
322
|
+
const STATUS_LABEL: Record<string, string> = {
|
|
323
|
+
queued: "Waiting",
|
|
324
|
+
reconciling: "Checking",
|
|
325
|
+
ready: "Ready",
|
|
326
|
+
blocked: "Needs attention",
|
|
179
327
|
};
|
|
180
328
|
|
|
181
329
|
// ---- ?pr=N deep link ---------------------------------------------------
|
|
@@ -225,23 +373,179 @@ export function initAdminPublishTab(): void {
|
|
|
225
373
|
|
|
226
374
|
// ---- Open change queue -------------------------------------------------
|
|
227
375
|
|
|
376
|
+
// r01: the SINGLE state hero. Computed from the SAME /api/changes payload the
|
|
377
|
+
// queue + aggregate already have — no duplicate read. It folds in the merge-
|
|
378
|
+
// doctor health summary (B1, mergeHealthBanner) and the platform-fault
|
|
379
|
+
// escalation (B3, mergeSupportEscalation), plus the queue count and ship plan,
|
|
380
|
+
// and expresses whichever is the most urgent true state as one sentence + one
|
|
381
|
+
// CTA (publishHeroState orders the precedence). There is no separate B1/B3
|
|
382
|
+
// banner any more — a conflict/platform blocker turns THIS element red/urgent.
|
|
383
|
+
const HERO_ICON: Record<string, string> = {
|
|
384
|
+
blocker: "#i-alert",
|
|
385
|
+
warn: "#i-alert",
|
|
386
|
+
attention: "#i-box",
|
|
387
|
+
ready: "#i-export",
|
|
388
|
+
calm: "#i-check",
|
|
389
|
+
};
|
|
390
|
+
const scrollToPanel = (sel: string) => {
|
|
391
|
+
const el = document.getElementById(sel);
|
|
392
|
+
if (el) el.scrollIntoView({ block: "start", behavior: "smooth" });
|
|
393
|
+
};
|
|
394
|
+
const renderHero = (data?: any) => {
|
|
395
|
+
if (!heroEl) return;
|
|
396
|
+
const payload = data || {};
|
|
397
|
+
const changes = Array.isArray(payload.changes) ? payload.changes : [];
|
|
398
|
+
const agg = payload.aggregate || null;
|
|
399
|
+
const stagedSha = agg && agg.aggregateSha;
|
|
400
|
+
const liveId = payload.current && payload.current.versionId;
|
|
401
|
+
const view = publishHeroState({
|
|
402
|
+
changeCount: changes.length,
|
|
403
|
+
health: mergeHealthBanner(payload),
|
|
404
|
+
escalation: mergeSupportEscalation(payload),
|
|
405
|
+
plan: payload.plan || null,
|
|
406
|
+
aheadCount: (agg && agg.includedPrs ? agg.includedPrs.length : 0),
|
|
407
|
+
matchesLive: Boolean(stagedSha && liveId && stagedSha === liveId),
|
|
408
|
+
canShip,
|
|
409
|
+
publicSiteHost,
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
(heroEl as any).dataset.tone = view.tone;
|
|
413
|
+
if (heroIconEl) {
|
|
414
|
+
const use = heroIconEl.querySelector("use");
|
|
415
|
+
if (use) use.setAttribute("href", HERO_ICON[view.tone] || "#i-check");
|
|
416
|
+
}
|
|
417
|
+
if (heroKickerEl) (heroKickerEl as any).hidden = !view.urgent;
|
|
418
|
+
if (heroHeadlineEl) heroHeadlineEl.textContent = view.headline;
|
|
419
|
+
if (heroDetailEl) heroDetailEl.textContent = view.detail;
|
|
420
|
+
|
|
421
|
+
// ONE CTA: a mailto link for the "report to support" escalation, a button
|
|
422
|
+
// for every in-page action, nothing for the calm caught-up state.
|
|
423
|
+
const cta = view.cta;
|
|
424
|
+
if (cta.kind === "report-support") {
|
|
425
|
+
if (heroCtaEl) (heroCtaEl as any).hidden = true;
|
|
426
|
+
if (heroCtaLinkEl) {
|
|
427
|
+
heroCtaLinkEl.textContent = cta.label;
|
|
428
|
+
if (cta.href) heroCtaLinkEl.setAttribute("href", cta.href);
|
|
429
|
+
(heroCtaLinkEl as any).hidden = false;
|
|
430
|
+
}
|
|
431
|
+
} else {
|
|
432
|
+
if (heroCtaLinkEl) (heroCtaLinkEl as any).hidden = true;
|
|
433
|
+
if (heroCtaEl) {
|
|
434
|
+
if (cta.kind === "none") {
|
|
435
|
+
(heroCtaEl as any).hidden = true;
|
|
436
|
+
(heroCtaEl as any).onclick = null;
|
|
437
|
+
} else {
|
|
438
|
+
heroCtaEl.textContent = cta.label;
|
|
439
|
+
(heroCtaEl as any).hidden = false;
|
|
440
|
+
(heroCtaEl as any).onclick = () => {
|
|
441
|
+
if (cta.kind === "publish") publishAggregate(heroCtaEl);
|
|
442
|
+
else if (cta.kind === "review-queue") scrollToPanel("publish-queue");
|
|
443
|
+
else if (cta.kind === "goto-release") scrollToPanel("publish-next-release");
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
(heroEl as any).hidden = false;
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
// B2: per-row merge-doctor finding. Reads THIS row's dominant finding out of
|
|
452
|
+
// the map computed once per render (mergeRowFindings over the same payload the
|
|
453
|
+
// queue already holds — no extra fetch, no per-row analysis) and renders its
|
|
454
|
+
// ownership tag as a chip. When the finding carries an action it LIGHTS UP the
|
|
455
|
+
// row's EXISTING mapped button (resolve → the conflict-recovery button, build/
|
|
456
|
+
// retry → the build button) via a "primed" class + the remedy as its tooltip —
|
|
457
|
+
// it does NOT re-declare the API call. A finding with no inline button to prime
|
|
458
|
+
// (e.g. a promotable-yet-stalled platform retry) gets a fallback CTA that still
|
|
459
|
+
// calls the SAME existing action function. An informational finding (action:
|
|
460
|
+
// null) shows the chip only; a healthy row has no finding, so no chip.
|
|
461
|
+
const rowFindingFor = (findings: any, change: any) => {
|
|
462
|
+
const byChange =
|
|
463
|
+
change.changeId != null ? findings.get(mergeRowKey(change.changeId, null)) : undefined;
|
|
464
|
+
return (
|
|
465
|
+
byChange ||
|
|
466
|
+
(change.prNumber != null ? findings.get(mergeRowKey(null, change.prNumber)) : undefined)
|
|
467
|
+
);
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const applyRowFinding = (actionsCell: any, change: any, findings: any, buttons: any = {}) => {
|
|
471
|
+
const finding = rowFindingFor(findings, change);
|
|
472
|
+
if (!finding) return;
|
|
473
|
+
const tag = chip(finding.ownershipLabel, finding.tone);
|
|
474
|
+
tag.classList.add("publish-finding-chip");
|
|
475
|
+
tag.title = finding.detail;
|
|
476
|
+
actionsCell.prepend(tag);
|
|
477
|
+
if (!finding.cta) return; // informational-only diagnosis — chip, no button
|
|
478
|
+
const mapped = finding.cta.kind === "resolve" ? buttons.resolve : buttons.build;
|
|
479
|
+
if (mapped) {
|
|
480
|
+
mapped.classList.add("publish-cta-primed");
|
|
481
|
+
mapped.title = finding.remedy;
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
// No inline button maps to this finding — add a CTA that reuses the SAME
|
|
485
|
+
// existing action function (never a re-declared fetch). Only when the viewer
|
|
486
|
+
// can actually ship; otherwise the chip alone diagnoses it (the row already
|
|
487
|
+
// carries a sign-in hint), never a dead disabled duplicate.
|
|
488
|
+
if (!canShip) return;
|
|
489
|
+
const cta = document.createElement("button");
|
|
490
|
+
cta.type = "button";
|
|
491
|
+
cta.className = "btn publish-cta-primed";
|
|
492
|
+
cta.textContent = finding.cta.label;
|
|
493
|
+
cta.title = finding.remedy;
|
|
494
|
+
cta.addEventListener("click", () =>
|
|
495
|
+
finding.cta.kind === "resolve"
|
|
496
|
+
? openConflictRecovery(change, cta)
|
|
497
|
+
: buildPreview(change, cta),
|
|
498
|
+
);
|
|
499
|
+
actionsCell.append(cta);
|
|
500
|
+
};
|
|
501
|
+
|
|
228
502
|
const renderQueue = (payload?: any) => {
|
|
229
503
|
const changes = Array.isArray(payload.changes) ? payload.changes : [];
|
|
504
|
+
const rowFindings = mergeRowFindings(payload);
|
|
505
|
+
// Ledger truth (fb-1787916141762-owjzis): which changes are ALREADY integrated
|
|
506
|
+
// into the staged aggregate, read straight from aggregate.includedPrs (the
|
|
507
|
+
// ledger), DECOUPLED from any per-row mergeable/rebuild signal. A change in
|
|
508
|
+
// this set has merged; its row must never offer conflict-recovery (which would
|
|
509
|
+
// try to refresh an already-merged candidate and error "No open candidate").
|
|
510
|
+
const stagedChangeIds = new Set<string>();
|
|
511
|
+
const stagedPrNumbers = new Set<number>();
|
|
512
|
+
for (const p of (payload.aggregate && payload.aggregate.includedPrs) || []) {
|
|
513
|
+
if (p && typeof p.changeId === "string") stagedChangeIds.add(p.changeId);
|
|
514
|
+
if (p && typeof p.prNumber === "number") stagedPrNumbers.add(p.prNumber);
|
|
515
|
+
}
|
|
516
|
+
const isIntegrated = (change: any) =>
|
|
517
|
+
stagedChangeIds.has(change.changeId) ||
|
|
518
|
+
(change.prNumber != null && stagedPrNumbers.has(change.prNumber));
|
|
519
|
+
// A change already staged into the next release has, by that act, been
|
|
520
|
+
// APPROVED — this panel asks "What needs your approval?", so it must not list
|
|
521
|
+
// it. It reappears here only if it's pulled back out of the next release (the
|
|
522
|
+
// "Next release" panel below owns that Undo/revert). So the review queue shows
|
|
523
|
+
// only changes NOT yet in the aggregate; the integrated ones live in "Next
|
|
524
|
+
// release". (Full `changes` is still used for the top "Open changes" metric,
|
|
525
|
+
// which counts everything waiting for review OR go-live.)
|
|
526
|
+
const pending = changes.filter((change: any) => !isIntegrated(change));
|
|
230
527
|
// rux1: surface a forge-listing failure as a visible strip — never let a
|
|
231
528
|
// degraded (short) list read as a genuine empty queue.
|
|
232
529
|
if (degradedStrip) degradedStrip.hidden = payload.degraded !== true;
|
|
233
530
|
queueBody.textContent = "";
|
|
234
531
|
if (queueCount) queueCount.textContent = String(changes.length);
|
|
235
532
|
if (queueChip) {
|
|
236
|
-
queueChip.textContent =
|
|
237
|
-
queueChip.className = "chip" + (
|
|
533
|
+
queueChip.textContent = pending.length === 1 ? "1 open" : pending.length + " open";
|
|
534
|
+
queueChip.className = "chip" + (pending.length ? " blue" : "");
|
|
238
535
|
}
|
|
239
536
|
|
|
240
|
-
if (!
|
|
537
|
+
if (!pending.length) {
|
|
241
538
|
const tr = document.createElement("tr");
|
|
539
|
+
// Distinguish "nothing here at all" from "everything open is already added" —
|
|
540
|
+
// the latter is the common state once a merchant has staged their changes, and
|
|
541
|
+
// "No changes waiting" would read as if their work vanished.
|
|
242
542
|
const td = cell(
|
|
243
543
|
subtle(
|
|
244
|
-
|
|
544
|
+
changes.length
|
|
545
|
+
? "Nothing waiting for your approval — every open change is already in your next " +
|
|
546
|
+
"release (see below). Pull one back out there if you want to review it again."
|
|
547
|
+
: "No changes waiting. Make an edit in your store and submit it — it'll show up here to " +
|
|
548
|
+
"review and add to your next release.",
|
|
245
549
|
),
|
|
246
550
|
);
|
|
247
551
|
td.colSpan = 6;
|
|
@@ -250,7 +554,7 @@ export function initAdminPublishTab(): void {
|
|
|
250
554
|
return;
|
|
251
555
|
}
|
|
252
556
|
|
|
253
|
-
for (const change of
|
|
557
|
+
for (const change of pending) {
|
|
254
558
|
const tr = document.createElement("tr");
|
|
255
559
|
if (change.prNumber != null) tr.dataset.prNumber = String(change.prNumber);
|
|
256
560
|
|
|
@@ -263,13 +567,17 @@ export function initAdminPublishTab(): void {
|
|
|
263
567
|
if (change.branch) meta.push(change.branch);
|
|
264
568
|
if (change.prNumber != null) meta.push("PR #" + change.prNumber);
|
|
265
569
|
if (meta.length) idCell.append(subtle(meta.join(" · ")));
|
|
266
|
-
// Owner-facing "who did this" — accept takes priority over build (a
|
|
570
|
+
// Owner-facing "who did this" — accept takes priority over submit/build (a
|
|
267
571
|
// candidate that's been accepted-to-preview is more meaningfully
|
|
268
|
-
// "acted on" than merely built). Reject never shows here: a rejected
|
|
572
|
+
// "acted on" than merely submitted or built). Reject never shows here: a rejected
|
|
269
573
|
// candidate is closed and has already left the open queue.
|
|
270
|
-
const actorRecord = change.actors && (change.actors.accept || change.actors.build);
|
|
574
|
+
const actorRecord = change.actors && (change.actors.accept || change.actors.submit || change.actors.build);
|
|
271
575
|
if (actorRecord) {
|
|
272
|
-
const label = change.actors.accept
|
|
576
|
+
const label = change.actors.accept
|
|
577
|
+
? "Added by "
|
|
578
|
+
: change.actors.submit
|
|
579
|
+
? "Submitted by "
|
|
580
|
+
: "Built by ";
|
|
273
581
|
idCell.append(subtle(label + (actorRecord.name || actorRecord.email)));
|
|
274
582
|
}
|
|
275
583
|
// Prefer the synthesized friendly per-PR route (handles every state);
|
|
@@ -287,6 +595,23 @@ export function initAdminPublishTab(): void {
|
|
|
287
595
|
wrap.append(preview);
|
|
288
596
|
idCell.append(wrap);
|
|
289
597
|
}
|
|
598
|
+
// r08: copy a shareable, view-only preview link for THIS candidate to hand
|
|
599
|
+
// to a non-technical stakeholder (no sign-in). Shown only when a share token
|
|
600
|
+
// was minted and the row has a PR number to point at.
|
|
601
|
+
const candidateShareUrl = candidatePreviewShareUrl(change.prNumber);
|
|
602
|
+
if (candidateShareUrl) {
|
|
603
|
+
const copyBtn = document.createElement("button");
|
|
604
|
+
copyBtn.type = "button";
|
|
605
|
+
copyBtn.className = "btn btn-compact publish-copy-preview";
|
|
606
|
+
const lbl = document.createElement("span");
|
|
607
|
+
lbl.textContent = "Copy preview link";
|
|
608
|
+
copyBtn.append(copyIcon(), lbl);
|
|
609
|
+
copyBtn.addEventListener("click", () => copyPreviewLink(candidateShareUrl, lbl));
|
|
610
|
+
const copyWrap = document.createElement("span");
|
|
611
|
+
copyWrap.className = "subtle";
|
|
612
|
+
copyWrap.append(copyBtn);
|
|
613
|
+
idCell.append(copyWrap);
|
|
614
|
+
}
|
|
290
615
|
tr.append(idCell);
|
|
291
616
|
|
|
292
617
|
// Reconcile status. u13 DISPLAY HONESTY: when the server could not confirm
|
|
@@ -296,12 +621,16 @@ export function initAdminPublishTab(): void {
|
|
|
296
621
|
// as current fact; keep the last-known label as a subtle, clearly-hedged note.
|
|
297
622
|
let statusCell;
|
|
298
623
|
if (change.liveStateConfirmed === false) {
|
|
299
|
-
statusCell = cell(chip("State unknown", "amber"));
|
|
624
|
+
statusCell = cell(chip("State unknown", "amber", CHIP_GLYPH.amber));
|
|
300
625
|
statusCell.append(
|
|
301
|
-
subtle("
|
|
626
|
+
subtle("Status couldn't be confirmed — last known: " +
|
|
627
|
+
(STATUS_LABEL[change.status] || change.status || "unknown")),
|
|
302
628
|
);
|
|
303
629
|
} else {
|
|
304
|
-
|
|
630
|
+
const tone = STATUS_CHIP[change.status] ?? "amber";
|
|
631
|
+
statusCell = cell(
|
|
632
|
+
chip(STATUS_LABEL[change.status] || change.status || "unknown", tone, CHIP_GLYPH[tone]),
|
|
633
|
+
);
|
|
305
634
|
if (change.lastError) statusCell.append(subtle(change.lastError));
|
|
306
635
|
}
|
|
307
636
|
tr.append(statusCell);
|
|
@@ -311,7 +640,9 @@ export function initAdminPublishTab(): void {
|
|
|
311
640
|
const failing = checks.filter((c: any) => c.status === "fail");
|
|
312
641
|
const promotable = Boolean(change.evidence && change.evidence.promotable);
|
|
313
642
|
const checksCell = cell(
|
|
314
|
-
promotable
|
|
643
|
+
promotable
|
|
644
|
+
? chip("Checks passed", "green", CHIP_GLYPH.green)
|
|
645
|
+
: chip("Checks blocked", "amber", CHIP_GLYPH.amber),
|
|
315
646
|
);
|
|
316
647
|
if (failing.length) {
|
|
317
648
|
checksCell.append(subtle("Failing: " + failing.map((c: any) => c.label || c.id).join(", ")));
|
|
@@ -330,14 +661,12 @@ export function initAdminPublishTab(): void {
|
|
|
330
661
|
const actions = document.createElement("td");
|
|
331
662
|
actions.className = "action publish-actions";
|
|
332
663
|
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
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.
|
|
664
|
+
// A synthetic not-built forge PR can be reviewed at the lifecycle level
|
|
665
|
+
// without paying to materialize it. Candidate-shaped branches carry a
|
|
666
|
+
// derived changeId, so the operator may Reject immediately; Build preview
|
|
667
|
+
// remains available when visual review is useful.
|
|
340
668
|
if (change.built === false || change.status === "not-built") {
|
|
669
|
+
let notBuiltBuild: any;
|
|
341
670
|
if (canShip) {
|
|
342
671
|
const build = document.createElement("button");
|
|
343
672
|
build.type = "button";
|
|
@@ -356,24 +685,47 @@ export function initAdminPublishTab(): void {
|
|
|
356
685
|
}
|
|
357
686
|
build.addEventListener("click", () => buildPreview(change, build));
|
|
358
687
|
actions.append(build);
|
|
688
|
+
notBuiltBuild = build;
|
|
689
|
+
if (change.changeId) {
|
|
690
|
+
const reject = document.createElement("button");
|
|
691
|
+
reject.type = "button";
|
|
692
|
+
reject.className = "btn";
|
|
693
|
+
reject.textContent = "Reject";
|
|
694
|
+
reject.disabled = busyChanges.has(change.changeId);
|
|
695
|
+
reject.title = "Close this change without building or publishing it";
|
|
696
|
+
reject.addEventListener("click", () => rejectChange(change, reject));
|
|
697
|
+
actions.append(reject);
|
|
698
|
+
}
|
|
359
699
|
} else {
|
|
360
|
-
actions.append(subtle("Owner/admin sign-in required to build"));
|
|
700
|
+
actions.append(subtle("Owner/admin sign-in required to build or reject"));
|
|
361
701
|
}
|
|
702
|
+
applyRowFinding(actions, change, rowFindings, { build: notBuiltBuild });
|
|
362
703
|
tr.append(actions);
|
|
363
704
|
queueBody.append(tr);
|
|
364
705
|
continue;
|
|
365
706
|
}
|
|
366
707
|
|
|
367
|
-
// rux7: while an integrate/reject
|
|
708
|
+
// rux7: while an integrate/reject for this change is in flight,
|
|
368
709
|
// keep its mutating actions disabled across a poll re-render so the
|
|
369
710
|
// operator can't double-submit against a freshly-minted button.
|
|
370
711
|
const changeBusy = busyChanges.has(change.changeId);
|
|
371
712
|
|
|
713
|
+
// A candidate that is still waiting for its reconciliation/evidence — or
|
|
714
|
+
// whose build FAILED (blocked) — has never become eligible for Accept. A
|
|
715
|
+
// disabled "Accept to preview" is a dead CTA; the safe recovery already
|
|
716
|
+
// exists at POST /api/preview/build, so make it the primary action: it
|
|
717
|
+
// re-materializes this exact PR head and re-runs its checks (does not merge
|
|
718
|
+
// or publish). `blocked` is included so a build-failed candidate keeps a
|
|
719
|
+
// live "Rebuild" nudge and never sits on a dead end (fail-visible, rule 3).
|
|
720
|
+
const buildFailed = change.status === "blocked";
|
|
721
|
+
const needsChecks = !promotable &&
|
|
722
|
+
(change.status === "queued" || change.status === "reconciling" || buildFailed);
|
|
723
|
+
|
|
372
724
|
// rux6 Review: expand the row into a single "review moment" pane —
|
|
373
725
|
// preview iframe + evidence chips + diff + Accept/Reject — so a reviewer
|
|
374
726
|
// never leaves the queue. Supersedes the standalone Diff button (the dg7
|
|
375
727
|
// diff now lives inside the pane, reusing renderDiff). The row's own
|
|
376
|
-
// Accept/Reject
|
|
728
|
+
// Accept/Reject buttons below are unchanged.
|
|
377
729
|
const reviewBtn = document.createElement("button");
|
|
378
730
|
reviewBtn.type = "button";
|
|
379
731
|
reviewBtn.className = "btn";
|
|
@@ -382,6 +734,26 @@ export function initAdminPublishTab(): void {
|
|
|
382
734
|
reviewBtn.title = "Open one pane to preview, diff, and act on this change";
|
|
383
735
|
reviewBtn.addEventListener("click", () => toggleReview(change, reviewBtn, tr));
|
|
384
736
|
actions.append(reviewBtn);
|
|
737
|
+
let runChecksBuild: any;
|
|
738
|
+
if (needsChecks) {
|
|
739
|
+
const build = document.createElement("button");
|
|
740
|
+
build.type = "button";
|
|
741
|
+
build.className = "btn primary";
|
|
742
|
+
build.title = buildFailed
|
|
743
|
+
? "This change's preview build failed — rebuild it (re-materializes this PR head and re-runs checks; does not merge or go live)"
|
|
744
|
+
: "Re-run this PR's preview build and checks (does not merge or go live)";
|
|
745
|
+
if (change.prNumber != null && inflightBuilds.has(change.prNumber)) {
|
|
746
|
+
build.textContent = buildFailed ? "Rebuilding…" : "Running checks…";
|
|
747
|
+
build.disabled = true;
|
|
748
|
+
build.dataset.building = "true";
|
|
749
|
+
} else {
|
|
750
|
+
build.textContent = buildFailed ? "Rebuild" : "Run checks";
|
|
751
|
+
build.disabled = !canShip || changeBusy;
|
|
752
|
+
}
|
|
753
|
+
build.addEventListener("click", () => buildPreview(change, build));
|
|
754
|
+
actions.append(build);
|
|
755
|
+
runChecksBuild = build;
|
|
756
|
+
}
|
|
385
757
|
// b11 SPLIT: the candidate-queue primary action is now ACCEPT TO PREVIEW
|
|
386
758
|
// (b08 integrate = advance the shared preview aggregate), NOT go-live.
|
|
387
759
|
// Integrate flips NO live channel, so it has NO paywall (entitled) gate —
|
|
@@ -396,21 +768,37 @@ export function initAdminPublishTab(): void {
|
|
|
396
768
|
// than presenting a generic, disabled "not ready" action. Other false
|
|
397
769
|
// mergeability states remain disabled: a rebuild cannot fix an incomplete
|
|
398
770
|
// reconcile or failed evidence.
|
|
771
|
+
// A change already in the staged aggregate has integrated — its stored
|
|
772
|
+
// `mergeable` can read false once the forge settles the just-merged PR
|
|
773
|
+
// against the tip, but that is NOT a conflict to recover. Read integration
|
|
774
|
+
// truth from the ledger and never route it to recovery.
|
|
775
|
+
const alreadyIntegrated = isIntegrated(change);
|
|
399
776
|
const needsPreviewUpdate =
|
|
400
|
-
change.status === "ready" && promotable && change.mergeable === false;
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
777
|
+
!alreadyIntegrated && change.status === "ready" && promotable && change.mergeable === false;
|
|
778
|
+
// Only ONE primary action at a time: while checks are pending, "Run checks"
|
|
779
|
+
// above is the whole story — a second disabled "Run checks first" button
|
|
780
|
+
// next to it said nothing new and just looked broken.
|
|
781
|
+
if (!needsChecks) {
|
|
782
|
+
if (alreadyIntegrated) {
|
|
783
|
+
accept.textContent = "Already added";
|
|
784
|
+
accept.disabled = true;
|
|
785
|
+
accept.title = "This change is already in your next release.";
|
|
786
|
+
} else {
|
|
787
|
+
accept.textContent = needsPreviewUpdate ? "Update change" : "Add to release";
|
|
788
|
+
accept.disabled = !canShip || (!needsPreviewUpdate && !change.mergeable) || changeBusy;
|
|
789
|
+
accept.title = !canShip
|
|
790
|
+
? "Requires a ship-on-behalf grant on your account — ask the store owner"
|
|
791
|
+
: needsPreviewUpdate
|
|
792
|
+
? "Preview changed while you worked — update this change, then add it"
|
|
793
|
+
: !change.mergeable
|
|
794
|
+
? "This change isn't ready to add yet"
|
|
795
|
+
: "Add this change to your next release (doesn't go live yet)";
|
|
796
|
+
accept.addEventListener("click", () =>
|
|
797
|
+
needsPreviewUpdate ? openConflictRecovery(change, accept) : integrateCandidate(change, accept),
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
actions.append(accept);
|
|
801
|
+
}
|
|
414
802
|
const reject = document.createElement("button");
|
|
415
803
|
reject.type = "button";
|
|
416
804
|
reject.className = "btn";
|
|
@@ -421,21 +809,13 @@ export function initAdminPublishTab(): void {
|
|
|
421
809
|
: "Requires a ship-on-behalf grant on your account — ask the store owner";
|
|
422
810
|
reject.addEventListener("click", () => rejectChange(change, reject));
|
|
423
811
|
actions.append(reject);
|
|
424
|
-
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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);
|
|
812
|
+
// B2: light up this row's mapped button — the build button when the row's
|
|
813
|
+
// finding is a build/retry, the conflict-recovery "Update change" accept
|
|
814
|
+
// button when it's a resolve.
|
|
815
|
+
applyRowFinding(actions, change, rowFindings, {
|
|
816
|
+
build: runChecksBuild,
|
|
817
|
+
resolve: needsPreviewUpdate ? accept : undefined,
|
|
818
|
+
});
|
|
439
819
|
tr.append(actions);
|
|
440
820
|
|
|
441
821
|
queueBody.append(tr);
|
|
@@ -443,13 +823,86 @@ export function initAdminPublishTab(): void {
|
|
|
443
823
|
}
|
|
444
824
|
};
|
|
445
825
|
|
|
826
|
+
// "See all" closed/rejected history. A rejected change leaves the open queue
|
|
827
|
+
// (server filters terminal status out of `changes`); the server now also returns
|
|
828
|
+
// the closed ones as `closedChanges` so a reject is recoverable-from-history
|
|
829
|
+
// rather than a black hole. Read-only: id + who-closed-it + a still-reachable
|
|
830
|
+
// preview link. The whole panel is HIDDEN when there are none (no empty chrome).
|
|
831
|
+
const renderClosed = (payload?: any) => {
|
|
832
|
+
if (!closedBody || !closedPanel) return;
|
|
833
|
+
const closed = Array.isArray(payload && payload.closedChanges) ? payload.closedChanges : [];
|
|
834
|
+
closedBody.textContent = "";
|
|
835
|
+
if (closedChip) closedChip.textContent = String(closed.length);
|
|
836
|
+
closedPanel.hidden = closed.length === 0;
|
|
837
|
+
if (!closed.length) return;
|
|
838
|
+
|
|
839
|
+
for (const change of closed) {
|
|
840
|
+
const tr = document.createElement("tr");
|
|
841
|
+
if (change.prNumber != null) tr.dataset.prNumber = String(change.prNumber);
|
|
842
|
+
|
|
843
|
+
// Change id + branch/PR + who closed it + a still-reachable preview link.
|
|
844
|
+
const idCell = document.createElement("td");
|
|
845
|
+
const idStrong = document.createElement("strong");
|
|
846
|
+
idStrong.textContent = change.changeId || "—";
|
|
847
|
+
idCell.append(idStrong);
|
|
848
|
+
const meta = [];
|
|
849
|
+
if (change.branch) meta.push(change.branch);
|
|
850
|
+
if (change.prNumber != null) meta.push("PR #" + change.prNumber);
|
|
851
|
+
if (meta.length) idCell.append(subtle(meta.join(" · ")));
|
|
852
|
+
// "Closed by" — the reject actor when known (fall back to the last actor
|
|
853
|
+
// who touched it, so the row is never anonymous when we have any attribution).
|
|
854
|
+
const closer = change.actors && (change.actors.reject || change.actors.accept || change.actors.build);
|
|
855
|
+
if (closer) idCell.append(subtle("Closed by " + (closer.name || closer.email)));
|
|
856
|
+
const previewUrl = candidatePrUrl(appDomain, change.prNumber) || change.previewUrl;
|
|
857
|
+
if (previewUrl) {
|
|
858
|
+
const preview = document.createElement("a");
|
|
859
|
+
preview.href = previewUrl;
|
|
860
|
+
preview.target = "_blank";
|
|
861
|
+
preview.rel = "noopener";
|
|
862
|
+
preview.textContent = "Open preview";
|
|
863
|
+
const wrap = document.createElement("span");
|
|
864
|
+
wrap.className = "subtle";
|
|
865
|
+
wrap.append(preview);
|
|
866
|
+
idCell.append(wrap);
|
|
867
|
+
}
|
|
868
|
+
tr.append(idCell);
|
|
869
|
+
|
|
870
|
+
// Closed (relative time of the last projection update = when it was closed).
|
|
871
|
+
tr.append(cell(age(change.updatedAt)));
|
|
872
|
+
|
|
873
|
+
// Head sha.
|
|
874
|
+
const headCell = cell(shortSha(change.headSha));
|
|
875
|
+
headCell.classList.add("publish-mono");
|
|
876
|
+
tr.append(headCell);
|
|
877
|
+
|
|
878
|
+
closedBody.append(tr);
|
|
879
|
+
}
|
|
880
|
+
};
|
|
881
|
+
|
|
446
882
|
const renderCurrent = (current?: any, shippedBy?: any) => {
|
|
447
|
-
|
|
883
|
+
// r02: the "Live version" metric is a HUMAN label — "v42" linked to the live
|
|
884
|
+
// store — not a raw SHA. The SHA is demoted to a "Technical details"
|
|
885
|
+
// disclosure. The counter (current.versionNumber) is derived from the
|
|
886
|
+
// promotion history server-side; a version with no number (unpromoted seed)
|
|
887
|
+
// falls back to its short SHA rather than fabricating one.
|
|
888
|
+
const linkEl = root.querySelector("[data-publish-live-version-link]");
|
|
889
|
+
const noneEl = root.querySelector("[data-publish-live-version-none]");
|
|
890
|
+
const techEl = root.querySelector("[data-publish-live-tech]");
|
|
891
|
+
const shaEl = root.querySelector("[data-publish-live-sha]");
|
|
448
892
|
const sourceEl = root.querySelector("[data-publish-live-source]");
|
|
449
893
|
const atEl = root.querySelector("[data-publish-live-at]");
|
|
450
894
|
const shippedByEl = root.querySelector("[data-publish-live-shipped-by]");
|
|
451
895
|
if (current && current.versionId) {
|
|
452
|
-
|
|
896
|
+
const label =
|
|
897
|
+
current.versionNumber != null ? "v" + current.versionNumber : shortSha(current.versionId);
|
|
898
|
+
if (linkEl) {
|
|
899
|
+
linkEl.textContent = label;
|
|
900
|
+
linkEl.setAttribute("href", ds.liveHref || "/");
|
|
901
|
+
(linkEl as any).hidden = false;
|
|
902
|
+
}
|
|
903
|
+
if (noneEl) (noneEl as any).hidden = true;
|
|
904
|
+
if (shaEl) shaEl.textContent = shortSha(current.versionId);
|
|
905
|
+
if (techEl) (techEl as any).hidden = false;
|
|
453
906
|
if (sourceEl) {
|
|
454
907
|
sourceEl.textContent =
|
|
455
908
|
(current.source === "commit" ? "Published from a change" : "Published version") +
|
|
@@ -463,7 +916,13 @@ export function initAdminPublishTab(): void {
|
|
|
463
916
|
shippedByEl.textContent = shippedBy ? "Shipped by " + (shippedBy.name || shippedBy.email) : "";
|
|
464
917
|
}
|
|
465
918
|
} else {
|
|
466
|
-
if (
|
|
919
|
+
if (linkEl) (linkEl as any).hidden = true;
|
|
920
|
+
if (noneEl) {
|
|
921
|
+
noneEl.textContent = "Not live yet";
|
|
922
|
+
(noneEl as any).hidden = false;
|
|
923
|
+
}
|
|
924
|
+
if (techEl) (techEl as any).hidden = true;
|
|
925
|
+
if (shaEl) shaEl.textContent = "—";
|
|
467
926
|
if (sourceEl) sourceEl.textContent = "Nothing has been published for this store";
|
|
468
927
|
if (atEl) atEl.textContent = "—";
|
|
469
928
|
if (shippedByEl) shippedByEl.textContent = "";
|
|
@@ -505,6 +964,11 @@ export function initAdminPublishTab(): void {
|
|
|
505
964
|
c.mergeable,
|
|
506
965
|
c.built,
|
|
507
966
|
c.lastError || null,
|
|
967
|
+
c.title || null,
|
|
968
|
+
c.description || null,
|
|
969
|
+
c.intent || null,
|
|
970
|
+
c.verification || null,
|
|
971
|
+
c.riskRollback || null,
|
|
508
972
|
!!(c.evidence && c.evidence.promotable),
|
|
509
973
|
c.actors ? Object.keys(c.actors).sort().join(",") : "",
|
|
510
974
|
]);
|
|
@@ -569,8 +1033,10 @@ export function initAdminPublishTab(): void {
|
|
|
569
1033
|
lastChangesSignature = sig;
|
|
570
1034
|
renderCurrent(data.current, data.shippedBy);
|
|
571
1035
|
renderQueue(data);
|
|
1036
|
+
renderClosed(data);
|
|
572
1037
|
applyPrDeeplink();
|
|
573
1038
|
renderAggregate(data);
|
|
1039
|
+
renderHero(data);
|
|
574
1040
|
return "changed";
|
|
575
1041
|
};
|
|
576
1042
|
|
|
@@ -611,6 +1077,7 @@ export function initAdminPublishTab(): void {
|
|
|
611
1077
|
if (res.status === 401 || res.status === 403) {
|
|
612
1078
|
if (queueChip) queueChip.textContent = "Sign-in required";
|
|
613
1079
|
if (degradedStrip) degradedStrip.hidden = true;
|
|
1080
|
+
if (heroEl) (heroEl as any).hidden = true;
|
|
614
1081
|
queueBody.textContent = "";
|
|
615
1082
|
const tr = document.createElement("tr");
|
|
616
1083
|
const td = cell(subtle("Sign in as a store owner or admin to view the publish queue."));
|
|
@@ -626,8 +1093,10 @@ export function initAdminPublishTab(): void {
|
|
|
626
1093
|
}
|
|
627
1094
|
renderCurrent(data.current, data.shippedBy);
|
|
628
1095
|
renderQueue(data);
|
|
1096
|
+
renderClosed(data);
|
|
629
1097
|
applyPrDeeplink();
|
|
630
1098
|
renderAggregate(data);
|
|
1099
|
+
renderHero(data);
|
|
631
1100
|
// rux7: this is fresh authoritative data — resync the poll clock so the
|
|
632
1101
|
// next tick dedupes against it, and reset the cadence to responsive
|
|
633
1102
|
// (a manual refresh / a just-completed action likely means more coming).
|
|
@@ -659,6 +1128,16 @@ export function initAdminPublishTab(): void {
|
|
|
659
1128
|
red: "red",
|
|
660
1129
|
shipped: "green",
|
|
661
1130
|
};
|
|
1131
|
+
// r04 — the Next-release chip in merchant words; the raw queueState still drives
|
|
1132
|
+
// logic, only the label is humanized (no "integrating"/"green"/"red" jargon).
|
|
1133
|
+
const AGG_STATE_LABEL: Record<string, string> = {
|
|
1134
|
+
empty: "No changes yet",
|
|
1135
|
+
queued: "Waiting",
|
|
1136
|
+
integrating: "Building…",
|
|
1137
|
+
green: "Ready to publish",
|
|
1138
|
+
red: "Needs attention",
|
|
1139
|
+
shipped: "Published",
|
|
1140
|
+
};
|
|
662
1141
|
// The plan we last rendered — the exact-digest guard the Publish POST sends
|
|
663
1142
|
// back so a stale plan (queue moved / artifact changed) is refused, never
|
|
664
1143
|
// shipped blind.
|
|
@@ -748,15 +1227,74 @@ export function initAdminPublishTab(): void {
|
|
|
748
1227
|
if (retryRow) retryRow.hidden = state !== "red";
|
|
749
1228
|
if (retryBtn) retryBtn.disabled = aggregateRetrying;
|
|
750
1229
|
if (chipEl) {
|
|
751
|
-
|
|
752
|
-
chipEl
|
|
1230
|
+
const tone = AGG_STATE_CHIP[state] || "";
|
|
1231
|
+
paintChip(chipEl, AGG_STATE_LABEL[state] || state, tone, CHIP_GLYPH[tone]);
|
|
1232
|
+
}
|
|
1233
|
+
// r11 — the human release name (aggregate.name), shown beside "Next release".
|
|
1234
|
+
// Display-only: absent/null → hidden, and the section header reads as before.
|
|
1235
|
+
const nameEl = aggPanel.querySelector("[data-aggregate-name]");
|
|
1236
|
+
if (nameEl) {
|
|
1237
|
+
const releaseName = agg && agg.name;
|
|
1238
|
+
if (releaseName) {
|
|
1239
|
+
nameEl.textContent = releaseName;
|
|
1240
|
+
(nameEl as any).hidden = false;
|
|
1241
|
+
} else {
|
|
1242
|
+
nameEl.textContent = "";
|
|
1243
|
+
(nameEl as any).hidden = true;
|
|
1244
|
+
}
|
|
753
1245
|
}
|
|
754
1246
|
if (shaEl) shaEl.textContent = agg && agg.aggregateSha ? shortSha(agg.aggregateSha) : "—";
|
|
1247
|
+
// Build-link honesty — the card's own sha is always the last-GREEN
|
|
1248
|
+
// materialized sha (a red cycle leaves it untouched), so it's renderable
|
|
1249
|
+
// whenever present: opening it reaches the last-good preview even while the
|
|
1250
|
+
// current cycle is red/in-flight. When the aggregate has NEVER gone green
|
|
1251
|
+
// there is no renderable sha — surface "build failed — rebuild" or "no build
|
|
1252
|
+
// yet", never a link that 404s (rules 1 & 3).
|
|
1253
|
+
const cardSha = agg && agg.aggregateSha;
|
|
1254
|
+
const buildLink = classifyBuildLink({
|
|
1255
|
+
sha: cardSha,
|
|
1256
|
+
built: Boolean(cardSha),
|
|
1257
|
+
building: Boolean(agg && (agg.activeRun || agg.queueState === "integrating")),
|
|
1258
|
+
attempted: Boolean(agg && (agg.lastRun || agg.queueState === "red")),
|
|
1259
|
+
});
|
|
755
1260
|
if (runLinkEl) {
|
|
756
1261
|
runLinkEl.textContent = "";
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
else
|
|
1262
|
+
if (buildLink.available && agg && agg.revHref) {
|
|
1263
|
+
runLinkEl.append(runLink(agg.revHref, buildLink.label)!);
|
|
1264
|
+
} else if (buildLink.kind === "failed") {
|
|
1265
|
+
runLinkEl.append(document.createTextNode(buildLink.note + " "));
|
|
1266
|
+
if (canShip && buildLink.action) {
|
|
1267
|
+
const rebuild = document.createElement("button");
|
|
1268
|
+
rebuild.type = "button";
|
|
1269
|
+
rebuild.className = "btn btn-compact publish-cta-primed";
|
|
1270
|
+
rebuild.textContent = buildLink.action.label;
|
|
1271
|
+
rebuild.disabled = aggregateRetrying;
|
|
1272
|
+
rebuild.addEventListener("click", () => retryBuild(rebuild));
|
|
1273
|
+
runLinkEl.append(rebuild);
|
|
1274
|
+
}
|
|
1275
|
+
} else {
|
|
1276
|
+
runLinkEl.append(document.createTextNode(buildLink.note));
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
// "Open preview" next to the Next-release header — a one-click hop to the
|
|
1280
|
+
// current preview build. Same honesty: shown only when a genuinely-built
|
|
1281
|
+
// revision exists, so it can never lead to a 404.
|
|
1282
|
+
const openPreviewEl = aggPanel.querySelector("[data-aggregate-open-preview]");
|
|
1283
|
+
if (openPreviewEl) {
|
|
1284
|
+
if (buildLink.available && agg && agg.revHref) {
|
|
1285
|
+
openPreviewEl.setAttribute("href", agg.revHref);
|
|
1286
|
+
(openPreviewEl as any).hidden = false;
|
|
1287
|
+
} else {
|
|
1288
|
+
(openPreviewEl as any).hidden = true;
|
|
1289
|
+
}
|
|
1290
|
+
// The Next-release header is now a <summary> (collapsible panel). This link
|
|
1291
|
+
// lives inside it, so a click would ALSO toggle the panel — stop that so
|
|
1292
|
+
// "Open preview" only opens the preview. Idempotent-guarded so re-renders
|
|
1293
|
+
// don't stack listeners.
|
|
1294
|
+
if (!(openPreviewEl as any).__noToggleBound) {
|
|
1295
|
+
openPreviewEl.addEventListener("click", (e: Event) => e.stopPropagation());
|
|
1296
|
+
(openPreviewEl as any).__noToggleBound = true;
|
|
1297
|
+
}
|
|
760
1298
|
}
|
|
761
1299
|
|
|
762
1300
|
// Combined evidence = the last (or in-flight) integration run's honest state.
|
|
@@ -785,10 +1323,18 @@ export function initAdminPublishTab(): void {
|
|
|
785
1323
|
else if (lastRun.state === "failed") parts.push("Combined evidence did not pass.");
|
|
786
1324
|
if (lastRun.finishedAt) parts.push(age(lastRun.finishedAt));
|
|
787
1325
|
evDetailEl.append(document.createTextNode(parts.join(" · ")));
|
|
788
|
-
|
|
789
|
-
|
|
1326
|
+
// A run's sha is renderable IFF that run PASSED — a failed run's sha never
|
|
1327
|
+
// materialized, so linking it 404s (the exact Open-Build trap). Link only a
|
|
1328
|
+
// passed run; a failed run shows its rebuild affordance via runLinkEl above.
|
|
1329
|
+
const runBuild = classifyBuildLink({
|
|
1330
|
+
sha: lastRun.aggregateSha,
|
|
1331
|
+
built: lastRun.state === "passed",
|
|
1332
|
+
building: lastRun.state === "running" || lastRun.state === "pending",
|
|
1333
|
+
attempted: true,
|
|
1334
|
+
});
|
|
1335
|
+
if (runBuild.available && lastRun.revHref) {
|
|
790
1336
|
evDetailEl.append(document.createTextNode(" · "));
|
|
791
|
-
evDetailEl.append(
|
|
1337
|
+
evDetailEl.append(runLink(lastRun.revHref, "Run build")!);
|
|
792
1338
|
}
|
|
793
1339
|
} else {
|
|
794
1340
|
evDetailEl.append(document.createTextNode("The last integration run's verdict."));
|
|
@@ -877,53 +1423,114 @@ export function initAdminPublishTab(): void {
|
|
|
877
1423
|
}
|
|
878
1424
|
}
|
|
879
1425
|
|
|
1426
|
+
// Merges what used to be two separate "Included changes" lists (this staged-
|
|
1427
|
+
// delta table + the Publish plan's own duplicate <details> list) into ONE:
|
|
1428
|
+
// `agg.includedPrs` is already server-enriched with the same title/description/
|
|
1429
|
+
// fileCount/lines/diff the plan's list used (see enrichIncludedPrs in
|
|
1430
|
+
// api/changes/index.ts) — no separate cross-reference against `plan.includedPrs`
|
|
1431
|
+
// is needed here, the row already carries everything.
|
|
880
1432
|
if (prsList) {
|
|
881
1433
|
prsList.textContent = "";
|
|
882
1434
|
for (const pr of prs) {
|
|
883
|
-
const
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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);
|
|
1435
|
+
const tr = document.createElement("tr");
|
|
1436
|
+
tr.className = "agg-included-row";
|
|
1437
|
+
|
|
1438
|
+
// Change: PR # + title (+ description underneath, + diff disclosure if any).
|
|
1439
|
+
const changeCell = document.createElement("td");
|
|
1440
|
+
const prEl = document.createElement("div");
|
|
1441
|
+
prEl.className = "agg-included-pr";
|
|
1442
|
+
prEl.textContent = pr.prNumber != null ? "PR #" + pr.prNumber : pr.changeId;
|
|
1443
|
+
changeCell.append(prEl);
|
|
896
1444
|
if (pr.title) {
|
|
897
|
-
const
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1445
|
+
const t = document.createElement("div");
|
|
1446
|
+
t.className = "agg-included-title";
|
|
1447
|
+
t.textContent = pr.title;
|
|
1448
|
+
changeCell.append(t);
|
|
1449
|
+
}
|
|
1450
|
+
const description = pr.description || pr.summary;
|
|
1451
|
+
if (description) {
|
|
1452
|
+
const desc = document.createElement("p");
|
|
1453
|
+
desc.className = "agg-included-desc";
|
|
1454
|
+
desc.textContent = description;
|
|
1455
|
+
changeCell.append(desc);
|
|
901
1456
|
}
|
|
902
|
-
|
|
1457
|
+
// A trivial change carries its own bounded diff — offer it in a "View diff"
|
|
1458
|
+
// disclosure (for a tiny change the diff IS the summary). Lines are colored
|
|
1459
|
+
// by their leading +/- marker.
|
|
1460
|
+
if (Array.isArray(pr.diff) && pr.diff.length) {
|
|
1461
|
+
const details = document.createElement("details");
|
|
1462
|
+
details.className = "agg-diff";
|
|
1463
|
+
const summaryEl = document.createElement("summary");
|
|
1464
|
+
summaryEl.textContent = "View diff";
|
|
1465
|
+
details.append(summaryEl);
|
|
1466
|
+
for (const file of pr.diff) {
|
|
1467
|
+
const fileEl = document.createElement("div");
|
|
1468
|
+
fileEl.className = "agg-diff-file";
|
|
1469
|
+
const path = document.createElement("div");
|
|
1470
|
+
path.className = "agg-diff-path publish-mono";
|
|
1471
|
+
path.textContent = file.path;
|
|
1472
|
+
fileEl.append(path);
|
|
1473
|
+
if (file.patch) {
|
|
1474
|
+
const pre = document.createElement("pre");
|
|
1475
|
+
pre.className = "agg-diff-patch publish-mono";
|
|
1476
|
+
for (const line of String(file.patch).split("\n")) {
|
|
1477
|
+
const ln = document.createElement("span");
|
|
1478
|
+
ln.className =
|
|
1479
|
+
line.startsWith("+") && !line.startsWith("+++") ? "agg-diff-add"
|
|
1480
|
+
: line.startsWith("-") && !line.startsWith("---") ? "agg-diff-del"
|
|
1481
|
+
: line.startsWith("@@") ? "agg-diff-hunk" : "";
|
|
1482
|
+
ln.textContent = line + "\n";
|
|
1483
|
+
pre.append(ln);
|
|
1484
|
+
}
|
|
1485
|
+
fileEl.append(pre);
|
|
1486
|
+
} else {
|
|
1487
|
+
const binary = document.createElement("div");
|
|
1488
|
+
binary.className = "subtle";
|
|
1489
|
+
binary.textContent = "(no line-level diff available)";
|
|
1490
|
+
fileEl.append(binary);
|
|
1491
|
+
}
|
|
1492
|
+
details.append(fileEl);
|
|
1493
|
+
}
|
|
1494
|
+
changeCell.append(details);
|
|
1495
|
+
}
|
|
1496
|
+
tr.append(changeCell);
|
|
903
1497
|
|
|
904
|
-
//
|
|
905
|
-
|
|
906
|
-
const line2 = document.createElement("div");
|
|
907
|
-
line2.className = "agg-change-line agg-change-sub";
|
|
1498
|
+
// Head sha.
|
|
1499
|
+
const shaCell = document.createElement("td");
|
|
908
1500
|
const sha = document.createElement("span");
|
|
909
1501
|
sha.className = "publish-mono sha-token";
|
|
910
1502
|
sha.textContent = shortSha(pr.headSha);
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
1503
|
+
shaCell.append(sha);
|
|
1504
|
+
tr.append(shaCell);
|
|
1505
|
+
|
|
1506
|
+
// Files changed.
|
|
1507
|
+
const filesCell = document.createElement("td");
|
|
1508
|
+
const fileCount = pr.fileCount != null ? pr.fileCount : pr.filesChanged;
|
|
1509
|
+
filesCell.textContent = fileCount != null ? String(fileCount) : "—";
|
|
1510
|
+
tr.append(filesCell);
|
|
1511
|
+
|
|
1512
|
+
// Lines added/removed (green/red).
|
|
1513
|
+
const linesCell = document.createElement("td");
|
|
1514
|
+
if (typeof pr.linesAdded === "number" || typeof pr.linesRemoved === "number") {
|
|
1515
|
+
linesCell.append(linesNode(pr.linesAdded, pr.linesRemoved));
|
|
1516
|
+
} else {
|
|
1517
|
+
linesCell.textContent = "—";
|
|
920
1518
|
}
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
1519
|
+
tr.append(linesCell);
|
|
1520
|
+
|
|
1521
|
+
// Integrated when/by.
|
|
1522
|
+
const whenCell = document.createElement("td");
|
|
1523
|
+
const bits = [];
|
|
1524
|
+
if (pr.integratedAt) bits.push("added " + age(pr.integratedAt));
|
|
1525
|
+
if (pr.integratedBy && pr.integratedBy.email) bits.push("by " + pr.integratedBy.email);
|
|
1526
|
+
whenCell.textContent = bits.length ? bits.join(" ") : "—";
|
|
1527
|
+
tr.append(whenCell);
|
|
1528
|
+
|
|
1529
|
+
// Actions: u14 Undo — per-change revert: roll THIS change OUT of the staged
|
|
1530
|
+
// preview (a revert commit + rebuild); the live site is untouched. Only for
|
|
1531
|
+
// a PR-numbered, still-ahead-of-live change; gated on ship capability.
|
|
1532
|
+
const actionsCell = document.createElement("td");
|
|
1533
|
+
actionsCell.className = "action";
|
|
927
1534
|
if (canShip && pr.prNumber != null && !matchesLive) {
|
|
928
1535
|
const undo = document.createElement("button");
|
|
929
1536
|
undo.type = "button";
|
|
@@ -932,35 +1539,80 @@ export function initAdminPublishTab(): void {
|
|
|
932
1539
|
undo.title =
|
|
933
1540
|
"Revert PR #" + pr.prNumber + " out of the staged preview (your live site is untouched)";
|
|
934
1541
|
undo.addEventListener("click", () => revertStaged(pr, undo));
|
|
935
|
-
|
|
1542
|
+
actionsCell.append(undo);
|
|
936
1543
|
}
|
|
937
|
-
|
|
1544
|
+
tr.append(actionsCell);
|
|
1545
|
+
|
|
1546
|
+
prsList.append(tr);
|
|
938
1547
|
}
|
|
939
1548
|
}
|
|
940
1549
|
|
|
941
1550
|
renderPlan(data.plan);
|
|
942
1551
|
|
|
943
1552
|
// 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.
|
|
1553
|
+
// a viewer who can ship. A paywalled plan is SHOWN (upsell), never hidden. Bug 3
|
|
1554
|
+
// publish-safety: computePublishGuard ALSO hard-blocks a red aggregate (the
|
|
1555
|
+
// latest rebuild failed) regardless of a possibly-stale shippable plan, and
|
|
1556
|
+
// surfaces a "serving stale build" indicator — see publish-guard.ts.
|
|
1557
|
+
const servingBuildAtLabel = (() => {
|
|
1558
|
+
const iso = data.current && data.current.createdAt;
|
|
1559
|
+
if (!iso) return null;
|
|
1560
|
+
const d = new Date(iso);
|
|
1561
|
+
return Number.isNaN(d.getTime()) ? null : d.toLocaleString();
|
|
1562
|
+
})();
|
|
1563
|
+
const guard = computePublishGuard({
|
|
1564
|
+
queueState: (agg && agg.queueState) || null,
|
|
1565
|
+
aggregatePublishing,
|
|
1566
|
+
canShip,
|
|
1567
|
+
shippable: Boolean(currentPlan),
|
|
1568
|
+
paywalled: Boolean(currentPlan && currentPlan.paywall && !currentPlan.paywall.allowed),
|
|
1569
|
+
alreadyShipped: Boolean(currentPlan && currentPlan.alreadyShipped),
|
|
1570
|
+
aheadCount,
|
|
1571
|
+
servingBuildAtLabel,
|
|
1572
|
+
});
|
|
945
1573
|
if (publishBtn) {
|
|
946
|
-
const shippable = Boolean(currentPlan);
|
|
947
|
-
const paywalled = currentPlan && currentPlan.paywall && !currentPlan.paywall.allowed;
|
|
948
1574
|
// 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
|
-
|
|
951
|
-
|
|
952
|
-
publishBtn.title =
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1575
|
+
// a poll refresh landing mid-publish must not open a double-submit (folded
|
|
1576
|
+
// into the guard's `aggregatePublishing` input).
|
|
1577
|
+
publishBtn.disabled = guard.disabled;
|
|
1578
|
+
publishBtn.title = guard.title;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
// The collapsed destination panel answers the release question in its
|
|
1582
|
+
// summary: green only when the staged release is already live; otherwise
|
|
1583
|
+
// expose the action that advances live to that release. Public-site parity
|
|
1584
|
+
// with the *old* live pointer must never paint this summary green.
|
|
1585
|
+
const liveReleaseState: any = root.querySelector("[data-live-release-state]");
|
|
1586
|
+
const liveReleasePublish: any = root.querySelector("[data-live-release-publish]");
|
|
1587
|
+
if (liveReleaseState && liveReleasePublish) {
|
|
1588
|
+
const liveReleaseView = liveReleaseSummaryState({
|
|
1589
|
+
matchesLive,
|
|
1590
|
+
hasStagedRelease: Boolean(stagedSha),
|
|
1591
|
+
publishDisabled: guard.disabled,
|
|
1592
|
+
publishTitle: guard.title,
|
|
1593
|
+
});
|
|
1594
|
+
if (liveReleaseView.kind === "current") {
|
|
1595
|
+
paintChip(liveReleaseState, liveReleaseView.label, "green", CHIP_GLYPH.green);
|
|
1596
|
+
liveReleaseState.hidden = false;
|
|
1597
|
+
liveReleasePublish.hidden = true;
|
|
1598
|
+
} else if (liveReleaseView.kind === "publish") {
|
|
1599
|
+
liveReleaseState.hidden = true;
|
|
1600
|
+
liveReleasePublish.hidden = false;
|
|
1601
|
+
liveReleasePublish.textContent = liveReleaseView.label;
|
|
1602
|
+
liveReleasePublish.disabled = liveReleaseView.disabled;
|
|
1603
|
+
liveReleasePublish.title = liveReleaseView.title;
|
|
1604
|
+
} else {
|
|
1605
|
+
paintChip(liveReleaseState, liveReleaseView.label, "", "");
|
|
1606
|
+
liveReleaseState.hidden = false;
|
|
1607
|
+
liveReleasePublish.hidden = true;
|
|
1608
|
+
}
|
|
961
1609
|
}
|
|
962
1610
|
if (publishDetail) {
|
|
963
|
-
if (
|
|
1611
|
+
if (guard.staleIndicator) {
|
|
1612
|
+
// A failed rebuild is holding new work back — lead with the honest stale
|
|
1613
|
+
// notice instead of the normal "publishes the exact build" copy.
|
|
1614
|
+
publishDetail.textContent = guard.staleIndicator;
|
|
1615
|
+
} else if (currentPlan && currentPlan.alreadyShipped) {
|
|
964
1616
|
publishDetail.textContent =
|
|
965
1617
|
"This reviewed build (" + shortSha(currentPlan.pinnedSha) + ") is already live. ";
|
|
966
1618
|
const view = document.createElement("a");
|
|
@@ -970,12 +1622,13 @@ export function initAdminPublishTab(): void {
|
|
|
970
1622
|
view.textContent = "View your live store →";
|
|
971
1623
|
publishDetail.append(view);
|
|
972
1624
|
} else if (currentPlan) {
|
|
973
|
-
publishDetail.textContent =
|
|
974
|
-
"Publishes the exact reviewed
|
|
975
|
-
|
|
1625
|
+
publishDetail.textContent = publicSiteHost
|
|
1626
|
+
? "Publishes the exact build you reviewed (" + shortSha(currentPlan.pinnedSha) +
|
|
1627
|
+
") to the hosted store immediately, then automatically republishes " + publicSiteHost + "."
|
|
1628
|
+
: "Publishes the exact build you reviewed (" + shortSha(currentPlan.pinnedSha) + ") — no rebuild.";
|
|
976
1629
|
} else {
|
|
977
1630
|
publishDetail.textContent =
|
|
978
|
-
"Publishes the exact reviewed
|
|
1631
|
+
"Publishes the exact build you reviewed — no rebuild. Available once your next release is ready and its checks pass.";
|
|
979
1632
|
}
|
|
980
1633
|
}
|
|
981
1634
|
|
|
@@ -996,133 +1649,9 @@ export function initAdminPublishTab(): void {
|
|
|
996
1649
|
}
|
|
997
1650
|
};
|
|
998
1651
|
|
|
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
1652
|
const renderPlan = (plan?: any) => {
|
|
1123
1653
|
const planBox = aggPanel && aggPanel.querySelector("[data-aggregate-plan]");
|
|
1124
1654
|
if (!planBox) return;
|
|
1125
|
-
const includedWrap = planBox.querySelector("[data-aggregate-plan-included]");
|
|
1126
1655
|
const chipEl = planBox.querySelector("[data-aggregate-plan-chip]");
|
|
1127
1656
|
const grid = planBox.querySelector("[data-aggregate-plan-grid]");
|
|
1128
1657
|
const note = planBox.querySelector("[data-aggregate-plan-note]");
|
|
@@ -1131,26 +1660,22 @@ export function initAdminPublishTab(): void {
|
|
|
1131
1660
|
// No plan at all (degenerate: no store bound) — hide the box.
|
|
1132
1661
|
if (!plan) {
|
|
1133
1662
|
planBox.hidden = true;
|
|
1134
|
-
if (includedWrap) includedWrap.hidden = true;
|
|
1135
1663
|
return;
|
|
1136
1664
|
}
|
|
1137
1665
|
planBox.hidden = false;
|
|
1138
1666
|
|
|
1139
1667
|
if (!plan.ok) {
|
|
1140
|
-
// An honest refusal — show WHY it isn't
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
chipEl.className = "chip amber";
|
|
1144
|
-
}
|
|
1145
|
-
if (note) note.textContent = plan.message || ("Not shippable (" + (plan.reason || "refused") + ").");
|
|
1146
|
-
if (includedWrap) includedWrap.hidden = true;
|
|
1668
|
+
// An honest refusal — show WHY it isn't ready, never a fake plan.
|
|
1669
|
+
paintChip(chipEl, "Not ready to publish", "amber", CHIP_GLYPH.amber);
|
|
1670
|
+
if (note) note.textContent = plan.message || ("Not ready to publish (" + (plan.reason || "refused") + ").");
|
|
1147
1671
|
return;
|
|
1148
1672
|
}
|
|
1149
1673
|
|
|
1150
1674
|
const paywalled = plan.paywall && !plan.paywall.allowed;
|
|
1151
1675
|
if (chipEl) {
|
|
1152
|
-
|
|
1153
|
-
|
|
1676
|
+
const label = plan.alreadyShipped ? "Already live" : paywalled ? "Upgrade required" : "Ready to publish";
|
|
1677
|
+
const tone = plan.alreadyShipped ? "green" : paywalled ? "amber" : "green";
|
|
1678
|
+
paintChip(chipEl, label, tone, CHIP_GLYPH[tone]);
|
|
1154
1679
|
}
|
|
1155
1680
|
|
|
1156
1681
|
const row = (term?: any, value?: any, mono?: any) => {
|
|
@@ -1181,16 +1706,15 @@ export function initAdminPublishTab(): void {
|
|
|
1181
1706
|
row("Rollback to", "None (first publish)");
|
|
1182
1707
|
}
|
|
1183
1708
|
row("Subscription", plan.paywall && plan.paywall.allowed ? "Enabled" : "Not enabled");
|
|
1184
|
-
|
|
1185
|
-
//
|
|
1186
|
-
renderIncludedChanges(planBox, plan.includedPrs || []);
|
|
1709
|
+
// Included changes: rendered ONCE, in the staged-delta table above (prsList) —
|
|
1710
|
+
// the plan's own includedPrs is the same canonical set, so no second list here.
|
|
1187
1711
|
|
|
1188
1712
|
if (note) {
|
|
1189
1713
|
note.textContent = paywalled
|
|
1190
1714
|
? (plan.paywall.message || "Publishing to your live store requires the paid plan.")
|
|
1191
1715
|
: plan.alreadyShipped
|
|
1192
1716
|
? "This reviewed build is already live — nothing to publish."
|
|
1193
|
-
: "Publish sends the exact
|
|
1717
|
+
: "Publish sends the exact build you reviewed to your live store. No rebuild.";
|
|
1194
1718
|
}
|
|
1195
1719
|
};
|
|
1196
1720
|
|
|
@@ -1211,24 +1735,69 @@ export function initAdminPublishTab(): void {
|
|
|
1211
1735
|
plan: shipoff.querySelector("[data-shipoff-plan]"),
|
|
1212
1736
|
flight: shipoff.querySelector("[data-shipoff-flight]"),
|
|
1213
1737
|
outcome: shipoff.querySelector("[data-shipoff-outcome]"),
|
|
1738
|
+
log: shipoff.querySelector("[data-shipoff-log]"),
|
|
1214
1739
|
grid: shipoff.querySelector("[data-shipoff-grid]"),
|
|
1215
1740
|
proceed: shipoff.querySelector("[data-shipoff-proceed]"),
|
|
1216
1741
|
cancel: shipoff.querySelector("[data-shipoff-cancel]"),
|
|
1217
1742
|
live: shipoff.querySelector("[data-shipoff-live]"),
|
|
1218
1743
|
elapsed: shipoff.querySelector("[data-shipoff-elapsed]"),
|
|
1744
|
+
hide: shipoff.querySelector("[data-shipoff-hide]"),
|
|
1219
1745
|
seal: shipoff.querySelector("[data-shipoff-seal]"),
|
|
1220
1746
|
outcomeTitle: shipoff.querySelector("[data-shipoff-outcome-title]"),
|
|
1221
1747
|
outcomeDetail: shipoff.querySelector("[data-shipoff-outcome-detail]"),
|
|
1748
|
+
failure: shipoff.querySelector("[data-shipoff-failure]"),
|
|
1749
|
+
failureDetail: shipoff.querySelector("[data-shipoff-failure-detail]"),
|
|
1750
|
+
copy: shipoff.querySelector("[data-shipoff-copy]"),
|
|
1222
1751
|
view: shipoff.querySelector("[data-shipoff-view]"),
|
|
1752
|
+
undo: shipoff.querySelector("[data-shipoff-undo]"),
|
|
1223
1753
|
done: shipoff.querySelector("[data-shipoff-done]"),
|
|
1224
1754
|
confetti: shipoff.querySelector("[data-shipoff-confetti]"),
|
|
1225
1755
|
}
|
|
1226
1756
|
: null;
|
|
1227
1757
|
let shipoffTimer: any = null;
|
|
1758
|
+
// r12 — the element that opened the publish ceremony, so closing returns focus
|
|
1759
|
+
// to it (the Publish button or the hero CTA) rather than dropping it to <body>.
|
|
1760
|
+
let shipoffOpenedFrom: any = null;
|
|
1761
|
+
const shipLog = makeLogConsole(shipoffEls && shipoffEls.log);
|
|
1762
|
+
let shipFailureCopyText = "";
|
|
1763
|
+
// A paste-ready brief for a coding agent when a publish-live did not complete —
|
|
1764
|
+
// the honest reason + the pinned build the human was shipping (mirrors
|
|
1765
|
+
// assembleBuildFailureCopy's framing).
|
|
1766
|
+
const assembleShipFailureCopy = (plan?: any, data?: any, message?: string) => {
|
|
1767
|
+
const tenant = appDomain || repo;
|
|
1768
|
+
return [
|
|
1769
|
+
"In my " + tenant + " store, publishing the reviewed build " +
|
|
1770
|
+
(plan && plan.pinnedSha ? shortSha(plan.pinnedSha) + " " : "") +
|
|
1771
|
+
"to my live store did not complete. Diagnose why from the detail below, then tell me the exact fix.",
|
|
1772
|
+
"",
|
|
1773
|
+
"What publish reported:",
|
|
1774
|
+
message || "(no detail was reported)",
|
|
1775
|
+
data && data.state ? "\nOutcome state: " + data.state : "",
|
|
1776
|
+
data && data.reason ? "Reason: " + data.reason : "",
|
|
1777
|
+
"\nContext: this is a Token of Trust storefront tenant. Go-live pins the exact reviewed " +
|
|
1778
|
+
"aggregate digest and promotes it to the live channel — it never rebuilds. A refusal " +
|
|
1779
|
+
"(not_green / paywall / sha_mismatch / bundle_unstaged) leaves the live store unchanged.",
|
|
1780
|
+
]
|
|
1781
|
+
.filter((l) => l !== "")
|
|
1782
|
+
.join("\n");
|
|
1783
|
+
};
|
|
1784
|
+
if (shipoffEls && shipoffEls.copy) {
|
|
1785
|
+
shipoffEls.copy.addEventListener("click", () => {
|
|
1786
|
+
navigator.clipboard.writeText(shipFailureCopyText).then(
|
|
1787
|
+
() => {
|
|
1788
|
+
shipoffEls.copy.textContent = "Copied";
|
|
1789
|
+
setTimeout(() => { shipoffEls.copy.textContent = "Copy for your AI agent"; }, 1600);
|
|
1790
|
+
},
|
|
1791
|
+
() => { /* clipboard denied — the detail is selectable text */ },
|
|
1792
|
+
);
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1228
1795
|
const closeShipoff = () => {
|
|
1229
1796
|
if (shipoffTimer) { clearInterval(shipoffTimer); shipoffTimer = null; }
|
|
1230
1797
|
if (shipoff) shipoff.hidden = true;
|
|
1231
1798
|
if (shipoffEls) shipoffEls.confetti.textContent = "";
|
|
1799
|
+
if (shipoffOpenedFrom && document.contains(shipoffOpenedFrom)) shipoffOpenedFrom.focus();
|
|
1800
|
+
shipoffOpenedFrom = null;
|
|
1232
1801
|
};
|
|
1233
1802
|
const showShipoffPhase = (phase?: any) => {
|
|
1234
1803
|
if (!shipoffEls) return;
|
|
@@ -1249,14 +1818,10 @@ export function initAdminPublishTab(): void {
|
|
|
1249
1818
|
return lines.join("\n");
|
|
1250
1819
|
};
|
|
1251
1820
|
|
|
1252
|
-
const shortDigest = (digest?: any) => {
|
|
1253
|
-
const d = String(digest || "").replace(/^sha256:/, "");
|
|
1254
|
-
return d ? "sha256:" + d.slice(0, 12) + "…" : "—";
|
|
1255
|
-
};
|
|
1256
|
-
|
|
1257
1821
|
const publishAggregate = (button?: any) => {
|
|
1258
1822
|
if (!currentPlan) return;
|
|
1259
1823
|
const plan = currentPlan;
|
|
1824
|
+
shipoffOpenedFrom = button || null; // r12: return focus here on close
|
|
1260
1825
|
if (!shipoff || !shipoffEls) {
|
|
1261
1826
|
// No-markup fallback only — the ceremony IS the confirm now.
|
|
1262
1827
|
if (!window.confirm(buildPublishPlanText(plan) + "\n\nPublish live?")) return;
|
|
@@ -1288,12 +1853,17 @@ export function initAdminPublishTab(): void {
|
|
|
1288
1853
|
const startedAt = Date.now();
|
|
1289
1854
|
if (shipoffEls) {
|
|
1290
1855
|
showShipoffPhase("flight");
|
|
1856
|
+
shipLog.reset();
|
|
1857
|
+
if (shipoffEls.failure) shipoffEls.failure.hidden = true;
|
|
1858
|
+
shipoffEls.live.textContent = "Pinning the exact reviewed build to your live channel…";
|
|
1859
|
+
shipoffEls.elapsed.textContent = "0:00";
|
|
1860
|
+
if (shipoffEls.hide) shipoffEls.hide.onclick = () => { shipoff.hidden = true; }; // stream keeps running
|
|
1291
1861
|
shipoffTimer = setInterval(() => {
|
|
1292
1862
|
const s = Math.floor((Date.now() - startedAt) / 1000);
|
|
1293
1863
|
shipoffEls.elapsed.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
|
|
1294
1864
|
}, 1000);
|
|
1295
1865
|
}
|
|
1296
|
-
const outcome = (state?: any, title?: any, detail?: any, showView?: any) => {
|
|
1866
|
+
const outcome = (state?: any, title?: any, detail?: any, showView?: any, failureDetail?: any) => {
|
|
1297
1867
|
if (!shipoffEls) return;
|
|
1298
1868
|
if (shipoffTimer) { clearInterval(shipoffTimer); shipoffTimer = null; }
|
|
1299
1869
|
shipoffEls.seal.dataset.state = state;
|
|
@@ -1301,6 +1871,20 @@ export function initAdminPublishTab(): void {
|
|
|
1301
1871
|
shipoffEls.outcomeDetail.textContent = detail;
|
|
1302
1872
|
shipoffEls.view.hidden = !showView;
|
|
1303
1873
|
if (showView) shipoffEls.view.href = ds.liveHref || "/";
|
|
1874
|
+
// r10: the Undo affordance is wired only on a successful ship (below); reset
|
|
1875
|
+
// it hidden for every outcome so a failure never shows a stale Undo.
|
|
1876
|
+
if (shipoffEls.undo) shipoffEls.undo.hidden = true;
|
|
1877
|
+
// A failure gets the honest detail + a "Copy for your AI agent" brief (same
|
|
1878
|
+
// affordance as the build flight); a clean success hides it.
|
|
1879
|
+
if (shipoffEls.failure) {
|
|
1880
|
+
if (failureDetail) {
|
|
1881
|
+
shipoffEls.failure.hidden = false;
|
|
1882
|
+
shipoffEls.failureDetail.textContent = failureDetail;
|
|
1883
|
+
} else {
|
|
1884
|
+
shipoffEls.failure.hidden = true;
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
if (shipoff.hidden) shipoff.hidden = false; // re-surface if backgrounded
|
|
1304
1888
|
showShipoffPhase("outcome");
|
|
1305
1889
|
shipoffEls.done.focus();
|
|
1306
1890
|
if (state === "ok") celebrate(shipoffEls.confetti);
|
|
@@ -1308,7 +1892,13 @@ export function initAdminPublishTab(): void {
|
|
|
1308
1892
|
try {
|
|
1309
1893
|
const res = await fetch(ds.shipEndpoint, {
|
|
1310
1894
|
method: "POST",
|
|
1311
|
-
headers: {
|
|
1895
|
+
headers: {
|
|
1896
|
+
"content-type": "application/json",
|
|
1897
|
+
// Real-time server-pushed progress (pin → promote → verify → record) over
|
|
1898
|
+
// SSE; falls back to buffered JSON for a non-streaming proxy.
|
|
1899
|
+
accept: "text/event-stream",
|
|
1900
|
+
"x-tot-capability": "ship-on-behalf",
|
|
1901
|
+
},
|
|
1312
1902
|
body: JSON.stringify({
|
|
1313
1903
|
aggregateId: plan.aggregateId,
|
|
1314
1904
|
// The HUMAN go-live gate + the reviewed-digest guards (b09 ship input).
|
|
@@ -1317,7 +1907,7 @@ export function initAdminPublishTab(): void {
|
|
|
1317
1907
|
expectedArtifactDigest: plan.artifactDigest,
|
|
1318
1908
|
}),
|
|
1319
1909
|
});
|
|
1320
|
-
const data = await res
|
|
1910
|
+
const { data } = await streamWithLog(res, shipLog);
|
|
1321
1911
|
// b09 AggregateShipResult: the ONLY live claim is state === "shipped".
|
|
1322
1912
|
if (data.state === "shipped") {
|
|
1323
1913
|
setStatus(
|
|
@@ -1325,13 +1915,26 @@ export function initAdminPublishTab(): void {
|
|
|
1325
1915
|
"Published live · build " + shortSha(data.pinnedSha) + " (receipt " + (data.receiptId || "—") + ").",
|
|
1326
1916
|
"ok",
|
|
1327
1917
|
);
|
|
1918
|
+
void autoSweepBranches();
|
|
1328
1919
|
outcome(
|
|
1329
1920
|
"ok",
|
|
1330
1921
|
"Your store is live",
|
|
1331
|
-
"Build " + shortSha(data.pinnedSha) + " —
|
|
1922
|
+
"Build " + shortSha(data.pinnedSha) + " — exactly what you reviewed — is now serving" +
|
|
1332
1923
|
(data.receiptId ? " (receipt " + data.receiptId + ")." : "."),
|
|
1333
1924
|
true,
|
|
1334
1925
|
);
|
|
1926
|
+
// r10: right after publishing, offer a one-click Undo that jumps to the
|
|
1927
|
+
// rollback row for the version this release just REPLACED (the plan's
|
|
1928
|
+
// rollback target = the prior live version), pre-targeted. Reuses the Live
|
|
1929
|
+
// Site rollback action; only offered when there IS a prior version.
|
|
1930
|
+
const undoTarget = plan.rollbackTarget && plan.rollbackTarget.aggregateSha;
|
|
1931
|
+
if (shipoffEls && shipoffEls.undo && undoTarget) {
|
|
1932
|
+
shipoffEls.undo.hidden = false;
|
|
1933
|
+
shipoffEls.undo.onclick = () => {
|
|
1934
|
+
closeShipoff();
|
|
1935
|
+
focusRollbackTarget(undoTarget);
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1335
1938
|
} else {
|
|
1336
1939
|
let message;
|
|
1337
1940
|
if (data.state === "promote_failed" || data.state === "record_failed") {
|
|
@@ -1340,10 +1943,15 @@ export function initAdminPublishTab(): void {
|
|
|
1340
1943
|
} else if (data.reason === "paywall") {
|
|
1341
1944
|
message = data.message || "Publishing to your live store is part of the paid plan.";
|
|
1342
1945
|
} else {
|
|
1343
|
-
message = data.message || data.error || "Publish refused (" + (data.reason || res.status) + ")";
|
|
1946
|
+
message = data.message || data.error || "Publish refused (" + (data.reason || data.status || res.status) + ")";
|
|
1344
1947
|
}
|
|
1345
1948
|
setStatus(aggStatusEl, message, "err");
|
|
1346
|
-
|
|
1949
|
+
shipFailureCopyText = assembleShipFailureCopy(plan, data, message);
|
|
1950
|
+
outcome("err", "Publish did not complete", message + " Your live store is unchanged.", false, message);
|
|
1951
|
+
// C5 — a refused/failed publish is an aggregate-level event (most tellingly
|
|
1952
|
+
// "not green"); append the health-banner roll-up of what's keeping it red to
|
|
1953
|
+
// the outcome detail (best-effort, additive).
|
|
1954
|
+
surfaceDoctorHint((p) => shipFailureDoctor(p), [shipoffEls && shipoffEls.outcomeDetail, aggStatusEl]);
|
|
1347
1955
|
}
|
|
1348
1956
|
} catch {
|
|
1349
1957
|
setStatus(aggStatusEl, "Publish failed — network error", "err");
|
|
@@ -1365,9 +1973,38 @@ export function initAdminPublishTab(): void {
|
|
|
1365
1973
|
aggPanel
|
|
1366
1974
|
?.querySelector("[data-aggregate-publish]")
|
|
1367
1975
|
?.addEventListener("click", (event: any) => publishAggregate(event.currentTarget));
|
|
1976
|
+
root
|
|
1977
|
+
.querySelector("[data-live-release-publish]")
|
|
1978
|
+
?.addEventListener("click", (event: any) => {
|
|
1979
|
+
// This action lives inside <summary>; publishing must not also toggle the
|
|
1980
|
+
// disclosure open or closed.
|
|
1981
|
+
event.preventDefault();
|
|
1982
|
+
event.stopPropagation();
|
|
1983
|
+
publishAggregate(event.currentTarget);
|
|
1984
|
+
});
|
|
1985
|
+
root
|
|
1986
|
+
.querySelector("[data-publish-stale-site-link]")
|
|
1987
|
+
?.addEventListener("click", () => {
|
|
1988
|
+
// The destination panel is collapsed by default; an alert promising the
|
|
1989
|
+
// full breakdown must reveal it before the anchor scroll lands.
|
|
1990
|
+
const details: any = root.querySelector(".live-site-details");
|
|
1991
|
+
if (details) details.open = true;
|
|
1992
|
+
});
|
|
1368
1993
|
aggPanel
|
|
1369
1994
|
?.querySelector("[data-aggregate-retry-build]")
|
|
1370
1995
|
?.addEventListener("click", (event: any) => retryBuild(event.currentTarget));
|
|
1996
|
+
// r08: the Next-release "Copy preview link" — shown only when a share token was
|
|
1997
|
+
// minted. The shared preview URL is static (token + canonical domain), so it's
|
|
1998
|
+
// wired once. stopPropagation keeps a copy from also toggling the collapsible.
|
|
1999
|
+
const aggCopyPreviewBtn: any = aggPanel && aggPanel.querySelector("[data-aggregate-copy-preview]");
|
|
2000
|
+
if (aggCopyPreviewBtn && sharedPreviewUrl()) {
|
|
2001
|
+
aggCopyPreviewBtn.hidden = false;
|
|
2002
|
+
const lbl = aggCopyPreviewBtn.querySelector("[data-copy-preview-label]");
|
|
2003
|
+
aggCopyPreviewBtn.addEventListener("click", (e: Event) => {
|
|
2004
|
+
e.stopPropagation();
|
|
2005
|
+
copyPreviewLink(sharedPreviewUrl(), lbl);
|
|
2006
|
+
});
|
|
2007
|
+
}
|
|
1371
2008
|
|
|
1372
2009
|
// ── Accept-to-preview LIFTOFF ─────────────────────────────────────────────
|
|
1373
2010
|
// The plan confirm + live integration flight + outcome ceremony (replaces
|
|
@@ -1388,6 +2025,7 @@ export function initAdminPublishTab(): void {
|
|
|
1388
2025
|
plan: liftoff.querySelector("[data-liftoff-plan]"),
|
|
1389
2026
|
flight: liftoff.querySelector("[data-liftoff-flight]"),
|
|
1390
2027
|
outcome: liftoff.querySelector("[data-liftoff-outcome]"),
|
|
2028
|
+
log: liftoff.querySelector("[data-liftoff-log]"),
|
|
1391
2029
|
title: liftoff.querySelector("[data-liftoff-title]"),
|
|
1392
2030
|
grid: liftoff.querySelector("[data-liftoff-grid]"),
|
|
1393
2031
|
proceed: liftoff.querySelector("[data-liftoff-proceed]"),
|
|
@@ -1400,6 +2038,7 @@ export function initAdminPublishTab(): void {
|
|
|
1400
2038
|
outcomeTitle: liftoff.querySelector("[data-liftoff-outcome-title]"),
|
|
1401
2039
|
outcomeDetail: liftoff.querySelector("[data-liftoff-outcome-detail]"),
|
|
1402
2040
|
review: liftoff.querySelector("[data-liftoff-review]"),
|
|
2041
|
+
rebuild: liftoff.querySelector("[data-liftoff-rebuild]"),
|
|
1403
2042
|
done: liftoff.querySelector("[data-liftoff-done]"),
|
|
1404
2043
|
confetti: liftoff.querySelector("[data-liftoff-confetti]"),
|
|
1405
2044
|
conflict: liftoff.querySelector("[data-liftoff-conflict]"),
|
|
@@ -1435,7 +2074,7 @@ export function initAdminPublishTab(): void {
|
|
|
1435
2074
|
// conflict markers was lurking and poisoned the first attempt), the
|
|
1436
2075
|
// two-stage auth self-heal (tot login fixes the session, NOT the token
|
|
1437
2076
|
// baked into the remote URL — u10 credential-helper removes this), the
|
|
1438
|
-
// conflict-intent framing, and the never-`--
|
|
2077
|
+
// conflict-intent framing, and the never-`--fork-candidate` rule.
|
|
1439
2078
|
const conflictPromptFor = (change?: any, rawMessage?: any) => {
|
|
1440
2079
|
const tenant = appDomain || repo;
|
|
1441
2080
|
const pr = change && change.prNumber != null ? "PR #" + change.prNumber : "my open candidate PR";
|
|
@@ -1448,8 +2087,8 @@ export function initAdminPublishTab(): void {
|
|
|
1448
2087
|
"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
2088
|
"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
2089
|
"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 `--
|
|
1452
|
-
"6. Confirm the push succeeded and the submit output reports the preview building. Then end by reminding me how to FINISH
|
|
2090
|
+
"5. Re-submit with `tot preview` (plain — never `--fork-candidate`, which would fork a separate PR). " + pr + " updates in place.",
|
|
2091
|
+
"6. Confirm the push succeeded and the submit output reports the preview building. Then end by reminding me how to FINISH adding it once the build is ready: back in the store admin's Publish tab (" + adminPublishUrl() + "), " + pr + " → Add to release" +
|
|
1453
2092
|
(change && change.prNumber != null
|
|
1454
2093
|
? " — or from this terminal: `tot accept --tenant " + tenant + " --pr " + change.prNumber + "`."
|
|
1455
2094
|
: "."),
|
|
@@ -1524,6 +2163,13 @@ export function initAdminPublishTab(): void {
|
|
|
1524
2163
|
setConflictStatus(statusEl2, "");
|
|
1525
2164
|
// Auto-re-integrate ONCE with the refreshed change (new PR/head).
|
|
1526
2165
|
runIntegration(action.change, button);
|
|
2166
|
+
} else if (action.kind === "already-integrated") {
|
|
2167
|
+
// The change already merged/integrated — recovery is a clean no-op success,
|
|
2168
|
+
// NOT an error (fb-1787916141762-owjzis). Close the panel and refresh the
|
|
2169
|
+
// queue so the now-stale row drops off; never reveal the expert fallback.
|
|
2170
|
+
setConflictStatus(statusEl2, "This change is already in your next release — nothing to recover.", "ok");
|
|
2171
|
+
if (liftoff) liftoff.hidden = true;
|
|
2172
|
+
loadQueue();
|
|
1527
2173
|
} else if (action.kind === "cards") {
|
|
1528
2174
|
if (source === "card") {
|
|
1529
2175
|
// A merge card that STILL overlaps — no safe auto-combine exists; hand
|
|
@@ -1624,7 +2270,7 @@ export function initAdminPublishTab(): void {
|
|
|
1624
2270
|
if (!liftoff || !liftoffEls) {
|
|
1625
2271
|
setStatus(
|
|
1626
2272
|
statusEl,
|
|
1627
|
-
"This change needs updating against the latest preview before it can
|
|
2273
|
+
"This change needs updating against the latest preview before it can be added.",
|
|
1628
2274
|
"err",
|
|
1629
2275
|
);
|
|
1630
2276
|
return;
|
|
@@ -1635,7 +2281,7 @@ export function initAdminPublishTab(): void {
|
|
|
1635
2281
|
liftoffEls.outcomeTitle.textContent = "Preview changed while you worked";
|
|
1636
2282
|
liftoffEls.outcomeDetail.textContent =
|
|
1637
2283
|
target + " passed its checks, but preview changed after it was created and the two now " +
|
|
1638
|
-
"conflict. Nothing was
|
|
2284
|
+
"conflict. Nothing was added — update this change automatically, then review the result.";
|
|
1639
2285
|
liftoffEls.review.hidden = true;
|
|
1640
2286
|
setConflictGuidance(true, "", change);
|
|
1641
2287
|
showPhase("outcome");
|
|
@@ -1664,6 +2310,18 @@ export function initAdminPublishTab(): void {
|
|
|
1664
2310
|
let liftoffTimer: any = null;
|
|
1665
2311
|
let liftoffPoll: any = null;
|
|
1666
2312
|
let liftoffOpenedFrom: any = null;
|
|
2313
|
+
const liftoffLog = makeLogConsole(liftoffEls && liftoffEls.log);
|
|
2314
|
+
// Map a server-pushed integrate phase onto the four-stage rail — the SAME rail,
|
|
2315
|
+
// now driven by REAL boundaries instead of a guessing poll: `merging` → Merge,
|
|
2316
|
+
// the aggregate rebuild's `reading`/`publishing` → Rebuild, `checking` → Evidence.
|
|
2317
|
+
const railFromIntegratePhase = (ev: { event: string; data: string }) => {
|
|
2318
|
+
if (ev.event !== "phase") return;
|
|
2319
|
+
let phase = "";
|
|
2320
|
+
try { phase = JSON.parse(ev.data).phase || ""; } catch { /* ignore */ }
|
|
2321
|
+
if (phase === "merging") railTo("merge");
|
|
2322
|
+
else if (phase === "reading" || phase === "publishing") railTo("build");
|
|
2323
|
+
else if (phase === "checking") railTo("evidence");
|
|
2324
|
+
};
|
|
1667
2325
|
const stopLiftoffLoops = () => {
|
|
1668
2326
|
if (liftoffTimer) { clearInterval(liftoffTimer); liftoffTimer = null; }
|
|
1669
2327
|
if (liftoffPoll) { clearInterval(liftoffPoll); liftoffPoll = null; }
|
|
@@ -1709,38 +2367,12 @@ export function initAdminPublishTab(): void {
|
|
|
1709
2367
|
setTimeout(() => { container.textContent = ""; }, 2400);
|
|
1710
2368
|
};
|
|
1711
2369
|
|
|
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
2370
|
|
|
1739
2371
|
const openLiftoff = (change?: any, button?: any) => {
|
|
1740
2372
|
if (!liftoff || !liftoffEls) return false;
|
|
1741
2373
|
liftoffOpenedFrom = button || null;
|
|
1742
2374
|
const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
|
|
1743
|
-
liftoffEls.title.textContent = "
|
|
2375
|
+
liftoffEls.title.textContent = "Add " + target + " to your next release";
|
|
1744
2376
|
liftoffEls.grid.textContent = "";
|
|
1745
2377
|
liftoffEls.grid.append(
|
|
1746
2378
|
...planRow("Store", appDomain || repo),
|
|
@@ -1776,42 +2408,140 @@ export function initAdminPublishTab(): void {
|
|
|
1776
2408
|
if (mode === "flight") shipoff.hidden = true;
|
|
1777
2409
|
else closeShipoff();
|
|
1778
2410
|
}
|
|
2411
|
+
if (buildoff && !buildoff.hidden) {
|
|
2412
|
+
const mode = buildoffCard && buildoffCard.dataset.mode;
|
|
2413
|
+
if (mode === "flight") buildoff.hidden = true; // stream keeps running
|
|
2414
|
+
else closeBuildoff();
|
|
2415
|
+
}
|
|
2416
|
+
if (rebuildoff && !rebuildoff.hidden) {
|
|
2417
|
+
const mode = rebuildoffCard && rebuildoffCard.dataset.mode;
|
|
2418
|
+
if (mode === "flight") rebuildoff.hidden = true; // stream keeps running
|
|
2419
|
+
else closeRebuildoff();
|
|
2420
|
+
}
|
|
1779
2421
|
});
|
|
1780
2422
|
|
|
2423
|
+
// r12 — focus trap. While a modal ceremony is open (aria-modal), Tab must cycle
|
|
2424
|
+
// WITHIN its card, never escaping to the page behind it. Only the visible phase's
|
|
2425
|
+
// controls are tabbable (the hidden phases' buttons fail the offsetParent test), and
|
|
2426
|
+
// a backgrounded ("Continue in background", hidden) dialog is not trapped.
|
|
2427
|
+
document.addEventListener("keydown", (ev) => {
|
|
2428
|
+
if (ev.key !== "Tab") return;
|
|
2429
|
+
const open: any = [liftoff, shipoff, buildoff, rebuildoff].find((d: any) => d && !d.hidden);
|
|
2430
|
+
if (!open) return;
|
|
2431
|
+
const card: any = open.querySelector(".liftoff-card");
|
|
2432
|
+
if (!card) return;
|
|
2433
|
+
const nodes = Array.from(
|
|
2434
|
+
card.querySelectorAll(
|
|
2435
|
+
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])',
|
|
2436
|
+
),
|
|
2437
|
+
).filter((el: any) => el.offsetParent !== null || el === document.activeElement) as any[];
|
|
2438
|
+
if (!nodes.length) {
|
|
2439
|
+
ev.preventDefault();
|
|
2440
|
+
card.focus();
|
|
2441
|
+
return;
|
|
2442
|
+
}
|
|
2443
|
+
const first = nodes[0];
|
|
2444
|
+
const last = nodes[nodes.length - 1];
|
|
2445
|
+
const active = document.activeElement;
|
|
2446
|
+
if (ev.shiftKey) {
|
|
2447
|
+
if (active === first || active === card || !card.contains(active)) {
|
|
2448
|
+
ev.preventDefault();
|
|
2449
|
+
last.focus();
|
|
2450
|
+
}
|
|
2451
|
+
} else if (active === last || active === card || !card.contains(active)) {
|
|
2452
|
+
ev.preventDefault();
|
|
2453
|
+
first.focus();
|
|
2454
|
+
}
|
|
2455
|
+
});
|
|
2456
|
+
|
|
2457
|
+
// Render one classified IntegrateOutcomeView onto the liftoff outcome phase —
|
|
2458
|
+
// the SINGLE place the honest result becomes UI. It reads the derived view
|
|
2459
|
+
// (never re-deciding from raw fields), so "Integration did not land" can only
|
|
2460
|
+
// appear when the merge genuinely did not land; a merge that landed with a
|
|
2461
|
+
// failed build reads "Integrated — preview build failed" and offers Rebuild;
|
|
2462
|
+
// an indeterminate outcome reads "unknown" and offers Re-check. Never a 404
|
|
2463
|
+
// review link (only shown when a genuinely-built revision exists).
|
|
2464
|
+
const applyIntegrateOutcome = (view: IntegrateOutcomeView, change?: any) => {
|
|
2465
|
+
if (!liftoffEls) return;
|
|
2466
|
+
// A successful seal (green OR already-integrated) shows every stage done; a
|
|
2467
|
+
// failure marks its honest failing stage.
|
|
2468
|
+
if (view.seal === "ok") {
|
|
2469
|
+
LIFTOFF_STAGES.forEach((s) => setStage(s, "done"));
|
|
2470
|
+
} else if (view.failStage) {
|
|
2471
|
+
setStage(view.failStage, "fail");
|
|
2472
|
+
}
|
|
2473
|
+
liftoffEls.seal.dataset.state = view.seal;
|
|
2474
|
+
liftoffEls.outcomeTitle.textContent = view.title;
|
|
2475
|
+
liftoffEls.outcomeDetail.textContent = view.detail;
|
|
2476
|
+
setConflictGuidance(
|
|
2477
|
+
view.conflictGuidance,
|
|
2478
|
+
view.conflictGuidance ? view.detail : "",
|
|
2479
|
+
change,
|
|
2480
|
+
);
|
|
2481
|
+
// Review deep-link — shown only for a genuinely-built revision (rule 3: no 404).
|
|
2482
|
+
if (view.review.show && view.review.sha) {
|
|
2483
|
+
liftoffEls.review.href =
|
|
2484
|
+
"/" + encodeURIComponent(appDomain) + "/preview/rev/" + view.review.sha + "/";
|
|
2485
|
+
liftoffEls.review.hidden = false;
|
|
2486
|
+
} else {
|
|
2487
|
+
liftoffEls.review.hidden = true;
|
|
2488
|
+
}
|
|
2489
|
+
// The concrete next-step action button (rebuild / re-check). Conflict recovery
|
|
2490
|
+
// owns its own primary button inside the conflict panel, so no duplicate here.
|
|
2491
|
+
const rebuildBtn = liftoffEls.rebuild;
|
|
2492
|
+
if (rebuildBtn) {
|
|
2493
|
+
const act = view.action;
|
|
2494
|
+
const inlineAction =
|
|
2495
|
+
act && (act.kind === "rebuild" || act.kind === "recheck") ? act : null;
|
|
2496
|
+
if (inlineAction) {
|
|
2497
|
+
rebuildBtn.hidden = false;
|
|
2498
|
+
rebuildBtn.textContent = inlineAction.label;
|
|
2499
|
+
rebuildBtn.onclick = () => {
|
|
2500
|
+
closeLiftoff();
|
|
2501
|
+
if (inlineAction.kind === "rebuild") retryBuild(rebuildBtn);
|
|
2502
|
+
else loadQueue();
|
|
2503
|
+
};
|
|
2504
|
+
} else {
|
|
2505
|
+
rebuildBtn.hidden = true;
|
|
2506
|
+
rebuildBtn.onclick = null;
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
if (liftoff.hidden) liftoff.hidden = false; // surface even if backgrounded
|
|
2510
|
+
showPhase("outcome");
|
|
2511
|
+
liftoffEls.done.focus();
|
|
2512
|
+
if (view.ok) celebrate(liftoffEls.confetti);
|
|
2513
|
+
};
|
|
2514
|
+
|
|
1781
2515
|
// b08 integrate returns an IntegrateOutcome — { ok, queueState, runState,
|
|
1782
2516
|
// aggregateSha, pointerMoved, statusMessage, reason? } — NEVER a bare
|
|
1783
|
-
// merged:true. Render it honestly: green advances
|
|
1784
|
-
//
|
|
2517
|
+
// merged:true. Render it honestly via classifyIntegrateOutcome: green advances
|
|
2518
|
+
// the aggregate; a landed-but-red merge says the build failed (not "did not
|
|
2519
|
+
// land"); an indeterminate result is an explicit unknown, never a silent pass.
|
|
1785
2520
|
const runIntegration = async (change?: any, button?: any) => {
|
|
1786
2521
|
const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
|
|
1787
2522
|
if (button) button.disabled = true;
|
|
1788
2523
|
busyChanges.add(change.changeId); // rux7: survive a poll re-render as busy
|
|
1789
|
-
setStatus(statusEl, "
|
|
2524
|
+
setStatus(statusEl, "Adding " + change.changeId + " to your next release…");
|
|
1790
2525
|
const startedAt = Date.now();
|
|
1791
|
-
let preFlightSha: any = null;
|
|
1792
2526
|
if (liftoffEls) {
|
|
1793
2527
|
showPhase("flight");
|
|
1794
|
-
|
|
2528
|
+
liftoffLog.reset();
|
|
2529
|
+
liftoffEls.flightTitle.textContent = "Adding " + target + " to your next release";
|
|
1795
2530
|
railTo("merge");
|
|
1796
|
-
liftoffEls.live.textContent = "
|
|
2531
|
+
liftoffEls.live.textContent = "Adding your change to the next release…";
|
|
1797
2532
|
liftoffTimer = setInterval(() => {
|
|
1798
2533
|
const s = Math.floor((Date.now() - startedAt) / 1000);
|
|
1799
2534
|
liftoffEls.elapsed.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
|
|
1800
2535
|
}, 1000);
|
|
1801
2536
|
}
|
|
1802
2537
|
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
2538
|
const res = await fetch(ds.integrateEndpoint, {
|
|
1812
2539
|
method: "POST",
|
|
1813
2540
|
headers: {
|
|
1814
2541
|
"content-type": "application/json",
|
|
2542
|
+
// Real-time server-pushed progress (merge → rebuild → evidence) over SSE;
|
|
2543
|
+
// falls back to buffered JSON for a non-streaming proxy.
|
|
2544
|
+
accept: "text/event-stream",
|
|
1815
2545
|
// Mirror of the server-side capability model (UX): the server
|
|
1816
2546
|
// re-derives capability and stays authoritative.
|
|
1817
2547
|
"x-tot-capability": "ship-on-behalf",
|
|
@@ -1823,118 +2553,44 @@ export function initAdminPublishTab(): void {
|
|
|
1823
2553
|
expectedHeadSha: change.headSha || undefined,
|
|
1824
2554
|
}),
|
|
1825
2555
|
});
|
|
1826
|
-
|
|
2556
|
+
// Drive the stage-rail + scrolling log from the REAL streamed phases (no
|
|
2557
|
+
// more client-side guessing poll); the terminal `result` carries the honest
|
|
2558
|
+
// IntegrateOutcome the branches below render.
|
|
2559
|
+
const { data } = await streamWithLog(res, liftoffLog, railFromIntegratePhase);
|
|
1827
2560
|
stopLiftoffLoops();
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
2561
|
+
// HONEST RESULT — derived from the actual merge/build signals, never a
|
|
2562
|
+
// separate optimistic flag. `aggregateSha` is the discriminator: the
|
|
2563
|
+
// orchestrator writes it IFF the forge merge landed, so a landed merge
|
|
2564
|
+
// whose build failed reads "Integrated — preview build failed" (with a
|
|
2565
|
+
// Rebuild), and only a genuinely-unlanded merge reads "did not land".
|
|
2566
|
+
const view = classifyIntegrateOutcome({
|
|
2567
|
+
httpOk: res.ok,
|
|
2568
|
+
ok: data.ok === true,
|
|
2569
|
+
aggregateSha: data.aggregateSha ?? null,
|
|
2570
|
+
queueState: data.queueState ?? null,
|
|
2571
|
+
reason: data.reason ?? null,
|
|
2572
|
+
statusMessage: data.statusMessage ?? null,
|
|
2573
|
+
error: data.error ?? null,
|
|
2574
|
+
target,
|
|
2575
|
+
});
|
|
2576
|
+
setStatus(statusEl, view.detail, view.seal);
|
|
2577
|
+
if (view.ok) void autoSweepBranches();
|
|
2578
|
+
if (view.seal === "err") {
|
|
2579
|
+
// C5 — append the merge-doctor's read of THIS candidate's failure to the
|
|
2580
|
+
// outcome detail (best-effort, additive; the view's copy stands on its own).
|
|
2581
|
+
surfaceDoctorHint(
|
|
2582
|
+
(p) => integrateFailureDoctor(p, { changeId: change.changeId, prNumber: change.prNumber }),
|
|
2583
|
+
[liftoffEls && liftoffEls.outcomeDetail, statusEl],
|
|
1834
2584
|
);
|
|
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
2585
|
}
|
|
2586
|
+
applyIntegrateOutcome(view, change);
|
|
1925
2587
|
} catch {
|
|
1926
2588
|
stopLiftoffLoops();
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
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
|
-
}
|
|
2589
|
+
// A network fault means we DON'T KNOW whether the merge landed — unknown =
|
|
2590
|
+
// error, never a silent "did not land". Re-check the queue, don't assume.
|
|
2591
|
+
const view = classifyIntegrateOutcome({ httpOk: false, networkError: true, target });
|
|
2592
|
+
setStatus(statusEl, view.detail, "err");
|
|
2593
|
+
applyIntegrateOutcome(view, change);
|
|
1938
2594
|
} finally {
|
|
1939
2595
|
busyChanges.delete(change.changeId);
|
|
1940
2596
|
if (button) button.disabled = false;
|
|
@@ -1948,19 +2604,21 @@ export function initAdminPublishTab(): void {
|
|
|
1948
2604
|
if (openLiftoff(change, button)) return;
|
|
1949
2605
|
const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
|
|
1950
2606
|
const plan =
|
|
1951
|
-
"
|
|
2607
|
+
"Add to release plan:\n store: " + (appDomain || repo) +
|
|
1952
2608
|
(change.prNumber != null ? "\n PR: #" + change.prNumber : "") +
|
|
1953
2609
|
"\n change id: " + change.changeId +
|
|
1954
2610
|
(change.headSha ? "\n head sha: " + shortSha(change.headSha) : "") +
|
|
1955
|
-
"\n effect:
|
|
1956
|
-
"re-
|
|
2611
|
+
"\n effect: add " + target + " to your next release, then rebuild the preview and " +
|
|
2612
|
+
"re-run its checks. Does NOT go live — you publish your release separately.";
|
|
1957
2613
|
if (!window.confirm(plan + "\n\nProceed?")) return;
|
|
1958
2614
|
runIntegration(change, button);
|
|
1959
2615
|
};
|
|
1960
2616
|
|
|
1961
2617
|
const rejectChange = async (change?: any, button?: any) => {
|
|
1962
2618
|
const reason = window.prompt(
|
|
1963
|
-
"Reject " + change.changeId +
|
|
2619
|
+
"Reject " + change.changeId +
|
|
2620
|
+
" — this closes the PR and removes its hosted preview and candidate branch. " +
|
|
2621
|
+
"Add a short note for the submitter:",
|
|
1964
2622
|
);
|
|
1965
2623
|
if (reason == null) return;
|
|
1966
2624
|
if (!reason.trim()) {
|
|
@@ -1984,7 +2642,16 @@ export function initAdminPublishTab(): void {
|
|
|
1984
2642
|
);
|
|
1985
2643
|
const data = await res.json().catch(() => ({}));
|
|
1986
2644
|
if (res.ok) {
|
|
1987
|
-
setStatus(
|
|
2645
|
+
setStatus(
|
|
2646
|
+
statusEl,
|
|
2647
|
+
data.cleanupWarning
|
|
2648
|
+
? "Change " + change.changeId + " rejected. " + data.cleanupWarning + "."
|
|
2649
|
+
: "Change " +
|
|
2650
|
+
change.changeId +
|
|
2651
|
+
" rejected; its PR, preview, and candidate branch are cleaned up.",
|
|
2652
|
+
data.cleanupWarning ? "err" : "ok",
|
|
2653
|
+
);
|
|
2654
|
+
void autoSweepBranches();
|
|
1988
2655
|
} else {
|
|
1989
2656
|
setStatus(statusEl, data.error || "Reject failed (" + res.status + ")", "err");
|
|
1990
2657
|
}
|
|
@@ -2050,109 +2717,162 @@ export function initAdminPublishTab(): void {
|
|
|
2050
2717
|
// PR already merged into preview, but the build/evidence step that follows
|
|
2051
2718
|
// died (e.g. a transient MCP error). `POST /api/changes/rebuild` re-runs
|
|
2052
2719
|
// ONLY that step against the sha the forge already carries — never a
|
|
2053
|
-
// second merge attempt, never a membership change.
|
|
2720
|
+
// second merge attempt, never a membership change. It streams its
|
|
2721
|
+
// reading/publishing/checking phases into the SAME shared scrolling live-log
|
|
2722
|
+
// console the other flights use, with Continue-in-background + a Copy-for-AI
|
|
2723
|
+
// failure brief.
|
|
2724
|
+
const rebuildoff: any = root.querySelector("[data-rebuildoff]");
|
|
2725
|
+
const rebuildoffCard = rebuildoff && rebuildoff.querySelector(".liftoff-card");
|
|
2726
|
+
const rebuildoffEls: any = rebuildoff
|
|
2727
|
+
? {
|
|
2728
|
+
flight: rebuildoff.querySelector("[data-rebuildoff-flight]"),
|
|
2729
|
+
outcome: rebuildoff.querySelector("[data-rebuildoff-outcome]"),
|
|
2730
|
+
log: rebuildoff.querySelector("[data-rebuildoff-log]"),
|
|
2731
|
+
live: rebuildoff.querySelector("[data-rebuildoff-live]"),
|
|
2732
|
+
elapsed: rebuildoff.querySelector("[data-rebuildoff-elapsed]"),
|
|
2733
|
+
hide: rebuildoff.querySelector("[data-rebuildoff-hide]"),
|
|
2734
|
+
seal: rebuildoff.querySelector("[data-rebuildoff-seal]"),
|
|
2735
|
+
outcomeTitle: rebuildoff.querySelector("[data-rebuildoff-outcome-title]"),
|
|
2736
|
+
outcomeDetail: rebuildoff.querySelector("[data-rebuildoff-outcome-detail]"),
|
|
2737
|
+
failure: rebuildoff.querySelector("[data-rebuildoff-failure]"),
|
|
2738
|
+
failureDetail: rebuildoff.querySelector("[data-rebuildoff-failure-detail]"),
|
|
2739
|
+
copy: rebuildoff.querySelector("[data-rebuildoff-copy]"),
|
|
2740
|
+
done: rebuildoff.querySelector("[data-rebuildoff-done]"),
|
|
2741
|
+
}
|
|
2742
|
+
: null;
|
|
2743
|
+
let rebuildoffTimer: any = null;
|
|
2744
|
+
let rebuildoffOpenedFrom: any = null; // r12: return focus here on close
|
|
2745
|
+
let rebuildFailureCopyText = "";
|
|
2746
|
+
const rebuildLog = makeLogConsole(rebuildoffEls && rebuildoffEls.log);
|
|
2747
|
+
const stopRebuildoffTimer = () => {
|
|
2748
|
+
if (rebuildoffTimer) { clearInterval(rebuildoffTimer); rebuildoffTimer = null; }
|
|
2749
|
+
};
|
|
2750
|
+
const closeRebuildoff = () => {
|
|
2751
|
+
stopRebuildoffTimer();
|
|
2752
|
+
if (rebuildoff) rebuildoff.hidden = true;
|
|
2753
|
+
if (rebuildoffOpenedFrom && document.contains(rebuildoffOpenedFrom)) rebuildoffOpenedFrom.focus();
|
|
2754
|
+
rebuildoffOpenedFrom = null;
|
|
2755
|
+
};
|
|
2756
|
+
const showRebuildoffPhase = (phase?: any) => {
|
|
2757
|
+
if (!rebuildoffEls) return;
|
|
2758
|
+
rebuildoffEls.flight.hidden = phase !== "flight";
|
|
2759
|
+
rebuildoffEls.outcome.hidden = phase !== "outcome";
|
|
2760
|
+
if (rebuildoffCard) rebuildoffCard.dataset.mode = phase;
|
|
2761
|
+
};
|
|
2762
|
+
if (rebuildoffEls) {
|
|
2763
|
+
rebuildoffEls.done.onclick = () => closeRebuildoff();
|
|
2764
|
+
if (rebuildoffEls.hide) rebuildoffEls.hide.onclick = () => { rebuildoff.hidden = true; }; // stream keeps running
|
|
2765
|
+
if (rebuildoffEls.copy) {
|
|
2766
|
+
rebuildoffEls.copy.addEventListener("click", () => {
|
|
2767
|
+
navigator.clipboard.writeText(rebuildFailureCopyText).then(
|
|
2768
|
+
() => {
|
|
2769
|
+
rebuildoffEls.copy.textContent = "Copied";
|
|
2770
|
+
setTimeout(() => { rebuildoffEls.copy.textContent = "Copy for your AI agent"; }, 1600);
|
|
2771
|
+
},
|
|
2772
|
+
() => { /* clipboard denied — the detail is selectable text */ },
|
|
2773
|
+
);
|
|
2774
|
+
});
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2054
2778
|
const retryBuild = async (button?: any) => {
|
|
2055
2779
|
if (!window.confirm(
|
|
2056
2780
|
"Retry the build for " + (appDomain || repo) + "?\n\n" +
|
|
2057
|
-
" effect:
|
|
2058
|
-
"
|
|
2781
|
+
" effect: rebuild your next release and re-run its checks —\n" +
|
|
2782
|
+
" nothing new is added.\n\n" +
|
|
2059
2783
|
"Proceed?",
|
|
2060
2784
|
)) return;
|
|
2061
2785
|
button.disabled = true;
|
|
2786
|
+
rebuildoffOpenedFrom = button || null; // r12: return focus here on close
|
|
2062
2787
|
aggregateRetrying = true; // rux7-style: hold disabled across a poll landing mid-retry
|
|
2063
2788
|
setStatus(aggStatusEl, "Retrying the build…");
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
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();
|
|
2789
|
+
const startedAt = Date.now();
|
|
2790
|
+
if (rebuildoffEls) {
|
|
2791
|
+
showRebuildoffPhase("flight");
|
|
2792
|
+
rebuildLog.reset();
|
|
2793
|
+
if (rebuildoffEls.failure) rebuildoffEls.failure.hidden = true;
|
|
2794
|
+
rebuildoffEls.live.textContent = "Rebuilding your next release preview…";
|
|
2795
|
+
rebuildoffEls.elapsed.textContent = "0:00";
|
|
2796
|
+
rebuildoff.hidden = false;
|
|
2797
|
+
stopRebuildoffTimer();
|
|
2798
|
+
rebuildoffTimer = setInterval(() => {
|
|
2799
|
+
const s = Math.floor((Date.now() - startedAt) / 1000);
|
|
2800
|
+
rebuildoffEls.elapsed.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
|
|
2801
|
+
}, 1000);
|
|
2091
2802
|
}
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
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 + "…");
|
|
2803
|
+
const outcome = (state?: any, title?: any, detail?: any, failureDetail?: any) => {
|
|
2804
|
+
stopRebuildoffTimer();
|
|
2805
|
+
if (!rebuildoffEls) return;
|
|
2806
|
+
rebuildoffEls.seal.dataset.state = state;
|
|
2807
|
+
rebuildoffEls.outcomeTitle.textContent = title;
|
|
2808
|
+
rebuildoffEls.outcomeDetail.textContent = detail;
|
|
2809
|
+
if (rebuildoffEls.failure) {
|
|
2810
|
+
if (failureDetail) {
|
|
2811
|
+
rebuildoffEls.failure.hidden = false;
|
|
2812
|
+
rebuildoffEls.failureDetail.textContent = failureDetail;
|
|
2813
|
+
} else {
|
|
2814
|
+
rebuildoffEls.failure.hidden = true;
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
if (rebuildoff.hidden) rebuildoff.hidden = false; // re-surface if backgrounded
|
|
2818
|
+
showRebuildoffPhase("outcome");
|
|
2819
|
+
rebuildoffEls.done.focus();
|
|
2820
|
+
};
|
|
2119
2821
|
try {
|
|
2120
|
-
const res = await fetch(ds.
|
|
2822
|
+
const res = await fetch(ds.changesEndpoint + "/rebuild", {
|
|
2121
2823
|
method: "POST",
|
|
2122
2824
|
headers: {
|
|
2123
2825
|
"content-type": "application/json",
|
|
2124
|
-
//
|
|
2125
|
-
//
|
|
2826
|
+
// Real-time server-pushed progress (reading → publishing → checking) over
|
|
2827
|
+
// SSE; falls back to buffered JSON for a non-streaming proxy.
|
|
2828
|
+
accept: "text/event-stream",
|
|
2126
2829
|
"x-tot-capability": "ship-on-behalf",
|
|
2127
2830
|
},
|
|
2128
|
-
body: JSON.stringify({
|
|
2129
|
-
changeId: change.changeId,
|
|
2130
|
-
pr: change.prNumber ?? undefined,
|
|
2131
|
-
}),
|
|
2831
|
+
body: JSON.stringify({ repo }),
|
|
2132
2832
|
});
|
|
2133
|
-
const data = await res
|
|
2134
|
-
if (
|
|
2135
|
-
setStatus(
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2833
|
+
const { data } = await streamWithLog(res, rebuildLog);
|
|
2834
|
+
if (data.ok) {
|
|
2835
|
+
setStatus(aggStatusEl, "Build retry passed — your next release is ready.", "ok");
|
|
2836
|
+
outcome("ok", "Your next release is ready", "The rebuild passed all your store's checks. Nothing is live yet.");
|
|
2837
|
+
} else if (data.reason === "nothing_to_rebuild") {
|
|
2838
|
+
setStatus(aggStatusEl, "Nothing to rebuild — there's no earlier build to retry yet.");
|
|
2839
|
+
outcome(
|
|
2140
2840
|
"ok",
|
|
2841
|
+
"Nothing to rebuild",
|
|
2842
|
+
"There's no earlier preview build for this store to retry yet.",
|
|
2141
2843
|
);
|
|
2142
2844
|
} else {
|
|
2143
|
-
|
|
2845
|
+
const detail =
|
|
2846
|
+
data.statusMessage || data.error || "your next release did not pass its checks (" + (data.reason || data.queueState) + ")";
|
|
2847
|
+
setStatus(aggStatusEl, "Build retry did not pass: " + detail, "err");
|
|
2848
|
+
rebuildFailureCopyText = [
|
|
2849
|
+
"In my " + (appDomain || repo) + " store, retrying the preview build did not pass. " +
|
|
2850
|
+
"Diagnose why from the detail below, then tell me the exact fix.",
|
|
2851
|
+
"",
|
|
2852
|
+
"What the rebuild reported:",
|
|
2853
|
+
detail,
|
|
2854
|
+
"\nContext: this re-runs ONLY the aggregate rebuild + combined evidence against the " +
|
|
2855
|
+
"last merged commit on the shared `preview` branch — no new merge, no membership change.",
|
|
2856
|
+
].join("\n");
|
|
2857
|
+
outcome("err", "The rebuild didn't pass", detail + " Your next release is unchanged.", detail);
|
|
2858
|
+
surfaceDoctorHint((p) => shipFailureDoctor(p), [rebuildoffEls && rebuildoffEls.outcomeDetail, aggStatusEl]);
|
|
2144
2859
|
}
|
|
2145
2860
|
} catch {
|
|
2146
|
-
setStatus(
|
|
2861
|
+
setStatus(aggStatusEl, "Retry failed — network error", "err");
|
|
2862
|
+
outcome(
|
|
2863
|
+
"err",
|
|
2864
|
+
"The rebuild didn't finish",
|
|
2865
|
+
"Network error mid-rebuild — refresh the queue to see your next release's honest state.",
|
|
2866
|
+
);
|
|
2147
2867
|
} finally {
|
|
2148
|
-
|
|
2868
|
+
aggregateRetrying = false;
|
|
2149
2869
|
button.disabled = false;
|
|
2150
2870
|
loadQueue();
|
|
2151
2871
|
}
|
|
2152
2872
|
};
|
|
2153
2873
|
|
|
2154
2874
|
// ---- rux5: build-on-demand for a synthetic not-built PR ------------------
|
|
2155
|
-
// Mirror of the integrate
|
|
2875
|
+
// Mirror of the integrate plan affordance: state EXACTLY what build
|
|
2156
2876
|
// does — materialize this PR's hosted preview at its forge head — and that it
|
|
2157
2877
|
// is INERT (flips no live channel). POSTs { pr, headSha?, baseSha? }; the row
|
|
2158
2878
|
// already carries the head sha GET /api/changes resolved via forge PR-read, and
|
|
@@ -2171,63 +2891,269 @@ export function initAdminPublishTab(): void {
|
|
|
2171
2891
|
return lines.join("\n");
|
|
2172
2892
|
};
|
|
2173
2893
|
|
|
2174
|
-
|
|
2894
|
+
// ── Build-flight dialog (real-time SSE build/check progress) ─────────────
|
|
2895
|
+
// Mirrors the accept liftoff's flight → outcome ceremony (same liftoff-* markup)
|
|
2896
|
+
// but is driven by the phase events `POST /api/preview/build` streams over SSE.
|
|
2897
|
+
// "Continue in background" hides the dialog while the stream keeps running; on a
|
|
2898
|
+
// FAILURE the outcome shows the honest detail + a "Copy for your AI agent" button.
|
|
2899
|
+
const buildoff: any = root.querySelector("[data-buildoff]");
|
|
2900
|
+
const buildoffCard = buildoff && buildoff.querySelector(".liftoff-card");
|
|
2901
|
+
const buildoffEls: any = buildoff
|
|
2902
|
+
? {
|
|
2903
|
+
flight: buildoff.querySelector("[data-buildoff-flight]"),
|
|
2904
|
+
outcome: buildoff.querySelector("[data-buildoff-outcome]"),
|
|
2905
|
+
log: buildoff.querySelector("[data-buildoff-log]"),
|
|
2906
|
+
flightTitle: buildoff.querySelector("[data-buildoff-flight-title]"),
|
|
2907
|
+
live: buildoff.querySelector("[data-buildoff-live]"),
|
|
2908
|
+
elapsed: buildoff.querySelector("[data-buildoff-elapsed]"),
|
|
2909
|
+
hide: buildoff.querySelector("[data-buildoff-hide]"),
|
|
2910
|
+
seal: buildoff.querySelector("[data-buildoff-seal]"),
|
|
2911
|
+
outcomeTitle: buildoff.querySelector("[data-buildoff-outcome-title]"),
|
|
2912
|
+
outcomeDetail: buildoff.querySelector("[data-buildoff-outcome-detail]"),
|
|
2913
|
+
failure: buildoff.querySelector("[data-buildoff-failure]"),
|
|
2914
|
+
failureDetail: buildoff.querySelector("[data-buildoff-failure-detail]"),
|
|
2915
|
+
copy: buildoff.querySelector("[data-buildoff-copy]"),
|
|
2916
|
+
review: buildoff.querySelector("[data-buildoff-review]"),
|
|
2917
|
+
done: buildoff.querySelector("[data-buildoff-done]"),
|
|
2918
|
+
}
|
|
2919
|
+
: null;
|
|
2920
|
+
|
|
2921
|
+
let buildoffTimer: any = null;
|
|
2922
|
+
let buildoffOpenedFrom: any = null;
|
|
2923
|
+
let buildFailureCopyText = "";
|
|
2924
|
+
const buildLog = makeLogConsole(buildoffEls && buildoffEls.log);
|
|
2925
|
+
const stopBuildoffTimer = () => {
|
|
2926
|
+
if (buildoffTimer) { clearInterval(buildoffTimer); buildoffTimer = null; }
|
|
2927
|
+
};
|
|
2928
|
+
const closeBuildoff = () => {
|
|
2929
|
+
stopBuildoffTimer();
|
|
2930
|
+
if (buildoff) buildoff.hidden = true;
|
|
2931
|
+
if (buildoffOpenedFrom && document.contains(buildoffOpenedFrom)) buildoffOpenedFrom.focus();
|
|
2932
|
+
buildoffOpenedFrom = null;
|
|
2933
|
+
};
|
|
2934
|
+
const showBuildoffPhase = (phase?: any) => {
|
|
2935
|
+
if (!buildoffEls) return;
|
|
2936
|
+
buildoffEls.flight.hidden = phase !== "flight";
|
|
2937
|
+
buildoffEls.outcome.hidden = phase !== "outcome";
|
|
2938
|
+
if (buildoffCard) buildoffCard.dataset.mode = phase;
|
|
2939
|
+
};
|
|
2940
|
+
const setBuildRail = (stageState?: any) => {
|
|
2941
|
+
if (!buildoff) return;
|
|
2942
|
+
BUILD_STAGES.forEach((s) => {
|
|
2943
|
+
const el = buildoff.querySelector('[data-buildoff-stage="' + s + '"]');
|
|
2944
|
+
if (el) el.dataset.state = stageState[s];
|
|
2945
|
+
});
|
|
2946
|
+
};
|
|
2947
|
+
if (buildoffEls) {
|
|
2948
|
+
// Copy-for-AI wiring (once): the honest failure brief for a coding agent.
|
|
2949
|
+
buildoffEls.copy.addEventListener("click", () => {
|
|
2950
|
+
navigator.clipboard.writeText(buildFailureCopyText).then(
|
|
2951
|
+
() => {
|
|
2952
|
+
buildoffEls.copy.textContent = "Copied";
|
|
2953
|
+
setTimeout(() => { buildoffEls.copy.textContent = "Copy for your AI agent"; }, 1600);
|
|
2954
|
+
},
|
|
2955
|
+
() => { /* clipboard denied — the detail is selectable text */ },
|
|
2956
|
+
);
|
|
2957
|
+
});
|
|
2958
|
+
buildoffEls.done.onclick = () => closeBuildoff();
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
// Stream the build over SSE, reflecting each phase on the flight rail. Falls back
|
|
2962
|
+
// to a one-shot JSON read if the response wasn't an event-stream (a buffering
|
|
2963
|
+
// proxy, or a server that doesn't stream) so the action always completes honestly.
|
|
2964
|
+
const streamBuild = async (change?: any, button?: any) => {
|
|
2175
2965
|
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
2966
|
if (change.prNumber != null) inflightBuilds.add(change.prNumber);
|
|
2967
|
+
if (button) {
|
|
2968
|
+
button.disabled = true;
|
|
2969
|
+
button.dataset.building = "true";
|
|
2970
|
+
button.textContent = "Building…";
|
|
2971
|
+
}
|
|
2184
2972
|
setStatus(statusEl, "Building preview for " + label + "…");
|
|
2973
|
+
|
|
2974
|
+
let progress: BuildProgress = initialBuildProgress();
|
|
2975
|
+
if (buildoffEls) {
|
|
2976
|
+
buildoffOpenedFrom = button || null;
|
|
2977
|
+
buildoffEls.flightTitle.textContent = "Building preview for " + label;
|
|
2978
|
+
setBuildRail(progress.stageState);
|
|
2979
|
+
buildLog.reset();
|
|
2980
|
+
buildoffEls.live.textContent = "Starting…";
|
|
2981
|
+
buildoffEls.elapsed.textContent = "0:00";
|
|
2982
|
+
if (buildoffEls.failure) buildoffEls.failure.hidden = true;
|
|
2983
|
+
if (buildoffEls.review) buildoffEls.review.hidden = true;
|
|
2984
|
+
showBuildoffPhase("flight");
|
|
2985
|
+
buildoff.hidden = false;
|
|
2986
|
+
const startedAt = Date.now();
|
|
2987
|
+
stopBuildoffTimer();
|
|
2988
|
+
buildoffTimer = setInterval(() => {
|
|
2989
|
+
const s = Math.floor((Date.now() - startedAt) / 1000);
|
|
2990
|
+
buildoffEls.elapsed.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
|
|
2991
|
+
}, 1000);
|
|
2992
|
+
buildoffEls.hide.onclick = () => { buildoff.hidden = true; }; // stream keeps running
|
|
2993
|
+
buildoffEls.done.onclick = () => closeBuildoff();
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
const applyEvent = (ev: any) => {
|
|
2997
|
+
if (ev && ev.event === "phase") buildLog.pushEvent(ev);
|
|
2998
|
+
progress = reduceBuildProgress(progress, ev);
|
|
2999
|
+
if (buildoffEls) {
|
|
3000
|
+
setBuildRail(progress.stageState);
|
|
3001
|
+
buildoffEls.live.textContent = progress.liveLabel;
|
|
3002
|
+
}
|
|
3003
|
+
};
|
|
3004
|
+
|
|
3005
|
+
const renderOutcome = (result: BuildResult) => {
|
|
3006
|
+
stopBuildoffTimer();
|
|
3007
|
+
buildLog.finish(result as StreamTerminalResult);
|
|
3008
|
+
if (result.ok) {
|
|
3009
|
+
setStatus(
|
|
3010
|
+
statusEl,
|
|
3011
|
+
"Built preview for " + label + " — it now appears as a built change in the queue.",
|
|
3012
|
+
"ok",
|
|
3013
|
+
);
|
|
3014
|
+
} else if (result.outcome === "blocked") {
|
|
3015
|
+
setStatus(
|
|
3016
|
+
statusEl,
|
|
3017
|
+
"Build did not complete for " + label + ": " + (buildFailureDetail(result) || "blocked"),
|
|
3018
|
+
"err",
|
|
3019
|
+
);
|
|
3020
|
+
} else {
|
|
3021
|
+
setStatus(
|
|
3022
|
+
statusEl,
|
|
3023
|
+
(result.errors && result.errors.join("; ")) || result.error || "Build failed",
|
|
3024
|
+
"err",
|
|
3025
|
+
);
|
|
3026
|
+
}
|
|
3027
|
+
if (!buildoffEls) return;
|
|
3028
|
+
if (result.ok) {
|
|
3029
|
+
buildoffEls.seal.dataset.state = "ok";
|
|
3030
|
+
buildoffEls.outcomeTitle.textContent = "Preview built";
|
|
3031
|
+
buildoffEls.outcomeDetail.textContent =
|
|
3032
|
+
label + " is materialized — it now appears as a built change in the queue, ready to review.";
|
|
3033
|
+
if (buildoffEls.failure) buildoffEls.failure.hidden = true;
|
|
3034
|
+
if (buildoffEls.review) {
|
|
3035
|
+
if (result.previewUrl) {
|
|
3036
|
+
buildoffEls.review.href = result.previewUrl;
|
|
3037
|
+
buildoffEls.review.hidden = false;
|
|
3038
|
+
} else {
|
|
3039
|
+
buildoffEls.review.hidden = true;
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
} else {
|
|
3043
|
+
buildoffEls.seal.dataset.state = "err";
|
|
3044
|
+
buildoffEls.outcomeTitle.textContent =
|
|
3045
|
+
result.outcome === "blocked" ? "A check blocked this build" : "The build didn't finish";
|
|
3046
|
+
buildoffEls.outcomeDetail.textContent =
|
|
3047
|
+
result.outcome === "blocked"
|
|
3048
|
+
? label + " built, but a check blocked it. Here's exactly what stopped it — copy it for your AI agent to fix:"
|
|
3049
|
+
: label + " couldn't be built. Here's what the build reported — copy it for your AI agent to diagnose:";
|
|
3050
|
+
if (buildoffEls.review) buildoffEls.review.hidden = true;
|
|
3051
|
+
buildFailureCopyText = assembleBuildFailureCopy({
|
|
3052
|
+
tenant: appDomain || repo,
|
|
3053
|
+
prNumber: change.prNumber,
|
|
3054
|
+
changeId: change.changeId,
|
|
3055
|
+
result,
|
|
3056
|
+
});
|
|
3057
|
+
if (buildoffEls.failure) {
|
|
3058
|
+
buildoffEls.failure.hidden = false;
|
|
3059
|
+
buildoffEls.failureDetail.textContent = buildFailureDetail(result) || "(no detail was reported)";
|
|
3060
|
+
}
|
|
3061
|
+
// Re-surface the dialog if it was dismissed to the background — a failure
|
|
3062
|
+
// needs the operator's eyes, unlike a clean success they already walked away from.
|
|
3063
|
+
if (buildoff.hidden) buildoff.hidden = false;
|
|
3064
|
+
}
|
|
3065
|
+
showBuildoffPhase("outcome");
|
|
3066
|
+
if (!buildoff.hidden) buildoffEls.done.focus();
|
|
3067
|
+
};
|
|
3068
|
+
|
|
2185
3069
|
try {
|
|
2186
3070
|
const res = await fetch(ds.buildEndpoint, {
|
|
2187
3071
|
method: "POST",
|
|
2188
3072
|
headers: {
|
|
2189
3073
|
"content-type": "application/json",
|
|
3074
|
+
accept: "text/event-stream",
|
|
2190
3075
|
// Mirror of the server-side capability model (UX); the server
|
|
2191
3076
|
// re-derives capability (buildGate) and stays authoritative.
|
|
2192
3077
|
"x-tot-capability": "ship-on-behalf",
|
|
2193
3078
|
},
|
|
2194
3079
|
body: JSON.stringify({
|
|
2195
3080
|
pr: change.prNumber,
|
|
3081
|
+
// A row for an ALREADY-KNOWN candidate must carry its real changeId, or
|
|
3082
|
+
// the server falls back to synthesizing "pr-<N>" and writes/reads a
|
|
3083
|
+
// SEPARATE phantom record — the real candidate's ReviewEnvironment never
|
|
3084
|
+
// advances no matter how many times "Run checks" is clicked. Omit it only
|
|
3085
|
+
// for a genuinely not-yet-built row (change.changeId is null there).
|
|
3086
|
+
changeId: change.changeId || undefined,
|
|
2196
3087
|
headSha: change.headSha || undefined,
|
|
2197
3088
|
baseSha: change.baseSha || undefined,
|
|
2198
3089
|
}),
|
|
2199
3090
|
});
|
|
2200
|
-
const
|
|
2201
|
-
if (res.
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
3091
|
+
const ct = res.headers.get("content-type") || "";
|
|
3092
|
+
if (res.body && ct.indexOf("text/event-stream") !== -1) {
|
|
3093
|
+
const reader = res.body.getReader();
|
|
3094
|
+
const decoder = new TextDecoder();
|
|
3095
|
+
let buf = "";
|
|
3096
|
+
for (;;) {
|
|
3097
|
+
const { value, done } = await reader.read();
|
|
3098
|
+
if (done) break;
|
|
3099
|
+
buf += decoder.decode(value, { stream: true });
|
|
3100
|
+
const parsed = parseSseChunk(buf);
|
|
3101
|
+
buf = parsed.rest;
|
|
3102
|
+
for (const ev of parsed.events) applyEvent(ev);
|
|
3103
|
+
if (progress.terminal) break;
|
|
3104
|
+
}
|
|
3105
|
+
if (!progress.terminal) {
|
|
3106
|
+
// Stream ended without a terminal result — treat as an honest failure.
|
|
3107
|
+
applyEvent({
|
|
3108
|
+
event: "result",
|
|
3109
|
+
data: JSON.stringify({ ok: false, outcome: "failed", errors: ["the build stream ended unexpectedly"] }),
|
|
3110
|
+
});
|
|
3111
|
+
}
|
|
3112
|
+
renderOutcome(progress.result as BuildResult);
|
|
2217
3113
|
} else {
|
|
2218
|
-
|
|
3114
|
+
// Non-streaming fallback: read the JSON body the endpoint returns to
|
|
3115
|
+
// non-streaming callers and normalize it into the same outcome shape.
|
|
3116
|
+
const data = await res.json().catch(() => ({}) as any);
|
|
3117
|
+
renderOutcome({
|
|
3118
|
+
ok: !!data.ok,
|
|
3119
|
+
outcome: data.outcome || (data.ok ? "ok" : res.status === 422 ? "blocked" : "failed"),
|
|
3120
|
+
tenantId: data.tenantId,
|
|
3121
|
+
changeId: data.changeId,
|
|
3122
|
+
headSha: data.headSha,
|
|
3123
|
+
previewUrl: data.previewUrl,
|
|
3124
|
+
errors: data.errors,
|
|
3125
|
+
error: data.error,
|
|
3126
|
+
findings: data.findings,
|
|
3127
|
+
});
|
|
2219
3128
|
}
|
|
2220
3129
|
} catch {
|
|
2221
3130
|
setStatus(statusEl, "Build failed — network error", "err");
|
|
3131
|
+
if (buildoffEls) {
|
|
3132
|
+
stopBuildoffTimer();
|
|
3133
|
+
buildoffEls.seal.dataset.state = "err";
|
|
3134
|
+
buildoffEls.outcomeTitle.textContent = "The build didn't finish";
|
|
3135
|
+
buildoffEls.outcomeDetail.textContent =
|
|
3136
|
+
"Network error mid-build — refresh the queue to see the honest state.";
|
|
3137
|
+
if (buildoffEls.failure) buildoffEls.failure.hidden = true;
|
|
3138
|
+
if (buildoffEls.review) buildoffEls.review.hidden = true;
|
|
3139
|
+
if (buildoff.hidden) buildoff.hidden = false;
|
|
3140
|
+
showBuildoffPhase("outcome");
|
|
3141
|
+
}
|
|
2222
3142
|
} finally {
|
|
2223
3143
|
if (change.prNumber != null) inflightBuilds.delete(change.prNumber);
|
|
2224
|
-
button
|
|
2225
|
-
|
|
2226
|
-
|
|
3144
|
+
if (button) {
|
|
3145
|
+
button.disabled = false;
|
|
3146
|
+
delete button.dataset.building;
|
|
3147
|
+
}
|
|
2227
3148
|
loadQueue();
|
|
2228
3149
|
}
|
|
2229
3150
|
};
|
|
2230
3151
|
|
|
3152
|
+
const buildPreview = async (change?: any, button?: any) => {
|
|
3153
|
+
if (!window.confirm(buildPreviewPlan(change) + "\n\nBuild the preview now?")) return;
|
|
3154
|
+
await streamBuild(change, button);
|
|
3155
|
+
};
|
|
3156
|
+
|
|
2231
3157
|
// ---- rux6: unified review pane -----------------------------------------
|
|
2232
3158
|
// "One review moment": expanding a candidate row reveals a single pane with
|
|
2233
3159
|
// the live preview (iframed SAME-ORIGIN — the /<tenant>/preview/pr/<N> route
|
|
@@ -2264,12 +3190,14 @@ export function initAdminPublishTab(): void {
|
|
|
2264
3190
|
const checks = (change.evidence && change.evidence.checks) || [];
|
|
2265
3191
|
const promotable = Boolean(change.evidence && change.evidence.promotable);
|
|
2266
3192
|
container.append(
|
|
2267
|
-
promotable
|
|
3193
|
+
promotable
|
|
3194
|
+
? chip("Checks passed", "green", CHIP_GLYPH.green)
|
|
3195
|
+
: chip("Checks blocked", "amber", CHIP_GLYPH.amber),
|
|
2268
3196
|
);
|
|
2269
3197
|
for (const c of checks) {
|
|
2270
3198
|
const tone = c.status === "pass" ? "green" : c.status === "fail" ? "red" : "amber";
|
|
2271
3199
|
const label = (c.label || c.id || "check") + (c.status && c.status !== "pass" ? " · " + c.status : "");
|
|
2272
|
-
const el = chip(label, tone);
|
|
3200
|
+
const el = chip(label, tone, CHIP_GLYPH[tone]);
|
|
2273
3201
|
if (c.detail) el.title = c.detail;
|
|
2274
3202
|
container.append(el);
|
|
2275
3203
|
}
|
|
@@ -2320,6 +3248,30 @@ export function initAdminPublishTab(): void {
|
|
|
2320
3248
|
const detailCol = document.createElement("div");
|
|
2321
3249
|
detailCol.className = "review-detail";
|
|
2322
3250
|
|
|
3251
|
+
const summaryValues = [
|
|
3252
|
+
["Intent", change.intent],
|
|
3253
|
+
["User-visible effect", change.description],
|
|
3254
|
+
["Verification", change.verification],
|
|
3255
|
+
["Risk / rollback", change.riskRollback],
|
|
3256
|
+
].filter((entry) => typeof entry[1] === "string" && entry[1].trim());
|
|
3257
|
+
if ((typeof change.title === "string" && change.title.trim()) || summaryValues.length) {
|
|
3258
|
+
const summarySection = document.createElement("div");
|
|
3259
|
+
summarySection.className = "review-section";
|
|
3260
|
+
const summaryTitle = document.createElement("b");
|
|
3261
|
+
summaryTitle.textContent = change.title && change.title.trim()
|
|
3262
|
+
? change.title.trim()
|
|
3263
|
+
: "About this change";
|
|
3264
|
+
summarySection.append(summaryTitle);
|
|
3265
|
+
for (const [label, value] of summaryValues) {
|
|
3266
|
+
const line = document.createElement("div");
|
|
3267
|
+
const labelEl = document.createElement("strong");
|
|
3268
|
+
labelEl.textContent = label + ": ";
|
|
3269
|
+
line.append(labelEl, document.createTextNode(value.trim()));
|
|
3270
|
+
summarySection.append(line);
|
|
3271
|
+
}
|
|
3272
|
+
detailCol.append(summarySection);
|
|
3273
|
+
}
|
|
3274
|
+
|
|
2323
3275
|
const evSection = document.createElement("div");
|
|
2324
3276
|
evSection.className = "review-section";
|
|
2325
3277
|
const evTitle = document.createElement("b");
|
|
@@ -2352,18 +3304,33 @@ export function initAdminPublishTab(): void {
|
|
|
2352
3304
|
change.status === "ready" &&
|
|
2353
3305
|
Boolean(change.evidence && change.evidence.promotable) &&
|
|
2354
3306
|
change.mergeable === false;
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
3307
|
+
const needsChecks = !Boolean(change.evidence && change.evidence.promotable) &&
|
|
3308
|
+
(change.status === "queued" || change.status === "reconciling");
|
|
3309
|
+
if (needsChecks) {
|
|
3310
|
+
const build = document.createElement("button");
|
|
3311
|
+
build.type = "button";
|
|
3312
|
+
build.className = "btn primary";
|
|
3313
|
+
build.textContent = "Run checks";
|
|
3314
|
+
build.disabled = !canShip || (change.prNumber != null && inflightBuilds.has(change.prNumber));
|
|
3315
|
+
build.title = "Re-run this PR's preview build and checks (does not merge or go live)";
|
|
3316
|
+
build.addEventListener("click", () => buildPreview(change, build));
|
|
3317
|
+
actionRow.append(build);
|
|
3318
|
+
}
|
|
3319
|
+
// Only ONE primary action at a time — see the queue-row fix above.
|
|
3320
|
+
if (!needsChecks) {
|
|
3321
|
+
accept.textContent = needsPreviewUpdate ? "Update change" : "Add to release";
|
|
3322
|
+
accept.disabled = !canShip || (!needsPreviewUpdate && !change.mergeable);
|
|
3323
|
+
accept.title = !canShip
|
|
3324
|
+
? "Requires a ship-on-behalf grant on your account — ask the store owner"
|
|
3325
|
+
: needsPreviewUpdate
|
|
3326
|
+
? "Preview changed while you worked — update this change, then add it"
|
|
3327
|
+
: !change.mergeable
|
|
3328
|
+
? "This change isn't ready to add yet"
|
|
3329
|
+
: "Add this change to your next release (doesn't go live yet)";
|
|
3330
|
+
accept.addEventListener("click", () =>
|
|
3331
|
+
needsPreviewUpdate ? openConflictRecovery(change, accept) : integrateCandidate(change, accept),
|
|
3332
|
+
);
|
|
3333
|
+
}
|
|
2367
3334
|
const reject = document.createElement("button");
|
|
2368
3335
|
reject.type = "button";
|
|
2369
3336
|
reject.className = "btn";
|
|
@@ -2373,7 +3340,7 @@ export function initAdminPublishTab(): void {
|
|
|
2373
3340
|
? "Close this change without publishing"
|
|
2374
3341
|
: "Requires an owner or admin sign-in";
|
|
2375
3342
|
reject.addEventListener("click", () => rejectChange(change, reject));
|
|
2376
|
-
actionRow.append(accept, reject);
|
|
3343
|
+
actionRow.append(...(needsChecks ? [] : [accept]), reject);
|
|
2377
3344
|
detailCol.append(actionRow);
|
|
2378
3345
|
|
|
2379
3346
|
pane.append(previewCol, detailCol);
|
|
@@ -2439,6 +3406,7 @@ export function initAdminPublishTab(): void {
|
|
|
2439
3406
|
|
|
2440
3407
|
// Authority: honest label of whether the diff describes the exact head.
|
|
2441
3408
|
const authority = diff.authority || {};
|
|
3409
|
+
const authTone = authority.authoritative ? "green" : authority.stale ? "amber" : "";
|
|
2442
3410
|
const authChip = chip(
|
|
2443
3411
|
authority.authoritative
|
|
2444
3412
|
? "Reviewed at head"
|
|
@@ -2447,7 +3415,8 @@ export function initAdminPublishTab(): void {
|
|
|
2447
3415
|
: authority.missing
|
|
2448
3416
|
? "No evidence yet"
|
|
2449
3417
|
: "Head",
|
|
2450
|
-
|
|
3418
|
+
authTone,
|
|
3419
|
+
CHIP_GLYPH[authTone],
|
|
2451
3420
|
);
|
|
2452
3421
|
head.append(authChip);
|
|
2453
3422
|
|
|
@@ -2472,7 +3441,7 @@ export function initAdminPublishTab(): void {
|
|
|
2472
3441
|
const reason = document.createElement("p");
|
|
2473
3442
|
reason.className = "note publish-diff-blocked";
|
|
2474
3443
|
reason.textContent =
|
|
2475
|
-
diff.blockedReason || "This
|
|
3444
|
+
diff.blockedReason || "This change has no preview to compare yet.";
|
|
2476
3445
|
td.append(reason);
|
|
2477
3446
|
return;
|
|
2478
3447
|
}
|
|
@@ -2512,6 +3481,43 @@ export function initAdminPublishTab(): void {
|
|
|
2512
3481
|
// Hosted and static-publish stores both render the same instant "Roll back" UX
|
|
2513
3482
|
// below — every rollback is an instant KV pointer flip.
|
|
2514
3483
|
|
|
3484
|
+
// r10: the post-publish "Undo" deep-link. focusRollbackTarget stashes the
|
|
3485
|
+
// version-id to undo, opens the Live Site + Rollback disclosures, forces a fresh
|
|
3486
|
+
// versions read, and scrolls the panel into view; renderVersions then applies the
|
|
3487
|
+
// pending focus once that row exists (applyRollbackFocus) — highlighting it and
|
|
3488
|
+
// focusing its "Roll back" button so the operator lands pre-targeted.
|
|
3489
|
+
let pendingRollbackFocus: string | null = null;
|
|
3490
|
+
const openLiveSiteRollback = () => {
|
|
3491
|
+
const liveSite: any = root.querySelector(".live-site-details");
|
|
3492
|
+
if (liveSite) liveSite.open = true;
|
|
3493
|
+
const versionsDetails: any = versionsPanel && versionsPanel.querySelector(".versions-details");
|
|
3494
|
+
if (versionsDetails) versionsDetails.open = true;
|
|
3495
|
+
};
|
|
3496
|
+
const applyRollbackFocus = () => {
|
|
3497
|
+
if (!pendingRollbackFocus || !versionsBody) return;
|
|
3498
|
+
let target: any = null;
|
|
3499
|
+
versionsBody.querySelectorAll("tr[data-version-id]").forEach((r: any) => {
|
|
3500
|
+
if (r.dataset.versionId === pendingRollbackFocus) target = r;
|
|
3501
|
+
});
|
|
3502
|
+
if (!target) return; // not rendered yet — a pending fetch will re-run this
|
|
3503
|
+
pendingRollbackFocus = null;
|
|
3504
|
+
target.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
3505
|
+
target.classList.remove("publish-row-highlight");
|
|
3506
|
+
void target.offsetWidth; // restart the flash if re-applied
|
|
3507
|
+
target.classList.add("publish-row-highlight");
|
|
3508
|
+
window.setTimeout(() => target.classList.remove("publish-row-highlight"), 2600);
|
|
3509
|
+
const btn: any = target.querySelector("button");
|
|
3510
|
+
if (btn) btn.focus();
|
|
3511
|
+
};
|
|
3512
|
+
const focusRollbackTarget = (versionId?: string) => {
|
|
3513
|
+
if (!versionId) return;
|
|
3514
|
+
pendingRollbackFocus = versionId;
|
|
3515
|
+
openLiveSiteRollback();
|
|
3516
|
+
loadVersions({ force: true }); // fresh read: the replaced version is now a prior row
|
|
3517
|
+
const panel = document.getElementById("publish-live-site");
|
|
3518
|
+
if (panel) panel.scrollIntoView({ block: "start", behavior: "smooth" });
|
|
3519
|
+
};
|
|
3520
|
+
|
|
2515
3521
|
const renderVersions = (view?: any) => {
|
|
2516
3522
|
if (!versionsBody) return;
|
|
2517
3523
|
const versions = Array.isArray(view.versions) ? view.versions : [];
|
|
@@ -2535,7 +3541,12 @@ export function initAdminPublishTab(): void {
|
|
|
2535
3541
|
|
|
2536
3542
|
if (!versions.length) {
|
|
2537
3543
|
const tr = document.createElement("tr");
|
|
2538
|
-
const td = cell(
|
|
3544
|
+
const td = cell(
|
|
3545
|
+
subtle(
|
|
3546
|
+
"Nothing published yet. When you publish your next release it becomes your first live " +
|
|
3547
|
+
"version — and any earlier versions you can roll back to will appear here.",
|
|
3548
|
+
),
|
|
3549
|
+
);
|
|
2539
3550
|
td.colSpan = 6;
|
|
2540
3551
|
tr.append(td);
|
|
2541
3552
|
versionsBody.append(tr);
|
|
@@ -2544,14 +3555,33 @@ export function initAdminPublishTab(): void {
|
|
|
2544
3555
|
|
|
2545
3556
|
for (const v of versions) {
|
|
2546
3557
|
const tr = document.createElement("tr");
|
|
2547
|
-
|
|
2548
|
-
//
|
|
3558
|
+
// r10: tag the row with its version id so the post-publish "Undo" can deep-
|
|
3559
|
+
// link straight to the replaced version's rollback row (applyRollbackFocus).
|
|
3560
|
+
tr.dataset.versionId = v.versionId;
|
|
3561
|
+
|
|
3562
|
+
// Version identity: the merchant-facing "v42" counter is the primary label;
|
|
3563
|
+
// the tenant Gitea SHA is demoted to a "Technical details" disclosure below
|
|
3564
|
+
// (r02 — a raw SHA is meaningless to a merchant). Falls back to the short SHA
|
|
3565
|
+
// only when a version carries no number (an unpromoted seed), never faked.
|
|
2549
3566
|
const idCell = document.createElement("td");
|
|
2550
3567
|
const strong = document.createElement("strong");
|
|
2551
|
-
|
|
2552
|
-
|
|
3568
|
+
// r11 — the human release name leads when present; the "v42" counter falls
|
|
3569
|
+
// back when it's null (and rides along as a subtle secondary when a name shows).
|
|
3570
|
+
const releaseName = v.name || null;
|
|
3571
|
+
strong.textContent = releaseName || (v.number != null ? "v" + v.number : shortSha(v.versionId));
|
|
2553
3572
|
idCell.append(strong);
|
|
3573
|
+
if (releaseName && v.number != null) idCell.append(subtle("v" + v.number));
|
|
2554
3574
|
if (v.source) idCell.append(subtle(v.source === "commit" ? "From a commit" : "Content hash"));
|
|
3575
|
+
const tech = document.createElement("details");
|
|
3576
|
+
tech.className = "ver-tech";
|
|
3577
|
+
const techSummary = document.createElement("summary");
|
|
3578
|
+
techSummary.textContent = "Technical details";
|
|
3579
|
+
tech.append(techSummary);
|
|
3580
|
+
const shaSpan = document.createElement("span");
|
|
3581
|
+
shaSpan.className = "publish-mono subtle";
|
|
3582
|
+
shaSpan.textContent = shortSha(v.versionId);
|
|
3583
|
+
tech.append(shaSpan);
|
|
3584
|
+
idCell.append(tech);
|
|
2555
3585
|
|
|
2556
3586
|
// "What shipped" — the SAME human summary the staged Publish console shows,
|
|
2557
3587
|
// now describing the RELEASE: a plain-language lead + an openable list of the
|
|
@@ -2621,7 +3651,13 @@ export function initAdminPublishTab(): void {
|
|
|
2621
3651
|
tr.append(revCell);
|
|
2622
3652
|
|
|
2623
3653
|
// State: current live vs prior.
|
|
2624
|
-
tr.append(
|
|
3654
|
+
tr.append(
|
|
3655
|
+
cell(
|
|
3656
|
+
v.isCurrent
|
|
3657
|
+
? chip("Current live", "green", CHIP_GLYPH.green)
|
|
3658
|
+
: chip("Prior", "", CHIP_GLYPH[""]),
|
|
3659
|
+
),
|
|
3660
|
+
);
|
|
2625
3661
|
|
|
2626
3662
|
// Action: roll back to a PRIOR version (never the current one).
|
|
2627
3663
|
const act = document.createElement("td");
|
|
@@ -2642,6 +3678,10 @@ export function initAdminPublishTab(): void {
|
|
|
2642
3678
|
|
|
2643
3679
|
versionsBody.append(tr);
|
|
2644
3680
|
}
|
|
3681
|
+
|
|
3682
|
+
// r10: land a pending post-publish "Undo" on the replaced version's row now
|
|
3683
|
+
// that the rows exist.
|
|
3684
|
+
applyRollbackFocus();
|
|
2645
3685
|
};
|
|
2646
3686
|
|
|
2647
3687
|
// Published versions only change when something is published, which moves the
|
|
@@ -2776,8 +3816,8 @@ export function initAdminPublishTab(): void {
|
|
|
2776
3816
|
: "Previous host (pantheon)";
|
|
2777
3817
|
}
|
|
2778
3818
|
if (stateChip) {
|
|
2779
|
-
|
|
2780
|
-
stateChip
|
|
3819
|
+
const tone = connected ? "green" : "amber";
|
|
3820
|
+
paintChip(stateChip, connected ? "Connected" : "Not connected", tone, CHIP_GLYPH[tone]);
|
|
2781
3821
|
}
|
|
2782
3822
|
if (sinceEl) {
|
|
2783
3823
|
sinceEl.textContent = domain.since
|
|
@@ -2875,8 +3915,8 @@ export function initAdminPublishTab(): void {
|
|
|
2875
3915
|
|
|
2876
3916
|
const chip = readinessBox.querySelector("[data-domain-readiness-chip]");
|
|
2877
3917
|
if (chip) {
|
|
2878
|
-
|
|
2879
|
-
chip
|
|
3918
|
+
const tone = domainReady ? "green" : "amber";
|
|
3919
|
+
paintChip(chip, domainReady ? "Ready" : "Not ready", tone, CHIP_GLYPH[tone]);
|
|
2880
3920
|
}
|
|
2881
3921
|
const list = readinessBox.querySelector("[data-domain-readiness-list]");
|
|
2882
3922
|
if (list) {
|
|
@@ -3040,8 +4080,8 @@ export function initAdminPublishTab(): void {
|
|
|
3040
4080
|
" classification: " + row.classificationLabel,
|
|
3041
4081
|
" reason: " + row.reason,
|
|
3042
4082
|
"",
|
|
3043
|
-
"This deletes the Git BRANCH only
|
|
3044
|
-
"
|
|
4083
|
+
"This deletes the Git BRANCH only. Reject already closes the PR and removes",
|
|
4084
|
+
"its hosted candidate artifacts. The server re-classifies this",
|
|
3045
4085
|
"branch fresh before deleting; a moved tip or a branch that is no longer",
|
|
3046
4086
|
"integrated/closed-or-rejected is refused, not silently skipped.",
|
|
3047
4087
|
].join("\n");
|
|
@@ -3065,6 +4105,33 @@ export function initAdminPublishTab(): void {
|
|
|
3065
4105
|
return { ok: res.ok && data.ok, status: res.status, data };
|
|
3066
4106
|
};
|
|
3067
4107
|
|
|
4108
|
+
// Old branches should clean themselves up — a non-developer shouldn't need to
|
|
4109
|
+
// find and click "Sweep dead branches" for routine housekeeping. Runs silently
|
|
4110
|
+
// (no confirm dialog, no visible status) after a successful accept/publish;
|
|
4111
|
+
// the manual sweep in the advanced panel below still exists for anyone who
|
|
4112
|
+
// wants to trigger or inspect it directly.
|
|
4113
|
+
const autoSweepBranches = async () => {
|
|
4114
|
+
if (!canShip) return;
|
|
4115
|
+
try {
|
|
4116
|
+
const res = await fetch(ds.branchesEndpoint, { headers: { accept: "application/json" } });
|
|
4117
|
+
if (!res.ok) return;
|
|
4118
|
+
const view = await res.json().catch(() => null);
|
|
4119
|
+
if (!view) return;
|
|
4120
|
+
const rows = Array.isArray(view.rows) ? view.rows : [];
|
|
4121
|
+
const sweepable = Array.isArray(view.sweepableRefs) ? view.sweepableRefs : [];
|
|
4122
|
+
if (!sweepable.length) return;
|
|
4123
|
+
const rowsByRef = new Map<string, any>(rows.map((r: any): [string, any] => [r.ref, r]));
|
|
4124
|
+
for (const ref of sweepable) {
|
|
4125
|
+
const row = rowsByRef.get(ref);
|
|
4126
|
+
if (!row) continue;
|
|
4127
|
+
await postDeleteRef(row.ref, row.sha).catch(() => {});
|
|
4128
|
+
}
|
|
4129
|
+
loadBranches(); // refresh the advanced panel if it happens to be open
|
|
4130
|
+
} catch {
|
|
4131
|
+
// Best-effort — a manual sweep in the advanced panel still works.
|
|
4132
|
+
}
|
|
4133
|
+
};
|
|
4134
|
+
|
|
3068
4135
|
const cleanupBranch = async (row?: any, button?: any) => {
|
|
3069
4136
|
if (!window.confirm(buildCleanupPlan(row) + "\n\nDelete this branch?")) return;
|
|
3070
4137
|
button.disabled = true;
|
|
@@ -3433,6 +4500,44 @@ export function initAdminPublishTab(): void {
|
|
|
3433
4500
|
// IntersectionObserver is the single visibility signal — a panel inside a
|
|
3434
4501
|
// hidden admin-tab (display:none) never intersects, so nothing fetches until
|
|
3435
4502
|
// the operator opens the Publish tab and scrolls the panel into view.
|
|
4503
|
+
// r05 — restore each collapsible section's open/closed state from localStorage
|
|
4504
|
+
// (per store, so one store's layout never bleeds into another) and persist it on
|
|
4505
|
+
// every toggle. When nothing is stored the section keeps its SSR default. Native
|
|
4506
|
+
// <details> carry no aria-expanded of their own, so we mirror it onto the summary
|
|
4507
|
+
// and keep it in sync — a later a11y bundle asserts against it.
|
|
4508
|
+
const setupCollapsePersistence = () => {
|
|
4509
|
+
let storage: Storage | null;
|
|
4510
|
+
try {
|
|
4511
|
+
storage = typeof window !== "undefined" ? window.localStorage : null;
|
|
4512
|
+
} catch {
|
|
4513
|
+
storage = null; // localStorage access can throw (private mode / disabled)
|
|
4514
|
+
}
|
|
4515
|
+
const sections: Array<{ id: CollapseSectionId; selector: string }> = [
|
|
4516
|
+
{ id: "review-queue", selector: ".publish-queue-details" },
|
|
4517
|
+
{ id: "closed-changes", selector: ".closed-changes-details" },
|
|
4518
|
+
{ id: "next-release", selector: ".agg-console-details" },
|
|
4519
|
+
{ id: "live-site", selector: ".live-site-details" },
|
|
4520
|
+
{ id: "rollback", selector: ".versions-details" },
|
|
4521
|
+
];
|
|
4522
|
+
for (const { id, selector } of sections) {
|
|
4523
|
+
const details: any = root.querySelector(selector);
|
|
4524
|
+
if (!details) continue;
|
|
4525
|
+
const summary = details.querySelector("summary");
|
|
4526
|
+
const syncAria = () => {
|
|
4527
|
+
if (summary) summary.setAttribute("aria-expanded", details.open ? "true" : "false");
|
|
4528
|
+
};
|
|
4529
|
+
if (storage) {
|
|
4530
|
+
const stored = readPersistedOpen(storage, appDomain, id);
|
|
4531
|
+
if (stored !== null) details.open = stored; // otherwise keep the SSR default
|
|
4532
|
+
}
|
|
4533
|
+
syncAria();
|
|
4534
|
+
details.addEventListener("toggle", () => {
|
|
4535
|
+
if (storage) writePersistedOpen(storage, appDomain, id, details.open);
|
|
4536
|
+
syncAria();
|
|
4537
|
+
});
|
|
4538
|
+
}
|
|
4539
|
+
};
|
|
4540
|
+
|
|
3436
4541
|
const revealDomainOnce = (() => {
|
|
3437
4542
|
let done = false;
|
|
3438
4543
|
return () => {
|
|
@@ -3515,5 +4620,6 @@ export function initAdminPublishTab(): void {
|
|
|
3515
4620
|
}
|
|
3516
4621
|
});
|
|
3517
4622
|
|
|
4623
|
+
setupCollapsePersistence();
|
|
3518
4624
|
observeVisibility();
|
|
3519
4625
|
}
|