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:
hatiyildiz 2026-05-16 12:55:19 +02:00
parent 0404ef63b8
commit 279a667908
6 changed files with 1095 additions and 2 deletions

View File

@ -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;
}
`

View File

@ -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} />
}

View File

@ -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()
})
})

View File

@ -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"

View File

@ -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)
})
})

View File

@ -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'
}