From 06aebaa6e188af9a0877b29f7c97e31e920f71e0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 15 Apr 2026 10:49:03 -0700 Subject: [PATCH 1/3] feat(logs): add retry from context menu and detail sidebar for failed runs --- .../components/log-details/log-details.tsx | 20 +++++++++- .../log-row-context-menu.tsx | 14 ++++++- .../app/workspace/[workspaceId]/logs/logs.tsx | 40 +++++++++++++++++++ .../app/workspace/[workspaceId]/logs/utils.ts | 28 +++++++++++++ apps/sim/hooks/queries/logs.ts | 33 ++++++++++++++- apps/sim/lib/logs/execution/logger.ts | 4 ++ apps/sim/lib/logs/types.ts | 1 + 7 files changed, 137 insertions(+), 3 deletions(-) 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..ec50a5bf08 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,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(null) @@ -389,6 +392,21 @@ 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..2c46371550 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,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 ( !open && onClose()} modal={false}> @@ -73,6 +76,15 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({ sideOffset={4} onCloseAutoFocus={(e) => e.preventDefault()} > + {isRetryable && ( + <> + + + Retry + + + + )} {isCancellable && ( <> diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 58efb79bcc..393772b04e 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,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} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts index 1f77a59043..5a38526e39 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,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 | undefined + if (!execData) return undefined + + if (execData.workflowInput !== undefined) { + return execData.workflowInput + } + + const executionState = execData.executionState as + | { blockStates?: Record } + | 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 +} 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 From ccf975146e82b85cc33eb8d696a14bc01ee5884a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 15 Apr 2026 10:59:01 -0700 Subject: [PATCH 2/3] =?UTF-8?q?fix(logs):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20redact=20workflowInput,=20improve=20fallback=20heur?= =?UTF-8?q?istic,=20add=20isPending=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../logs/components/log-details/log-details.tsx | 4 ++++ .../log-row-context-menu/log-row-context-menu.tsx | 6 ++++-- apps/sim/app/workspace/[workspaceId]/logs/logs.tsx | 3 +++ apps/sim/app/workspace/[workspaceId]/logs/utils.ts | 11 +++++++++-- apps/sim/lib/logs/execution/logger.ts | 5 ++++- 5 files changed, 24 insertions(+), 5 deletions(-) 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 ec50a5bf08..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 @@ -266,6 +266,8 @@ interface LogDetailsProps { hasPrev?: boolean /** Callback to retry a failed execution */ onRetryExecution?: () => void + /** Whether a retry is currently in progress */ + isRetryPending?: boolean } /** @@ -283,6 +285,7 @@ export const LogDetails = memo(function LogDetails({ hasNext = false, hasPrev = false, onRetryExecution, + isRetryPending = false, }: LogDetailsProps) { const [isExecutionSnapshotOpen, setIsExecutionSnapshotOpen] = useState(false) const scrollAreaRef = useRef(null) @@ -399,6 +402,7 @@ export const LogDetails = memo(function LogDetails({ variant='ghost' className='!p-1' onClick={() => onRetryExecution?.()} + disabled={isRetryPending} aria-label='Retry execution' > 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 2c46371550..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 @@ -24,6 +24,7 @@ interface LogRowContextMenuProps { onClearAllFilters: () => void onCancelExecution: () => void onRetryExecution: () => void + isRetryPending?: boolean isFilteredByThisWorkflow: boolean hasActiveFilters: boolean } @@ -45,6 +46,7 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({ onClearAllFilters, onCancelExecution, onRetryExecution, + isRetryPending = false, isFilteredByThisWorkflow, hasActiveFilters, }: LogRowContextMenuProps) { @@ -78,9 +80,9 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({ > {isRetryable && ( <> - + - Retry + {isRetryPending ? 'Retrying...' : 'Retry'} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 393772b04e..33bc43e372 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -821,6 +821,7 @@ export default function Logs() { hasNext={selectedLogIndex < sortedLogs.length - 1} hasPrev={selectedLogIndex > 0} onRetryExecution={handleRetrySidebarExecution} + isRetryPending={retryExecution.isPending} /> ), [ @@ -830,6 +831,7 @@ export default function Logs() { handleNavigateNext, handleNavigatePrev, handleRetrySidebarExecution, + retryExecution.isPending, selectedLogIndex, sortedLogs.length, ] @@ -1231,6 +1233,7 @@ export default function Logs() { 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 5a38526e39..43fd35be5f 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts @@ -438,12 +438,19 @@ export function extractRetryInput(log: WorkflowLog): unknown | undefined { } const executionState = execData.executionState as - | { blockStates?: Record } + | { + 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.output && typeof state.output === 'object' && 'input' in state.output) { + if (state.executed === false && state.executionTime === 0 && state.output != null) { return state.output } } diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index acca8db01c..f7b298a131 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -372,6 +372,9 @@ export class ExecutionLogger implements IExecutionLoggerService { ? Math.max(0, Math.round(rawDurationMs)) : 0 + const redactedWorkflowInput = + workflowInput !== undefined ? redactApiKeys(filterForDisplay(workflowInput)) : undefined + const completedExecutionData = this.buildCompletedExecutionData({ existingExecutionData, traceSpans: redactedTraceSpans, @@ -380,7 +383,7 @@ export class ExecutionLogger implements IExecutionLoggerService { completionFailure, executionCost, executionState, - workflowInput, + workflowInput: redactedWorkflowInput, }) const [updatedLog] = await db From 2591102ca9abd768e29935ec5461ca254c90a402 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 15 Apr 2026 11:43:01 -0700 Subject: [PATCH 3/3] fix(logs): store workflowInput unredacted to preserve retry fidelity workflowInput is internal execution data used for replay, same as executionState which is also stored unredacted. Redacting at storage time corrupts the data for retry use cases. Co-Authored-By: Claude Opus 4.6 --- apps/sim/lib/logs/execution/logger.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index f7b298a131..acca8db01c 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -372,9 +372,6 @@ export class ExecutionLogger implements IExecutionLoggerService { ? Math.max(0, Math.round(rawDurationMs)) : 0 - const redactedWorkflowInput = - workflowInput !== undefined ? redactApiKeys(filterForDisplay(workflowInput)) : undefined - const completedExecutionData = this.buildCompletedExecutionData({ existingExecutionData, traceSpans: redactedTraceSpans, @@ -383,7 +380,7 @@ export class ExecutionLogger implements IExecutionLoggerService { completionFailure, executionCost, executionState, - workflowInput: redactedWorkflowInput, + workflowInput, }) const [updatedLog] = await db