feat(logs): add retry from context menu and detail sidebar for failed runs

This commit is contained in:
Waleed Latif
2026-04-15 10:49:03 -07:00
parent 842aa2c254
commit 06aebaa6e1
7 changed files with 137 additions and 3 deletions

View File

@@ -15,7 +15,7 @@ import {
Input,
Tooltip,
} from '@/components/emcn'
import { Copy as CopyIcon, Search as SearchIcon } from '@/components/emcn/icons'
import { Copy as CopyIcon, Redo, Search as SearchIcon } from '@/components/emcn/icons'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
@@ -264,6 +264,8 @@ interface LogDetailsProps {
hasNext?: boolean
/** Whether there is a previous log available */
hasPrev?: boolean
/** Callback to retry a failed execution */
onRetryExecution?: () => void
}
/**
@@ -280,6 +282,7 @@ export const LogDetails = memo(function LogDetails({
onNavigatePrev,
hasNext = false,
hasPrev = false,
onRetryExecution,
}: LogDetailsProps) {
const [isExecutionSnapshotOpen, setIsExecutionSnapshotOpen] = useState(false)
const scrollAreaRef = useRef<HTMLDivElement>(null)
@@ -389,6 +392,21 @@ export const LogDetails = memo(function LogDetails({
>
<ChevronUp className='h-[14px] w-[14px] rotate-180' />
</Button>
{log?.status === 'failed' && (log?.workflow?.id || log?.workflowId) && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='!p-1'
onClick={() => onRetryExecution?.()}
aria-label='Retry execution'
>
<Redo className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>Retry</Tooltip.Content>
</Tooltip.Root>
)}
<Button variant='ghost' className='!p-1' onClick={onClose} aria-label='Close'>
<X className='h-[14px] w-[14px]' />
</Button>

View File

@@ -8,7 +8,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/emcn'
import { Copy, Eye, Link, ListFilter, SquareArrowUpRight, X } from '@/components/emcn/icons'
import { Copy, Eye, Link, ListFilter, Redo, SquareArrowUpRight, X } from '@/components/emcn/icons'
import type { WorkflowLog } from '@/stores/logs/filters/types'
interface LogRowContextMenuProps {
@@ -23,6 +23,7 @@ interface LogRowContextMenuProps {
onToggleWorkflowFilter: () => void
onClearAllFilters: () => void
onCancelExecution: () => void
onRetryExecution: () => void
isFilteredByThisWorkflow: boolean
hasActiveFilters: boolean
}
@@ -43,6 +44,7 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
onToggleWorkflowFilter,
onClearAllFilters,
onCancelExecution,
onRetryExecution,
isFilteredByThisWorkflow,
hasActiveFilters,
}: LogRowContextMenuProps) {
@@ -50,6 +52,7 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
const hasWorkflow = Boolean(log?.workflow?.id || log?.workflowId)
const isCancellable =
(log?.status === 'running' || log?.status === 'pending') && hasExecutionId && hasWorkflow
const isRetryable = log?.status === 'failed' && hasWorkflow
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
@@ -73,6 +76,15 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{isRetryable && (
<>
<DropdownMenuItem onSelect={onRetryExecution}>
<Redo />
Retry
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{isCancellable && (
<>
<DropdownMenuItem onSelect={onCancelExecution}>

View File

@@ -15,6 +15,7 @@ import {
DropdownMenuTrigger,
Library,
Loader,
toast,
} from '@/components/emcn'
import { DatePicker } from '@/components/emcn/components/date-picker/date-picker'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
@@ -53,11 +54,14 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
import { getBlock } from '@/blocks/registry'
import { useFolderMap, useFolders } from '@/hooks/queries/folders'
import {
fetchLogDetail,
logKeys,
prefetchLogDetail,
useCancelExecution,
useDashboardStats,
useLogDetail,
useLogsList,
useRetryExecution,
} from '@/hooks/queries/logs'
import { useWorkflowMap, useWorkflows } from '@/hooks/queries/workflows'
import { useDebounce } from '@/hooks/use-debounce'
@@ -74,6 +78,7 @@ import {
import {
DELETED_WORKFLOW_COLOR,
DELETED_WORKFLOW_LABEL,
extractRetryInput,
formatDate,
getDisplayStatus,
type LogStatus,
@@ -536,6 +541,7 @@ export default function Logs() {
}, [contextMenuLog])
const cancelExecution = useCancelExecution()
const retryExecution = useRetryExecution()
const handleCancelExecution = useCallback(() => {
const workflowId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
@@ -546,6 +552,37 @@ export default function Logs() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contextMenuLog])
const retryLog = useCallback(
async (log: WorkflowLog | null) => {
const workflowId = log?.workflow?.id || log?.workflowId
const logId = log?.id
if (!workflowId || !logId) return
try {
const detailLog = await queryClient.fetchQuery({
queryKey: logKeys.detail(logId),
queryFn: ({ signal }) => fetchLogDetail(logId, signal),
staleTime: 30 * 1000,
})
const input = extractRetryInput(detailLog)
await retryExecution.mutateAsync({ workflowId, input })
toast.success('Retry started')
} catch {
toast.error('Failed to retry execution')
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
)
const handleRetryExecution = useCallback(() => {
retryLog(contextMenuLog)
}, [contextMenuLog, retryLog])
const handleRetrySidebarExecution = useCallback(() => {
retryLog(selectedLog)
}, [selectedLog, retryLog])
const contextMenuWorkflowId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
const isFilteredByThisWorkflow = Boolean(
contextMenuWorkflowId && workflowIds.length === 1 && workflowIds[0] === contextMenuWorkflowId
@@ -783,6 +820,7 @@ export default function Logs() {
onNavigatePrev={handleNavigatePrev}
hasNext={selectedLogIndex < sortedLogs.length - 1}
hasPrev={selectedLogIndex > 0}
onRetryExecution={handleRetrySidebarExecution}
/>
),
[
@@ -791,6 +829,7 @@ export default function Logs() {
handleCloseSidebar,
handleNavigateNext,
handleNavigatePrev,
handleRetrySidebarExecution,
selectedLogIndex,
sortedLogs.length,
]
@@ -1191,6 +1230,7 @@ export default function Logs() {
onOpenWorkflow={handleOpenWorkflow}
onOpenPreview={handleOpenPreview}
onCancelExecution={handleCancelExecution}
onRetryExecution={handleRetryExecution}
onToggleWorkflowFilter={handleToggleWorkflowFilter}
onClearAllFilters={handleClearAllFilters}
isFilteredByThisWorkflow={isFilteredByThisWorkflow}

View File

@@ -4,6 +4,7 @@ import { Badge } from '@/components/emcn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
import { getBlock } from '@/blocks/registry'
import type { WorkflowLog } from '@/stores/logs/filters/types'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
export const LOG_COLUMNS = {
@@ -422,3 +423,30 @@ export const formatDate = (dateString: string) => {
})(),
}
}
/**
* Extracts the original workflow input from a log entry for retry.
* Prefers the persisted `workflowInput` field (new logs), falls back to
* reconstructing from `executionState.blockStates` (old logs).
*/
export function extractRetryInput(log: WorkflowLog): unknown | undefined {
const execData = log.executionData as Record<string, unknown> | undefined
if (!execData) return undefined
if (execData.workflowInput !== undefined) {
return execData.workflowInput
}
const executionState = execData.executionState as
| { blockStates?: Record<string, { output?: unknown }> }
| undefined
if (!executionState?.blockStates) return undefined
for (const state of Object.values(executionState.blockStates)) {
if (state.output && typeof state.output === 'object' && 'input' in state.output) {
return state.output
}
}
return undefined
}

View File

@@ -120,7 +120,7 @@ async function fetchLogsPage(
}
}
async function fetchLogDetail(logId: string, signal?: AbortSignal): Promise<WorkflowLog> {
export async function fetchLogDetail(logId: string, signal?: AbortSignal): Promise<WorkflowLog> {
const response = await fetch(`/api/logs/${logId}`, { signal })
if (!response.ok) {
@@ -331,3 +331,34 @@ export function useCancelExecution() {
},
})
}
export function useRetryExecution() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workflowId, input }: { workflowId: string; input?: unknown }) => {
const res = await fetch(`/api/workflows/${workflowId}/execute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input, triggerType: 'manual', stream: true }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || 'Failed to retry execution')
}
// The ReadableStream is lazy — start() only runs when read.
// Read one chunk to trigger execution, then cancel.
// Execution continues server-side after client disconnect.
const reader = res.body?.getReader()
if (reader) {
await reader.read()
reader.cancel()
}
return { started: true }
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: logKeys.lists() })
queryClient.invalidateQueries({ queryKey: logKeys.details() })
queryClient.invalidateQueries({ queryKey: [...logKeys.all, 'stats'] })
},
})
}

View File

@@ -85,6 +85,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
models: NonNullable<WorkflowExecutionLog['executionData']['models']>
}
executionState?: SerializableExecutionState
workflowInput?: unknown
}): WorkflowExecutionLog['executionData'] {
const {
existingExecutionData,
@@ -94,6 +95,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
completionFailure,
executionCost,
executionState,
workflowInput,
} = params
const traceSpanCount = countTraceSpans(traceSpans)
@@ -129,6 +131,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
},
models: executionCost.models,
...(executionState ? { executionState } : {}),
...(workflowInput !== undefined ? { workflowInput } : {}),
}
}
@@ -377,6 +380,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
completionFailure,
executionCost,
executionState,
workflowInput,
})
const [updatedLog] = await db

View File

@@ -149,6 +149,7 @@ export interface WorkflowExecutionLog {
>
executionState?: SerializableExecutionState
finalOutput?: any
workflowInput?: unknown
errorDetails?: {
blockId: string
blockName: string