From 279a66790861af570e03ca51275b5e9abee21137 Mon Sep 17 00:00:00 2001 From: hatiyildiz Date: Sat, 16 May 2026 12:55:19 +0200 Subject: [PATCH] fix(sovereign-ui): derive synthetic Apps/Handover stage status from deployment record + auto-redirect after handover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes Gaps C + D from session_2026_05_16_t117_dod_partial.md, which broke DoD gates D6 (0 pending) + D7 (mothership ≡ child) on every multi-region Sovereign post-handover. Gap C — UI synthesizes Apps / Handover / Cutover stage rows (and per- region variants) that catalyst-api's openova-flow snapshot emits at depth=1 so the canvas surfaces the full five-phase lifecycle. When those groups have NO descendants — the common case for Apps (no operator apps installed yet) and Handover (a once-per-Sovereign event with no per-region job rows) — the API emits Status="pending" and the bottom-up rollup leaves it there. Result on JobsPage: 8 phantom "Pending" rows per multi-region prov contradicting the deployment record's status=ready + handoverFiredAt truth. Fix: new `handoverStageOverride.ts` re-derives these stages' status from the deployment snapshot. When handover has fired (status=ready OR handoverFiredAt non-null), pending/running Apps/Handover/Cutover synthetic stages get coerced to "succeeded". Terminal statuses and non-lifecycle jobs (bootstrap-kit, provisioner, install-*) are passed through untouched — backend signal always wins over UI inference. Scoped strictly to the three lifecycle slugs via id- suffix match so install-* jobs are never affected. Gap D — No auto-redirect to the Sovereign Console from JobsPage. The operator typically watches convergence from the Jobs table; without an in-page redirect they get stranded on the mothership even after the Sovereign is ready. AppsPage has the redirect but operators on /jobs miss it. Fix: new `HandoverRedirectBanner.tsx` renders a 3-2-1 countdown + CTA + "Stay on mothership" Cancel button when `handoverReady` from useDeploymentEvents is set AND not in chroot mode. Auto-fires `window.location.assign(handoverURL)` once when countdown reaches 0 (idempotent guard via redirectFiredRef). Cancel suppresses the banner + timer for the rest of the page lifetime. Per the brief: do NOT touch catalyst-api (`internal/handler/flow_ snapshot_local.go` is the canonical group emitter and its contract is stable). UI-layer fix only. Tests: - handoverStageOverride.test.ts — 18 unit cases covering the slug matcher, the handover gate, and every override branch (terminal pass-through, non-lifecycle pass-through, per-region coercion, mixed-mode array stability). - JobsPage.handover.test.tsx — 5 integration cases proving the JobsPage wires both fixes correctly (synthetic stages render as Succeeded when ready; banner renders + Cancel suppresses; auto- redirect fires `window.location.assign` exactly once when the countdown drains; still-installing snapshot keeps stages Pending and banner hidden). All 26 new tests pass. Project lint + typecheck error counts are unchanged from main baseline (27 typecheck errors + 67 lint errors, all in unrelated files — see project drift in JobsTable.tsx / openova-flow/canvas etc.). The new test file inherits the same pre-existing `import/first` rule-not-found error already present in JobsPage.flow-merge.test.tsx — same lint-config drift, not new. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../sovereign/HandoverRedirectBanner.css.ts | 96 +++++ .../sovereign/HandoverRedirectBanner.tsx | 203 ++++++++++ .../sovereign/JobsPage.handover.test.tsx | 349 ++++++++++++++++++ .../ui/src/pages/sovereign/JobsPage.tsx | 60 ++- .../sovereign/handoverStageOverride.test.ts | 205 ++++++++++ .../pages/sovereign/handoverStageOverride.ts | 184 +++++++++ 6 files changed, 1095 insertions(+), 2 deletions(-) create mode 100644 products/catalyst/bootstrap/ui/src/pages/sovereign/HandoverRedirectBanner.css.ts create mode 100644 products/catalyst/bootstrap/ui/src/pages/sovereign/HandoverRedirectBanner.tsx create mode 100644 products/catalyst/bootstrap/ui/src/pages/sovereign/JobsPage.handover.test.tsx create mode 100644 products/catalyst/bootstrap/ui/src/pages/sovereign/handoverStageOverride.test.ts create mode 100644 products/catalyst/bootstrap/ui/src/pages/sovereign/handoverStageOverride.ts diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/HandoverRedirectBanner.css.ts b/products/catalyst/bootstrap/ui/src/pages/sovereign/HandoverRedirectBanner.css.ts new file mode 100644 index 00000000..6a3f6603 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/HandoverRedirectBanner.css.ts @@ -0,0 +1,96 @@ +/** + * HandoverRedirectBanner.css.ts — banner stylesheet for + * `./HandoverRedirectBanner.tsx`, extracted into its own module so the + * .tsx file only exports React components (react-refresh/only-export- + * components — non-component exports break Vite's HMR boundary). + * + * Style tokens mirror AppsPage.handover-ready-banner so the JobsPage + * + AppsPage handover banners feel identical across the two surfaces. + * The Cancel button is a ghost variant (transparent bg + dim border) + * so the CTA stays the dominant call to action. + */ +export const HANDOVER_REDIRECT_BANNER_CSS = ` +.handover-redirect-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem 1rem; + margin: 0.75rem 0 0; + border: 1.5px solid color-mix(in srgb, var(--color-success) 55%, var(--color-border)); + border-radius: 12px; + background: color-mix(in srgb, var(--color-success) 12%, var(--color-surface)); +} +.handover-redirect-body { + display: flex; + flex-direction: column; + gap: 0.2rem; + flex: 1 1 auto; + min-width: 0; +} +.handover-redirect-title { + color: var(--color-success); + font-size: 0.95rem; + font-weight: 700; + letter-spacing: 0.01em; +} +.handover-redirect-sub { + color: var(--color-text); + font-size: 0.82rem; + line-height: 1.45; +} +.handover-redirect-count { + display: inline-block; + min-width: 1.2em; + text-align: center; + font-weight: 700; + color: var(--color-success); + font-variant-numeric: tabular-nums; +} +.handover-redirect-actions { + display: flex; + gap: 0.5rem; + align-items: center; + flex: 0 0 auto; +} +.handover-redirect-cta { + background: var(--color-success); + color: #fff; + padding: 0.55rem 1.1rem; + border-radius: 8px; + font-size: 0.88rem; + font-weight: 600; + text-decoration: none; + white-space: nowrap; + border: 1px solid transparent; + transition: filter 0.15s, box-shadow 0.15s; +} +.handover-redirect-cta:hover { + filter: brightness(0.92); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18); +} +.handover-redirect-cta:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} +.handover-redirect-cancel { + background: transparent; + color: var(--color-text-dim); + padding: 0.5rem 0.9rem; + border-radius: 8px; + font-size: 0.85rem; + font-weight: 500; + white-space: nowrap; + border: 1px solid var(--color-border); + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} +.handover-redirect-cancel:hover { + color: var(--color-text); + border-color: color-mix(in srgb, var(--color-text) 30%, var(--color-border)); +} +.handover-redirect-cancel:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} +` diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/HandoverRedirectBanner.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/HandoverRedirectBanner.tsx new file mode 100644 index 00000000..e4dae168 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/HandoverRedirectBanner.tsx @@ -0,0 +1,203 @@ +/** + * HandoverRedirectBanner — countdown + auto-redirect banner the + * JobsPage renders when the deployment record signals the Sovereign + * lifecycle has reached handover. + * + * Why this lives alongside the AppsPage version: + * AppsPage already drives a global toast + 5-second redirect via + * `useDeploymentEvents().handoverReady` (see AppsPage.tsx lines 284- + * 361). But the operator routinely navigates to /jobs (especially + * while watching convergence) — without an in-page redirect there + * the operator gets stranded on the mothership Jobs view even + * though the Sovereign is ready. + * + * Per founder feedback (Gap D, session_2026_05_16_t117_dod_partial.md): + * + * "After handoverFiredAt, the operator should be auto-routed to + * https://console./auth/handover?token=. Currently the + * operator stays on the mothership Jobs page indefinitely." + * + * This component renders the SAME affordance as the AppsPage banner + * (green hero panel + CTA link) but adds a visible 3-2-1 countdown + * + a "Stay on this page" Cancel button so the operator can pin the + * mothership view if they want to inspect state first. + * + * Why a separate countdown banner (vs. reusing AppsPage's 5-second + * silent timer): + * The AppsPage redirect fires from a global notification toast that + * sits in the corner — visually adjacent to the apps grid. On the + * JobsPage the operator's attention is on the table; a silent + * redirect would feel abrupt. A visible countdown gives 3-2-1 + * warning and matches the founder's brief: + * + * "Show a 3-2-1 countdown banner before redirect so the operator + * can cancel if they want to inspect the mothership state first. + * Cancel button = stay on mothership." + * + * Idempotency: + * The redirect MUST fire at most once per page lifetime — even if + * the deployment record's handoverFiredAt arrives via multiple + * channels (SSE typed event, GET-replay, snapshot reload). A + * `redirectFiredRef` ref guards window.location.assign() so a + * re-render with the same handover state doesn't re-navigate. + * + * Test seam: + * `disableAutoRedirect` short-circuits the timer + window navigate + * so vitest can assert the banner DOM + cancel button without a + * real timer or jsdom-incompatible window.location mutation. Production + * call sites never set it. + */ + +import { useEffect, useRef, useState } from 'react' + +// CSS for this banner lives in `./HandoverRedirectBanner.css.ts` — +// non-component exports break Vite's HMR boundary +// (react-refresh/only-export-components), so this .tsx file only +// exports the React component + its props interface. The JobsPage +// imports the CSS directly from the `.css.ts` sibling. + +/** + * Visible countdown duration in seconds. 3-2-1 per the brief. The + * tick interval is fixed at 1s. + */ +const COUNTDOWN_SECONDS = 3 + +export interface HandoverRedirectBannerProps { + /** + * Canonical handover URL — empty string disables the redirect even + * if `active` is true (defensive guard against partial states). + */ + handoverURL: string + /** + * True when the deployment record proves handover has fired AND the + * caller wants the redirect to run (chroot Sovereign-side renders + * suppress this — there's nowhere to redirect to). + */ + active: boolean + /** + * Sovereign FQDN — displayed in the title for operator context. + * Optional; falls back to "your new Sovereign". + */ + sovereignFQDN?: string | null + /** + * Test seam — when true, the redirect timer + window.location.assign() + * are not scheduled. The banner DOM still renders so visual + cancel- + * button assertions work without faking timers / window.location. + */ + disableAutoRedirect?: boolean +} + +/** + * Inner banner — receives `active === true` as an invariant from its + * parent wrapper. The wrapper conditionally mounts/unmounts the inner + * on activation transitions, which gives us a fresh component + * lifecycle every time handover fires anew (the countdown + cancel + * state initialise from scratch on mount; React handles the cleanup + * on unmount). This avoids the react-hooks/set-state-in-effect + * anti-pattern of resetting state imperatively when a prop flips. + */ +function HandoverRedirectBannerInner(props: HandoverRedirectBannerProps) { + const { handoverURL, sovereignFQDN, disableAutoRedirect = false } = props + + // Operator cancel — drops the banner and the timer for the rest of + // this mount's lifetime. + const [cancelled, setCancelled] = useState(false) + // Countdown tick — decrements each second. + const [remaining, setRemaining] = useState(COUNTDOWN_SECONDS) + // Idempotency guard — once we've fired the redirect for THIS mount, + // never fire again. Lives across re-renders. + const redirectFiredRef = useRef(false) + + // Tick driver — 1s interval that decrements `remaining` while the + // banner is uncancelled. When remaining hits 0 the redirect fires + // once and the interval is cleared. Disabled entirely under the + // test seam. + useEffect(() => { + if (cancelled || disableAutoRedirect) return + if (handoverURL === '') return + if (redirectFiredRef.current) return + + const id = window.setInterval(() => { + setRemaining((prev) => { + if (prev <= 1) { + // Fire the redirect exactly once. Use window.location.assign + // per the brief — it pushes to history (unlike replace) so + // the operator's back button still works to return to the + // mothership view. Guarded by the ref to survive a stray + // double-tick under React Strict Mode. + if (!redirectFiredRef.current) { + redirectFiredRef.current = true + window.location.assign(handoverURL) + } + return 0 + } + return prev - 1 + }) + }, 1000) + + return () => { + window.clearInterval(id) + } + }, [cancelled, disableAutoRedirect, handoverURL]) + + if (cancelled || handoverURL === '') return null + + const targetLabel = sovereignFQDN ?? 'your new Sovereign' + + return ( +
+
+ + ✓ Sovereign is ready{sovereignFQDN ? ` — ${sovereignFQDN}` : ''} + + + Redirecting to {targetLabel} in{' '} + + {remaining} + + {' '}second{remaining === 1 ? '' : 's'}. + +
+
+ + Open your Sovereign console → + + +
+
+ ) +} + +/** + * Public wrapper — gates on `active` so the inner banner's state + * (countdown + cancel + redirect-fired ref) gets a fresh React + * lifecycle every time the deployment record transitions to + * handover-fired. The conditional render here is the only React- + * idiomatic way to "reset" a child's useState without falling back + * to setState-in-effect (react-hooks/set-state-in-effect). + */ +export function HandoverRedirectBanner(props: HandoverRedirectBannerProps) { + if (!props.active) return null + return +} diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/JobsPage.handover.test.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/JobsPage.handover.test.tsx new file mode 100644 index 00000000..964e0b20 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/JobsPage.handover.test.tsx @@ -0,0 +1,349 @@ +/** + * JobsPage.handover.test.tsx — lock-in for Gap C + Gap D fixes (see + * memory: session_2026_05_16_t117_dod_partial.md). + * + * Gap C — synthetic Apps / Handover stages must render as 'succeeded' + * once the deployment record signals handover has fired (status=ready + * OR handoverFiredAt non-null). Before this fix the JobsPage showed + * 8 phantom "Pending" rows per multi-region Sovereign even after the + * deployment was fully ready, breaking DoD gates D6 (0 pending) and + * D7 (mothership ≡ child). + * + * Gap D — once handover fires the operator should see a visible 3-2-1 + * countdown banner with an "Open your Sovereign console →" CTA and a + * "Stay on mothership" Cancel button. The redirect timer auto-fires + * window.location.assign(handoverURL) once when the countdown reaches + * 0; clicking Cancel suppresses both the banner and the timer for the + * rest of the page lifetime. + * + * The auto-redirect timer is stubbed via the JobsPage's + * `disableHandoverAutoRedirect` prop so we can assert banner DOM + + * Cancel button behavior without faking timers / window.location. + * The unit-test suite handoverStageOverride.test.ts covers the + * synthetic-stage status coercion in isolation; this file is the + * end-to-end integration test that proves JobsPage wires both pieces + * correctly. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react' +import { + RouterProvider, + createRouter, + createRootRoute, + createRoute, + createMemoryHistory, + Outlet, +} from '@tanstack/react-router' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { FlowNode, FlowInstance } from '@openova/flow-core' +import { useWizardStore } from '@/entities/deployment/store' +import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model' + +/* ──────────────────────────────────────────────────────────────────── + * Module-level mock for useFlowStream — mirrors the pattern used in + * JobsPage.flow-merge.test.tsx. Each test seeds mockStreamState.nodes + * with the synthetic lifecycle-phase group nodes the catalyst-api + * openova-flow snapshot emits in production. + * ──────────────────────────────────────────────────────────────────── */ + +const mockStreamState = { + flow: null as FlowInstance | null, + nodes: new Map(), + relationships: new Map(), + streamStatus: 'completed' as const, + streamError: null as string | null, +} + +vi.mock('@/lib/openflow-adapter-sse', () => ({ + useFlowStream: () => mockStreamState, +})) + +// eslint-disable-next-line import/first +import { JobsPage } from './JobsPage' + +const DEP_ID = 'f9472ed4d2b9cc2d' +const HANDOVER_URL = + 'https://console.t120.omani.works/auth/handover?token=eyJ.AAA.BBB' + +/** + * Seed mockStreamState.nodes with the 8 lifecycle-stage group nodes + * the catalyst-api flow_snapshot_local.go emits at line ~542+ on a + * fully-converged multi-region Sovereign (1 primary + 3 secondary + * regions, like t120.omani.works on 2026-05-16). Status="pending" + * because these groups have no leaf descendants and the bottom-up + * rollup leaves them as the emitted placeholder. + */ +function seedSyntheticLifecycleStageNodes() { + const stages = [ + // Top-level — flow_snapshot_local.go extraPhases block + { id: `${DEP_ID}:apps`, label: 'Apps' }, + { id: `${DEP_ID}:handover`, label: 'Handover' }, + // Per-region — flow_snapshot_local.go regionsFromJobs loop + { id: `${DEP_ID}:hel1-2:apps`, label: 'Apps (hel1-2)' }, + { id: `${DEP_ID}:hel1-2:handover`, label: 'Handover (hel1-2)' }, + { id: `${DEP_ID}:nbg1-1:apps`, label: 'Apps (nbg1-1)' }, + { id: `${DEP_ID}:nbg1-1:handover`, label: 'Handover (nbg1-1)' }, + { id: `${DEP_ID}:sin-2:apps`, label: 'Apps (sin-2)' }, + { id: `${DEP_ID}:sin-2:handover`, label: 'Handover (sin-2)' }, + ] + for (const s of stages) { + mockStreamState.nodes.set(s.id, { + id: s.id, + flowId: DEP_ID, + label: s.label, + status: 'pending', + }) + } +} + +function renderJobs(opts: { disableHandoverAutoRedirect?: boolean } = {}) { + const rootRoute = createRootRoute({ component: () => }) + const jobsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/provision/$deploymentId/jobs', + component: () => ( + + ), + }) + const homeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/provision/$deploymentId', + component: () =>
, + }) + const jobDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/provision/$deploymentId/jobs/$jobId', + component: () =>
, + }) + const flowRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/provision/$deploymentId/flow', + component: () =>
, + }) + const wizardRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/wizard', + component: () =>
, + }) + const dashboardRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/dashboard', + component: () =>
, + }) + const tree = rootRoute.addChildren([ + jobsRoute, + homeRoute, + jobDetailRoute, + flowRoute, + wizardRoute, + dashboardRoute, + ]) + const router = createRouter({ + routeTree: tree, + history: createMemoryHistory({ + initialEntries: [`/provision/${DEP_ID}/jobs`], + }), + }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }) + return render( + + + , + ) +} + +beforeEach(() => { + useWizardStore.setState({ ...INITIAL_WIZARD_STATE }) + mockStreamState.flow = null + mockStreamState.nodes = new Map() + mockStreamState.relationships = new Map() + mockStreamState.streamStatus = 'completed' + mockStreamState.streamError = null + + // Stub the deployment events fetch — returns a 'ready' snapshot with + // handoverURL + handoverFiredAt populated. Mirrors the wire shape + // catalyst-api emits after fireHandover persists the record. + globalThis.fetch = (() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + events: [], + state: { + id: DEP_ID, + status: 'ready', + sovereignFQDN: 't120.omani.works', + handoverURL: HANDOVER_URL, + handoverFiredAt: '2026-05-16T09:55:06Z', + phase1Outcome: 'ready', + }, + done: true, + }), + } as unknown as Response)) as typeof fetch +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +describe('JobsPage — Gap C: synthetic stage status (handover fired)', () => { + it('renders Apps / Handover synthetic stages as succeeded (not pending) when handoverFiredAt is set', async () => { + seedSyntheticLifecycleStageNodes() + renderJobs() + + // Each row's status pill carries a status data-attribute via + // JobsTable; the cell text contains "Succeeded"/"Pending"/etc per + // statusBadge(). We assert by reading the row's status cell text. + const appsRow = await screen.findByTestId(`jobs-table-row-${DEP_ID}:apps`) + expect(appsRow.textContent).toContain('Succeeded') + expect(appsRow.textContent).not.toContain('Pending') + + const handoverRow = await screen.findByTestId(`jobs-table-row-${DEP_ID}:handover`) + expect(handoverRow.textContent).toContain('Succeeded') + expect(handoverRow.textContent).not.toContain('Pending') + }) + + it('coerces per-region Apps / Handover stages to succeeded as well', async () => { + seedSyntheticLifecycleStageNodes() + renderJobs() + + for (const region of ['hel1-2', 'nbg1-1', 'sin-2']) { + const appsRow = await screen.findByTestId( + `jobs-table-row-${DEP_ID}:${region}:apps`, + ) + expect(appsRow.textContent).toContain('Succeeded') + + const handoverRow = await screen.findByTestId( + `jobs-table-row-${DEP_ID}:${region}:handover`, + ) + expect(handoverRow.textContent).toContain('Succeeded') + } + }) +}) + +describe('JobsPage — Gap D: handover redirect banner', () => { + it('renders the 3-2-1 countdown banner with the canonical handoverURL when handover has fired', async () => { + seedSyntheticLifecycleStageNodes() + renderJobs({ disableHandoverAutoRedirect: true }) + + const banner = await screen.findByTestId('sov-jobs-handover-redirect-banner') + expect(banner).toBeTruthy() + expect(banner.textContent).toContain('Sovereign is ready') + expect(banner.textContent).toContain('t120.omani.works') + + const cta = await screen.findByTestId('sov-jobs-handover-redirect-cta') + expect(cta.getAttribute('href')).toBe(HANDOVER_URL) + expect(cta.textContent).toContain('Open your Sovereign console') + + // Initial countdown value is rendered (3 seconds). + const countdown = await screen.findByTestId( + 'sov-jobs-handover-redirect-countdown', + ) + expect(countdown.textContent).toBe('3') + }) + + it('Cancel button suppresses the banner for the rest of the page lifetime', async () => { + seedSyntheticLifecycleStageNodes() + renderJobs({ disableHandoverAutoRedirect: true }) + + const cancel = await screen.findByTestId('sov-jobs-handover-redirect-cancel') + fireEvent.click(cancel) + + // Banner is removed from the DOM after cancel. + await waitFor(() => { + expect( + screen.queryByTestId('sov-jobs-handover-redirect-banner'), + ).toBeNull() + }) + }) + + it('auto-redirect fires window.location.assign(handoverURL) once when the countdown reaches 0', async () => { + seedSyntheticLifecycleStageNodes() + + // Stub window.location.assign — production code performs a real + // top-level navigation, which jsdom can't follow. Replace + // window.location with a writeable proxy whose `assign` is a spy. + const original = window.location + const assignSpy = vi.fn() + delete (window as { location?: Location }).location + ;(window as unknown as { location: Partial }).location = { + ...original, + assign: assignSpy as unknown as Location['assign'], + } as Location + + try { + // Render with the auto-redirect ENABLED so the interval timer + // ticks down to 0. The countdown is 3 seconds; with the 1s + // setInterval that's a 3-4 second real-time wait. + renderJobs({ disableHandoverAutoRedirect: false }) + + const banner = await screen.findByTestId( + 'sov-jobs-handover-redirect-banner', + ) + expect(banner).toBeTruthy() + + // Poll up to 6 seconds for the timer to drain. We use real timers + // because the JobsPage's effect chain (useDeploymentEvents fetch + // + state update + banner mount + interval scheduling) is async- + // sensitive; mixing vi.useFakeTimers() with @testing-library + // waitFor produced a hang in the parallel AppsPage test. + await waitFor( + () => { + expect(assignSpy).toHaveBeenCalledWith(HANDOVER_URL) + }, + { timeout: 6000, interval: 200 }, + ) + // The redirect MUST fire exactly once across the countdown + // lifecycle — the redirectFiredRef guards a second navigate + // even if a stray tick fires after 0. + expect(assignSpy).toHaveBeenCalledTimes(1) + } finally { + ;(window as unknown as { location: Location }).location = original + } + }, 10000) +}) + +describe('JobsPage — Gap C: still-installing snapshot leaves stages pending', () => { + it('leaves Apps / Handover stages as pending when handover has NOT fired', async () => { + // Override the fetch stub: snapshot has no handoverFiredAt + status + // is still installing. + globalThis.fetch = (() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + events: [], + state: { + id: DEP_ID, + status: 'phase1-installing', + sovereignFQDN: 't120.omani.works', + }, + done: false, + }), + } as unknown as Response)) as typeof fetch + + seedSyntheticLifecycleStageNodes() + renderJobs() + + const appsRow = await screen.findByTestId(`jobs-table-row-${DEP_ID}:apps`) + expect(appsRow.textContent).toContain('Pending') + + const handoverRow = await screen.findByTestId( + `jobs-table-row-${DEP_ID}:handover`, + ) + expect(handoverRow.textContent).toContain('Pending') + + // And the banner is NOT rendered. + expect( + screen.queryByTestId('sov-jobs-handover-redirect-banner'), + ).toBeNull() + }) +}) diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/JobsPage.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/JobsPage.tsx index fa8eae0d..cf33eeb2 100644 --- a/products/catalyst/bootstrap/ui/src/pages/sovereign/JobsPage.tsx +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/JobsPage.tsx @@ -31,17 +31,28 @@ import { DETECTED_MODE } from '@/shared/lib/detectMode' import { useFlowStream } from '@/lib/openflow-adapter-sse' import { synthesizeJobFromFlowNode } from '@/lib/synthesizeJobFromFlowNode' import type { Job } from '@/lib/jobs.types' +import { applyHandoverStageOverride } from './handoverStageOverride' +import { HandoverRedirectBanner } from './HandoverRedirectBanner' +import { HANDOVER_REDIRECT_BANNER_CSS } from './HandoverRedirectBanner.css' interface JobsPageProps { /** Test seam — disables the live SSE EventSource attach. */ disableStream?: boolean /** Test seam — disables the live-jobs backfill polling. */ disableJobsBackfill?: boolean + /** + * Test seam — disables the auto-redirect timer + window.location.assign() + * on the handover banner. The banner DOM still renders so tests can + * assert visibility + cancel-button behavior without faking timers + * or window.location. Production call sites never set this. + */ + disableHandoverAutoRedirect?: boolean } export function JobsPage({ disableStream = false, disableJobsBackfill = false, + disableHandoverAutoRedirect = false, }: JobsPageProps = {}) { const { deploymentId: resolvedId } = useResolvedDeploymentId() const deploymentId = resolvedId ?? '' @@ -53,7 +64,7 @@ export function JobsPage({ ) const applicationIds = useMemo(() => applications.map((a) => a.id), [applications]) - const { state, snapshot, streamStatus } = useDeploymentEvents({ + const { state, snapshot, streamStatus, handoverReady } = useDeploymentEvents({ deploymentId, applicationIds, disableStream, @@ -115,7 +126,7 @@ export function JobsPage({ // Behavior unchanged when the flow stream is empty (Sovereigns // without openova-flow-server deployed) — flowJobs.length === 0 and // the dedupe loop returns legacyMerged untouched. - const flatJobs: Job[] = useMemo(() => { + const mergedJobs: Job[] = useMemo(() => { if (flowJobs.length === 0) return legacyMerged const seen = new Set(legacyMerged.map((j) => j.id)) const extra: Job[] = [] @@ -128,8 +139,44 @@ export function JobsPage({ return [...legacyMerged, ...extra] }, [legacyMerged, flowJobs]) + // Gap C fix (session_2026_05_16_t117_dod_partial.md): the openova- + // flow snapshot emits synthetic Apps / Handover / Cutover stages + // (and per-region variants) at depth=1 so the canvas surfaces the + // full five-phase lifecycle. When those groups have NO descendants + // — which is the common case for Apps (no operator-installed apps + // yet) and Handover (a once-per-Sovereign event with no per-region + // job rows) — the API leaves them at the placeholder "pending" + // status. The result on the JobsPage was 8 phantom "Pending" rows + // that contradicted the deployment record's status=ready + + // handoverFiredAt truth, breaking DoD gates D6 + D7. + // + // applyHandoverStageOverride re-derives those stages' status from + // the deployment snapshot: when handover has fired (status="ready" + // OR handoverFiredAt non-null), pending Apps/Handover/Cutover + // synthetic stages get coerced to "succeeded". Terminal statuses + // and non-lifecycle jobs are passed through untouched. See + // ./handoverStageOverride.ts for the full scope-of-effect rules. + const flatJobs: Job[] = useMemo( + () => applyHandoverStageOverride(mergedJobs, snapshot), + [mergedJobs, snapshot], + ) + const liveBackfillActive = liveJobs.length > 0 + // Gap D fix — auto-redirect to the Sovereign Console after handover. + // The mothership Jobs view is where the operator typically lands + // while watching convergence; without an in-page redirect the + // operator gets stranded here even though the Sovereign is ready. + // Mirrors AppsPage's redirect surface but adds a visible 3-2-1 + // countdown + Cancel button per the founder's brief. + // + // Suppressed in chroot Sovereign mode (`isSovereignMode` declared + // above for the live-backfill gate) — the operator is already at the + // destination; redirecting back to the same URL would loop. + const handoverURL = handoverReady?.handoverURL ?? '' + const handoverActive = + handoverReady !== null && handoverURL !== '' && !isSovereignMode + return ( } > + + + + {liveBackfillActive ? (
= {}): Job { + return { + id, + jobName: id, + type: 'group', + appId: '', + parentId: '', + dependsOn: [], + childIds: [], + status, + startedAt: null, + finishedAt: null, + durationMs: 0, + ...overrides, + } +} + +describe('matchLifecycleStageSlug', () => { + it('matches the three lifecycle slugs in their bare top-level form', () => { + expect(matchLifecycleStageSlug(`${DEP}:apps`)).toBe('apps') + expect(matchLifecycleStageSlug(`${DEP}:handover`)).toBe('handover') + expect(matchLifecycleStageSlug(`${DEP}:cutover`)).toBe('cutover') + }) + + it('matches the per-region variant suffix', () => { + expect(matchLifecycleStageSlug(`${DEP}:hel1-2:apps`)).toBe('apps') + expect(matchLifecycleStageSlug(`${DEP}:nbg1-1:handover`)).toBe('handover') + expect(matchLifecycleStageSlug(`${DEP}:sin-2:cutover`)).toBe('cutover') + }) + + it('does NOT match install-* leaf job ids (anti-regression)', () => { + expect(matchLifecycleStageSlug(`${DEP}:install-cilium`)).toBeNull() + expect(matchLifecycleStageSlug(`${DEP}:install-self-sovereign-cutover`)).toBeNull() + expect(matchLifecycleStageSlug(`${DEP}:install-hel1-2:cilium`)).toBeNull() + }) + + it('does NOT match other group slugs (bootstrap-kit / provisioner)', () => { + expect(matchLifecycleStageSlug(`${DEP}:bootstrap-kit`)).toBeNull() + expect(matchLifecycleStageSlug(`${DEP}:provisioner`)).toBeNull() + expect(matchLifecycleStageSlug(`${DEP}:hel1-2:bootstrap-kit`)).toBeNull() + }) + + it('returns null for empty / non-matching strings', () => { + expect(matchLifecycleStageSlug('')).toBeNull() + expect(matchLifecycleStageSlug('apps')).toBeNull() // no `:` separator + expect(matchLifecycleStageSlug(`${DEP}`)).toBeNull() + }) +}) + +describe('isHandoverFired', () => { + it('is false for null / undefined snapshot', () => { + expect(isHandoverFired(null)).toBe(false) + expect(isHandoverFired(undefined)).toBe(false) + }) + + it('is true when status="ready" alone', () => { + expect(isHandoverFired({ status: 'ready' })).toBe(true) + }) + + it('is true when handoverFiredAt is non-empty (top-level)', () => { + expect(isHandoverFired({ handoverFiredAt: '2026-05-16T09:55:06Z' })).toBe(true) + }) + + it('is true when handoverFiredAt is non-empty (nested under result)', () => { + expect( + isHandoverFired({ result: { handoverFiredAt: '2026-05-16T09:55:06Z' } }), + ).toBe(true) + }) + + it('is false for a still-installing snapshot', () => { + expect(isHandoverFired({ status: 'phase1-installing' })).toBe(false) + expect(isHandoverFired({ status: 'phase0-running' })).toBe(false) + }) + + it('is false when handoverFiredAt is an empty string', () => { + expect(isHandoverFired({ handoverFiredAt: '' })).toBe(false) + expect(isHandoverFired({ result: { handoverFiredAt: '' } })).toBe(false) + }) +}) + +describe('applyHandoverStageOverride', () => { + const HANDOVER_FIRED_SNAPSHOT = { + status: 'ready', + handoverFiredAt: '2026-05-16T09:55:06Z', + handoverURL: 'https://console.example.com/auth/handover?token=eyJ', + } + const STILL_INSTALLING_SNAPSHOT = { + status: 'phase1-installing', + } + + it('coerces pending Apps / Handover / Cutover stages to succeeded when handover fired', () => { + const jobs: Job[] = [ + makeJob(`${DEP}:apps`, 'pending'), + makeJob(`${DEP}:handover`, 'pending'), + makeJob(`${DEP}:cutover`, 'pending'), + ] + const out = applyHandoverStageOverride(jobs, HANDOVER_FIRED_SNAPSHOT) + expect(out.map((j) => j.status)).toEqual(['succeeded', 'succeeded', 'succeeded']) + }) + + it('coerces per-region lifecycle stages to succeeded when handover fired', () => { + const jobs: Job[] = [ + makeJob(`${DEP}:hel1-2:apps`, 'pending'), + makeJob(`${DEP}:nbg1-1:handover`, 'pending'), + makeJob(`${DEP}:sin-2:cutover`, 'pending'), + ] + const out = applyHandoverStageOverride(jobs, HANDOVER_FIRED_SNAPSHOT) + expect(out.map((j) => j.status)).toEqual(['succeeded', 'succeeded', 'succeeded']) + }) + + it('coerces running lifecycle stages too (running is non-terminal)', () => { + const jobs: Job[] = [makeJob(`${DEP}:apps`, 'running')] + const out = applyHandoverStageOverride(jobs, HANDOVER_FIRED_SNAPSHOT) + expect(out[0]!.status).toBe('succeeded') + }) + + it('does NOT touch lifecycle stages when handover has NOT fired', () => { + const jobs: Job[] = [ + makeJob(`${DEP}:apps`, 'pending'), + makeJob(`${DEP}:handover`, 'pending'), + ] + const out = applyHandoverStageOverride(jobs, STILL_INSTALLING_SNAPSHOT) + expect(out.map((j) => j.status)).toEqual(['pending', 'pending']) + // Same reference returned for no-op invocation. + expect(out).toBe(jobs) + }) + + it('does NOT touch a terminal succeeded lifecycle stage (backend wins)', () => { + const jobs: Job[] = [makeJob(`${DEP}:apps`, 'succeeded')] + const out = applyHandoverStageOverride(jobs, HANDOVER_FIRED_SNAPSHOT) + expect(out[0]!.status).toBe('succeeded') + expect(out).toBe(jobs) // no-op → same reference + }) + + it('does NOT touch a terminal failed lifecycle stage (backend wins)', () => { + const jobs: Job[] = [makeJob(`${DEP}:cutover`, 'failed')] + const out = applyHandoverStageOverride(jobs, HANDOVER_FIRED_SNAPSHOT) + expect(out[0]!.status).toBe('failed') + expect(out).toBe(jobs) + }) + + it('passes non-lifecycle jobs through untouched even when handover fired', () => { + const jobs: Job[] = [ + makeJob(`${DEP}:install-cilium`, 'pending', { type: 'install' }), + makeJob(`${DEP}:bootstrap-kit`, 'pending'), + makeJob(`${DEP}:provisioner`, 'running'), + ] + const out = applyHandoverStageOverride(jobs, HANDOVER_FIRED_SNAPSHOT) + expect(out.map((j) => j.status)).toEqual(['pending', 'pending', 'running']) + expect(out).toBe(jobs) + }) + + it('mixes overridden + pass-through correctly in a single call', () => { + const jobs: Job[] = [ + makeJob(`${DEP}:install-cilium`, 'succeeded', { type: 'install' }), // pass-through + makeJob(`${DEP}:apps`, 'pending'), // override + makeJob(`${DEP}:bootstrap-kit`, 'succeeded'), // pass-through + makeJob(`${DEP}:hel1-2:handover`, 'pending'), // override + makeJob(`${DEP}:cutover`, 'failed'), // pass-through (terminal) + ] + const out = applyHandoverStageOverride(jobs, HANDOVER_FIRED_SNAPSHOT) + expect(out.map((j) => j.status)).toEqual([ + 'succeeded', + 'succeeded', + 'succeeded', + 'succeeded', + 'failed', + ]) + // Returns a new array (mutation happened) so memo-aware consumers + // re-render. + expect(out).not.toBe(jobs) + // Pass-through items keep object identity. + expect(out[0]).toBe(jobs[0]) + expect(out[2]).toBe(jobs[2]) + expect(out[4]).toBe(jobs[4]) + }) + + it('does not mutate the input array or input job objects', () => { + const jobs: Job[] = [makeJob(`${DEP}:apps`, 'pending')] + const snapshotBefore = JSON.parse(JSON.stringify(jobs)) + applyHandoverStageOverride(jobs, HANDOVER_FIRED_SNAPSHOT) + expect(jobs).toEqual(snapshotBefore) + }) +}) diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/handoverStageOverride.ts b/products/catalyst/bootstrap/ui/src/pages/sovereign/handoverStageOverride.ts new file mode 100644 index 00000000..27a2cc2f --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/handoverStageOverride.ts @@ -0,0 +1,184 @@ +/** + * handoverStageOverride — derive synthetic Apps / Handover / Cutover + * stage status from the deployment record's handover state. + * + * Why this exists: + * The catalyst-api openova-flow snapshot (flow_snapshot_local.go, + * line 595+) ALWAYS emits the three lifecycle phase groups (cutover, + * handover, apps) at depth=1 so the canvas surfaces the full + * five-phase lifecycle even when those phases have no leaf jobs yet. + * The same emitter also writes per-region sub-groups for every + * phase ("::apps" etc). When those groups have NO + * descendants — the common case on a freshly-converged Sovereign + * that has no operator-installed apps yet, and where Sovereign-wide + * handover has fired but no per-region handover job rows exist + * because handover is a once-per-Sovereign event — the API emits + * them with Status="pending" (line 582). The bottom-up rollup at + * line 745-761 then leaves them as "pending" because they have no + * children to derive from. + * + * Result on the JobsPage: 8 phantom "Pending" rows (Apps, Handover, + * plus Apps () + Handover () per region) that + * contradict the deployment record's `status=ready` + + * `handoverFiredAt` truth and break DoD gates D6 + D7. + * + * This module bridges the gap at the UI layer (the catalyst-api + * contract is stable; we cannot modify the snapshot emitter without + * coordinating with every other flow-snapshot consumer). When the + * deployment record carries proof the lifecycle has reached the + * handover phase (status="ready" OR handoverFiredAt non-null), we + * override these synthetic stages' status from "pending" to + * "succeeded" — they have no work to do, vacuously done. + * + * Scope of the override (DO NOT widen without re-thinking): + * 1. Only stages whose id matches `:apps`, `:handover`, + * `:cutover`, or the per-region variants + * `::apps`/`/handover`/`/cutover`. Other stages + * (bootstrap-kit, provisioner) carry their own real status from + * backend job rows and must NEVER be touched. + * 2. Only when the deployment record proves handover has fired. A + * genuinely-pending Apps stage on a still-installing Sovereign + * MUST remain pending. + * 3. Only override `pending` (and `running`). NEVER overwrite a + * terminal `succeeded` / `failed` value — that would mask real + * backend signal. + * + * Per docs/INVIOLABLE-PRINCIPLES.md: + * #1 (waterfall): no MVP "show as Done badge" — actually mutate the + * status field so every downstream consumer (sort, filter, + * status-pill, DoD validator) sees the correct truth. + * #2 (no compromise): we don't fabricate timestamps. The synthetic + * stages keep their startedAt/finishedAt as the backend emitted + * them (null for empty groups). + * #4 (never hardcode): every slug comes from `LIFECYCLE_STAGE_SLUGS`, + * mirroring `products/catalyst/bootstrap/api/internal/jobs/types.go` + * `GroupApps`/`GroupHandover`/`GroupCutover`. + */ + +import type { Job, JobStatus } from '@/lib/jobs.types' + +/** + * Canonical lifecycle phase slugs eligible for the handover-fired + * override. These mirror the Go constants in + * `products/catalyst/bootstrap/api/internal/jobs/types.go`: + * + * GroupApps = "apps" + * GroupHandover = "handover" + * GroupCutover = "cutover" + * + * Bootstrap-kit + provisioner are deliberately NOT included — those + * stages carry real backend status from leaf job rollups and must not + * be coerced from the deployment record. + */ +export const LIFECYCLE_STAGE_SLUGS = ['apps', 'handover', 'cutover'] as const + +export type LifecycleStageSlug = (typeof LIFECYCLE_STAGE_SLUGS)[number] + +/** + * Minimal subset of `DeploymentSnapshot` this module reads. Keeps the + * coupling between JobsPage + handover state explicit. + */ +export interface HandoverGate { + /** Deployment status — "ready" means Phase-1 reached OutcomeReady. */ + status?: string + /** Top-level RFC 3339 timestamp; non-null after fireHandover persists. */ + handoverFiredAt?: string + /** Top-level handover JWT URL; non-empty after fireHandover persists. */ + handoverURL?: string + /** Same fields nested on `result` per the wire shape. */ + result?: { + handoverFiredAt?: string + handoverURL?: string + } +} + +/** + * True when the deployment record proves the lifecycle has reached + * handover. Reads BOTH top-level AND `result.*` so a reply that only + * populates one shape (the Go server emits both in parallel) still + * trips the gate. + * + * status="ready" alone is sufficient — Phase-1 OutcomeReady is the + * only path to that status, and OutcomeReady triggers fireHandover + * synchronously (see phase1_watch.markPhase1Done). The + * handoverFiredAt check is the belt-and-braces fallback for replays + * where the record was loaded mid-fire. + */ +export function isHandoverFired(snapshot: HandoverGate | null | undefined): boolean { + if (!snapshot) return false + if (snapshot.status === 'ready') return true + if (snapshot.handoverFiredAt && snapshot.handoverFiredAt !== '') return true + if (snapshot.result?.handoverFiredAt && snapshot.result.handoverFiredAt !== '') return true + return false +} + +/** + * Recognise a synthetic lifecycle-stage job id. Returns the matched + * slug ("apps"/"handover"/"cutover") or null. Matches: + * + * :apps + * :handover + * :cutover + * ::apps + * ::handover + * ::cutover + * + * The deploymentId is opaque (16 hex chars in practice; not validated + * here — the suffix match is sufficient because no real install-* job + * ID can end in a bare lifecycle slug). + * + * Type-guarding only — does not allocate. + */ +export function matchLifecycleStageSlug(jobId: string): LifecycleStageSlug | null { + for (const slug of LIFECYCLE_STAGE_SLUGS) { + // Both top-level (":apps") and per-region ("::apps") end + // with the same `:apps` suffix; one suffix check covers both. + if (jobId.endsWith(':' + slug)) { + return slug + } + } + return null +} + +/** + * Apply the handover-fired override across a job list. Pure function + * — no mutation of the input array or its elements. + * + * • Identifies lifecycle-stage jobs via `matchLifecycleStageSlug`. + * • For each match, if the snapshot proves handover fired AND the + * current status is non-terminal (`pending` or `running`), emits a + * new Job object with `status: 'succeeded'`. + * • Terminal statuses (`succeeded` / `failed`) are passed through + * untouched — backend signal wins over UI inference. + * • Non-matching jobs are passed through by identity (reference + * stability for memoised consumers). + * + * Returns the same array reference when nothing was overridden, so + * useMemo callers see a stable result on no-op invocations. + */ +export function applyHandoverStageOverride( + jobs: readonly Job[], + snapshot: HandoverGate | null | undefined, +): Job[] { + if (!isHandoverFired(snapshot)) { + return jobs as Job[] + } + let mutated = false + const out: Job[] = new Array(jobs.length) + for (let i = 0; i < jobs.length; i++) { + const j = jobs[i]! + const slug = matchLifecycleStageSlug(j.id) + if (slug !== null && isOverridableStatus(j.status)) { + out[i] = { ...j, status: 'succeeded' } + mutated = true + } else { + out[i] = j + } + } + return mutated ? out : (jobs as Job[]) +} + +/** Only override non-terminal status values. */ +function isOverridableStatus(s: JobStatus): boolean { + return s === 'pending' || s === 'running' +}