diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index 994b4d7daf..c8a485a2ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -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,10 @@ interface LogDetailsProps { hasNext?: boolean /** Whether there is a previous log available */ hasPrev?: boolean + /** Callback to retry a failed execution */ + onRetryExecution?: () => void + /** Whether a retry is currently in progress */ + isRetryPending?: boolean } /** @@ -280,6 +284,8 @@ export const LogDetails = memo(function LogDetails({ onNavigatePrev, hasNext = false, hasPrev = false, + onRetryExecution, + isRetryPending = false, }: LogDetailsProps) { const [isExecutionSnapshotOpen, setIsExecutionSnapshotOpen] = useState(false) const scrollAreaRef = useRef(null) @@ -389,6 +395,22 @@ export const LogDetails = memo(function LogDetails({ > + {log?.status === 'failed' && (log?.workflow?.id || log?.workflowId) && ( + + + + + Retry + + )} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx index a9dba9f471..01b867e25e 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx @@ -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,8 @@ interface LogRowContextMenuProps { onToggleWorkflowFilter: () => void onClearAllFilters: () => void onCancelExecution: () => void + onRetryExecution: () => void + isRetryPending?: boolean isFilteredByThisWorkflow: boolean hasActiveFilters: boolean } @@ -43,6 +45,8 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({ onToggleWorkflowFilter, onClearAllFilters, onCancelExecution, + onRetryExecution, + isRetryPending = false, isFilteredByThisWorkflow, hasActiveFilters, }: LogRowContextMenuProps) { @@ -50,6 +54,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 ( !open && onClose()} modal={false}> @@ -73,6 +78,15 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({ sideOffset={4} onCloseAutoFocus={(e) => e.preventDefault()} > + {isRetryable && ( + <> + + + {isRetryPending ? 'Retrying...' : 'Retry'} + + + + )} {isCancellable && ( <> diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 58efb79bcc..33bc43e372 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -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,8 @@ export default function Logs() { onNavigatePrev={handleNavigatePrev} hasNext={selectedLogIndex < sortedLogs.length - 1} hasPrev={selectedLogIndex > 0} + onRetryExecution={handleRetrySidebarExecution} + isRetryPending={retryExecution.isPending} /> ), [ @@ -791,6 +830,8 @@ export default function Logs() { handleCloseSidebar, handleNavigateNext, handleNavigatePrev, + handleRetrySidebarExecution, + retryExecution.isPending, selectedLogIndex, sortedLogs.length, ] @@ -1191,6 +1232,8 @@ export default function Logs() { onOpenWorkflow={handleOpenWorkflow} onOpenPreview={handleOpenPreview} onCancelExecution={handleCancelExecution} + onRetryExecution={handleRetryExecution} + isRetryPending={retryExecution.isPending} onToggleWorkflowFilter={handleToggleWorkflowFilter} onClearAllFilters={handleClearAllFilters} isFilteredByThisWorkflow={isFilteredByThisWorkflow} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts index 1f77a59043..43fd35be5f 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts @@ -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,37 @@ 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 | undefined + if (!execData) return undefined + + if (execData.workflowInput !== undefined) { + return execData.workflowInput + } + + const executionState = execData.executionState as + | { + blockStates?: Record< + string, + { output?: unknown; executed?: boolean; executionTime?: number } + > + } + | undefined + if (!executionState?.blockStates) return undefined + + // Starter/trigger blocks are pre-populated with executed: false and executionTime: 0, + // which distinguishes them from blocks that actually ran during execution. + for (const state of Object.values(executionState.blockStates)) { + if (state.executed === false && state.executionTime === 0 && state.output != null) { + return state.output + } + } + + return undefined +} diff --git a/apps/sim/hooks/queries/logs.ts b/apps/sim/hooks/queries/logs.ts index cab3f63ecb..7732467283 100644 --- a/apps/sim/hooks/queries/logs.ts +++ b/apps/sim/hooks/queries/logs.ts @@ -120,7 +120,7 @@ async function fetchLogsPage( } } -async function fetchLogDetail(logId: string, signal?: AbortSignal): Promise { +export async function fetchLogDetail(logId: string, signal?: AbortSignal): Promise { 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'] }) + }, + }) +} diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index afa70de6ea..acca8db01c 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -85,6 +85,7 @@ export class ExecutionLogger implements IExecutionLoggerService { models: NonNullable } 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 diff --git a/apps/sim/lib/logs/types.ts b/apps/sim/lib/logs/types.ts index 20f568ab41..e62287ba10 100644 --- a/apps/sim/lib/logs/types.ts +++ b/apps/sim/lib/logs/types.ts @@ -149,6 +149,7 @@ export interface WorkflowExecutionLog { > executionState?: SerializableExecutionState finalOutput?: any + workflowInput?: unknown errorDetails?: { blockId: string blockName: string