From 5d5dfdf6c7be264c0886ab3da0d253bc1af0009c Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Sun, 11 Oct 2026 00:48:25 -0400 Subject: [PATCH 1/7] fix(applications): explain a git deploy with no root package.json Deploying a git repository without a root package.json fails in Harper with Node's raw "ENOENT: no such file or directory, open '/tmp/harper-git-clone-XXXXXX/package.json'", because the reclone path reads the manifest unguarded (harper components/Application.ts:555). Studio showed that verbatim everywhere a deploy failure surfaces: the import and redeploy toasts, the global mutation toast, the redeploy progress panel, and Config > Deployments' error panel and activity log. It names a temp directory on the node and not the problem, and RUM shows users retrying it up to nine times. Recognize that failure and replace it with guidance. The deploy mutation swaps the error at its source, so every consumer of the rejection (the global toast included) gets the guidance; each error keeps its type and envelope (an SSEOperationError for RUM redaction, an AxiosError's status and config for the buffered path). Config > Deployments maps the stored record's error and its activity log the same way. The root fix is tracked in HarperFast/harper#3142. Closes #1699 Co-Authored-By: Claude Opus 5.5 --- .../components/DeploymentDetail.test.tsx | 57 +++++++++ .../components/DeploymentDetail.tsx | 7 +- .../applications/deployComponent.test.ts | 65 +++++++++- .../instance/applications/deployComponent.ts | 11 +- .../applications/explainDeployFailure.test.ts | 115 ++++++++++++++++++ .../applications/explainDeployFailure.ts | 55 +++++++++ 6 files changed, 306 insertions(+), 4 deletions(-) create mode 100644 src/features/instance/config/deployments/components/DeploymentDetail.test.tsx create mode 100644 src/integrations/api/instance/applications/explainDeployFailure.test.ts create mode 100644 src/integrations/api/instance/applications/explainDeployFailure.ts diff --git a/src/features/instance/config/deployments/components/DeploymentDetail.test.tsx b/src/features/instance/config/deployments/components/DeploymentDetail.test.tsx new file mode 100644 index 000000000..b5018f045 --- /dev/null +++ b/src/features/instance/config/deployments/components/DeploymentDetail.test.tsx @@ -0,0 +1,57 @@ +/** + * @vitest-environment jsdom + */ +import { Deployment } from '@/integrations/api/instance/deployments/types'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DeploymentDetail } from './DeploymentDetail'; + +vi.mock('@/config/useInstanceClient', () => { + const params = { instanceClient: { post: vi.fn() }, entityId: 'entity-1', entityType: 'instance' }; + return { useInstanceClientIdParams: () => params }; +}); +vi.mock('@/features/instance/applications/hooks/useSupportsDeploymentSSE', () => ({ + useSupportsDeploymentSSE: () => false, +})); + +afterEach(cleanup); + +function renderFailedDeployment(message: string) { + const deployment: Deployment = { + deployment_id: 'd1', + project: 'widgets', + status: 'failed', + error: { message, phase: 'prepare' }, + // Harper appends the streamed `error` event to the log before recording the failure. + event_log: [ + { t: 1, event: 'phase', data: { phase: 'prepare', status: 'start' } }, + { t: 2, event: 'error', data: { message, code: 500, phase: 'prepare' } }, + ], + }; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + queryClient.setQueryData(['entity-1', 'get_deployment', 'd1'], deployment); + return render( + + + , + ); +} + +describe('DeploymentDetail', () => { + it.each([ + "ENOENT: no such file or directory, open '/tmp/harper-git-clone-oXPS7c/package.json'", + "ENOENT: no such file or directory, open 'C:\\Users\\h\\AppData\\Local\\Temp\\harper-git-clone-q1W2e3\\package.json'", + ])("explains a repository with no root package.json instead of showing the node's clone path: %s", (raw) => { + renderFailedDeployment(raw); + + expect(screen.getAllByText(/This repository has no package\.json in its root folder\./)).toHaveLength(2); + expect(screen.queryByText(/harper-git-clone/)).toBeNull(); + }); + + it('shows any other failure as Harper reported it', () => { + renderFailedDeployment('Failed to clone package acme/widgets: fatal: repository not found'); + + expect(screen.getByText('Failed to clone package acme/widgets: fatal: repository not found')).toBeTruthy(); + }); +}); diff --git a/src/features/instance/config/deployments/components/DeploymentDetail.tsx b/src/features/instance/config/deployments/components/DeploymentDetail.tsx index 01c45ec6e..ac1034b81 100644 --- a/src/features/instance/config/deployments/components/DeploymentDetail.tsx +++ b/src/features/instance/config/deployments/components/DeploymentDetail.tsx @@ -5,6 +5,7 @@ import { DeployProgress } from '@/features/instance/applications/components/Depl import { useDeploymentStream } from '@/features/instance/applications/components/DeployProgress/useDeploymentStream'; import { deploymentRecordPath } from '@/features/instance/applications/hooks/useDeploymentRecordAction'; import { useSupportsDeploymentSSE } from '@/features/instance/applications/hooks/useSupportsDeploymentSSE'; +import { explainDeployFailureText } from '@/integrations/api/instance/applications/explainDeployFailure'; import { getDeploymentQueryOptions, getDeploymentStream } from '@/integrations/api/instance/deployments/getDeployment'; import { Deployment, isTerminalDeploymentStatus } from '@/integrations/api/instance/deployments/types'; import { cn } from '@/lib/cn'; @@ -167,7 +168,9 @@ export function DeploymentDetail({ deploymentId }: { deploymentId: string }) { ? ` (${deployment.error.code})` : ''} - {errorText(deployment.error) ?? 'Unknown error'} + + {explainDeployFailureText(errorText(deployment.error) ?? 'Unknown error')} + )} @@ -225,7 +228,7 @@ function DeploymentEventLog({ deployment }: { deployment: Deployment }) { {new Date(entry.t).toLocaleTimeString()} {entry.event} - {typeof entry.data === 'string' ? entry.data : JSON.stringify(entry.data)} + {explainDeployFailureText(typeof entry.data === 'string' ? entry.data : JSON.stringify(entry.data))} ))} diff --git a/src/integrations/api/instance/applications/deployComponent.test.ts b/src/integrations/api/instance/applications/deployComponent.test.ts index 199ca4f79..05312d296 100644 --- a/src/integrations/api/instance/applications/deployComponent.test.ts +++ b/src/integrations/api/instance/applications/deployComponent.test.ts @@ -1,10 +1,15 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; vi.mock('./deployComponentStream', () => ({ deployComponentStream: vi.fn(), })); +vi.mock('sonner', () => ({ toast: { error: vi.fn(), dismiss: vi.fn() } })); import { SSEInconclusiveError, SSEOperationError, SSEUnsupportedError } from '@/integrations/api/sse/errors'; +import { mutationErrorHandler } from '@/react-query/queryClient'; +import { MutationCache, MutationObserver, QueryClient } from '@tanstack/react-query'; +import { AxiosError, AxiosHeaders, InternalAxiosRequestConfig } from 'axios'; +import { toast } from 'sonner'; import { deployAndFollowComponent, deployRequestBody, onDeployComponentSubmit } from './deployComponent'; import { deployComponentStream } from './deployComponentStream'; import { DeployRolloutError } from './restartJob'; @@ -176,6 +181,64 @@ describe('onDeployComponentSubmit', () => { ).rejects.toBeInstanceOf(SSEOperationError); expect(client.post).not.toHaveBeenCalled(); }); + + describe('a git repository with no root package.json', () => { + const raw = "ENOENT: no such file or directory, open '/tmp/harper-git-clone-oXPS7c/package.json'"; + const GUIDANCE = /^This repository has no package\.json in its root folder\./; + + it('rejects a streamed deploy with guidance, never the clone path', async () => { + mockStream.mockRejectedValue(new SSEOperationError(raw, { phase: 'prepare' })); + const error = await onDeployComponentSubmit({ + ...baseArgs, + useSSE: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + instanceClient: fakeClient({}) as any, + }).catch((rejection: unknown) => rejection); + + expect(error).toBeInstanceOf(SSEOperationError); + expect((error as SSEOperationError).message).toMatch(GUIDANCE); + expect((error as SSEOperationError).phase).toBe('prepare'); + }); + + it('rejects a buffered deploy with guidance, never the clone path', async () => { + const config = { headers: new AxiosHeaders() } as InternalAxiosRequestConfig; + const client = { + post: vi.fn().mockRejectedValue( + new AxiosError('Request failed with status code 500', AxiosError.ERR_BAD_RESPONSE, config, undefined, { + data: { error: 'ENOENT', message: raw }, + status: 500, + statusText: 'Internal Server Error', + headers: {}, + config, + }), + ), + }; + + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onDeployComponentSubmit({ ...baseArgs, instanceClient: client as any }), + ).rejects.toThrow(GUIDANCE); + }); + + it('reaches the global mutation toast as guidance too', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + onTestFinished(() => consoleError.mockRestore()); + mockStream.mockRejectedValue(new SSEOperationError(raw)); + const queryClient = new QueryClient({ mutationCache: new MutationCache({ onError: mutationErrorHandler }) }); + // The function `useDeployComponentMutation` runs, so a failure reaches the toast the way it does in the app. + const observer = new MutationObserver(queryClient, { mutationFn: deployAndFollowComponent }); + + await observer + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mutate({ ...baseArgs, useSSE: true, instanceClient: fakeClient({}) as any }) + .catch(() => undefined); + + expect(vi.mocked(toast.error)).toHaveBeenCalledOnce(); + const [title, options] = vi.mocked(toast.error).mock.calls[0]; + expect(title).toBe('Error'); + expect(options?.description).toMatch(GUIDANCE); + }); + }); }); describe('deployAndFollowComponent', () => { diff --git a/src/integrations/api/instance/applications/deployComponent.ts b/src/integrations/api/instance/applications/deployComponent.ts index d5c3e0ec9..413d55487 100644 --- a/src/integrations/api/instance/applications/deployComponent.ts +++ b/src/integrations/api/instance/applications/deployComponent.ts @@ -4,6 +4,7 @@ import { ReplicatedResponse } from '@/integrations/api/replication'; import { SSEInconclusiveError, SSEUnsupportedError } from '@/integrations/api/sse/errors'; import { useMutation } from '@tanstack/react-query'; import { DeployComponentResult, deployComponentStream, DeployStreamEvent } from './deployComponentStream'; +import { explainDeployFailure } from './explainDeployFailure'; import { DeployRolloutError, followRestartJob } from './restartJob'; /** @@ -54,7 +55,15 @@ function refusedBeforeRunning({ status }: SSEUnsupportedError): boolean { return status !== undefined && status >= 400 && status < 500; } -export async function onDeployComponentSubmit({ +export async function onDeployComponentSubmit(args: DeployComponentArgs): Promise { + try { + return await deployComponent(args); + } catch (error) { + throw explainDeployFailure(error); + } +} + +async function deployComponent({ request, entityType, entityId, diff --git a/src/integrations/api/instance/applications/explainDeployFailure.test.ts b/src/integrations/api/instance/applications/explainDeployFailure.test.ts new file mode 100644 index 000000000..342f5c600 --- /dev/null +++ b/src/integrations/api/instance/applications/explainDeployFailure.test.ts @@ -0,0 +1,115 @@ +import { SSEInconclusiveError, SSEOperationError } from '@/integrations/api/sse/errors'; +import { AxiosError, AxiosHeaders, InternalAxiosRequestConfig, isAxiosError } from 'axios'; +import { describe, expect, it } from 'vitest'; +import { + explainDeployFailure, + explainDeployFailureText, + MISSING_ROOT_PACKAGE_JSON_MESSAGE, +} from './explainDeployFailure'; + +const LINUX = "ENOENT: no such file or directory, open '/tmp/harper-git-clone-oXPS7c/package.json'"; +const MAC = "ENOENT: no such file or directory, open '/var/folders/qs/x/T/harper-git-clone-AbC123/package.json'"; +const WINDOWS = + "ENOENT: no such file or directory, open 'C:\\Users\\h\\AppData\\Local\\Temp\\harper-git-clone-q1W2e3\\package.json'"; + +function axiosFailure(data: unknown) { + const config = { headers: new AxiosHeaders() } as InternalAxiosRequestConfig; + return new AxiosError('Request failed with status code 500', AxiosError.ERR_BAD_RESPONSE, config, undefined, { + data, + status: 500, + statusText: 'Internal Server Error', + headers: {}, + config, + }); +} + +describe('explainDeployFailureText', () => { + it.each([LINUX, MAC, WINDOWS])('replaces the missing root package.json failure: %s', (raw) => { + expect(explainDeployFailureText(raw)).toBe(MISSING_ROOT_PACKAGE_JSON_MESSAGE); + }); + + it('still recognizes the failure when Harper wraps it', () => { + expect(explainDeployFailureText(`Failed to deploy my-app: ${LINUX}`)).toBe(MISSING_ROOT_PACKAGE_JSON_MESSAGE); + expect(explainDeployFailureText(`Error: ENOENT\n at open '/tmp/harper-git-clone-oXPS7c/package.json'`)) + .toBe(MISSING_ROOT_PACKAGE_JSON_MESSAGE); + }); + + it.each([LINUX, WINDOWS])('recognizes the failure inside an event serialized as JSON: %s', (raw) => { + expect(explainDeployFailureText(JSON.stringify({ message: raw, code: 500, phase: 'prepare' }))) + .toBe(MISSING_ROOT_PACKAGE_JSON_MESSAGE); + }); + + it('leaves other failures alone, including other files in the clone and other ENOENTs', () => { + const others = [ + 'Failed to deploy private repository git@github.com:acme/widgets.git: SSH access failed.', + "ENOENT: no such file or directory, open '/tmp/harper-git-clone-oXPS7c/config.yaml'", + "ENOENT: no such file or directory, open '/home/harper/app/package.json'", + 'The operation failed.', + ]; + for (const text of others) { + expect(explainDeployFailureText(text)).toBe(text); + } + }); + + it('never names the temporary clone directory', () => { + expect(explainDeployFailureText(LINUX)).not.toMatch(/harper-git-clone|\/tmp\//); + }); +}); + +describe('explainDeployFailure', () => { + it('swaps a streamed failure for one carrying the guidance, keeping its type and details', () => { + const raw = new SSEOperationError(LINUX, { code: 'ENOENT', phase: 'prepare', deploymentId: 'd1' }); + const explained = explainDeployFailure(raw); + + expect(explained).toBeInstanceOf(SSEOperationError); + const { message, code, phase, deploymentId } = explained as SSEOperationError; + expect({ message, code, phase, deploymentId }).toEqual({ + message: MISSING_ROOT_PACKAGE_JSON_MESSAGE, + code: 'ENOENT', + phase: 'prepare', + deploymentId: 'd1', + }); + }); + + it.each([ + [ + 'an `{ error }` string, as deploy_component sends', + { error: LINUX, phase: 'prepare', deployment_id: 'd1' }, + { error: MISSING_ROOT_PACKAGE_JSON_MESSAGE, phase: 'prepare', deployment_id: 'd1' }, + ], + [ + 'an `{ error, message }` record', + { error: 'ENOENT', message: LINUX }, + { error: MISSING_ROOT_PACKAGE_JSON_MESSAGE }, + ], + [ + 'RFC 9457 Problem Details', + { code: 'ENOENT', title: LINUX, status: 500 }, + { error: MISSING_ROOT_PACKAGE_JSON_MESSAGE, code: 'ENOENT', status: 500 }, + ], + ['a bare string body', LINUX, MISSING_ROOT_PACKAGE_JSON_MESSAGE], + ])('swaps a buffered failure whose body is %s, keeping the HTTP envelope and body shape', (_, data, expected) => { + const raw = axiosFailure(data); + const explained = explainDeployFailure(raw); + + expect(isAxiosError(explained)).toBe(true); + const { message, status, config, response } = explained as AxiosError; + expect(message).toBe(MISSING_ROOT_PACKAGE_JSON_MESSAGE); + expect(status).toBe(500); + expect(config).toBe(raw.config); + expect(response?.data).toEqual(expected); + expect(JSON.stringify(response?.data)).not.toContain('harper-git-clone'); + }); + + it('returns any other error untouched', () => { + const others = [ + new SSEOperationError('Failed to clone package acme/widgets: fatal: repository not found'), + new SSEInconclusiveError('stream ended'), + axiosFailure({ error: 'Error', message: 'Component not found' }), + new Error(LINUX), + ]; + for (const error of others) { + expect(explainDeployFailure(error)).toBe(error); + } + }); +}); diff --git a/src/integrations/api/instance/applications/explainDeployFailure.ts b/src/integrations/api/instance/applications/explainDeployFailure.ts new file mode 100644 index 000000000..aad471846 --- /dev/null +++ b/src/integrations/api/instance/applications/explainDeployFailure.ts @@ -0,0 +1,55 @@ +import { SSEOperationError } from '@/integrations/api/sse/errors'; +import { errorText } from '@/lib/errorText'; +import { AxiosError, isAxiosError } from 'axios'; + +/** + * Harper reads a git deploy's root `package.json` without checking that it exists (harper + * `components/Application.ts`, `packGitReferenceWithoutScripts`, through v5.3.1), so a repository + * without one fails with Node's raw ENOENT naming the node's temporary clone directory: a path the + * user cannot see, deleted moments later, that names neither the problem nor the fix. Needed until + * no Harper version Studio supports reports it raw (HarperFast/harper#3142). + */ +// `[/\\]+` because the activity log renders event data as JSON, which doubles a Windows separator. +const MISSING_ROOT_PACKAGE_JSON = /\bENOENT\b.*\bharper-git-clone-[^/\\\s]*[/\\]+package\.json\b/s; + +export const MISSING_ROOT_PACKAGE_JSON_MESSAGE = 'This repository has no package.json in its root folder. ' + + 'Harper deploys a git repository as an npm package, which needs one there, ' + + 'so add a package.json to the root of the repository and deploy again.'; + +type DeployFailureBody = { error?: unknown; message?: unknown; title?: unknown; detail?: unknown } | string; + +export function explainDeployFailureText(text: string): string { + return MISSING_ROOT_PACKAGE_JSON.test(text) ? MISSING_ROOT_PACKAGE_JSON_MESSAGE : text; +} + +/** Each error keeps its type: `redactRelayedMessage` keys on `SSEOperationError`'s. */ +export function explainDeployFailure(error: unknown): unknown { + if (error instanceof SSEOperationError) { + const message = explainDeployFailureText(error.message); + return message === error.message + ? error + : new SSEOperationError(message, { code: error.code, phase: error.phase, deploymentId: error.deploymentId }); + } + if (isAxiosError(error) && error.response) { + const { data } = error.response; + const texts = typeof data === 'string' + ? [data] + : [data?.error, data?.message, data?.title, data?.detail].map((field) => errorText(field)); + if (texts.some((text) => text !== undefined && MISSING_ROOT_PACKAGE_JSON.test(text))) { + const explained: DeployFailureBody = typeof data === 'string' + ? MISSING_ROOT_PACKAGE_JSON_MESSAGE + : { + ...data, + error: MISSING_ROOT_PACKAGE_JSON_MESSAGE, + message: undefined, + title: undefined, + detail: undefined, + }; + return new AxiosError(MISSING_ROOT_PACKAGE_JSON_MESSAGE, error.code, error.config, error.request, { + ...error.response, + data: explained, + }); + } + } + return error; +} From 7233fb04d1b875d72bab3e86542a25c8df1f2718 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 12 Oct 2026 11:42:45 -0400 Subject: [PATCH 2/7] fix(applications): omit the raw failure fields from an explained deploy body The explained Axios body set message/title/detail to undefined, so the keys stayed present on the object a consumer receives. Omit them instead, and pin the body shape with toStrictEqual, which the old code fails. Co-Authored-By: Claude Opus 5.5 --- .../components/DeploymentDetail.test.tsx | 4 +++ .../applications/explainDeployFailure.test.ts | 8 ++++-- .../applications/explainDeployFailure.ts | 27 ++++++++++++------- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/features/instance/config/deployments/components/DeploymentDetail.test.tsx b/src/features/instance/config/deployments/components/DeploymentDetail.test.tsx index b5018f045..8135b465e 100644 --- a/src/features/instance/config/deployments/components/DeploymentDetail.test.tsx +++ b/src/features/instance/config/deployments/components/DeploymentDetail.test.tsx @@ -14,6 +14,10 @@ vi.mock('@/config/useInstanceClient', () => { vi.mock('@/features/instance/applications/hooks/useSupportsDeploymentSSE', () => ({ useSupportsDeploymentSSE: () => false, })); +vi.mock('@tanstack/react-router', () => { + const params = {}; + return { useParams: () => params, Link: () => null }; +}); afterEach(cleanup); diff --git a/src/integrations/api/instance/applications/explainDeployFailure.test.ts b/src/integrations/api/instance/applications/explainDeployFailure.test.ts index 342f5c600..ceef53fed 100644 --- a/src/integrations/api/instance/applications/explainDeployFailure.test.ts +++ b/src/integrations/api/instance/applications/explainDeployFailure.test.ts @@ -6,6 +6,7 @@ import { explainDeployFailureText, MISSING_ROOT_PACKAGE_JSON_MESSAGE, } from './explainDeployFailure'; +import { DeployRolloutError } from './restartJob'; const LINUX = "ENOENT: no such file or directory, open '/tmp/harper-git-clone-oXPS7c/package.json'"; const MAC = "ENOENT: no such file or directory, open '/var/folders/qs/x/T/harper-git-clone-AbC123/package.json'"; @@ -58,7 +59,8 @@ describe('explainDeployFailureText', () => { describe('explainDeployFailure', () => { it('swaps a streamed failure for one carrying the guidance, keeping its type and details', () => { - const raw = new SSEOperationError(LINUX, { code: 'ENOENT', phase: 'prepare', deploymentId: 'd1' }); + const certification = { status: 'rejected', reason: 'canary failed', restored: null, failed_closed: true }; + const raw = new SSEOperationError(LINUX, { code: 'ENOENT', phase: 'prepare', deploymentId: 'd1', certification }); const explained = explainDeployFailure(raw); expect(explained).toBeInstanceOf(SSEOperationError); @@ -69,6 +71,7 @@ describe('explainDeployFailure', () => { phase: 'prepare', deploymentId: 'd1', }); + expect((explained as SSEOperationError).certification).toBe(certification); }); it.each([ @@ -97,7 +100,7 @@ describe('explainDeployFailure', () => { expect(message).toBe(MISSING_ROOT_PACKAGE_JSON_MESSAGE); expect(status).toBe(500); expect(config).toBe(raw.config); - expect(response?.data).toEqual(expected); + expect(response?.data).toStrictEqual(expected); expect(JSON.stringify(response?.data)).not.toContain('harper-git-clone'); }); @@ -105,6 +108,7 @@ describe('explainDeployFailure', () => { const others = [ new SSEOperationError('Failed to clone package acme/widgets: fatal: repository not found'), new SSEInconclusiveError('stream ended'), + new DeployRolloutError({ jobId: 'j1', state: 'failed', nodes: [], message: 'node-2 did not restart' }, 'd1'), axiosFailure({ error: 'Error', message: 'Component not found' }), new Error(LINUX), ]; diff --git a/src/integrations/api/instance/applications/explainDeployFailure.ts b/src/integrations/api/instance/applications/explainDeployFailure.ts index aad471846..2228063e7 100644 --- a/src/integrations/api/instance/applications/explainDeployFailure.ts +++ b/src/integrations/api/instance/applications/explainDeployFailure.ts @@ -4,10 +4,13 @@ import { AxiosError, isAxiosError } from 'axios'; /** * Harper reads a git deploy's root `package.json` without checking that it exists (harper - * `components/Application.ts`, `packGitReferenceWithoutScripts`, through v5.3.1), so a repository + * `components/Application.ts`, `packGitReferenceWithoutScripts`, through v5.3.2), so a repository * without one fails with Node's raw ENOENT naming the node's temporary clone directory: a path the * user cannot see, deleted moments later, that names neither the problem nor the fix. Needed until - * no Harper version Studio supports reports it raw (HarperFast/harper#3142). + * no Harper version Studio supports reports it raw (HarperFast/harper#3142). Only the deploy itself + * fails this way: the `restart_service` job a rolling deploy hands off to (harper + * `components/operations.js`) restarts or activates a release every node already built, and clones + * nothing, so a `DeployRolloutError` never carries it. */ // `[/\\]+` because the activity log renders event data as JSON, which doubles a Windows separator. const MISSING_ROOT_PACKAGE_JSON = /\bENOENT\b.*\bharper-git-clone-[^/\\\s]*[/\\]+package\.json\b/s; @@ -18,6 +21,11 @@ export const MISSING_ROOT_PACKAGE_JSON_MESSAGE = 'This repository has no package type DeployFailureBody = { error?: unknown; message?: unknown; title?: unknown; detail?: unknown } | string; +function withoutFailureText(body: Exclude): Exclude { + const { message: _message, title: _title, detail: _detail, ...kept } = body; + return kept; +} + export function explainDeployFailureText(text: string): string { return MISSING_ROOT_PACKAGE_JSON.test(text) ? MISSING_ROOT_PACKAGE_JSON_MESSAGE : text; } @@ -28,7 +36,12 @@ export function explainDeployFailure(error: unknown): unknown { const message = explainDeployFailureText(error.message); return message === error.message ? error - : new SSEOperationError(message, { code: error.code, phase: error.phase, deploymentId: error.deploymentId }); + : new SSEOperationError(message, { + code: error.code, + phase: error.phase, + deploymentId: error.deploymentId, + certification: error.certification, + }); } if (isAxiosError(error) && error.response) { const { data } = error.response; @@ -38,13 +51,7 @@ export function explainDeployFailure(error: unknown): unknown { if (texts.some((text) => text !== undefined && MISSING_ROOT_PACKAGE_JSON.test(text))) { const explained: DeployFailureBody = typeof data === 'string' ? MISSING_ROOT_PACKAGE_JSON_MESSAGE - : { - ...data, - error: MISSING_ROOT_PACKAGE_JSON_MESSAGE, - message: undefined, - title: undefined, - detail: undefined, - }; + : { ...withoutFailureText(data), error: MISSING_ROOT_PACKAGE_JSON_MESSAGE }; return new AxiosError(MISSING_ROOT_PACKAGE_JSON_MESSAGE, error.code, error.config, error.request, { ...error.response, data: explained, From ba5ce015dc75b0c1c32880069cac063cb7066899 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Sun, 11 Oct 2026 02:25:23 -0400 Subject: [PATCH 3/7] fix(applications): show a failed deploy once, and keep a failed import beside its form A failed deploy showed the same failure in two toasts: the deploy mutation's global one, and the caller's own. The import form's only failure display was a toast that faded, for failures the user has to act on and retry. The deploy mutation now opts out of the global toast and keeps its RUM report with a mutation-level console.error. The import form keeps the failure inline above the Import button until the next attempt, with the SSH-keys link the redeploy panel already offered. Redeploy shows a streamed failure in its progress panel, toasting only when the panel is gone, and the buffered path, which relied on the global toast, now says why it failed. Both callers await mutateAsync so a failure that settles after the form unmounts still reaches the user, and every failure display reads the server's reason through describeError rather than axios's generic message. Closes #1833 Co-Authored-By: Claude Opus 5.5 --- .../DeployProgress/DeployFailure.tsx | 48 ++++ .../DeployProgress/DeployProgress.tsx | 38 +-- .../NewApplication/ImportInstructions.tsx | 12 + .../NewApplication/NewApplication.test.tsx | 43 +++- .../components/NewApplication/index.tsx | 3 +- .../useImportApplication.test.ts | 151 ++++++++++++ .../NewApplication/useImportApplication.ts | 221 ++++++++++-------- .../modals/RedeployApplicationModal.test.tsx | 155 ++++++++++++ .../modals/RedeployApplicationModal.tsx | 219 +++++++++-------- .../applications/deployComponent.test.ts | 73 ++++-- .../instance/applications/deployComponent.ts | 6 +- 11 files changed, 709 insertions(+), 260 deletions(-) create mode 100644 src/features/instance/applications/components/DeployProgress/DeployFailure.tsx create mode 100644 src/features/instance/applications/components/NewApplication/useImportApplication.test.ts create mode 100644 src/features/instance/applications/modals/RedeployApplicationModal.test.tsx diff --git a/src/features/instance/applications/components/DeployProgress/DeployFailure.tsx b/src/features/instance/applications/components/DeployProgress/DeployFailure.tsx new file mode 100644 index 000000000..76c0e2942 --- /dev/null +++ b/src/features/instance/applications/components/DeployProgress/DeployFailure.tsx @@ -0,0 +1,48 @@ +import { buildAbsoluteLinkToPage } from '@/lib/urls/buildAbsoluteLinkToPage'; +import { Link, useParams } from '@tanstack/react-router'; +import { CircleIcon } from 'lucide-react'; + +/** + * A deploy that failed because the *instance* couldn't reach a private repository over SSH is + * fixed in Config > SSH Keys (install a key, grant it access to the repo, add the git host to + * `known_hosts`), so link the user there instead of leaving them to find it. Harper's message + * already spells out the remediation, and only the deploy UI reports the failure: it's an + * instance configuration state, not a Studio bug, so `shouldKeepEvent` drops the RUM event + * (which also kept the customer's private repo URL out of Error Tracking). + */ +const SSH_ACCESS_FAILURE = /Failed to deploy private repository\b|SSH access failed/i; + +/** A failed deploy's message, persistent beside the form that started it. */ +export function DeployFailure({ + title, + message, + onNavigateAway, +}: { + title?: string; + message: string; + /** Called when a guidance link navigates away — lets a hosting modal close itself first. */ + onNavigateAway?: () => void; +}) { + const params: { clusterId?: string; instanceId?: string; organizationId?: string } = useParams({ strict: false }); + + return ( +
+
+ +
+ {title && {title}} + {message} +
+
+ {SSH_ACCESS_FAILURE.test(message) && ( + + Manage SSH keys + + )} +
+ ); +} diff --git a/src/features/instance/applications/components/DeployProgress/DeployProgress.tsx b/src/features/instance/applications/components/DeployProgress/DeployProgress.tsx index bec4f3674..eacadf535 100644 --- a/src/features/instance/applications/components/DeployProgress/DeployProgress.tsx +++ b/src/features/instance/applications/components/DeployProgress/DeployProgress.tsx @@ -7,18 +7,10 @@ import { CertificationDecision } from '@/integrations/api/instance/applications/ import { RolloutNode, RolloutSnapshot } from '@/integrations/api/instance/applications/restartJob'; import { cn } from '@/lib/cn'; import { errorText } from '@/lib/errorText'; -import { buildAbsoluteLinkToPage } from '@/lib/urls/buildAbsoluteLinkToPage'; import { Link, useParams } from '@tanstack/react-router'; -import { - CheckIcon, - CircleDashedIcon, - CircleIcon, - Loader2Icon, - ShieldCheckIcon, - TriangleAlertIcon, - XIcon, -} from 'lucide-react'; +import { CheckIcon, CircleDashedIcon, Loader2Icon, ShieldCheckIcon, TriangleAlertIcon, XIcon } from 'lucide-react'; import { useEffect, useRef } from 'react'; +import { DeployFailure } from './DeployFailure'; import { describeCertification, describeDecision, @@ -36,16 +28,6 @@ const PHASE_LABELS: Record = { staged: 'Staged', }; -/** - * A deploy that failed because the *instance* couldn't reach a private repository over SSH is - * fixed in Config > SSH Keys (install a key, grant it access to the repo, add the git host to - * `known_hosts`), so link the user there instead of leaving them to find it. Harper's message - * already spells out the remediation, and this UI is now the only place the failure surfaces: - * it's an instance configuration state, not a Studio bug, so `shouldKeepEvent` drops the RUM - * event (which also kept the customer's private repo URL out of Error Tracking). - */ -const SSH_ACCESS_FAILURE = /Failed to deploy private repository\b|SSH access failed/i; - function PhaseIcon({ status, isCurrent }: { status?: PhaseStatus; isCurrent: boolean }) { if (status === 'done') { return ; @@ -164,21 +146,7 @@ export function DeployProgress({ {phasesFailed && state.decision && } {phasesFailed && state.error && !state.decision && ( -
-
- - {state.error} -
- {SSH_ACCESS_FAILURE.test(state.error) && ( - - Manage SSH keys - - )} -
+ )} {state.lifecycle === 'inconclusive' && !state.rollout && ( diff --git a/src/features/instance/applications/components/NewApplication/ImportInstructions.tsx b/src/features/instance/applications/components/NewApplication/ImportInstructions.tsx index a63381c2d..168e481ee 100644 --- a/src/features/instance/applications/components/NewApplication/ImportInstructions.tsx +++ b/src/features/instance/applications/components/NewApplication/ImportInstructions.tsx @@ -10,6 +10,7 @@ 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 { DeployFailure } from '@/features/instance/applications/components/DeployProgress/DeployFailure'; import { useStagedDeploymentsAvailable } from '@/features/instance/applications/hooks/useSupportsDeploymentSSE'; import { GitMergeIcon, LinkIcon, PackageIcon, RocketIcon } from 'lucide-react'; import { useCallback } from 'react'; @@ -17,16 +18,19 @@ import { Control, FormState, UseFormSetValue, UseFormWatch } from 'react-hook-fo import { z } from 'zod'; import { ImportAuthorization } from './ImportAuthorization'; import { NewApplicationSchema } from './schema'; +import { ImportFailure } from './useImportApplication'; export function ImportInstructions({ control, formState, + importFailure, isImportingApplication, setValue, watch, }: { control: Control>; formState: FormState>; + importFailure?: ImportFailure; isImportingApplication: boolean; setValue: UseFormSetValue>; watch: UseFormWatch>; @@ -180,6 +184,14 @@ export function ImportInstructions({ + {importFailure && ( + + )} +