From 87063b28eaf285e9d81918193948d7b8fa03cc4f Mon Sep 17 00:00:00 2001 From: Fabien FLEUREAU Date: Wed, 14 Oct 2026 11:18:32 +0200 Subject: [PATCH 1/3] feat(agentic-workflow): replay webhook runs from run details --- .../src/lib/domains-services-data-access.ts | 28 +++++ libs/domains/services/feature/src/index.ts | 1 + .../agentic-workflow-runs.spec.tsx | 105 +++++++++++++++++ .../agentic-workflow-runs.tsx | 39 ++++++- .../use-replay-agentic-workflow-run.spec.tsx | 110 ++++++++++++++++++ .../use-replay-agentic-workflow-run.ts | 23 ++++ 6 files changed, 302 insertions(+), 4 deletions(-) create mode 100644 libs/domains/services/feature/src/lib/hooks/use-replay-agentic-workflow-run/use-replay-agentic-workflow-run.spec.tsx create mode 100644 libs/domains/services/feature/src/lib/hooks/use-replay-agentic-workflow-run/use-replay-agentic-workflow-run.ts diff --git a/libs/domains/services/data-access/src/lib/domains-services-data-access.ts b/libs/domains/services/data-access/src/lib/domains-services-data-access.ts index ebc0236f57a..a062108bee4 100644 --- a/libs/domains/services/data-access/src/lib/domains-services-data-access.ts +++ b/libs/domains/services/data-access/src/lib/domains-services-data-access.ts @@ -972,11 +972,39 @@ type EditAdvancedSettingsRequest = { } & TerraformAdvancedSettings) } +function isJson(value: string) { + try { + JSON.parse(value) + return true + } catch { + return false + } +} + export const mutations = { async deployAgenticWorkflow({ agenticWorkflowId }: { agenticWorkflowId: string }) { const response = await agenticWorkflowsApi.deployAgenticWorkflow(agenticWorkflowId) return response.data }, + // Sent straight from the browser to the agent webhook, so the webhook must allow CORS for the Console origin. + // Run history does not store request headers, so the content type is inferred from the stored body. + async replayAgenticWorkflowRun({ webhookUrl, payload }: { webhookUrl: string; payload: string }) { + let response: Response + try { + response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': isJson(payload) ? 'application/json' : 'text/plain' }, + body: payload, + credentials: 'omit', + }) + } catch { + throw new Error('The webhook could not be reached. Check your network connection and its CORS settings.') + } + if (!response.ok) { + throw new Error(`The webhook responded with status ${response.status}.`) + } + return response + }, async cloneService({ serviceId, serviceType, payload }: CloneServiceRequest) { const { mutation } = match(serviceType) .with('APPLICATION', (serviceType) => ({ diff --git a/libs/domains/services/feature/src/index.ts b/libs/domains/services/feature/src/index.ts index df0c2664d4f..7af5eeb2d20 100644 --- a/libs/domains/services/feature/src/index.ts +++ b/libs/domains/services/feature/src/index.ts @@ -57,6 +57,7 @@ export * from './lib/hooks/use-restart-service/use-restart-service' export * from './lib/hooks/use-deploy-all-services/use-deploy-all-services' export * from './lib/hooks/use-deploy-service/use-deploy-service' export * from './lib/hooks/use-deploy-agentic-workflow/use-deploy-agentic-workflow' +export * from './lib/hooks/use-replay-agentic-workflow-run/use-replay-agentic-workflow-run' export * from './lib/hooks/use-redeploy-service-action/use-redeploy-service-action' export * from './lib/hooks/use-stop-all-services/use-stop-all-services' export * from './lib/hooks/use-uninstall-all-services/use-uninstall-all-services' diff --git a/libs/domains/services/feature/src/lib/agentic-workflow-runs/agentic-workflow-runs.spec.tsx b/libs/domains/services/feature/src/lib/agentic-workflow-runs/agentic-workflow-runs.spec.tsx index 00db8558bde..8013a54adb8 100644 --- a/libs/domains/services/feature/src/lib/agentic-workflow-runs/agentic-workflow-runs.spec.tsx +++ b/libs/domains/services/feature/src/lib/agentic-workflow-runs/agentic-workflow-runs.spec.tsx @@ -4,6 +4,9 @@ import { renderWithProviders, screen } from '@qovery/shared/util-tests' import { AgenticWorkflowLastRun, AgenticWorkflowRuns } from './agentic-workflow-runs' const mockUseRunHistory = jest.fn() +const mockUseService = jest.fn() +const mockToast = jest.fn() +const mockFetch = jest.fn() const mockCopyToClipboard = jest.fn() const mockMonacoEditor = jest.fn() const mockDisposeContentSizeListener = jest.fn() @@ -33,11 +36,20 @@ jest.mock('@monaco-editor/react', () => { jest.mock('../hooks/use-agentic-workflow-run-history/use-agentic-workflow-run-history', () => ({ useAgenticWorkflowRunHistory: (args: unknown) => mockUseRunHistory(args), })) +jest.mock('../hooks/use-service/use-service', () => ({ + useService: (args: unknown) => mockUseService(args), +})) +jest.mock('@qovery/shared/ui', () => ({ + ...jest.requireActual('@qovery/shared/ui'), + toast: (...args: unknown[]) => mockToast(...args), +})) jest.mock('@qovery/shared/util-hooks', () => ({ ...jest.requireActual('@qovery/shared/util-hooks'), useCopyToClipboard: () => [jest.fn(), mockCopyToClipboard], })) +const webhookUrl = 'https://webhook.example.com/agentic/workflow-123' + const run: AgenticWorkflowRun = { id: 'run-123', source_workflow_id: 'workflow-123', @@ -52,6 +64,9 @@ describe('AgenticWorkflowRuns', () => { jest.clearAllMocks() contentSizeListener = undefined mockGetContentHeight.mockReturnValue(0) + mockUseService.mockReturnValue({ data: { webhook: { url: webhookUrl } } }) + mockFetch.mockResolvedValue({ ok: true, status: 202 }) + global.fetch = mockFetch mockUseRunHistory.mockReturnValue({ data: [run], isLoading: false, @@ -528,4 +543,94 @@ describe('AgenticWorkflowRuns', () => { expect(refetch).toHaveBeenCalledTimes(1) }) + + describe('replay', () => { + const payload = '{ "event": "deploy",\n "id": 12345678901234567890 }' + + async function openRun(overrides: Partial = {}) { + mockUseRunHistory.mockReturnValue({ + data: [{ ...run, payload, ...overrides }], + isLoading: false, + isError: false, + }) + const rendered = renderWithProviders() + await rendered.userEvent.click(screen.getByRole('button', { name: /run-123/i })) + return rendered + } + + it('offers a replay for a webhook run with a stored payload', async () => { + await openRun() + + expect(within(screen.getByRole('dialog')).getByRole('button', { name: 'Replay' })).toBeEnabled() + expect(mockUseService).toHaveBeenCalledWith({ serviceId: 'workflow-123', serviceType: 'AGENTIC_WORKFLOW' }) + }) + + it('offers a replay for a webhook run with an empty stored payload', async () => { + await openRun({ payload: '' }) + + expect(within(screen.getByRole('dialog')).getByRole('button', { name: 'Replay' })).toBeInTheDocument() + }) + + it.each([ + ['a webhook run without a stored payload', { trigger: 'WEBHOOK', payload: null }], + ['a manual run', { trigger: 'MANUAL', payload: payload }], + ['a scheduled run', { trigger: 'SCHEDULE', payload: payload }], + ] as const)('does not offer a replay for %s', async (_name, overrides) => { + await openRun(overrides) + + expect(within(screen.getByRole('dialog')).queryByRole('button', { name: 'Replay' })).not.toBeInTheDocument() + }) + + it('disables the replay until the webhook URL is known', async () => { + mockUseService.mockReturnValue({ data: undefined }) + const { userEvent } = await openRun() + + const button = within(screen.getByRole('dialog')).getByRole('button', { name: 'Replay' }) + expect(button).toBeDisabled() + await userEvent.click(button) + + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('posts the exact original payload to the agent webhook URL', async () => { + const { userEvent } = await openRun() + + await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Replay' })) + + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)) + const [url, init] = mockFetch.mock.calls[0] + expect(url).toBe(webhookUrl) + expect(init).toEqual(expect.objectContaining({ method: 'POST', body: payload })) + expect(init.headers).toEqual({ 'Content-Type': 'application/json' }) + }) + + it('shows a loading state while the request is in flight and a success message afterwards', async () => { + let resolveFetch: (value: { ok: boolean; status: number }) => void = () => undefined + mockFetch.mockReturnValue(new Promise((resolve) => (resolveFetch = resolve))) + const { userEvent } = await openRun() + + const button = within(screen.getByRole('dialog')).getByRole('button', { name: 'Replay' }) + await userEvent.click(button) + + await waitFor(() => expect(button).toHaveClass('pointer-events-none')) + expect(mockToast).not.toHaveBeenCalled() + + await act(async () => resolveFetch({ ok: true, status: 202 })) + + await waitFor(() => expect(mockToast).toHaveBeenCalledWith('success', 'Run replayed', expect.any(String))) + expect(button).not.toHaveClass('pointer-events-none') + }) + + it('shows an error message when the webhook rejects the replay', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500 }) + const { userEvent } = await openRun() + + await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Replay' })) + + await waitFor(() => + expect(mockToast).toHaveBeenCalledWith('error', 'Replay failed', 'The webhook responded with status 500.') + ) + expect(mockToast).not.toHaveBeenCalledWith('success', expect.anything(), expect.anything()) + }) + }) }) diff --git a/libs/domains/services/feature/src/lib/agentic-workflow-runs/agentic-workflow-runs.tsx b/libs/domains/services/feature/src/lib/agentic-workflow-runs/agentic-workflow-runs.tsx index 997e1e90d0e..fd3dc87bccd 100644 --- a/libs/domains/services/feature/src/lib/agentic-workflow-runs/agentic-workflow-runs.tsx +++ b/libs/domains/services/feature/src/lib/agentic-workflow-runs/agentic-workflow-runs.tsx @@ -17,6 +17,8 @@ import { import { dateFullFormat, formatDuration, timeAgo } from '@qovery/shared/util-dates' import { useCopyToClipboard } from '@qovery/shared/util-hooks' import { useAgenticWorkflowRunHistory } from '../hooks/use-agentic-workflow-run-history/use-agentic-workflow-run-history' +import { useReplayAgenticWorkflowRun } from '../hooks/use-replay-agentic-workflow-run/use-replay-agentic-workflow-run' +import { useService } from '../hooks/use-service/use-service' const { Table } = TablePrimitives @@ -226,9 +228,33 @@ function PayloadEditor({ value }: { value: string }) { ) } -function RunDetails({ run, onClose }: { run: RunWithLifecycle; onClose: () => void }) { +function ReplayRunButton({ serviceId, payload }: { serviceId: string; payload: string }) { + const { data: service } = useService({ serviceId, serviceType: 'AGENTIC_WORKFLOW' }) + const { mutate: replayRun, isLoading } = useReplayAgenticWorkflowRun({ serviceId }) + const webhookUrl = service?.webhook.url + + return ( + + ) +} + +function RunDetails({ run, serviceId, onClose }: { run: RunWithLifecycle; serviceId: string; onClose: () => void }) { const closeButtonRef = useRef(null) const formattedJson = run.payload ? formatJsonPayload(run.payload) : null + // Only a webhook call has a body to resend. Run history never stores request headers. + const replayPayload = run.trigger === AgenticWorkflowRunTrigger.WEBHOOK ? run.payload : undefined return ( !open && onClose()}> @@ -277,7 +303,12 @@ function RunDetails({ run, onClose }: { run: RunWithLifecycle; onClose: () => vo
- Payload +
+ Payload + {typeof replayPayload === 'string' && ( + + )} +
{formattedJson !== null ? (
- {selectedRun && setSelectedRun(null)} />} + {selectedRun && setSelectedRun(null)} />}
) } @@ -521,7 +552,7 @@ export function AgenticWorkflowLastRun({ serviceId }: { serviceId: string }) { )} - {selectedRun && setSelectedRun(null)} />} + {selectedRun && setSelectedRun(null)} />} ) } diff --git a/libs/domains/services/feature/src/lib/hooks/use-replay-agentic-workflow-run/use-replay-agentic-workflow-run.spec.tsx b/libs/domains/services/feature/src/lib/hooks/use-replay-agentic-workflow-run/use-replay-agentic-workflow-run.spec.tsx new file mode 100644 index 00000000000..48ace3d8310 --- /dev/null +++ b/libs/domains/services/feature/src/lib/hooks/use-replay-agentic-workflow-run/use-replay-agentic-workflow-run.spec.tsx @@ -0,0 +1,110 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { type PropsWithChildren } from 'react' +import { act, renderHook, waitFor } from '@qovery/shared/util-tests' +import { queries } from '@qovery/state/util-queries' +import { useReplayAgenticWorkflowRun } from './use-replay-agentic-workflow-run' + +const mockToast = jest.fn() +const mockFetch = jest.fn() + +jest.mock('@qovery/shared/ui', () => ({ + ...jest.requireActual('@qovery/shared/ui'), + toast: (...args: unknown[]) => mockToast(...args), +})) + +const webhookUrl = 'https://webhook.example.com/agentic/abc' + +describe('useReplayAgenticWorkflowRun', () => { + let queryClient: QueryClient + let invalidateQueries: jest.SpyInstance + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ) + + beforeEach(() => { + jest.clearAllMocks() + queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } }) + invalidateQueries = jest.spyOn(queryClient, 'invalidateQueries') + global.fetch = mockFetch + mockFetch.mockResolvedValue({ ok: true, status: 202 }) + }) + + afterEach(() => { + queryClient.clear() + }) + + it('posts the original payload unchanged to the webhook URL from the browser', async () => { + const payload = '{ "id": 12345678901234567890,\n "a":"\\u00e9" }' + const { result } = renderHook(() => useReplayAgenticWorkflowRun({ serviceId: 'service-1' }), { wrapper }) + + await act(async () => { + await result.current.mutateAsync({ webhookUrl, payload }) + }) + + expect(mockFetch).toHaveBeenCalledTimes(1) + const [url, init] = mockFetch.mock.calls[0] + expect(url).toBe(webhookUrl) + expect(init).toEqual( + expect.objectContaining({ + method: 'POST', + body: payload, + headers: { 'Content-Type': 'application/json' }, + credentials: 'omit', + }) + ) + }) + + it('sends a non-JSON payload as plain text', async () => { + const { result } = renderHook(() => useReplayAgenticWorkflowRun({ serviceId: 'service-1' }), { wrapper }) + + await act(async () => { + await result.current.mutateAsync({ webhookUrl, payload: 'not json' }) + }) + + expect(mockFetch.mock.calls[0][1]).toEqual( + expect.objectContaining({ body: 'not json', headers: { 'Content-Type': 'text/plain' } }) + ) + }) + + it('refreshes the run history of the service and shows a success toast', async () => { + const { result } = renderHook(() => useReplayAgenticWorkflowRun({ serviceId: 'service-1' }), { wrapper }) + + await act(async () => { + await result.current.mutateAsync({ webhookUrl, payload: '{}' }) + }) + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: [...queries.services.agenticWorkflowRunHistory._def, 'service-1'], + }) + expect(mockToast).toHaveBeenCalledWith('success', 'Run replayed', expect.any(String)) + }) + + it('shows the HTTP status and does not refresh the history when the webhook rejects the request', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 403 }) + const { result } = renderHook(() => useReplayAgenticWorkflowRun({ serviceId: 'service-1' }), { wrapper }) + + await act(async () => { + await result.current.mutateAsync({ webhookUrl, payload: '{}' }).catch(() => undefined) + }) + + await waitFor(() => + expect(mockToast).toHaveBeenCalledWith('error', 'Replay failed', 'The webhook responded with status 403.') + ) + expect(invalidateQueries).not.toHaveBeenCalled() + expect(mockToast).not.toHaveBeenCalledWith('success', expect.anything(), expect.anything()) + }) + + it('explains a network or CORS failure', async () => { + mockFetch.mockRejectedValue(new TypeError('Failed to fetch')) + const { result } = renderHook(() => useReplayAgenticWorkflowRun({ serviceId: 'service-1' }), { wrapper }) + + await act(async () => { + await result.current.mutateAsync({ webhookUrl, payload: '{}' }).catch(() => undefined) + }) + + await waitFor(() => + expect(mockToast).toHaveBeenCalledWith('error', 'Replay failed', expect.stringContaining('CORS')) + ) + expect(invalidateQueries).not.toHaveBeenCalled() + }) +}) diff --git a/libs/domains/services/feature/src/lib/hooks/use-replay-agentic-workflow-run/use-replay-agentic-workflow-run.ts b/libs/domains/services/feature/src/lib/hooks/use-replay-agentic-workflow-run/use-replay-agentic-workflow-run.ts new file mode 100644 index 00000000000..12805fd152b --- /dev/null +++ b/libs/domains/services/feature/src/lib/hooks/use-replay-agentic-workflow-run/use-replay-agentic-workflow-run.ts @@ -0,0 +1,23 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { mutations } from '@qovery/domains/services/data-access' +import { toast } from '@qovery/shared/ui' +import { queries } from '@qovery/state/util-queries' + +export function useReplayAgenticWorkflowRun({ serviceId }: { serviceId: string }) { + const queryClient = useQueryClient() + + return useMutation(mutations.replayAgenticWorkflowRun, { + onSuccess() { + queryClient.invalidateQueries({ + queryKey: [...queries.services.agenticWorkflowRunHistory._def, serviceId], + }) + + toast('success', 'Run replayed', 'The original payload was sent to the agent task webhook.') + }, + onError(error) { + toast('error', 'Replay failed', error instanceof Error ? error.message : undefined) + }, + }) +} + +export default useReplayAgenticWorkflowRun From b0d33795769f506ea91c55f011c5ee570bb5a42c Mon Sep 17 00:00:00 2001 From: Fabien FLEUREAU Date: Wed, 14 Oct 2026 11:57:00 +0200 Subject: [PATCH 2/3] feat(agentic-workflow): move replay to run history actions column --- .../src/lib/domains-services-data-access.ts | 8 +- .../agentic-workflow-runs.spec.tsx | 104 ++++++++++++------ .../agentic-workflow-runs.tsx | 28 ++--- .../use-replay-agentic-workflow-run.spec.tsx | 13 +++ 4 files changed, 100 insertions(+), 53 deletions(-) diff --git a/libs/domains/services/data-access/src/lib/domains-services-data-access.ts b/libs/domains/services/data-access/src/lib/domains-services-data-access.ts index a062108bee4..7bf08792e5d 100644 --- a/libs/domains/services/data-access/src/lib/domains-services-data-access.ts +++ b/libs/domains/services/data-access/src/lib/domains-services-data-access.ts @@ -988,13 +988,15 @@ export const mutations = { }, // Sent straight from the browser to the agent webhook, so the webhook must allow CORS for the Console origin. // Run history does not store request headers, so the content type is inferred from the stored body. - async replayAgenticWorkflowRun({ webhookUrl, payload }: { webhookUrl: string; payload: string }) { + // Runs without a stored payload (manual, scheduled) are replayed with an empty body. + async replayAgenticWorkflowRun({ webhookUrl, payload }: { webhookUrl: string; payload?: string | null }) { let response: Response try { response = await fetch(webhookUrl, { method: 'POST', - headers: { 'Content-Type': isJson(payload) ? 'application/json' : 'text/plain' }, - body: payload, + headers: + typeof payload === 'string' ? { 'Content-Type': isJson(payload) ? 'application/json' : 'text/plain' } : {}, + body: typeof payload === 'string' ? payload : undefined, credentials: 'omit', }) } catch { diff --git a/libs/domains/services/feature/src/lib/agentic-workflow-runs/agentic-workflow-runs.spec.tsx b/libs/domains/services/feature/src/lib/agentic-workflow-runs/agentic-workflow-runs.spec.tsx index 8013a54adb8..04c94b525ed 100644 --- a/libs/domains/services/feature/src/lib/agentic-workflow-runs/agentic-workflow-runs.spec.tsx +++ b/libs/domains/services/feature/src/lib/agentic-workflow-runs/agentic-workflow-runs.spec.tsx @@ -116,6 +116,7 @@ describe('AgenticWorkflowRuns', () => { 'Payload', 'Duration', 'Cost', + 'Actions', ]) expect(screen.getByText('Completed')).toBeInTheDocument() expect(screen.getByText('Completed').closest('[data-accent-color]')).not.toBeInTheDocument() @@ -547,85 +548,116 @@ describe('AgenticWorkflowRuns', () => { describe('replay', () => { const payload = '{ "event": "deploy",\n "id": 12345678901234567890 }' - async function openRun(overrides: Partial = {}) { + function renderRuns(runs: Partial[] = [{}]) { mockUseRunHistory.mockReturnValue({ - data: [{ ...run, payload, ...overrides }], + data: runs.map((overrides, index) => ({ ...run, id: `run-${index + 1}`, payload, ...overrides })), isLoading: false, isError: false, }) - const rendered = renderWithProviders() - await rendered.userEvent.click(screen.getByRole('button', { name: /run-123/i })) - return rendered + return renderWithProviders() } - it('offers a replay for a webhook run with a stored payload', async () => { - await openRun() + function replayButtons() { + return screen.getAllByRole('button', { name: 'Replay' }) + } - expect(within(screen.getByRole('dialog')).getByRole('button', { name: 'Replay' })).toBeEnabled() + it('shows an Actions column with a Replay button on every row, whatever the trigger or payload', () => { + renderRuns([ + { trigger: 'WEBHOOK', payload }, + { trigger: 'WEBHOOK', payload: '' }, + { trigger: 'WEBHOOK', payload: null }, + { trigger: 'MANUAL', payload: undefined }, + { trigger: 'SCHEDULE', payload: null }, + ]) + + expect(screen.getByRole('columnheader', { name: 'Actions' })).toBeInTheDocument() + expect(replayButtons()).toHaveLength(5) + replayButtons().forEach((button) => expect(button).toBeEnabled()) expect(mockUseService).toHaveBeenCalledWith({ serviceId: 'workflow-123', serviceType: 'AGENTIC_WORKFLOW' }) }) - it('offers a replay for a webhook run with an empty stored payload', async () => { - await openRun({ payload: '' }) - - expect(within(screen.getByRole('dialog')).getByRole('button', { name: 'Replay' })).toBeInTheDocument() - }) - - it.each([ - ['a webhook run without a stored payload', { trigger: 'WEBHOOK', payload: null }], - ['a manual run', { trigger: 'MANUAL', payload: payload }], - ['a scheduled run', { trigger: 'SCHEDULE', payload: payload }], - ] as const)('does not offer a replay for %s', async (_name, overrides) => { - await openRun(overrides) + it('does not offer a replay in the run details', async () => { + const { userEvent } = renderRuns() + await userEvent.click(screen.getByRole('button', { name: /run-1/i })) expect(within(screen.getByRole('dialog')).queryByRole('button', { name: 'Replay' })).not.toBeInTheDocument() }) - it('disables the replay until the webhook URL is known', async () => { + it('disables every replay until the webhook URL is known', async () => { mockUseService.mockReturnValue({ data: undefined }) - const { userEvent } = await openRun() + const { userEvent } = renderRuns([{}, { trigger: 'MANUAL', payload: null }]) - const button = within(screen.getByRole('dialog')).getByRole('button', { name: 'Replay' }) - expect(button).toBeDisabled() - await userEvent.click(button) + for (const button of replayButtons()) { + expect(button).toBeDisabled() + await userEvent.click(button) + } expect(mockFetch).not.toHaveBeenCalled() }) - it('posts the exact original payload to the agent webhook URL', async () => { - const { userEvent } = await openRun() + it('posts the exact original payload to the agent webhook URL without opening the run details', async () => { + const { userEvent } = renderRuns() - await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Replay' })) + await userEvent.click(replayButtons()[0]) await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)) const [url, init] = mockFetch.mock.calls[0] expect(url).toBe(webhookUrl) expect(init).toEqual(expect.objectContaining({ method: 'POST', body: payload })) expect(init.headers).toEqual({ 'Content-Type': 'application/json' }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('posts an empty stored payload as is', async () => { + const { userEvent } = renderRuns([{ payload: '' }]) + + await userEvent.click(replayButtons()[0]) + + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)) + expect(mockFetch.mock.calls[0][1]).toEqual(expect.objectContaining({ method: 'POST', body: '' })) + }) + + it.each([ + ['a webhook run with a null payload', { trigger: 'WEBHOOK', payload: null }], + ['a webhook run with a missing payload', { trigger: 'WEBHOOK', payload: undefined }], + ['a manual run', { trigger: 'MANUAL', payload: null }], + ['a scheduled run', { trigger: 'SCHEDULE', payload: undefined }], + ] as const)('posts an empty body for %s', async (_name, overrides) => { + const { userEvent } = renderRuns([overrides]) + + await userEvent.click(replayButtons()[0]) + + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)) + const [url, init] = mockFetch.mock.calls[0] + expect(url).toBe(webhookUrl) + expect(init.method).toBe('POST') + expect(init.body).toBeUndefined() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) - it('shows a loading state while the request is in flight and a success message afterwards', async () => { + it('shows the loading state on the activated row only and a success message afterwards', async () => { let resolveFetch: (value: { ok: boolean; status: number }) => void = () => undefined mockFetch.mockReturnValue(new Promise((resolve) => (resolveFetch = resolve))) - const { userEvent } = await openRun() + const { userEvent } = renderRuns([{}, { trigger: 'MANUAL', payload: null }]) + const [first, second] = replayButtons() - const button = within(screen.getByRole('dialog')).getByRole('button', { name: 'Replay' }) - await userEvent.click(button) + await userEvent.click(first) - await waitFor(() => expect(button).toHaveClass('pointer-events-none')) + await waitFor(() => expect(first).toHaveClass('pointer-events-none')) + expect(second).not.toHaveClass('pointer-events-none') expect(mockToast).not.toHaveBeenCalled() await act(async () => resolveFetch({ ok: true, status: 202 })) await waitFor(() => expect(mockToast).toHaveBeenCalledWith('success', 'Run replayed', expect.any(String))) - expect(button).not.toHaveClass('pointer-events-none') + expect(first).not.toHaveClass('pointer-events-none') }) it('shows an error message when the webhook rejects the replay', async () => { mockFetch.mockResolvedValue({ ok: false, status: 500 }) - const { userEvent } = await openRun() + const { userEvent } = renderRuns() - await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Replay' })) + await userEvent.click(replayButtons()[0]) await waitFor(() => expect(mockToast).toHaveBeenCalledWith('error', 'Replay failed', 'The webhook responded with status 500.') diff --git a/libs/domains/services/feature/src/lib/agentic-workflow-runs/agentic-workflow-runs.tsx b/libs/domains/services/feature/src/lib/agentic-workflow-runs/agentic-workflow-runs.tsx index fd3dc87bccd..7a6c97e91ff 100644 --- a/libs/domains/services/feature/src/lib/agentic-workflow-runs/agentic-workflow-runs.tsx +++ b/libs/domains/services/feature/src/lib/agentic-workflow-runs/agentic-workflow-runs.tsx @@ -228,7 +228,7 @@ function PayloadEditor({ value }: { value: string }) { ) } -function ReplayRunButton({ serviceId, payload }: { serviceId: string; payload: string }) { +function ReplayRunButton({ serviceId, payload }: { serviceId: string; payload?: string | null }) { const { data: service } = useService({ serviceId, serviceType: 'AGENTIC_WORKFLOW' }) const { mutate: replayRun, isLoading } = useReplayAgenticWorkflowRun({ serviceId }) const webhookUrl = service?.webhook.url @@ -242,7 +242,10 @@ function ReplayRunButton({ serviceId, payload }: { serviceId: string; payload: s className="gap-2" disabled={!webhookUrl} loading={isLoading} - onClick={() => webhookUrl && replayRun({ webhookUrl, payload })} + onClick={(event) => { + event.stopPropagation() + if (webhookUrl) replayRun({ webhookUrl, payload }) + }} > Replay @@ -250,11 +253,9 @@ function ReplayRunButton({ serviceId, payload }: { serviceId: string; payload: s ) } -function RunDetails({ run, serviceId, onClose }: { run: RunWithLifecycle; serviceId: string; onClose: () => void }) { +function RunDetails({ run, onClose }: { run: RunWithLifecycle; onClose: () => void }) { const closeButtonRef = useRef(null) const formattedJson = run.payload ? formatJsonPayload(run.payload) : null - // Only a webhook call has a body to resend. Run history never stores request headers. - const replayPayload = run.trigger === AgenticWorkflowRunTrigger.WEBHOOK ? run.payload : undefined return ( !open && onClose()}> @@ -303,12 +304,7 @@ function RunDetails({ run, serviceId, onClose }: { run: RunWithLifecycle; servic
-
- Payload - {typeof replayPayload === 'string' && ( - - )} -
+ Payload {formattedJson !== null ? (
- + Date @@ -410,6 +406,7 @@ export function AgenticWorkflowRuns({ serviceId }: { serviceId: string }) { Payload Duration Cost + Actions @@ -482,11 +479,14 @@ export function AgenticWorkflowRuns({ serviceId }: { serviceId: string }) { + + + ))} - {selectedRun && setSelectedRun(null)} />} + {selectedRun && setSelectedRun(null)} />}
) } @@ -552,7 +552,7 @@ export function AgenticWorkflowLastRun({ serviceId }: { serviceId: string }) { )} - {selectedRun && setSelectedRun(null)} />} + {selectedRun && setSelectedRun(null)} />} ) } diff --git a/libs/domains/services/feature/src/lib/hooks/use-replay-agentic-workflow-run/use-replay-agentic-workflow-run.spec.tsx b/libs/domains/services/feature/src/lib/hooks/use-replay-agentic-workflow-run/use-replay-agentic-workflow-run.spec.tsx index 48ace3d8310..c82f7ff7ec9 100644 --- a/libs/domains/services/feature/src/lib/hooks/use-replay-agentic-workflow-run/use-replay-agentic-workflow-run.spec.tsx +++ b/libs/domains/services/feature/src/lib/hooks/use-replay-agentic-workflow-run/use-replay-agentic-workflow-run.spec.tsx @@ -66,6 +66,19 @@ describe('useReplayAgenticWorkflowRun', () => { ) }) + it.each([null, undefined])('posts an empty body without content type when the payload is %s', async (payload) => { + const { result } = renderHook(() => useReplayAgenticWorkflowRun({ serviceId: 'service-1' }), { wrapper }) + + await act(async () => { + await result.current.mutateAsync({ webhookUrl, payload }) + }) + + const [url, init] = mockFetch.mock.calls[0] + expect(url).toBe(webhookUrl) + expect(init).toEqual(expect.objectContaining({ method: 'POST', headers: {}, credentials: 'omit' })) + expect(init.body).toBeUndefined() + }) + it('refreshes the run history of the service and shows a success toast', async () => { const { result } = renderHook(() => useReplayAgenticWorkflowRun({ serviceId: 'service-1' }), { wrapper }) From 7f9a055f3f512cd69a9e4bb6bc095c3b7b9f0794 Mon Sep 17 00:00:00 2001 From: Fabien FLEUREAU Date: Wed, 14 Oct 2026 12:35:51 +0200 Subject: [PATCH 3/3] fix(agentic-workflow): block duplicate replays and report indeterminate delivery --- .../src/lib/domains-services-data-access.ts | 5 +++- .../agentic-workflow-runs.spec.tsx | 27 +++++++++++++++++++ .../agentic-workflow-runs.tsx | 4 +-- .../use-replay-agentic-workflow-run.spec.tsx | 13 +++++++-- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/libs/domains/services/data-access/src/lib/domains-services-data-access.ts b/libs/domains/services/data-access/src/lib/domains-services-data-access.ts index 7bf08792e5d..a8c6565eb6c 100644 --- a/libs/domains/services/data-access/src/lib/domains-services-data-access.ts +++ b/libs/domains/services/data-access/src/lib/domains-services-data-access.ts @@ -1000,7 +1000,10 @@ export const mutations = { credentials: 'omit', }) } catch { - throw new Error('The webhook could not be reached. Check your network connection and its CORS settings.') + // The browser also rejects when the response lacks CORS headers, even if the webhook accepted the request. + throw new Error( + 'Delivery could not be confirmed. The webhook may have received the request despite the browser error. Check the run history before replaying again.' + ) } if (!response.ok) { throw new Error(`The webhook responded with status ${response.status}.`) diff --git a/libs/domains/services/feature/src/lib/agentic-workflow-runs/agentic-workflow-runs.spec.tsx b/libs/domains/services/feature/src/lib/agentic-workflow-runs/agentic-workflow-runs.spec.tsx index 04c94b525ed..9c361d517cf 100644 --- a/libs/domains/services/feature/src/lib/agentic-workflow-runs/agentic-workflow-runs.spec.tsx +++ b/libs/domains/services/feature/src/lib/agentic-workflow-runs/agentic-workflow-runs.spec.tsx @@ -644,6 +644,7 @@ describe('AgenticWorkflowRuns', () => { await userEvent.click(first) await waitFor(() => expect(first).toHaveClass('pointer-events-none')) + expect(first).toBeDisabled() expect(second).not.toHaveClass('pointer-events-none') expect(mockToast).not.toHaveBeenCalled() @@ -653,6 +654,32 @@ describe('AgenticWorkflowRuns', () => { expect(first).not.toHaveClass('pointer-events-none') }) + it('disables the pending row so a second click or key press cannot send the replay again', async () => { + let resolveFetch: (value: { ok: boolean; status: number }) => void = () => undefined + mockFetch.mockReturnValue(new Promise((resolve) => (resolveFetch = resolve))) + const { userEvent } = renderRuns([{}, { trigger: 'MANUAL', payload: null }]) + const [first, second] = replayButtons() + + first.focus() + await userEvent.keyboard('{Enter}') + + await waitFor(() => expect(first).toBeDisabled()) + expect(second).toBeEnabled() + + first.focus() + await userEvent.keyboard('{Enter}') + await userEvent.keyboard(' ') + await userEvent.click(first) + + expect(mockFetch).toHaveBeenCalledTimes(1) + + await act(async () => resolveFetch({ ok: true, status: 202 })) + + await waitFor(() => expect(first).toBeEnabled()) + await userEvent.click(first) + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)) + }) + it('shows an error message when the webhook rejects the replay', async () => { mockFetch.mockResolvedValue({ ok: false, status: 500 }) const { userEvent } = renderRuns() diff --git a/libs/domains/services/feature/src/lib/agentic-workflow-runs/agentic-workflow-runs.tsx b/libs/domains/services/feature/src/lib/agentic-workflow-runs/agentic-workflow-runs.tsx index 7a6c97e91ff..e101abfecf2 100644 --- a/libs/domains/services/feature/src/lib/agentic-workflow-runs/agentic-workflow-runs.tsx +++ b/libs/domains/services/feature/src/lib/agentic-workflow-runs/agentic-workflow-runs.tsx @@ -240,11 +240,11 @@ function ReplayRunButton({ serviceId, payload }: { serviceId: string; payload?: variant="outline" size="md" className="gap-2" - disabled={!webhookUrl} + disabled={!webhookUrl || isLoading} loading={isLoading} onClick={(event) => { event.stopPropagation() - if (webhookUrl) replayRun({ webhookUrl, payload }) + if (webhookUrl && !isLoading) replayRun({ webhookUrl, payload }) }} > diff --git a/libs/domains/services/feature/src/lib/hooks/use-replay-agentic-workflow-run/use-replay-agentic-workflow-run.spec.tsx b/libs/domains/services/feature/src/lib/hooks/use-replay-agentic-workflow-run/use-replay-agentic-workflow-run.spec.tsx index c82f7ff7ec9..ab17da640a9 100644 --- a/libs/domains/services/feature/src/lib/hooks/use-replay-agentic-workflow-run/use-replay-agentic-workflow-run.spec.tsx +++ b/libs/domains/services/feature/src/lib/hooks/use-replay-agentic-workflow-run/use-replay-agentic-workflow-run.spec.tsx @@ -107,7 +107,7 @@ describe('useReplayAgenticWorkflowRun', () => { expect(mockToast).not.toHaveBeenCalledWith('success', expect.anything(), expect.anything()) }) - it('explains a network or CORS failure', async () => { + it('reports an indeterminate delivery and advises checking the run history on a network or CORS failure', async () => { mockFetch.mockRejectedValue(new TypeError('Failed to fetch')) const { result } = renderHook(() => useReplayAgenticWorkflowRun({ serviceId: 'service-1' }), { wrapper }) @@ -116,7 +116,16 @@ describe('useReplayAgenticWorkflowRun', () => { }) await waitFor(() => - expect(mockToast).toHaveBeenCalledWith('error', 'Replay failed', expect.stringContaining('CORS')) + expect(mockToast).toHaveBeenCalledWith( + 'error', + 'Replay failed', + 'Delivery could not be confirmed. The webhook may have received the request despite the browser error. Check the run history before replaying again.' + ) + ) + expect(mockToast).not.toHaveBeenCalledWith( + 'error', + 'Replay failed', + expect.stringContaining('could not be reached') ) expect(invalidateQueries).not.toHaveBeenCalled() })