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 95b77130f..e58317fc0 100644
--- a/src/features/instance/applications/DESIGN.md
+++ b/src/features/instance/applications/DESIGN.md
@@ -31,3 +31,27 @@ 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.
+
+## 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/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..fb44778fe 100644
--- a/src/features/instance/applications/components/NewApplication/NewApplication.test.tsx
+++ b/src/features/instance/applications/components/NewApplication/NewApplication.test.tsx
@@ -21,6 +21,9 @@ 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: () => [] }));
+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..d55d7da44 100644
--- a/src/features/instance/applications/components/NewApplication/schema.ts
+++ b/src/features/instance/applications/components/NewApplication/schema.ts
@@ -20,6 +20,7 @@ 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(),
+ stageOnly: z.boolean(),
});
export const defaultImportOptions: z.infer = {
@@ -29,6 +30,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
? (
-
+
{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.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 45ca0050d..199ca4f79 100644
--- a/src/integrations/api/instance/applications/deployComponent.test.ts
+++ b/src/integrations/api/instance/applications/deployComponent.test.ts
@@ -4,8 +4,8 @@ vi.mock('./deployComponentStream', () => ({
deployComponentStream: vi.fn(),
}));
-import { SSEOperationError, SSEUnsupportedError } from '@/integrations/api/sse/errors';
-import { deployAndFollowComponent, onDeployComponentSubmit } from './deployComponent';
+import { SSEInconclusiveError, SSEOperationError, SSEUnsupportedError } from '@/integrations/api/sse/errors';
+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({
@@ -53,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 a9fab9b35..d5c3e0ec9 100644
--- a/src/integrations/api/instance/applications/deployComponent.ts
+++ b/src/integrations/api/instance/applications/deployComponent.ts
@@ -1,18 +1,44 @@
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';
-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). */
@@ -23,10 +49,13 @@ export interface DeployComponentArgs extends DeployComponentFormData, InstanceCl
signal?: AbortSignal;
}
+/** 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;
+}
+
export async function onDeployComponentSubmit({
- applicationName,
- applicationUrl,
- installCommand,
+ request,
entityType,
entityId,
instanceClient,
@@ -34,44 +63,28 @@ 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.
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 },
+ );
+ }
}
}
- 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;
}
@@ -100,8 +113,10 @@ export async function deployAndFollowComponent(args: DeployComponentArgs): Promi
return { ...result, rollout };
}
-export function useDeployComponentMutation() {
+export function useDeployComponentMutation(mutationKey?: readonly unknown[], meta?: Record) {
return useMutation({
+ mutationKey,
+ meta,
mutationFn: deployAndFollowComponent,
});
}
diff --git a/src/integrations/api/instance/applications/deployComponentStream.ts b/src/integrations/api/instance/applications/deployComponentStream.ts
index c502a7778..2d06e1406 100644
--- a/src/integrations/api/instance/applications/deployComponentStream.ts
+++ b/src/integrations/api/instance/applications/deployComponentStream.ts
@@ -7,7 +7,7 @@ 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];
+export const STAGE_PHASE_ORDER = ['prepare', 'load', 'replicate', 'staged'] as const;
export interface DeployPhaseEvent {
phase: string;
@@ -51,6 +51,7 @@ export interface DeployComponentResult extends ReplicatedResponse {
deployment_id?: string;
phase?: string;
certification?: DeployCertification;
+ staged?: boolean;
/** The `restart_service` job a rolling restart hands the other nodes to. */
restartJobId?: string;
/** The `warning` events the deploy streamed. */
@@ -80,11 +81,7 @@ export function toDeployStreamEvent(message: SSEMessage): DeployStreamEvent | un
export interface DeployComponentStreamParams {
connection: ResolveInstanceConnectionParams;
- package: string;
- project: string;
- replicated: boolean;
- install_command?: string;
- restart?: string;
+ body: Record;
signal?: AbortSignal;
/** Called for each live deploy event so the UI can render progress. */
onEvent?: (event: DeployStreamEvent) => void;
@@ -95,31 +92,21 @@ 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.
*/
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 } }]);
diff --git a/src/integrations/api/sse/errors.ts b/src/integrations/api/sse/errors.ts
index fa00d8d9d..66911be20 100644
--- a/src/integrations/api/sse/errors.ts
+++ b/src/integrations/api/sse/errors.ts
@@ -8,12 +8,16 @@
* 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 }) {
+ 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 },
);
}