From 63c9f2a706278f5a4292f711d0b71c374f9b8f51 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Sun, 11 Oct 2026 02:35:51 -0400 Subject: [PATCH 1/5] feat(deploy): stage a release now and activate it later (Harper 5.3+) Redeploy and Import offer Stage only where the instance reads 5.3.0 or later: it sends activate: false and no restart, and reports the staged deployment id without a health check. A staged record on the Deployments page has an Activate action, which sends deploy_component with its deployment_id and a rolling restart through the same mutation, so it is followed like any other rolling deploy; the progress panel ends a stage at Staged. All three request shapes come from one deployRequestBody, and the editor's Application menu links to a component's staged releases. Refs #1787 Co-Authored-By: Claude Opus 5.5 --- src/features/instance/applications/DESIGN.md | 12 ++ .../components/ContentActions.tsx | 19 ++- .../DeployProgress/DeployProgress.test.tsx | 14 +++ .../DeployProgress/DeployProgress.tsx | 7 +- .../NewApplication/ImportInstructions.tsx | 26 +++- .../NewApplication/NewApplication.test.tsx | 4 + .../components/NewApplication/schema.ts | 3 + .../NewApplication/useImportApplication.ts | 41 ++++-- .../hooks/useSupportsDeploymentSSE.test.ts | 20 +++ .../hooks/useSupportsDeploymentSSE.ts | 10 ++ .../modals/RedeployApplicationModal.tsx | 100 ++++++++++++--- .../modals/reportDeployHealth.test.ts | 42 ++++++- .../applications/modals/reportDeployHealth.ts | 41 +++++- .../components/ActivateStagedRelease.test.tsx | 70 +++++++++++ .../components/ActivateStagedRelease.tsx | 119 ++++++++++++++++++ .../components/DeploymentDetail.tsx | 10 +- .../applications/deployComponent.test.ts | 76 ++++++++++- .../instance/applications/deployComponent.ts | 66 +++++----- .../applications/deployComponentStream.ts | 27 ++-- .../applications/deployOutcome.test.ts | 10 +- 20 files changed, 615 insertions(+), 102 deletions(-) create mode 100644 src/features/instance/applications/hooks/useSupportsDeploymentSSE.test.ts create mode 100644 src/features/instance/config/deployments/components/ActivateStagedRelease.test.tsx create mode 100644 src/features/instance/config/deployments/components/ActivateStagedRelease.tsx diff --git a/src/features/instance/applications/DESIGN.md b/src/features/instance/applications/DESIGN.md index 95b77130f..6531d8933 100644 --- a/src/features/instance/applications/DESIGN.md +++ b/src/features/instance/applications/DESIGN.md @@ -31,3 +31,15 @@ node the job could not reach is listed with its error and the job still complete Canary certification is shown only when the response carries it, which only 5.4+ does. There is no version check on the deploy path: `registration_info` is denied to a role with an operations allowlist, which is exactly the kind of role that may be allowed to deploy, so a version gate there would hide the result from it. + +## Staging and activation are gated the other way + +Stage only (`activate: false`) and Activate (`deployment_id`) are offered only when `registration_info` reads 5.3.0 or +later (`useStagedDeploymentsAvailable`), and an unreadable version offers neither. The difference from certification is +what happens when the guess is wrong: a node older than 5.3.0 accepts `activate: false` as an unknown field and makes +the release live, which is the opposite of what the person asked for. Certification only adds a field to a response. + +All three kinds of request are built in one place, [`deployRequestBody`](../../../integrations/api/instance/applications/deployComponent.ts), +because Harper's validator refuses the wrong mix outright: a stage with `restart`, or an activation carrying `package`, +install options or `activate`. A stage succeeds only once every other node confirmed staging it, so a stage restarts +nothing and is reported without a health check. Rollback to a kept release (#1232) is the same activation call. diff --git a/src/features/instance/applications/components/ContentActions.tsx b/src/features/instance/applications/components/ContentActions.tsx index ec9879872..39fd53629 100644 --- a/src/features/instance/applications/components/ContentActions.tsx +++ b/src/features/instance/applications/components/ContentActions.tsx @@ -16,7 +16,10 @@ import { useEditorFileContent } from '@/features/instance/applications/context/e import { isDirectory } from '@/features/instance/applications/context/isDirectory'; import { useEditorView } from '@/features/instance/applications/hooks/useEditorView'; import { useEntryActions } from '@/features/instance/applications/hooks/useEntryActions'; -import { useDeploymentsAvailable } from '@/features/instance/applications/hooks/useSupportsDeploymentSSE'; +import { + useDeploymentsAvailable, + useStagedDeploymentsAvailable, +} from '@/features/instance/applications/hooks/useSupportsDeploymentSSE'; import { useInstanceBrowseManagePermission, useInstanceManagePermission } from '@/hooks/usePermissions'; import { useRestartInstanceClick } from '@/hooks/useRestartInstanceClick'; import { emitToListeners, useEmitToListeners } from '@/lib/events/listener'; @@ -38,6 +41,7 @@ import { PanelRightOpenIcon, PencilIcon, PlusIcon, + RocketIcon, RotateCcwIcon, SaveIcon, TrashIcon, @@ -79,6 +83,7 @@ export function ContentActions({ const canManageBrowseInstance = useInstanceBrowseManagePermission(); const canManageInstance = useInstanceManagePermission(); const deploymentsAvailable = useDeploymentsAvailable(); + const stagingAvailable = useStagedDeploymentsAvailable(); const canViewDeployments = canManageInstance && deploymentsAvailable; const navigate = useNavigate(); const routeParams: { clusterId?: string; instanceId?: string; organizationId?: string } = useParams({ @@ -413,6 +418,18 @@ export function ContentActions({ View Deployments )} + {deploymentsProject && stagingAvailable && ( + + void navigate({ + to: buildAbsoluteLinkToPage(routeParams, 'config/deployments'), + search: { project: deploymentsProject, status: 'staged' }, + })} + > + + Staged Releases + + )} )} diff --git a/src/features/instance/applications/components/DeployProgress/DeployProgress.test.tsx b/src/features/instance/applications/components/DeployProgress/DeployProgress.test.tsx index 70f582348..fb867318e 100644 --- a/src/features/instance/applications/components/DeployProgress/DeployProgress.test.tsx +++ b/src/features/instance/applications/components/DeployProgress/DeployProgress.test.tsx @@ -119,6 +119,20 @@ describe('DeployProgress canary certification (Harper 5.4+)', () => { }); }); +describe('DeployProgress staging (Harper 5.3+)', () => { + it('ends a stage at Staged, with no restart', () => { + render( + , + ); + expect(screen.getByText('Staged')).toBeTruthy(); + expect(screen.queryByText('Restart')).toBeNull(); + expect(screen.queryByText('Finish')).toBeNull(); + }); +}); + describe('DeployProgress warnings and rollout', () => { it('keeps the warnings the deploy streamed', () => { render(); diff --git a/src/features/instance/applications/components/DeployProgress/DeployProgress.tsx b/src/features/instance/applications/components/DeployProgress/DeployProgress.tsx index 40c517ccc..bec4f3674 100644 --- a/src/features/instance/applications/components/DeployProgress/DeployProgress.tsx +++ b/src/features/instance/applications/components/DeployProgress/DeployProgress.tsx @@ -2,7 +2,7 @@ import { Badge } from '@/components/ui/badge'; import { ScrollArea } from '@/components/ui/scrollArea'; import { Separator } from '@/components/ui/separator'; import { deploymentRecordPath } from '@/features/instance/applications/hooks/useDeploymentRecordAction'; -import { DEPLOY_PHASE_ORDER } from '@/integrations/api/instance/applications/deployComponentStream'; +import { DEPLOY_PHASE_ORDER, STAGE_PHASE_ORDER } from '@/integrations/api/instance/applications/deployComponentStream'; import { CertificationDecision } from '@/integrations/api/instance/applications/deployOutcome'; import { RolloutNode, RolloutSnapshot } from '@/integrations/api/instance/applications/restartJob'; import { cn } from '@/lib/cn'; @@ -33,6 +33,7 @@ const PHASE_LABELS: Record = { replicate: 'Replicate', restart: 'Restart', success: 'Finish', + staged: 'Staged', }; /** @@ -62,10 +63,12 @@ function PhaseIcon({ status, isCurrent }: { status?: PhaseStatus; isCurrent: boo export function DeployProgress({ state, onNavigateAway, + staging = false, }: { state: DeploymentStreamState; /** Called when a guidance link navigates away — lets a hosting modal close itself first. */ onNavigateAway?: () => void; + staging?: boolean; }) { const logEndRef = useRef(null); const params: { clusterId?: string; instanceId?: string; organizationId?: string } = useParams({ strict: false }); @@ -79,7 +82,7 @@ export function DeployProgress({ return (
    - {DEPLOY_PHASE_ORDER.map((phase) => { + {(staging ? STAGE_PHASE_ORDER : DEPLOY_PHASE_ORDER).map((phase) => { const status = state.phases[phase]; const isCurrent = state.currentPhase === phase && status !== 'done'; return ( diff --git a/src/features/instance/applications/components/NewApplication/ImportInstructions.tsx b/src/features/instance/applications/components/NewApplication/ImportInstructions.tsx index 4399f8c89..a63381c2d 100644 --- a/src/features/instance/applications/components/NewApplication/ImportInstructions.tsx +++ b/src/features/instance/applications/components/NewApplication/ImportInstructions.tsx @@ -1,13 +1,16 @@ import { Button } from '@/components/ui/button'; import { CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { FormControl } from '@/components/ui/form/FormControl'; +import { FormDescription } from '@/components/ui/form/FormDescription'; import { FormField } from '@/components/ui/form/FormField'; import { FormItem } from '@/components/ui/form/FormItem'; import { FormLabel } from '@/components/ui/form/FormLabel'; import { FormMessage } from '@/components/ui/form/FormMessage'; import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; +import { Switch } from '@/components/ui/switch'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useStagedDeploymentsAvailable } from '@/features/instance/applications/hooks/useSupportsDeploymentSSE'; import { GitMergeIcon, LinkIcon, PackageIcon, RocketIcon } from 'lucide-react'; import { useCallback } from 'react'; import { Control, FormState, UseFormSetValue, UseFormWatch } from 'react-hook-form'; @@ -29,6 +32,7 @@ export function ImportInstructions({ watch: UseFormWatch>; }) { const importSource = watch('contents.source'); + const canStage = useStagedDeploymentsAvailable(); const setImportSource = useCallback((source: string) => { setValue('contents.source', source as 'git' | 'npm' | 'tarball'); }, [setValue]); @@ -154,6 +158,26 @@ export function ImportInstructions({ {importSource === 'git' && } + {canStage && ( + ( + + + + +
    + Stage only + + Install it on every node without making it live. Activate it later from Deployments. + +
    +
    + )} + /> + )} + diff --git a/src/features/instance/applications/components/NewApplication/NewApplication.test.tsx b/src/features/instance/applications/components/NewApplication/NewApplication.test.tsx index b2d2f39bf..c9fb7ec9a 100644 --- a/src/features/instance/applications/components/NewApplication/NewApplication.test.tsx +++ b/src/features/instance/applications/components/NewApplication/NewApplication.test.tsx @@ -21,6 +21,10 @@ vi.mock('./useCheckCLI', () => ({ useCheckCLI: () => ({ checkCLI: vi.fn() }) })) // These read the instance's SSH keys and auth, which aren't what these tests exercise. vi.mock('./ImportAuthorization', () => ({ ImportAuthorization: () => null })); vi.mock('./useCLISteps', () => ({ useCLISteps: () => [] })); +// Reads the instance's version to decide whether to offer staging. +vi.mock('@/features/instance/applications/hooks/useSupportsDeploymentSSE', () => ({ + useStagedDeploymentsAvailable: () => false, +})); import { NewApplication } from './index'; diff --git a/src/features/instance/applications/components/NewApplication/schema.ts b/src/features/instance/applications/components/NewApplication/schema.ts index 70c1d1d9f..bd270adbf 100644 --- a/src/features/instance/applications/components/NewApplication/schema.ts +++ b/src/features/instance/applications/components/NewApplication/schema.ts @@ -20,6 +20,8 @@ export const ImportSchema = z.object({ // The SSH key the import URL is expected to use. Form-only state used to verify the URL's host // — it is not sent to deploy_component (the backend resolves the key from the URL host alias). sshKeyName: z.string(), + // Build and install on every node without making it live (Harper 5.3.0+); offered only where that is known. + stageOnly: z.boolean(), }); export const defaultImportOptions: z.infer = { @@ -29,6 +31,7 @@ export const defaultImportOptions: z.infer = { requiresAuth: false, installCommand: '', sshKeyName: '', + stageOnly: false, }; export const CLISchema = z.object({ diff --git a/src/features/instance/applications/components/NewApplication/useImportApplication.ts b/src/features/instance/applications/components/NewApplication/useImportApplication.ts index 30f6cccb7..156156d32 100644 --- a/src/features/instance/applications/components/NewApplication/useImportApplication.ts +++ b/src/features/instance/applications/components/NewApplication/useImportApplication.ts @@ -2,8 +2,11 @@ import { useInstanceClientIdParams } from '@/config/useInstanceClient'; import { useComponentHealthCheck } from '@/features/instance/applications/hooks/useComponentHealthCheck'; import { useDeploymentRecordAction } from '@/features/instance/applications/hooks/useDeploymentRecordAction'; import { useEditorView } from '@/features/instance/applications/hooks/useEditorView'; -import { useSupportsDeploymentSSE } from '@/features/instance/applications/hooks/useSupportsDeploymentSSE'; -import { reportDeployOutcome } from '@/features/instance/applications/modals/reportDeployHealth'; +import { + useStagedDeploymentsAvailable, + useSupportsDeploymentSSE, +} from '@/features/instance/applications/hooks/useSupportsDeploymentSSE'; +import { reportDeployOutcome, reportStaged } from '@/features/instance/applications/modals/reportDeployHealth'; import { useDeployComponentMutation } from '@/integrations/api/instance/applications/deployComponent'; import { deployFailureDetails } from '@/integrations/api/instance/applications/deployOutcome'; import { DeployRolloutError } from '@/integrations/api/instance/applications/restartJob'; @@ -31,6 +34,7 @@ export function useImportApplication( const queryClient = useQueryClient(); const instanceParams = useInstanceClientIdParams(); const sseDeploy = useSupportsDeploymentSSE(); + const canStage = useStagedDeploymentsAvailable(); const runHealthCheck = useComponentHealthCheck(instanceParams); const recordAction = useDeploymentRecordAction(); const { reloadRootEntries, setFocusedItem, setExpandedItems, setSelectedItems } = useEditorView(); @@ -59,32 +63,36 @@ export function useImportApplication( contents: z.infer; project: string; }) => { - const toastId = toast.loading(`Importing application...`, { + const stage = canStage && contents.stageOnly; + const toastId = toast.loading(stage ? 'Staging application...' : 'Importing application...', { description: 'This may take a bit.', duration: 300_000, }); mutate({ - applicationName: project, - applicationUrl: contents.ref, - installCommand: contents.installCommand, + request: { + kind: stage ? 'stage' : 'deploy', + project, + package: contents.ref, + installCommand: contents.installCommand, + }, useSSE: sseDeploy, onEvent: (event) => { // Stream live progress into the loading toast: phase transitions, and the latest // install output line during the install/load phase (so npm output shows through). if (event.type === 'phase' && event.data.status === 'start') { - toast.loading(`Importing ${project}…`, { + toast.loading(`${stage ? 'Staging' : 'Importing'} ${project}…`, { id: toastId, description: PHASE_LABELS[event.data.phase] ?? event.data.phase, duration: 300_000, }); } else if (event.type === 'install' && event.data.line?.trim()) { - toast.loading(`Importing ${project}…`, { + toast.loading(`${stage ? 'Staging' : 'Importing'} ${project}…`, { id: toastId, description: event.data.line.trim().slice(0, 140), duration: 300_000, }); } else if (event.type === 'rollout' && event.data.state === 'following') { - toast.loading(`Importing ${project}…`, { + toast.loading(`${stage ? 'Staging' : 'Importing'} ${project}…`, { id: toastId, description: 'Rolling out to the other nodes…', duration: Infinity, @@ -95,6 +103,11 @@ export function useImportApplication( }, { onSuccess: async (result) => { toast.dismiss(toastId); + if (stage) { + // Nothing went live, so there is no new application in the tree to open. + reportStaged(project, result, { action: recordAction(result?.deployment_id) }); + return; + } onImported(project); // A followed rollout restarted the other nodes, so there is a health to check however the deploy was sent. if (sseDeploy || result?.rollout) { @@ -111,6 +124,12 @@ export function useImportApplication( onError: async (error) => { toast.dismiss(toastId); // Live stream dropped after the deploy started — verify rather than assume failure. + if (error instanceof SSEInconclusiveError && stage) { + toast.warning(`${project} may not be staged`, { + description: 'Live updates ended before the stage finished. Check Deployments for its record.', + }); + return; + } if (error instanceof SSEInconclusiveError) { onImported(project); await reportDeployOutcome(project, undefined, runHealthCheck); @@ -122,13 +141,13 @@ export function useImportApplication( toast.error(`${project} did not reach every node`, { description: error.message, action }); return; } - toast.error(`Failed to import ${project}`, { + toast.error(`Failed to ${stage ? 'stage' : 'import'} ${project}`, { description: error instanceof Error ? error.message : undefined, action, }); }, }); - }, [mutate, sseDeploy, instanceParams, runHealthCheck, onImported, recordAction]); + }, [mutate, sseDeploy, canStage, instanceParams, runHealthCheck, onImported, recordAction]); return useMemo(() => ({ isImportingApplication, diff --git a/src/features/instance/applications/hooks/useSupportsDeploymentSSE.test.ts b/src/features/instance/applications/hooks/useSupportsDeploymentSSE.test.ts new file mode 100644 index 000000000..5fb026b9d --- /dev/null +++ b/src/features/instance/applications/hooks/useSupportsDeploymentSSE.test.ts @@ -0,0 +1,20 @@ +/** @vitest-environment jsdom */ +import { describe, expect, it } from 'vitest'; +import { supportsStagedDeployments } from './useSupportsDeploymentSSE'; + +describe('supportsStagedDeployments', () => { + it.each([ + ['5.3.0', true], + ['5.3.2', true], + ['5.4.0', true], + ['6.0.0', true], + ['5.2.15', false], + ['4.7.9', false], + // An older node reads `activate: false` as an ordinary deploy, so anything short of a release is refused. + ['5.3.0-beta.4', false], + [undefined, false], + ['not a version', false], + ])('%s -> %s', (version, expected) => { + expect(supportsStagedDeployments(version)).toBe(expected); + }); +}); diff --git a/src/features/instance/applications/hooks/useSupportsDeploymentSSE.ts b/src/features/instance/applications/hooks/useSupportsDeploymentSSE.ts index d34e7ccdd..4bdbf78a6 100644 --- a/src/features/instance/applications/hooks/useSupportsDeploymentSSE.ts +++ b/src/features/instance/applications/hooks/useSupportsDeploymentSSE.ts @@ -25,6 +25,16 @@ export function supportsStagedDeployments(harperVersion: string | undefined): bo return !!harperVersion && wasAReleasedBeforeB(MIN_STAGED_DEPLOYMENT_VERSION, harperVersion); } +/** + * Whether to offer staging and activation. Unlike a put, an unreadable version must not offer them: a node older + * than 5.3.0 accepts `activate: false` as an unknown field and deploys the release live. + */ +export function useStagedDeploymentsAvailable(): boolean { + const params = useInstanceClientIdParams(); + const { data } = useQuery(getRegistrationInfoQueryOptions(params)); + return supportsStagedDeployments(data?.version); +} + /** * True when the instance/cluster is Harper >= 5.1.0, so the deployment history/detail * feature (`list_deployments` / `get_deployment`) is available. This gates feature diff --git a/src/features/instance/applications/modals/RedeployApplicationModal.tsx b/src/features/instance/applications/modals/RedeployApplicationModal.tsx index 6882bbed7..2047b14a1 100644 --- a/src/features/instance/applications/modals/RedeployApplicationModal.tsx +++ b/src/features/instance/applications/modals/RedeployApplicationModal.tsx @@ -2,18 +2,23 @@ import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Form } from '@/components/ui/form/Form'; import { FormControl } from '@/components/ui/form/FormControl'; +import { FormDescription } from '@/components/ui/form/FormDescription'; import { FormField } from '@/components/ui/form/FormField'; import { FormItem } from '@/components/ui/form/FormItem'; import { FormLabel } from '@/components/ui/form/FormLabel'; import { FormMessage } from '@/components/ui/form/FormMessage'; import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; import { useInstanceClientIdParams } from '@/config/useInstanceClient'; import { DeployProgress } from '@/features/instance/applications/components/DeployProgress/DeployProgress'; import { useDeploymentStream } from '@/features/instance/applications/components/DeployProgress/useDeploymentStream'; import { useComponentHealthCheck } from '@/features/instance/applications/hooks/useComponentHealthCheck'; import { useDeploymentRecordAction } from '@/features/instance/applications/hooks/useDeploymentRecordAction'; import { useEditorView } from '@/features/instance/applications/hooks/useEditorView'; -import { useSupportsDeploymentSSE } from '@/features/instance/applications/hooks/useSupportsDeploymentSSE'; +import { + useStagedDeploymentsAvailable, + useSupportsDeploymentSSE, +} from '@/features/instance/applications/hooks/useSupportsDeploymentSSE'; import { useDeployComponentMutation } from '@/integrations/api/instance/applications/deployComponent'; import { deployFailureDetails } from '@/integrations/api/instance/applications/deployOutcome'; import { DeployRolloutError } from '@/integrations/api/instance/applications/restartJob'; @@ -26,7 +31,7 @@ import { RefreshCwIcon } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; -import { deployNotes, reportDeployOutcome, reportDeployResult } from './reportDeployHealth'; +import { deployNotes, reportDeployOutcome, reportDeployResult, reportStaged } from './reportDeployHealth'; export function RedeployApplicationModal() { const { value: isModalOpen, trigger } = useWatchedValue('ShowRedeployApplicationModal', false); @@ -34,6 +39,7 @@ export function RedeployApplicationModal() { const queryClient = useQueryClient(); const instanceParams = useInstanceClientIdParams(); const sseDeploy = useSupportsDeploymentSSE(); + const canStage = useStagedDeploymentsAvailable(); const stream = useDeploymentStream(); const runHealthCheck = useComponentHealthCheck(instanceParams); const recordAction = useDeploymentRecordAction(); @@ -43,25 +49,29 @@ export function RedeployApplicationModal() { const { mutate: reDeployApplication, isPending } = useDeployComponentMutation(); const [isStreaming, setIsStreaming] = useState(false); + const [isStaging, setIsStaging] = useState(false); const methods = useForm({ defaultValues: { applicationUrl: packageUrl, installCommand: '', + stageOnly: false, }, }); - const { control, handleSubmit, reset } = methods; + const { control, handleSubmit, reset, watch } = methods; + const stageOnlyChecked = canStage && watch('stageOnly'); useEffect(() => { - reset({ applicationUrl: packageUrl }); + reset({ applicationUrl: packageUrl, stageOnly: false }); }, [reset, packageUrl]); const closeModal = useCallback(() => { setWatchedValue('ShowRedeployApplicationModal', false); attemptToRestoreFocus(trigger); - reset({ applicationUrl: packageUrl }); + reset({ applicationUrl: packageUrl, stageOnly: false }); setIsStreaming(false); + setIsStaging(false); stream.reset(); }, [reset, packageUrl, trigger, stream]); @@ -73,7 +83,7 @@ export function RedeployApplicationModal() { }, [queryClient, instanceParams.entityId]); const redeployWithProgress = useCallback( - (applicationUrl: string, installCommand: string | undefined) => { + (applicationUrl: string, installCommand: string | undefined, stage: boolean) => { if (!openedEntry) { return; } @@ -81,18 +91,25 @@ export function RedeployApplicationModal() { stream.reset(); stream.markStarted(); setIsStreaming(true); + setIsStaging(stage); reDeployApplication( { - applicationName: project, - applicationUrl, - installCommand, + request: { kind: stage ? 'stage' : 'deploy', project, package: applicationUrl, installCommand }, useSSE: true, onEvent: stream.onEvent, ...instanceParams, }, { onSuccess: async (result) => { + if (stage) { + stream.markSettled('success', undefined, { result }); + reportStaged(project, result, { action: recordAction(result?.deployment_id) }); + if (!deployNotes(result).warn) { + closeModal(); + } + return; + } const known = result?.rollout?.state !== 'unfollowable'; stream.markSettled(known ? 'success' : 'inconclusive', undefined, { result }); invalidateComponents(); @@ -109,6 +126,14 @@ export function RedeployApplicationModal() { // instead of reporting a failure we don't actually know happened. if (error instanceof SSEInconclusiveError) { stream.markSettled('inconclusive'); + if (stage) { + // A stage changes nothing that is running, so the component's health says nothing about it. + toast.warning(`${project} may not be staged`, { + description: 'Live updates ended before the stage finished. Check Deployments for its record.', + }); + closeModal(); + return; + } invalidateComponents(); await reportDeployOutcome(project, undefined, runHealthCheck); closeModal(); @@ -125,7 +150,7 @@ export function RedeployApplicationModal() { stream.markSettled('error', error instanceof Error ? error.message : String(error), { failure: error, }); - toast.error(`Failed to redeploy ${project}`, { + toast.error(`Failed to ${stage ? 'stage' : 'redeploy'} ${project}`, { description: error instanceof Error ? error.message : undefined, action, }); @@ -146,16 +171,28 @@ export function RedeployApplicationModal() { ); const redeployBuffered = useCallback( - (applicationUrl: string, installCommand: string | undefined) => { + (applicationUrl: string, installCommand: string | undefined, stage: boolean) => { if (!openedEntry) { return; } closeModal(); - const toastId = toast.loading('Redeploying...'); + const toastId = toast.loading(stage ? 'Staging...' : 'Redeploying...'); reDeployApplication( - { applicationName: openedEntry.project, applicationUrl, installCommand, ...instanceParams }, + { + request: { + kind: stage ? 'stage' : 'deploy', + project: openedEntry.project, + package: applicationUrl, + installCommand, + }, + ...instanceParams, + }, { onSuccess: (result) => { + if (stage) { + reportStaged(openedEntry.project, result, { id: toastId, action: recordAction(result?.deployment_id) }); + return; + } reportDeployResult( openedEntry.project, result, @@ -177,7 +214,7 @@ export function RedeployApplicationModal() { return; } if (action) { - toast.error(`Failed to redeploy ${openedEntry.project}`, { + toast.error(`Failed to ${stage ? 'stage' : 'redeploy'} ${openedEntry.project}`, { id: toastId, description: describeError(error).message, action, @@ -192,17 +229,19 @@ export function RedeployApplicationModal() { [reDeployApplication, openedEntry, instanceParams, invalidateComponents, closeModal, recordAction], ); - const submitForm = ({ applicationUrl, installCommand }: { + const submitForm = ({ applicationUrl, installCommand, stageOnly }: { applicationUrl: string | undefined; installCommand: string | undefined; + stageOnly?: boolean; }) => { if (!applicationUrl) { return; } + const stage = canStage && !!stageOnly; if (sseDeploy) { - redeployWithProgress(applicationUrl, installCommand); + redeployWithProgress(applicationUrl, installCommand, stage); } else { - redeployBuffered(applicationUrl, installCommand); + redeployBuffered(applicationUrl, installCommand, stage); } }; @@ -215,14 +254,16 @@ export function RedeployApplicationModal() { Redeploy Package - {isStreaming ? `Redeploying ${openedEntry?.project ?? 'package'}…` : 'Redeploy this package?'} + {isStreaming + ? `${isStaging ? 'Staging' : 'Redeploying'} ${openedEntry?.project ?? 'package'}…` + : 'Redeploy this package?'} {isStreaming ? (
    - + + {started && ( + <> + + + + )} + + + + Activate this release of {project}? + + Deployment {deploymentId}{' '} + goes live on every node, which restart one at a time. Nothing is installed: each node swaps in the release + it staged. + + + + Cancel + Activate + + + + + ); +} diff --git a/src/features/instance/config/deployments/components/DeploymentDetail.tsx b/src/features/instance/config/deployments/components/DeploymentDetail.tsx index 02afaf76e..01c45ec6e 100644 --- a/src/features/instance/config/deployments/components/DeploymentDetail.tsx +++ b/src/features/instance/config/deployments/components/DeploymentDetail.tsx @@ -15,6 +15,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Link, useParams } from '@tanstack/react-router'; import { useEffect } from 'react'; import { describePeerInstall } from '../functions/describePeerInstall'; +import { ActivateStagedRelease } from './ActivateStagedRelease'; import { DeploymentStatusBadge } from './deploymentStatusBadge'; function Field({ label, value }: { label: string; value: React.ReactNode }) { @@ -139,9 +140,12 @@ export function DeploymentDetail({ deploymentId }: { deploymentId: string }) {
    {deployment.status === 'staged' && ( -
    - Staged: built and installed, but not live. Activating it makes it live with nothing to install. -
    + <> +
    + Staged: built and installed, but not live. Activating it makes it live with nothing to install. +
    + + )} {showLiveProgress && ( diff --git a/src/integrations/api/instance/applications/deployComponent.test.ts b/src/integrations/api/instance/applications/deployComponent.test.ts index 45ca0050d..8820daef7 100644 --- a/src/integrations/api/instance/applications/deployComponent.test.ts +++ b/src/integrations/api/instance/applications/deployComponent.test.ts @@ -5,7 +5,7 @@ vi.mock('./deployComponentStream', () => ({ })); import { SSEOperationError, SSEUnsupportedError } from '@/integrations/api/sse/errors'; -import { deployAndFollowComponent, onDeployComponentSubmit } from './deployComponent'; +import { deployAndFollowComponent, deployRequestBody, onDeployComponentSubmit } from './deployComponent'; import { deployComponentStream } from './deployComponentStream'; import { DeployRolloutError } from './restartJob'; @@ -16,8 +16,7 @@ function fakeClient(data: unknown) { } const baseArgs = { - applicationName: 'my-app', - applicationUrl: 'npm:my-app', + request: { kind: 'deploy' as const, project: 'my-app', package: 'npm:my-app' }, entityId: 'ins-1' as const, entityType: 'instance' as const, }; @@ -26,7 +25,78 @@ afterEach(() => { vi.clearAllMocks(); }); +// Harper's validator decides these shapes: a stage refuses `restart` (harper `v5.3.0:components/operationsValidation.js:517-524`), +// and an activation refuses `package`, install options and `activate` alongside `deployment_id` (`:613-616`). +describe('deployRequestBody', () => { + it('deploys with a rolling restart', () => { + expect(deployRequestBody({ kind: 'deploy', project: 'web', package: 'npm:web', installCommand: 'npm ci' }, true)) + .toEqual({ + operation: 'deploy_component', + package: 'npm:web', + project: 'web', + replicated: true, + install_command: 'npm ci', + restart: 'rolling', + }); + }); + + it('stages with activate: false and no restart', () => { + const body = deployRequestBody({ kind: 'stage', project: 'web', package: 'npm:web' }, true); + expect(body).toEqual({ + operation: 'deploy_component', + package: 'npm:web', + project: 'web', + replicated: true, + install_command: undefined, + activate: false, + }); + expect(body).not.toHaveProperty('restart'); + }); + + it('activates by id alone, with a rolling restart and nothing to install', () => { + const body = deployRequestBody({ kind: 'activate', project: 'web', deploymentId: 'a3f8c2d1' }, false); + expect(body).toEqual({ + operation: 'deploy_component', + project: 'web', + deployment_id: 'a3f8c2d1', + replicated: false, + restart: 'rolling', + }); + expect(JSON.parse(JSON.stringify(body))).not.toHaveProperty('package'); + }); +}); + describe('onDeployComponentSubmit', () => { + it('stages through the buffered path exactly as built', async () => { + const client = fakeClient({ message: 'Staged: my-app', staged: true, deployment_id: 'd1' }); + await onDeployComponentSubmit({ + ...baseArgs, + request: { kind: 'stage', project: 'my-app', package: 'npm:my-app' }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + instanceClient: client as any, + }); + expect(client.post).toHaveBeenCalledWith( + '/', + expect.objectContaining({ activate: false, project: 'my-app', replicated: false }), + { timeout: 300_000 }, + ); + expect(client.post.mock.calls[0][1]).not.toHaveProperty('restart'); + }); + + it('streams the same body it would post', async () => { + mockStream.mockResolvedValue({ message: 'ok' }); + await onDeployComponentSubmit({ + ...baseArgs, + request: { kind: 'activate', project: 'my-app', deploymentId: 'd1' }, + useSSE: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + instanceClient: fakeClient({}) as any, + }); + expect(mockStream).toHaveBeenCalledWith(expect.objectContaining({ + body: deployRequestBody({ kind: 'activate', project: 'my-app', deploymentId: 'd1' }, false), + })); + }); + it('uses the buffered axios path when SSE is not requested', async () => { const client = fakeClient({ message: 'buffered' }); const result = await onDeployComponentSubmit({ diff --git a/src/integrations/api/instance/applications/deployComponent.ts b/src/integrations/api/instance/applications/deployComponent.ts index a9fab9b35..e700082cc 100644 --- a/src/integrations/api/instance/applications/deployComponent.ts +++ b/src/integrations/api/instance/applications/deployComponent.ts @@ -6,13 +6,39 @@ import { useMutation } from '@tanstack/react-query'; import { DeployComponentResult, deployComponentStream, DeployStreamEvent } from './deployComponentStream'; import { DeployRolloutError, followRestartJob } from './restartJob'; -export interface DeployComponentFormData { - applicationName: string; - applicationUrl: string; - installCommand?: string; +/** + * What a `deploy_component` call does. `stage` builds and installs the release on every node without making it + * live, and takes no `restart` (Harper refuses one); `activate` makes a staged or kept release live by its id and + * installs nothing. Both need Harper 5.3.0 on every node — an older node reads a stage as an ordinary deploy and + * serves it — so a caller offers them only on a version it could read. + */ +export type DeployRequest = + | { kind: 'deploy'; project: string; package: string; installCommand?: string } + | { kind: 'stage'; project: string; package: string; installCommand?: string } + | { kind: 'activate'; project: string; deploymentId: string }; + +export function deployRequestBody(request: DeployRequest, replicated: boolean): Record { + if (request.kind === 'activate') { + return { + operation: 'deploy_component', + project: request.project, + deployment_id: request.deploymentId, + replicated, + restart: 'rolling', + }; + } + return { + operation: 'deploy_component', + package: request.package, + project: request.project, + replicated, + install_command: request.installCommand || undefined, + ...(request.kind === 'stage' ? { activate: false } : { restart: 'rolling' }), + }; } -export interface DeployComponentArgs extends DeployComponentFormData, InstanceClientConfig, InstanceTypeConfig { +export interface DeployComponentArgs extends InstanceClientConfig, InstanceTypeConfig { + request: DeployRequest; /** Entity id, used to resolve the SSE connection. Present on `useInstanceClientIdParams`. */ entityId?: EntityIds; /** Opt into the live SSE deploy (only when the instance is Harper >= 5.1.0). */ @@ -24,9 +50,7 @@ export interface DeployComponentArgs extends DeployComponentFormData, InstanceCl } export async function onDeployComponentSubmit({ - applicationName, - applicationUrl, - installCommand, + request, entityType, entityId, instanceClient, @@ -34,20 +58,11 @@ export async function onDeployComponentSubmit({ onEvent, signal, }: DeployComponentArgs): Promise { - const replicated = entityType === 'cluster'; + const body = deployRequestBody(request, entityType === 'cluster'); if (useSSE && entityId) { try { - return await deployComponentStream({ - connection: { id: entityId }, - package: applicationUrl, - project: applicationName, - replicated, - install_command: installCommand, - restart: 'rolling', - signal, - onEvent, - }); + return await deployComponentStream({ connection: { id: entityId }, body, signal, onEvent }); } catch (error) { // Couldn't establish a stream (bad response/content-type/connection) and no deploy // has started yet — safe to fall back to the proven buffered path below. @@ -60,18 +75,7 @@ export async function onDeployComponentSubmit({ } } - const { data } = await instanceClient.post( - '/', - { - operation: 'deploy_component', - package: applicationUrl, - project: applicationName, - replicated, - install_command: installCommand || undefined, - restart: 'rolling', - }, - { timeout: 300_000 }, - ); + const { data } = await instanceClient.post('/', body, { timeout: 300_000 }); return data as ReplicatedResponse; } diff --git a/src/integrations/api/instance/applications/deployComponentStream.ts b/src/integrations/api/instance/applications/deployComponentStream.ts index c502a7778..cc760ce98 100644 --- a/src/integrations/api/instance/applications/deployComponentStream.ts +++ b/src/integrations/api/instance/applications/deployComponentStream.ts @@ -7,7 +7,8 @@ import { RolloutSnapshot } from './restartJob'; /** Lifecycle phases Harper's `deploy_component` emits, in order. */ export const DEPLOY_PHASE_ORDER = ['prepare', 'load', 'replicate', 'restart', 'success'] as const; -export type DeployPhase = (typeof DEPLOY_PHASE_ORDER)[number]; +/** A stage (5.3.0+) restarts nothing, and ends `staged` where a deploy ends `success`. */ +export const STAGE_PHASE_ORDER = ['prepare', 'load', 'replicate', 'staged'] as const; export interface DeployPhaseEvent { phase: string; @@ -51,6 +52,8 @@ export interface DeployComponentResult extends ReplicatedResponse { deployment_id?: string; phase?: string; certification?: DeployCertification; + /** Set by every node that staged the release rather than making it live (5.3.0+). */ + staged?: boolean; /** The `restart_service` job a rolling restart hands the other nodes to. */ restartJobId?: string; /** The `warning` events the deploy streamed. */ @@ -80,11 +83,8 @@ export function toDeployStreamEvent(message: SSEMessage): DeployStreamEvent | un export interface DeployComponentStreamParams { connection: ResolveInstanceConnectionParams; - package: string; - project: string; - replicated: boolean; - install_command?: string; - restart?: string; + /** The `deploy_component` operation, as `deployRequestBody` builds it. */ + body: Record; signal?: AbortSignal; /** Called for each live deploy event so the UI can render progress. */ onEvent?: (event: DeployStreamEvent) => void; @@ -101,25 +101,14 @@ export interface DeployComponentStreamParams { */ export async function deployComponentStream({ connection, - package: packageRef, - project, - replicated, - install_command, - restart, + body, signal, onEvent, }: DeployComponentStreamParams): Promise { const warnings: string[] = []; const result = await streamOperation({ connection, - body: { - operation: 'deploy_component', - package: packageRef, - project, - replicated, - install_command: install_command || undefined, - restart, - }, + body, signal, onMessage: (message) => { const event = toDeployStreamEvent(message); diff --git a/src/integrations/api/instance/applications/deployOutcome.test.ts b/src/integrations/api/instance/applications/deployOutcome.test.ts index 5ccef5569..73deb7064 100644 --- a/src/integrations/api/instance/applications/deployOutcome.test.ts +++ b/src/integrations/api/instance/applications/deployOutcome.test.ts @@ -9,6 +9,7 @@ vi.mock('@/integrations/api/sse/resolveInstanceConnection', () => ({ }), })); +import { deployRequestBody } from './deployComponent'; import { deployComponentStream } from './deployComponentStream'; import { deployFailureDetails, readCertificationDecision } from './deployOutcome'; import { DeployRolloutError } from './restartJob'; @@ -39,10 +40,7 @@ function sseResponse(body: string): Response { function streamDeploy() { return deployComponentStream({ connection: { id: 'ins-1' }, - package: 'npm:web', - project: 'web', - replicated: true, - restart: 'rolling', + body: deployRequestBody({ kind: 'deploy', project: 'web', package: 'npm:web' }, true), }); } @@ -130,9 +128,7 @@ describe('deployComponentStream', () => { const events: unknown[] = []; const result = await deployComponentStream({ connection: { id: 'ins-1' }, - package: 'npm:web', - project: 'web', - replicated: true, + body: deployRequestBody({ kind: 'deploy', project: 'web', package: 'npm:web' }, true), onEvent: (event) => events.push(event), }); expect(events).toEqual([{ type: 'warning', data: { message: drift } }]); From c9b7cda2991d7d4ad051a80a7266f28ea946f27b Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Sun, 11 Oct 2026 03:10:34 -0400 Subject: [PATCH 2/5] fix(deploy): an activation survives its detail closing, and is never sent twice Activate awaits the mutation instead of passing it callbacks, which React Query drops once the detail unmounts, so the report and the refresh still happen for an activation someone closed the dialog on. The mutation is keyed by entity and deployment, so a detail reopened mid-rollout shows it pending and offers no second activation. A stream that ends early says the outcome is unknown instead of reporting a failure. Refs #1787 Co-Authored-By: Claude Opus 5.5 --- .../NewApplication/NewApplication.test.tsx | 1 - .../components/NewApplication/schema.ts | 1 - .../components/ActivateStagedRelease.test.tsx | 75 ++++++++++++--- .../components/ActivateStagedRelease.tsx | 95 ++++++++++++------- .../instance/applications/deployComponent.ts | 3 +- .../applications/deployComponentStream.ts | 3 - 6 files changed, 126 insertions(+), 52 deletions(-) diff --git a/src/features/instance/applications/components/NewApplication/NewApplication.test.tsx b/src/features/instance/applications/components/NewApplication/NewApplication.test.tsx index c9fb7ec9a..fb44778fe 100644 --- a/src/features/instance/applications/components/NewApplication/NewApplication.test.tsx +++ b/src/features/instance/applications/components/NewApplication/NewApplication.test.tsx @@ -21,7 +21,6 @@ vi.mock('./useCheckCLI', () => ({ useCheckCLI: () => ({ checkCLI: vi.fn() }) })) // These read the instance's SSH keys and auth, which aren't what these tests exercise. vi.mock('./ImportAuthorization', () => ({ ImportAuthorization: () => null })); vi.mock('./useCLISteps', () => ({ useCLISteps: () => [] })); -// Reads the instance's version to decide whether to offer staging. vi.mock('@/features/instance/applications/hooks/useSupportsDeploymentSSE', () => ({ useStagedDeploymentsAvailable: () => false, })); diff --git a/src/features/instance/applications/components/NewApplication/schema.ts b/src/features/instance/applications/components/NewApplication/schema.ts index bd270adbf..d55d7da44 100644 --- a/src/features/instance/applications/components/NewApplication/schema.ts +++ b/src/features/instance/applications/components/NewApplication/schema.ts @@ -20,7 +20,6 @@ export const ImportSchema = z.object({ // The SSH key the import URL is expected to use. Form-only state used to verify the URL's host // — it is not sent to deploy_component (the backend resolves the key from the URL host alias). sshKeyName: z.string(), - // Build and install on every node without making it live (Harper 5.3.0+); offered only where that is known. stageOnly: z.boolean(), }); diff --git a/src/features/instance/config/deployments/components/ActivateStagedRelease.test.tsx b/src/features/instance/config/deployments/components/ActivateStagedRelease.test.tsx index bc387c5e9..5cff3eb4d 100644 --- a/src/features/instance/config/deployments/components/ActivateStagedRelease.test.tsx +++ b/src/features/instance/config/deployments/components/ActivateStagedRelease.test.tsx @@ -1,10 +1,17 @@ /** @vitest-environment jsdom */ import { Deployment } from '@/integrations/api/instance/deployments/types'; +import { SSEInconclusiveError } from '@/integrations/api/sse/errors'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -const state = vi.hoisted(() => ({ canStage: true, mutate: vi.fn() })); +const state = vi.hoisted(() => ({ + canStage: true, + mutateAsync: vi.fn(), + reportDeployOutcome: vi.fn(), + reportDeployFailure: vi.fn(), + warning: vi.fn(), +})); vi.mock('@/config/useInstanceClient', () => ({ useInstanceClientIdParams: () => ({ entityId: 'clu-a', entityType: 'cluster', instanceClient: {} }), })); @@ -21,11 +28,16 @@ vi.mock('@/features/instance/applications/hooks/useDeploymentRecordAction', () = vi.mock('@/features/instance/applications/components/DeployProgress/DeployProgress', () => ({ DeployProgress: () =>
    progress
    , })); +vi.mock('@/features/instance/applications/modals/reportDeployHealth', () => ({ + reportDeployOutcome: state.reportDeployOutcome, + reportDeployFailure: state.reportDeployFailure, +})); vi.mock('@/integrations/api/instance/applications/deployComponent', () => ({ - useDeployComponentMutation: () => ({ mutate: state.mutate, isPending: false }), + useDeployComponentMutation: () => ({ mutateAsync: state.mutateAsync }), })); +vi.mock('sonner', () => ({ toast: { warning: state.warning } })); -import { ActivateStagedRelease } from './ActivateStagedRelease'; +import { ActivateStagedRelease, activationMutationKey } from './ActivateStagedRelease'; const staged = { deployment_id: 'a1b2c3d4-0000-4000-8000-000000000001', @@ -33,35 +45,76 @@ const staged = { status: 'staged', } as Deployment; -function renderButton() { - render( - +function renderButton(queryClient = new QueryClient()) { + return render( + , ); } +function confirm() { + fireEvent.click(screen.getByRole('button', { name: /Activate/ })); + fireEvent.click(screen.getByRole('button', { name: 'Activate' })); +} + afterEach(() => { cleanup(); state.canStage = true; - state.mutate.mockClear(); + vi.clearAllMocks(); }); describe('ActivateStagedRelease', () => { it('activates the staged release by its id, only once confirmed', () => { + state.mutateAsync.mockReturnValue(new Promise(() => {})); renderButton(); fireEvent.click(screen.getByRole('button', { name: /Activate/ })); - expect(state.mutate).not.toHaveBeenCalled(); + expect(state.mutateAsync).not.toHaveBeenCalled(); expect(screen.getByText('Activate this release of web?')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: 'Activate' })); - expect(state.mutate).toHaveBeenCalledOnce(); - expect(state.mutate.mock.calls[0][0]).toMatchObject({ + expect(state.mutateAsync).toHaveBeenCalledOnce(); + expect(state.mutateAsync.mock.calls[0][0]).toMatchObject({ request: { kind: 'activate', project: 'web', deploymentId: staged.deployment_id }, entityType: 'cluster', }); }); + it('still reports an activation whose detail was closed before it settled', async () => { + let settle: (value: object) => void = () => {}; + state.mutateAsync.mockReturnValue(new Promise((resolve) => (settle = resolve))); + const { unmount } = renderButton(); + confirm(); + unmount(); + await act(async () => settle({ message: 'ok', deployment_id: 'd2' })); + expect(state.reportDeployOutcome).toHaveBeenCalledWith( + 'web', + { message: 'ok', deployment_id: 'd2' }, + expect.any(Function), + { + action: undefined, + }, + ); + }); + + it('does not offer a second activation of a release that is still being activated', async () => { + const queryClient = new QueryClient(); + void queryClient.getMutationCache().build(queryClient, { + mutationKey: activationMutationKey('clu-a', staged.deployment_id), + mutationFn: () => new Promise(() => {}), + }).execute(undefined); + renderButton(queryClient); + expect((await screen.findByRole('button', { name: /Activating/ }) as HTMLButtonElement).disabled).toBe(true); + }); + + it('says the outcome is unknown, not that it failed, when live updates end early', async () => { + state.mutateAsync.mockRejectedValue(new SSEInconclusiveError('idle')); + renderButton(); + confirm(); + await vi.waitFor(() => expect(state.warning).toHaveBeenCalledWith('web may not be activated', expect.anything())); + expect(state.reportDeployFailure).not.toHaveBeenCalled(); + }); + it('offers nothing where staging is not known to exist', () => { state.canStage = false; renderButton(); diff --git a/src/features/instance/config/deployments/components/ActivateStagedRelease.tsx b/src/features/instance/config/deployments/components/ActivateStagedRelease.tsx index a88c518e6..d68b10344 100644 --- a/src/features/instance/config/deployments/components/ActivateStagedRelease.tsx +++ b/src/features/instance/config/deployments/components/ActivateStagedRelease.tsx @@ -24,15 +24,17 @@ import { useDeployComponentMutation } from '@/integrations/api/instance/applicat import { deployFailureDetails } from '@/integrations/api/instance/applications/deployOutcome'; import { DeployRolloutError } from '@/integrations/api/instance/applications/restartJob'; import { Deployment } from '@/integrations/api/instance/deployments/types'; +import { SSEInconclusiveError } from '@/integrations/api/sse/errors'; import { invalidateEntityQueries } from '@/react-query/invalidateEntityQueries'; -import { useQueryClient } from '@tanstack/react-query'; +import { useIsMutating, useQueryClient } from '@tanstack/react-query'; import { RocketIcon } from 'lucide-react'; -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { toast } from 'sonner'; + +export function activationMutationKey(entityId: string | undefined, deploymentId: string) { + return ['activate_deployment', entityId, deploymentId] as const; +} -/** - * Make a staged release live (Harper 5.3.0+), restarting each node in turn: `deploy_component` with its - * `deployment_id`, followed like any other rolling deploy. - */ export function ActivateStagedRelease({ deployment }: { deployment: Deployment }) { const instanceParams = useInstanceClientIdParams(); const queryClient = useQueryClient(); @@ -41,42 +43,65 @@ export function ActivateStagedRelease({ deployment }: { deployment: Deployment } const stream = useDeploymentStream(); const runHealthCheck = useComponentHealthCheck(instanceParams); const recordAction = useDeploymentRecordAction(); - const { mutate, isPending } = useDeployComponentMutation(); + const { project, deployment_id: deploymentId } = deployment; + // Keyed so a detail closed and reopened mid-rollout still knows this release is being activated. + const mutationKey = useMemo(() => activationMutationKey(instanceParams.entityId, deploymentId), [ + instanceParams.entityId, + deploymentId, + ]); + const { mutateAsync } = useDeployComponentMutation(mutationKey); + const isPending = useIsMutating({ mutationKey }) > 0; const [confirming, setConfirming] = useState(false); const [started, setStarted] = useState(false); - const { project, deployment_id: deploymentId } = deployment; - const activate = useCallback(() => { + const activate = useCallback(async () => { setConfirming(false); setStarted(true); stream.reset(); stream.markStarted(); - mutate({ - request: { kind: 'activate', project, deploymentId }, - useSSE: sseDeploy, - onEvent: stream.onEvent, - ...instanceParams, - }, { - onSuccess: async (result) => { - stream.markSettled(result?.rollout?.state === 'unfollowable' ? 'inconclusive' : 'success', undefined, { - result, - }); - void invalidateEntityQueries(queryClient, instanceParams.entityId); - await reportDeployOutcome(project, result, runHealthCheck, { action: recordAction(result?.deployment_id) }); - }, - onError: (error) => { - stream.markSettled( - 'error', - error instanceof DeployRolloutError ? undefined : error instanceof Error ? error.message : String(error), - { failure: error }, - ); - void invalidateEntityQueries(queryClient, instanceParams.entityId); - reportDeployFailure(project, error, 'activate', { - action: recordAction(deployFailureDetails(error).deploymentId), + // Awaited here rather than in `mutate` callbacks, which React Query drops once this detail unmounts: the + // report and the invalidation still belong to an activation someone closed the dialog on. + try { + const result = await mutateAsync({ + request: { kind: 'activate', project, deploymentId }, + useSSE: sseDeploy, + onEvent: stream.onEvent, + ...instanceParams, + }); + stream.markSettled(result?.rollout?.state === 'unfollowable' ? 'inconclusive' : 'success', undefined, { + result, + }); + void invalidateEntityQueries(queryClient, instanceParams.entityId); + await reportDeployOutcome(project, result, runHealthCheck, { action: recordAction(result?.deployment_id) }); + } catch (error) { + void invalidateEntityQueries(queryClient, instanceParams.entityId); + if (error instanceof SSEInconclusiveError) { + stream.markSettled('inconclusive'); + toast.warning(`${project} may not be activated`, { + description: 'Live updates ended before the activation finished. Check Deployments for its record.', }); - }, - }); - }, [mutate, project, deploymentId, sseDeploy, stream, instanceParams, queryClient, runHealthCheck, recordAction]); + return; + } + stream.markSettled( + 'error', + error instanceof DeployRolloutError ? undefined : error instanceof Error ? error.message : String(error), + { failure: error }, + ); + reportDeployFailure(project, error, 'activate', { + action: recordAction(deployFailureDetails(error).deploymentId), + }); + } + }, [ + mutateAsync, + project, + deploymentId, + sseDeploy, + stream, + instanceParams, + queryClient, + runHealthCheck, + recordAction, + ]); if (!canActivate) { return null; @@ -110,7 +135,7 @@ export function ActivateStagedRelease({ deployment }: { deployment: Deployment } Cancel - Activate + void activate()}>Activate diff --git a/src/integrations/api/instance/applications/deployComponent.ts b/src/integrations/api/instance/applications/deployComponent.ts index e700082cc..3eec2fa4d 100644 --- a/src/integrations/api/instance/applications/deployComponent.ts +++ b/src/integrations/api/instance/applications/deployComponent.ts @@ -104,8 +104,9 @@ export async function deployAndFollowComponent(args: DeployComponentArgs): Promi return { ...result, rollout }; } -export function useDeployComponentMutation() { +export function useDeployComponentMutation(mutationKey?: readonly unknown[]) { return useMutation({ + mutationKey, mutationFn: deployAndFollowComponent, }); } diff --git a/src/integrations/api/instance/applications/deployComponentStream.ts b/src/integrations/api/instance/applications/deployComponentStream.ts index cc760ce98..1a4ad5ec8 100644 --- a/src/integrations/api/instance/applications/deployComponentStream.ts +++ b/src/integrations/api/instance/applications/deployComponentStream.ts @@ -7,7 +7,6 @@ import { RolloutSnapshot } from './restartJob'; /** Lifecycle phases Harper's `deploy_component` emits, in order. */ export const DEPLOY_PHASE_ORDER = ['prepare', 'load', 'replicate', 'restart', 'success'] as const; -/** A stage (5.3.0+) restarts nothing, and ends `staged` where a deploy ends `success`. */ export const STAGE_PHASE_ORDER = ['prepare', 'load', 'replicate', 'staged'] as const; export interface DeployPhaseEvent { @@ -52,7 +51,6 @@ export interface DeployComponentResult extends ReplicatedResponse { deployment_id?: string; phase?: string; certification?: DeployCertification; - /** Set by every node that staged the release rather than making it live (5.3.0+). */ staged?: boolean; /** The `restart_service` job a rolling restart hands the other nodes to. */ restartJobId?: string; @@ -83,7 +81,6 @@ export function toDeployStreamEvent(message: SSEMessage): DeployStreamEvent | un export interface DeployComponentStreamParams { connection: ResolveInstanceConnectionParams; - /** The `deploy_component` operation, as `deployRequestBody` builds it. */ body: Record; signal?: AbortSignal; /** Called for each live deploy event so the UI can render progress. */ From 1b1bbdbb42bc79e01e49d7a64a54385c1aac915c Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Sun, 11 Oct 2026 03:20:02 -0400 Subject: [PATCH 3/5] fix(deploy): an unknown activation is not also toasted as a failure The activation reports every outcome itself, so it opts out of the global mutation toast, which would have called an early-ended stream a failure alongside the local 'may not be activated'. It logs the error with console.error as that handler did, so Datadog still sees it. Refs #1787 Co-Authored-By: Claude Opus 5.5 --- .../components/ActivateStagedRelease.test.tsx | 23 ++++++++++++++++++- .../components/ActivateStagedRelease.tsx | 5 +++- .../instance/applications/deployComponent.ts | 3 ++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/features/instance/config/deployments/components/ActivateStagedRelease.test.tsx b/src/features/instance/config/deployments/components/ActivateStagedRelease.test.tsx index 5cff3eb4d..caea0ab6f 100644 --- a/src/features/instance/config/deployments/components/ActivateStagedRelease.test.tsx +++ b/src/features/instance/config/deployments/components/ActivateStagedRelease.test.tsx @@ -11,6 +11,7 @@ const state = vi.hoisted(() => ({ reportDeployOutcome: vi.fn(), reportDeployFailure: vi.fn(), warning: vi.fn(), + useDeployComponentMutation: vi.fn(), })); vi.mock('@/config/useInstanceClient', () => ({ useInstanceClientIdParams: () => ({ entityId: 'clu-a', entityType: 'cluster', instanceClient: {} }), @@ -33,7 +34,10 @@ vi.mock('@/features/instance/applications/modals/reportDeployHealth', () => ({ reportDeployFailure: state.reportDeployFailure, })); vi.mock('@/integrations/api/instance/applications/deployComponent', () => ({ - useDeployComponentMutation: () => ({ mutateAsync: state.mutateAsync }), + useDeployComponentMutation: (...args: unknown[]) => { + state.useDeployComponentMutation(...args); + return { mutateAsync: state.mutateAsync }; + }, })); vi.mock('sonner', () => ({ toast: { warning: state.warning } })); @@ -115,6 +119,23 @@ describe('ActivateStagedRelease', () => { expect(state.reportDeployFailure).not.toHaveBeenCalled(); }); + it('reports every failure itself, instead of also through the global toast, and still to Datadog', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const failure = new Error('No deployment found with id a1b2c3d4'); + state.mutateAsync.mockRejectedValue(failure); + renderButton(); + expect(state.useDeployComponentMutation).toHaveBeenCalledWith( + activationMutationKey('clu-a', staged.deployment_id), + { skipGlobalErrorToast: true }, + ); + confirm(); + await vi.waitFor(() => + expect(state.reportDeployFailure).toHaveBeenCalledWith('web', failure, 'activate', { action: undefined }) + ); + expect(consoleError).toHaveBeenCalledWith(failure); + consoleError.mockRestore(); + }); + it('offers nothing where staging is not known to exist', () => { state.canStage = false; renderButton(); diff --git a/src/features/instance/config/deployments/components/ActivateStagedRelease.tsx b/src/features/instance/config/deployments/components/ActivateStagedRelease.tsx index d68b10344..01b9f4cf6 100644 --- a/src/features/instance/config/deployments/components/ActivateStagedRelease.tsx +++ b/src/features/instance/config/deployments/components/ActivateStagedRelease.tsx @@ -49,7 +49,8 @@ export function ActivateStagedRelease({ deployment }: { deployment: Deployment } instanceParams.entityId, deploymentId, ]); - const { mutateAsync } = useDeployComponentMutation(mutationKey); + // Every outcome is reported below, so the global toast would only contradict an unknown one. + const { mutateAsync } = useDeployComponentMutation(mutationKey, { skipGlobalErrorToast: true }); const isPending = useIsMutating({ mutationKey }) > 0; const [confirming, setConfirming] = useState(false); const [started, setStarted] = useState(false); @@ -74,6 +75,8 @@ export function ActivateStagedRelease({ deployment }: { deployment: Deployment } void invalidateEntityQueries(queryClient, instanceParams.entityId); await reportDeployOutcome(project, result, runHealthCheck, { action: recordAction(result?.deployment_id) }); } catch (error) { + // What the skipped global handler would have done for Datadog; RUM reports `console.error` (AGENTS.md). + console.error(error); void invalidateEntityQueries(queryClient, instanceParams.entityId); if (error instanceof SSEInconclusiveError) { stream.markSettled('inconclusive'); diff --git a/src/integrations/api/instance/applications/deployComponent.ts b/src/integrations/api/instance/applications/deployComponent.ts index 3eec2fa4d..bc2dd93dc 100644 --- a/src/integrations/api/instance/applications/deployComponent.ts +++ b/src/integrations/api/instance/applications/deployComponent.ts @@ -104,9 +104,10 @@ export async function deployAndFollowComponent(args: DeployComponentArgs): Promi return { ...result, rollout }; } -export function useDeployComponentMutation(mutationKey?: readonly unknown[]) { +export function useDeployComponentMutation(mutationKey?: readonly unknown[], meta?: Record) { return useMutation({ mutationKey, + meta, mutationFn: deployAndFollowComponent, }); } From 7c13bb0a50931c5747f8c9988e582741dd955d3f Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Sun, 11 Oct 2026 05:32:18 -0400 Subject: [PATCH 4/5] fix(deploy): resend a live deploy only when Harper refused it, never after it may have started When the SSE deploy got no event stream, onDeployComponentSubmit fell back to the buffered deploy_component on any SSEUnsupportedError: a gateway 502/503/504, a 2xx that was not a stream, a dropped connection, or the 120s idle watchdog firing before headers. Harper starts an SSE operation as soon as it accepts it and answers with a 200 stream, so each of those can follow a deploy that is already running, and the fallback deployed the same package a second time (a second install and rolling restart, or two deploys of one project racing). SSEUnsupportedError now carries the response status. The deploy falls back only on a 4xx, which Harper answers for a request it refused before running it (authentication, permissions, validation). Anything else becomes SSEInconclusiveError, which the import, redeploy and activate callers already answer by checking instead of resending. Closes #1889 Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 2 +- src/features/instance/applications/DESIGN.md | 12 ++ .../deployComponent.fallback.test.ts | 103 ++++++++++++++++++ .../applications/deployComponent.test.ts | 41 +++++-- .../instance/applications/deployComponent.ts | 21 +++- src/integrations/api/sse/errors.ts | 9 +- .../api/sse/streamOperation.test.ts | 32 ++++-- src/integrations/api/sse/streamOperation.ts | 9 +- 8 files changed, 204 insertions(+), 25 deletions(-) create mode 100644 src/integrations/api/instance/applications/deployComponent.fallback.test.ts diff --git a/DESIGN.md b/DESIGN.md index fa10c50db..9a656f28f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -12,7 +12,7 @@ This file collects architecture and design notes for studio that aren't otherwis - [`src/features/instance/databases/DESIGN.md`](src/features/instance/databases/DESIGN.md) — when the browse grid may show its spinner, and why a table without a primary key can't be listed. -- [`src/features/instance/applications/DESIGN.md`](src/features/instance/applications/DESIGN.md) — when a rolling deploy is done: following its restart job, and why a job Studio can't follow is never a success. +- [`src/features/instance/applications/DESIGN.md`](src/features/instance/applications/DESIGN.md) — when a rolling deploy is done: following its restart job, and why a job Studio can't follow is never a success; and when a live deploy may be sent again. - [`src/features/instance/config/roles/DESIGN.md`](src/features/instance/config/roles/DESIGN.md) — why the role editor's JSON text is the only source of truth, what forces the JSON view, and what Save changes on purpose. - [`src/router/DESIGN.md`](src/router/DESIGN.md) — why a failed route load is reported and shown only by its route, and why a preload still toasts. diff --git a/src/features/instance/applications/DESIGN.md b/src/features/instance/applications/DESIGN.md index 6531d8933..e58317fc0 100644 --- a/src/features/instance/applications/DESIGN.md +++ b/src/features/instance/applications/DESIGN.md @@ -43,3 +43,15 @@ All three kinds of request are built in one place, [`deployRequestBody`](../../. because Harper's validator refuses the wrong mix outright: a stage with `restart`, or an activation carrying `package`, install options or `activate`. A stage succeeds only once every other node confirmed staging it, so a stage restarts nothing and is reported without a health check. Rollback to a kept release (#1232) is the same activation call. + +## A live deploy is sent once + +A live (SSE) deploy that gets no event stream goes out again as the buffered POST only when its status proves Harper +never started it: a 4xx. Harper refuses a request before the operation starts (authentication, permissions, +validation) with a 4xx, and answers one it started with a 200 event stream at once. Everything else can follow a deploy +that is already running: a gateway's 5xx after forwarding, a 2xx that is not a stream, a dropped connection, the idle +watchdog firing before headers. Harper's own request-queue 503 never started anything either, but status alone cannot +tell it from a gateway's. So [`onDeployComponentSubmit`](../../../integrations/api/instance/applications/deployComponent.ts) +turns those into `SSEInconclusiveError`, which every deploy caller already answers by checking rather than resending. +`streamOperation` stays neutral: it puts the `status` on `SSEUnsupportedError` and leaves the decision to the caller, +because a read (`get_deployment`) may fall back on anything. diff --git a/src/integrations/api/instance/applications/deployComponent.fallback.test.ts b/src/integrations/api/instance/applications/deployComponent.fallback.test.ts new file mode 100644 index 000000000..7eaed9539 --- /dev/null +++ b/src/integrations/api/instance/applications/deployComponent.fallback.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/integrations/api/sse/resolveInstanceConnection', () => ({ + resolveInstanceConnection: () => ({ + url: 'https://ins-1.example.com:9925/', + headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' }, + credentials: 'same-origin' as RequestCredentials, + mode: 'direct', + }), +})); + +import { SSEInconclusiveError } from '@/integrations/api/sse/errors'; +import { onDeployComponentSubmit } from './deployComponent'; + +const live = { + request: { kind: 'deploy' as const, project: 'my-app', package: 'npm:my-app' }, + entityId: 'ins-1' as const, + entityType: 'instance' as const, + useSSE: true, +}; + +function bufferedClient() { + return { post: vi.fn().mockResolvedValue({ data: { message: 'Successfully deployed: my-app' } }) }; +} + +function deploy(client: ReturnType) { + return onDeployComponentSubmit({ ...live, instanceClient: client as never }); +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('the live deploy, through the real stream', () => { + it.each([502, 503, 504])( + 'is not sent again after a %i from a gateway, which may have forwarded it', + async (status) => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response('Bad Gateway', { + status, + headers: { 'content-type': 'text/html' }, + }), + ), + ); + const client = bufferedClient(); + + await expect(deploy(client)).rejects.toBeInstanceOf(SSEInconclusiveError); + expect(client.post).not.toHaveBeenCalled(); + }, + ); + + it('is not sent again after a 2xx that is not a stream, which means it ran', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(Response.json({ message: 'Successfully deployed: my-app' })), + ); + const client = bufferedClient(); + + await expect(deploy(client)).rejects.toBeInstanceOf(SSEInconclusiveError); + expect(client.post).not.toHaveBeenCalled(); + }); + + it('is not sent again when the connection drops after the request went out', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))); + const client = bufferedClient(); + + await expect(deploy(client)).rejects.toBeInstanceOf(SSEInconclusiveError); + expect(client.post).not.toHaveBeenCalled(); + }); + + it('is not sent again when no answer arrives within the idle window', async () => { + vi.useFakeTimers(); + vi.stubGlobal( + 'fetch', + vi.fn((_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))); + }) + ), + ); + const client = bufferedClient(); + + const outcome = deploy(client).catch((e: unknown) => e); + await vi.advanceTimersByTimeAsync(120_000); + + expect(await outcome).toBeInstanceOf(SSEInconclusiveError); + expect(client.post).not.toHaveBeenCalled(); + }); + + it.each([401, 403])('goes out on the buffered path when Harper refused the stream with a %i', async (status) => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(Response.json({ error: 'Login failed' }, { status })), + ); + const client = bufferedClient(); + + await expect(deploy(client)).resolves.toEqual({ message: 'Successfully deployed: my-app' }); + expect(client.post).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/integrations/api/instance/applications/deployComponent.test.ts b/src/integrations/api/instance/applications/deployComponent.test.ts index 8820daef7..199ca4f79 100644 --- a/src/integrations/api/instance/applications/deployComponent.test.ts +++ b/src/integrations/api/instance/applications/deployComponent.test.ts @@ -4,7 +4,7 @@ vi.mock('./deployComponentStream', () => ({ deployComponentStream: vi.fn(), })); -import { SSEOperationError, SSEUnsupportedError } from '@/integrations/api/sse/errors'; +import { SSEInconclusiveError, SSEOperationError, SSEUnsupportedError } from '@/integrations/api/sse/errors'; import { deployAndFollowComponent, deployRequestBody, onDeployComponentSubmit } from './deployComponent'; import { deployComponentStream } from './deployComponentStream'; import { DeployRolloutError } from './restartJob'; @@ -123,17 +123,44 @@ describe('onDeployComponentSubmit', () => { expect(client.post).not.toHaveBeenCalled(); }); - it('falls back to the buffered path when the stream is unsupported', async () => { - mockStream.mockRejectedValue(new SSEUnsupportedError('no stream')); + it.each([400, 401, 403, 404, 406, 413, 429])( + 'falls back to the buffered path when the stream was refused with a %i', + async (status) => { + mockStream.mockRejectedValue(new SSEUnsupportedError('no stream', { status })); + const client = fakeClient({ message: 'buffered' }); + const result = await onDeployComponentSubmit({ + ...baseArgs, + useSSE: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + instanceClient: client as any, + }); + expect(result).toEqual({ message: 'buffered' }); + expect(client.post).toHaveBeenCalledOnce(); + }, + ); + + it.each([ + ['a gateway 502', 502], + ['a gateway 503', 503], + ['a gateway 504', 504], + ['a Harper 500', 500], + ['a 2xx that is not a stream', 200], + ['no response at all', undefined], + ])('never sends the deploy again after %s, which can follow a deploy already running', async (_, status) => { + const unsupported = new SSEUnsupportedError('no stream', { status }); + mockStream.mockRejectedValue(unsupported); const client = fakeClient({ message: 'buffered' }); - const result = await onDeployComponentSubmit({ + + const error = await onDeployComponentSubmit({ ...baseArgs, useSSE: true, // eslint-disable-next-line @typescript-eslint/no-explicit-any instanceClient: client as any, - }); - expect(result).toEqual({ message: 'buffered' }); - expect(client.post).toHaveBeenCalledOnce(); + }).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(SSEInconclusiveError); + expect((error as Error).cause).toBe(unsupported); + expect(client.post).not.toHaveBeenCalled(); }); it('rethrows a deploy failure without re-POSTing (no double deploy)', async () => { diff --git a/src/integrations/api/instance/applications/deployComponent.ts b/src/integrations/api/instance/applications/deployComponent.ts index bc2dd93dc..1289ac063 100644 --- a/src/integrations/api/instance/applications/deployComponent.ts +++ b/src/integrations/api/instance/applications/deployComponent.ts @@ -1,7 +1,7 @@ import { InstanceClientConfig, InstanceTypeConfig } from '@/config/instanceClientConfig'; import { EntityIds } from '@/features/auth/store/authStore'; import { ReplicatedResponse } from '@/integrations/api/replication'; -import { SSEUnsupportedError } from '@/integrations/api/sse/errors'; +import { SSEInconclusiveError, SSEUnsupportedError } from '@/integrations/api/sse/errors'; import { useMutation } from '@tanstack/react-query'; import { DeployComponentResult, deployComponentStream, DeployStreamEvent } from './deployComponentStream'; import { DeployRolloutError, followRestartJob } from './restartJob'; @@ -49,6 +49,17 @@ export interface DeployComponentArgs extends InstanceClientConfig, InstanceTypeC signal?: AbortSignal; } +/** + * Whether a live deploy that produced no stream provably never ran, so its body may go out again on the + * buffered path. Harper refuses a request before starting the operation (authentication, permissions, + * validation) with a 4xx, and answers one it started with a 200 event stream at once (harper + * `server/serverHelpers/serverHandlers.js`, `handlePostRequest`). A gateway's 5xx, a 2xx that is not a + * stream, or no response at all can each follow a deploy that is already running. + */ +function refusedBeforeRunning({ status }: SSEUnsupportedError): boolean { + return status !== undefined && status >= 400 && status < 500; +} + export async function onDeployComponentSubmit({ request, entityType, @@ -64,14 +75,18 @@ export async function onDeployComponentSubmit({ try { return await deployComponentStream({ connection: { id: entityId }, body, signal, onEvent }); } catch (error) { - // Couldn't establish a stream (bad response/content-type/connection) and no deploy - // has started yet — safe to fall back to the proven buffered path below. if (!(error instanceof SSEUnsupportedError)) { // A terminal error, an inconclusive stream, or a deliberate abort. The deploy has // already started server-side, so we must NOT re-POST it (that would deploy // twice). Let the caller handle it (show the error, or poll on inconclusive). throw error; } + if (!refusedBeforeRunning(error)) { + throw new SSEInconclusiveError( + 'The live deploy did not stream and may already have started, so it was not sent again.', + { cause: error }, + ); + } } } diff --git a/src/integrations/api/sse/errors.ts b/src/integrations/api/sse/errors.ts index fa00d8d9d..7263217d3 100644 --- a/src/integrations/api/sse/errors.ts +++ b/src/integrations/api/sse/errors.ts @@ -8,12 +8,17 @@ * The server did not (or could not) speak SSE for this request: the response was not OK, * arrived without a `text/event-stream` content-type (e.g. an older Harper that ignores * the `Accept` header, or a proxy that rewrote it), or the connection failed before any - * bytes streamed. The caller should fall back to the classic buffered operation. + * bytes streamed. A read can fall back to the classic buffered operation; a write only when + * `status` shows the server refused it (see `onDeployComponentSubmit`). */ export class SSEUnsupportedError extends Error { - constructor(message: string, options?: { cause?: unknown }) { + /** The response's status, or undefined when no response arrived. */ + readonly status?: number; + + constructor(message: string, options?: { cause?: unknown; status?: number }) { super(message, options); this.name = 'SSEUnsupportedError'; + this.status = options?.status; } } diff --git a/src/integrations/api/sse/streamOperation.test.ts b/src/integrations/api/sse/streamOperation.test.ts index 734733250..2c667f123 100644 --- a/src/integrations/api/sse/streamOperation.test.ts +++ b/src/integrations/api/sse/streamOperation.test.ts @@ -109,23 +109,39 @@ describe('streamOperation', () => { ).rejects.toMatchObject({ name: 'SSEOperationError', message: 'The operation failed.' }); }); - it('falls back (SSEUnsupportedError) when the response is not an event stream', async () => { + it('falls back (SSEUnsupportedError) when the response is not an event stream, with its status', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue(sseResponse('{"message":"ok"}', { contentType: 'application/json' })), ); - await expect( - streamOperation({ connection: { id: 'ins-1' }, body: { operation: 'deploy_component' } }), - ).rejects.toBeInstanceOf(SSEUnsupportedError); + const error = await streamOperation({ connection: { id: 'ins-1' }, body: { operation: 'deploy_component' } }) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(SSEUnsupportedError); + expect((error as SSEUnsupportedError).status).toBe(200); }); - it('falls back (SSEUnsupportedError) when fetch rejects before streaming', async () => { + it('reports the status of a response that refused the stream', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(sseResponse('{"error":"Gateway Timeout"}', { status: 504, contentType: 'text/html' })), + ); + + const error = await streamOperation({ connection: { id: 'ins-1' }, body: { operation: 'deploy_component' } }) + .catch((e: unknown) => e); + + expect((error as SSEUnsupportedError).status).toBe(504); + }); + + it('falls back (SSEUnsupportedError) when fetch rejects before streaming, with no status', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network down'))); - await expect( - streamOperation({ connection: { id: 'ins-1' }, body: { operation: 'deploy_component' } }), - ).rejects.toBeInstanceOf(SSEUnsupportedError); + const error = await streamOperation({ connection: { id: 'ins-1' }, body: { operation: 'deploy_component' } }) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(SSEUnsupportedError); + expect((error as SSEUnsupportedError).status).toBeUndefined(); }); it('throws SSEInconclusiveError when the stream ends without a terminal event', async () => { diff --git a/src/integrations/api/sse/streamOperation.ts b/src/integrations/api/sse/streamOperation.ts index 841e474f7..206c1051d 100644 --- a/src/integrations/api/sse/streamOperation.ts +++ b/src/integrations/api/sse/streamOperation.ts @@ -25,8 +25,8 @@ export interface StreamOperationOptions { * * Failure modes are explicit (see `./errors`): * - {@link SSEUnsupportedError} — couldn't establish a stream (bad response, wrong - * content-type, or connection error before any bytes). Caller should fall back to the - * buffered axios path. + * content-type, or connection error before any bytes). A read can fall back to the buffered + * axios path; the request may still have reached the server, so a write must check `status`. * - {@link SSEOperationError} — a terminal `error` event arrived. * - {@link SSEInconclusiveError} — the stream ended (or idled out) without a terminal * event. The outcome is unknown; caller must poll to determine it, never assume success. @@ -70,8 +70,8 @@ export async function streamOperation({ if (signal?.aborted) { throw error; } - // Anything else (network error, DNS, idle before headers) means we never got a - // stream going; let the caller fall back to the buffered request. + // Anything else (network error, DNS, idle before headers) means we never got a stream + // going, though the request may have been sent: there is no `status` to say otherwise. throw new SSEUnsupportedError('Failed to open the SSE stream.', { cause: error }); } @@ -81,6 +81,7 @@ export async function streamOperation({ await response.body?.cancel().catch(() => {}); throw new SSEUnsupportedError( `Server did not return an event stream (status ${response.status}, content-type "${contentType}").`, + { status: response.status }, ); } From 377d6b08bba324d9f0269b64f485a0935cf7e1c5 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Sun, 11 Oct 2026 05:40:15 -0400 Subject: [PATCH 5/5] docs(deploy): say a write may resend a live deploy only on a 4xx refusal deployComponentStream's contract still told every caller to retry on SSEUnsupportedError, which is the resend this branch removes. Also trims two comments that restated the design note. Co-Authored-By: Claude Opus 5.5 --- .../api/instance/applications/deployComponent.ts | 8 +------- .../api/instance/applications/deployComponentStream.ts | 3 ++- src/integrations/api/sse/errors.ts | 1 - 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/integrations/api/instance/applications/deployComponent.ts b/src/integrations/api/instance/applications/deployComponent.ts index 1289ac063..d5c3e0ec9 100644 --- a/src/integrations/api/instance/applications/deployComponent.ts +++ b/src/integrations/api/instance/applications/deployComponent.ts @@ -49,13 +49,7 @@ export interface DeployComponentArgs extends InstanceClientConfig, InstanceTypeC signal?: AbortSignal; } -/** - * Whether a live deploy that produced no stream provably never ran, so its body may go out again on the - * buffered path. Harper refuses a request before starting the operation (authentication, permissions, - * validation) with a 4xx, and answers one it started with a 200 event stream at once (harper - * `server/serverHelpers/serverHandlers.js`, `handlePostRequest`). A gateway's 5xx, a 2xx that is not a - * stream, or no response at all can each follow a deploy that is already running. - */ +/** Only a 4xx proves Harper never started the deploy: applications/DESIGN.md, "A live deploy is sent once". */ function refusedBeforeRunning({ status }: SSEUnsupportedError): boolean { return status !== undefined && status >= 400 && status < 500; } diff --git a/src/integrations/api/instance/applications/deployComponentStream.ts b/src/integrations/api/instance/applications/deployComponentStream.ts index 1a4ad5ec8..2d06e1406 100644 --- a/src/integrations/api/instance/applications/deployComponentStream.ts +++ b/src/integrations/api/instance/applications/deployComponentStream.ts @@ -92,7 +92,8 @@ export interface DeployComponentStreamParams { * and resolving with the terminal `done` result. * * Throws the transport errors from `@/integrations/api/sse/errors`: - * - `SSEUnsupportedError` — the server/proxy didn't stream; caller should retry buffered. + * - `SSEUnsupportedError` — the server/proxy didn't stream. Resend buffered only when its `status` is a 4xx + * refusal (`onDeployComponentSubmit` decides); anything else may follow a deploy already running. * - `SSEOperationError` — the deploy failed (terminal `error` event). * - `SSEInconclusiveError` — the stream ended without a verdict; caller must poll. */ diff --git a/src/integrations/api/sse/errors.ts b/src/integrations/api/sse/errors.ts index 7263217d3..66911be20 100644 --- a/src/integrations/api/sse/errors.ts +++ b/src/integrations/api/sse/errors.ts @@ -12,7 +12,6 @@ * `status` shows the server refused it (see `onDeployComponentSubmit`). */ export class SSEUnsupportedError extends Error { - /** The response's status, or undefined when no response arrived. */ readonly status?: number; constructor(message: string, options?: { cause?: unknown; status?: number }) {