Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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, {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we keep this behind our usual API flow instead of posting directly to the webhook from the browser? CORS and replay delivery should be handled by the backend or infra. We shouldn't need to infer the content type with isJson or handle uncertain delivery here

method: 'POST',
headers:
typeof payload === 'string' ? { 'Content-Type': isJson(payload) ? 'application/json' : 'text/plain' } : {},
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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) => ({
Expand Down
1 change: 1 addition & 0 deletions libs/domains/services/feature/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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',
Expand All @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -528,4 +544,152 @@ describe('AgenticWorkflowRuns', () => {

expect(refetch).toHaveBeenCalledTimes(1)
})

describe('replay', () => {
const payload = '{ "event": "deploy",\n "id": 12345678901234567890 }'

function renderRuns(runs: Partial<AgenticWorkflowRun>[] = [{}]) {
mockUseRunHistory.mockReturnValue({
data: runs.map((overrides, index) => ({ ...run, id: `run-${index + 1}`, payload, ...overrides })),
isLoading: false,
isError: false,
})
return renderWithProviders(<AgenticWorkflowRuns serviceId="workflow-123" />)
}

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())
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 (
<Button
type="button"
color="neutral"
variant="outline"
size="md"
className="gap-2"
disabled={!webhookUrl || isLoading}
loading={isLoading}
onClick={(event) => {
event.stopPropagation()
if (webhookUrl && !isLoading) replayRun({ webhookUrl, payload })
}}
>
<Icon iconName="rotate-right" iconStyle="regular" />
Replay

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make this an icon-only button with rotate-right and a “Replay” tooltip on hover?

</Button>
)
}

function RunDetails({ run, onClose }: { run: RunWithLifecycle; onClose: () => void }) {
const closeButtonRef = useRef<HTMLButtonElement>(null)
const formattedJson = run.payload ? formatJsonPayload(run.payload) : null
Expand Down Expand Up @@ -370,7 +397,7 @@ export function AgenticWorkflowRuns({ serviceId }: { serviceId: string }) {

return (
<div className="flex grow flex-col justify-between">
<Table.Root className="w-full min-w-[1204px] table-fixed text-ssm">
<Table.Root className="w-full min-w-[1324px] table-fixed text-ssm">
<Table.Header>
<Table.Row className="divide-x divide-neutral">
<Table.ColumnHeaderCell className="w-[420px] font-medium">Date</Table.ColumnHeaderCell>
Expand All @@ -379,6 +406,7 @@ export function AgenticWorkflowRuns({ serviceId }: { serviceId: string }) {
<Table.ColumnHeaderCell className="w-[180px] font-medium">Payload</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell className="w-[160px] font-medium">Duration</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell className="w-[120px] font-medium">Cost</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell className="w-[120px] font-medium">Actions</Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
Expand Down Expand Up @@ -451,6 +479,9 @@ export function AgenticWorkflowRuns({ serviceId }: { serviceId: string }) {
<Table.Cell className="w-[120px] whitespace-nowrap font-mono">
<RunCost cost={run.cost} />
</Table.Cell>
<Table.Cell className="w-[120px]">
<ReplayRunButton serviceId={serviceId} payload={run.payload} />
</Table.Cell>
</Table.Row>
))}
</Table.Body>
Expand Down
Loading
Loading