fix(sovereign-ui): derive synthetic Apps/Handover stage status from deployment record + auto-redirect after handover
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) <noreply@anthropic.com>
This commit is contained in:
parent
0404ef63b8
commit
279a667908
@ -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;
|
||||
}
|
||||
`
|
||||
@ -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.<fqdn>/auth/handover?token=<jwt>. 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<number>(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 (
|
||||
<div
|
||||
className="handover-redirect-banner"
|
||||
data-testid="sov-jobs-handover-redirect-banner"
|
||||
role="status"
|
||||
>
|
||||
<div className="handover-redirect-body">
|
||||
<span className="handover-redirect-title">
|
||||
✓ Sovereign is ready{sovereignFQDN ? ` — ${sovereignFQDN}` : ''}
|
||||
</span>
|
||||
<span
|
||||
className="handover-redirect-sub"
|
||||
data-testid="sov-jobs-handover-redirect-sub"
|
||||
>
|
||||
Redirecting to {targetLabel} in{' '}
|
||||
<span
|
||||
className="handover-redirect-count"
|
||||
data-testid="sov-jobs-handover-redirect-countdown"
|
||||
>
|
||||
{remaining}
|
||||
</span>
|
||||
{' '}second{remaining === 1 ? '' : 's'}.
|
||||
</span>
|
||||
</div>
|
||||
<div className="handover-redirect-actions">
|
||||
<a
|
||||
className="handover-redirect-cta"
|
||||
href={handoverURL}
|
||||
data-testid="sov-jobs-handover-redirect-cta"
|
||||
>
|
||||
Open your Sovereign console →
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="handover-redirect-cancel"
|
||||
data-testid="sov-jobs-handover-redirect-cancel"
|
||||
onClick={() => setCancelled(true)}
|
||||
>
|
||||
Stay on mothership
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <HandoverRedirectBannerInner {...props} />
|
||||
}
|
||||
@ -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<string, FlowNode>(),
|
||||
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: () => <Outlet /> })
|
||||
const jobsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/$deploymentId/jobs',
|
||||
component: () => (
|
||||
<JobsPage
|
||||
disableStream
|
||||
disableJobsBackfill
|
||||
disableHandoverAutoRedirect={opts.disableHandoverAutoRedirect ?? true}
|
||||
/>
|
||||
),
|
||||
})
|
||||
const homeRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/$deploymentId',
|
||||
component: () => <div data-testid="apps-target" />,
|
||||
})
|
||||
const jobDetailRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/$deploymentId/jobs/$jobId',
|
||||
component: () => <div data-testid="job-detail-target" />,
|
||||
})
|
||||
const flowRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/$deploymentId/flow',
|
||||
component: () => <div data-testid="flow-target" />,
|
||||
})
|
||||
const wizardRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/wizard',
|
||||
component: () => <div data-testid="wizard-target" />,
|
||||
})
|
||||
const dashboardRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/dashboard',
|
||||
component: () => <div data-testid="dashboard-target" />,
|
||||
})
|
||||
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(
|
||||
<QueryClientProvider client={qc}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
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> }).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()
|
||||
})
|
||||
})
|
||||
@ -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 (
|
||||
<PortalShell
|
||||
deploymentId={deploymentId}
|
||||
@ -145,6 +192,15 @@ export function JobsPage({
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<style>{HANDOVER_REDIRECT_BANNER_CSS}</style>
|
||||
|
||||
<HandoverRedirectBanner
|
||||
handoverURL={handoverURL}
|
||||
active={handoverActive}
|
||||
sovereignFQDN={sovereignFQDN}
|
||||
disableAutoRedirect={disableHandoverAutoRedirect}
|
||||
/>
|
||||
|
||||
{liveBackfillActive ? (
|
||||
<div
|
||||
role="status"
|
||||
|
||||
@ -0,0 +1,205 @@
|
||||
/**
|
||||
* handoverStageOverride.test.ts — unit tests for the synthetic-stage
|
||||
* status override that resolves the Gap C JobsPage phantom-Pending
|
||||
* issue (session_2026_05_16_t117_dod_partial.md).
|
||||
*
|
||||
* The override module is the only piece of logic that re-derives
|
||||
* Apps / Handover / Cutover stage status from the deployment record.
|
||||
* If this drifts every JobsPage consumer's notion of "pending" drifts
|
||||
* with it, so every contract bullet gets a direct test.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { Job } from '@/lib/jobs.types'
|
||||
import {
|
||||
applyHandoverStageOverride,
|
||||
isHandoverFired,
|
||||
matchLifecycleStageSlug,
|
||||
} from './handoverStageOverride'
|
||||
|
||||
const DEP = 'abc123def456'
|
||||
|
||||
function makeJob(id: string, status: Job['status'], overrides: Partial<Job> = {}): 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)
|
||||
})
|
||||
})
|
||||
@ -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 ("<dep>:<region>: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 (<region>) + Handover (<region>) 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 `<dep>:apps`, `<dep>:handover`,
|
||||
* `<dep>:cutover`, or the per-region variants
|
||||
* `<dep>:<region>: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:
|
||||
*
|
||||
* <deploymentId>:apps
|
||||
* <deploymentId>:handover
|
||||
* <deploymentId>:cutover
|
||||
* <deploymentId>:<region>:apps
|
||||
* <deploymentId>:<region>:handover
|
||||
* <deploymentId>:<region>: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 (":<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'
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user