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..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 @@ -972,11 +972,44 @@ 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. + // 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: + typeof payload === 'string' ? { 'Content-Type': isJson(payload) ? 'application/json' : 'text/plain' } : {}, + body: typeof payload === 'string' ? payload : undefined, + credentials: 'omit', + }) + } catch { + // 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}.`) + } + 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..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 @@ -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, @@ -101,6 +116,7 @@ describe('AgenticWorkflowRuns', () => { 'Payload', 'Duration', 'Cost', + 'Actions', ]) expect(screen.getByText('Completed')).toBeInTheDocument() expect(screen.getByText('Completed').closest('[data-accent-color]')).not.toBeInTheDocument() @@ -528,4 +544,152 @@ describe('AgenticWorkflowRuns', () => { expect(refetch).toHaveBeenCalledTimes(1) }) + + describe('replay', () => { + const payload = '{ "event": "deploy",\n "id": 12345678901234567890 }' + + function renderRuns(runs: Partial[] = [{}]) { + mockUseRunHistory.mockReturnValue({ + data: runs.map((overrides, index) => ({ ...run, id: `run-${index + 1}`, payload, ...overrides })), + isLoading: false, + isError: false, + }) + return renderWithProviders() + } + + function replayButtons() { + return screen.getAllByRole('button', { name: 'Replay' }) + } + + 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('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 every replay until the webhook URL is known', async () => { + mockUseService.mockReturnValue({ data: undefined }) + const { userEvent } = renderRuns([{}, { trigger: 'MANUAL', payload: null }]) + + 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 without opening the run details', async () => { + const { userEvent } = renderRuns() + + 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 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 } = renderRuns([{}, { trigger: 'MANUAL', payload: null }]) + const [first, second] = replayButtons() + + 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() + + await act(async () => resolveFetch({ ok: true, status: 202 })) + + await waitFor(() => expect(mockToast).toHaveBeenCalledWith('success', 'Run replayed', expect.any(String))) + 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() + + await userEvent.click(replayButtons()[0]) + + 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..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 @@ -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,6 +228,31 @@ function PayloadEditor({ value }: { value: 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 + + return ( + + ) +} + function RunDetails({ run, onClose }: { run: RunWithLifecycle; onClose: () => void }) { const closeButtonRef = useRef(null) const formattedJson = run.payload ? formatJsonPayload(run.payload) : null @@ -370,7 +397,7 @@ export function AgenticWorkflowRuns({ serviceId }: { serviceId: string }) { return (
- + Date @@ -379,6 +406,7 @@ export function AgenticWorkflowRuns({ serviceId }: { serviceId: string }) { Payload Duration Cost + Actions @@ -451,6 +479,9 @@ export function AgenticWorkflowRuns({ serviceId }: { serviceId: string }) { + + + ))} 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..ab17da640a9 --- /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,132 @@ +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.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 }) + + 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('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 }) + + await act(async () => { + await result.current.mutateAsync({ webhookUrl, payload: '{}' }).catch(() => undefined) + }) + + await waitFor(() => + 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() + }) +}) 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