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 994b4d7daf0..ec50a5bf083 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 a9dba9f471d..2c463715503 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 58efb79bcca..393772b04e6 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 1f77a590435..5a38526e39b 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 cab3f63ecbd..77324672832 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 afa70de6ea5..acca8db01c2 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 20f568ab41c..e62287ba10c 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 ec50a5bf083..c8a485a2efb 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 2c463715503..01b867e25e7 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 393772b04e6..33bc43e3727 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 5a38526e39b..43fd35be5f5 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 acca8db01c2..f7b298a1315 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 f7b298a1315..acca8db01c2 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