Skip to content

Commit 06aebaa

Browse files
committed
feat(logs): add retry from context menu and detail sidebar for failed runs
1 parent 842aa2c commit 06aebaa

7 files changed

Lines changed: 137 additions & 3 deletions

File tree

apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
Input,
1616
Tooltip,
1717
} from '@/components/emcn'
18-
import { Copy as CopyIcon, Search as SearchIcon } from '@/components/emcn/icons'
18+
import { Copy as CopyIcon, Redo, Search as SearchIcon } from '@/components/emcn/icons'
1919
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
2020
import { cn } from '@/lib/core/utils/cn'
2121
import { formatDuration } from '@/lib/core/utils/formatting'
@@ -264,6 +264,8 @@ interface LogDetailsProps {
264264
hasNext?: boolean
265265
/** Whether there is a previous log available */
266266
hasPrev?: boolean
267+
/** Callback to retry a failed execution */
268+
onRetryExecution?: () => void
267269
}
268270

269271
/**
@@ -280,6 +282,7 @@ export const LogDetails = memo(function LogDetails({
280282
onNavigatePrev,
281283
hasNext = false,
282284
hasPrev = false,
285+
onRetryExecution,
283286
}: LogDetailsProps) {
284287
const [isExecutionSnapshotOpen, setIsExecutionSnapshotOpen] = useState(false)
285288
const scrollAreaRef = useRef<HTMLDivElement>(null)
@@ -389,6 +392,21 @@ export const LogDetails = memo(function LogDetails({
389392
>
390393
<ChevronUp className='h-[14px] w-[14px] rotate-180' />
391394
</Button>
395+
{log?.status === 'failed' && (log?.workflow?.id || log?.workflowId) && (
396+
<Tooltip.Root>
397+
<Tooltip.Trigger asChild>
398+
<Button
399+
variant='ghost'
400+
className='!p-1'
401+
onClick={() => onRetryExecution?.()}
402+
aria-label='Retry execution'
403+
>
404+
<Redo className='h-[14px] w-[14px]' />
405+
</Button>
406+
</Tooltip.Trigger>
407+
<Tooltip.Content side='bottom'>Retry</Tooltip.Content>
408+
</Tooltip.Root>
409+
)}
392410
<Button variant='ghost' className='!p-1' onClick={onClose} aria-label='Close'>
393411
<X className='h-[14px] w-[14px]' />
394412
</Button>

apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
DropdownMenuSeparator,
99
DropdownMenuTrigger,
1010
} from '@/components/emcn'
11-
import { Copy, Eye, Link, ListFilter, SquareArrowUpRight, X } from '@/components/emcn/icons'
11+
import { Copy, Eye, Link, ListFilter, Redo, SquareArrowUpRight, X } from '@/components/emcn/icons'
1212
import type { WorkflowLog } from '@/stores/logs/filters/types'
1313

1414
interface LogRowContextMenuProps {
@@ -23,6 +23,7 @@ interface LogRowContextMenuProps {
2323
onToggleWorkflowFilter: () => void
2424
onClearAllFilters: () => void
2525
onCancelExecution: () => void
26+
onRetryExecution: () => void
2627
isFilteredByThisWorkflow: boolean
2728
hasActiveFilters: boolean
2829
}
@@ -43,13 +44,15 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
4344
onToggleWorkflowFilter,
4445
onClearAllFilters,
4546
onCancelExecution,
47+
onRetryExecution,
4648
isFilteredByThisWorkflow,
4749
hasActiveFilters,
4850
}: LogRowContextMenuProps) {
4951
const hasExecutionId = Boolean(log?.executionId)
5052
const hasWorkflow = Boolean(log?.workflow?.id || log?.workflowId)
5153
const isCancellable =
5254
(log?.status === 'running' || log?.status === 'pending') && hasExecutionId && hasWorkflow
55+
const isRetryable = log?.status === 'failed' && hasWorkflow
5356

5457
return (
5558
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
@@ -73,6 +76,15 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
7376
sideOffset={4}
7477
onCloseAutoFocus={(e) => e.preventDefault()}
7578
>
79+
{isRetryable && (
80+
<>
81+
<DropdownMenuItem onSelect={onRetryExecution}>
82+
<Redo />
83+
Retry
84+
</DropdownMenuItem>
85+
<DropdownMenuSeparator />
86+
</>
87+
)}
7688
{isCancellable && (
7789
<>
7890
<DropdownMenuItem onSelect={onCancelExecution}>

apps/sim/app/workspace/[workspaceId]/logs/logs.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
DropdownMenuTrigger,
1616
Library,
1717
Loader,
18+
toast,
1819
} from '@/components/emcn'
1920
import { DatePicker } from '@/components/emcn/components/date-picker/date-picker'
2021
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
@@ -53,11 +54,14 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
5354
import { getBlock } from '@/blocks/registry'
5455
import { useFolderMap, useFolders } from '@/hooks/queries/folders'
5556
import {
57+
fetchLogDetail,
58+
logKeys,
5659
prefetchLogDetail,
5760
useCancelExecution,
5861
useDashboardStats,
5962
useLogDetail,
6063
useLogsList,
64+
useRetryExecution,
6165
} from '@/hooks/queries/logs'
6266
import { useWorkflowMap, useWorkflows } from '@/hooks/queries/workflows'
6367
import { useDebounce } from '@/hooks/use-debounce'
@@ -74,6 +78,7 @@ import {
7478
import {
7579
DELETED_WORKFLOW_COLOR,
7680
DELETED_WORKFLOW_LABEL,
81+
extractRetryInput,
7782
formatDate,
7883
getDisplayStatus,
7984
type LogStatus,
@@ -536,6 +541,7 @@ export default function Logs() {
536541
}, [contextMenuLog])
537542

538543
const cancelExecution = useCancelExecution()
544+
const retryExecution = useRetryExecution()
539545

540546
const handleCancelExecution = useCallback(() => {
541547
const workflowId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
@@ -546,6 +552,37 @@ export default function Logs() {
546552
// eslint-disable-next-line react-hooks/exhaustive-deps
547553
}, [contextMenuLog])
548554

555+
const retryLog = useCallback(
556+
async (log: WorkflowLog | null) => {
557+
const workflowId = log?.workflow?.id || log?.workflowId
558+
const logId = log?.id
559+
if (!workflowId || !logId) return
560+
561+
try {
562+
const detailLog = await queryClient.fetchQuery({
563+
queryKey: logKeys.detail(logId),
564+
queryFn: ({ signal }) => fetchLogDetail(logId, signal),
565+
staleTime: 30 * 1000,
566+
})
567+
const input = extractRetryInput(detailLog)
568+
await retryExecution.mutateAsync({ workflowId, input })
569+
toast.success('Retry started')
570+
} catch {
571+
toast.error('Failed to retry execution')
572+
}
573+
},
574+
// eslint-disable-next-line react-hooks/exhaustive-deps
575+
[]
576+
)
577+
578+
const handleRetryExecution = useCallback(() => {
579+
retryLog(contextMenuLog)
580+
}, [contextMenuLog, retryLog])
581+
582+
const handleRetrySidebarExecution = useCallback(() => {
583+
retryLog(selectedLog)
584+
}, [selectedLog, retryLog])
585+
549586
const contextMenuWorkflowId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
550587
const isFilteredByThisWorkflow = Boolean(
551588
contextMenuWorkflowId && workflowIds.length === 1 && workflowIds[0] === contextMenuWorkflowId
@@ -783,6 +820,7 @@ export default function Logs() {
783820
onNavigatePrev={handleNavigatePrev}
784821
hasNext={selectedLogIndex < sortedLogs.length - 1}
785822
hasPrev={selectedLogIndex > 0}
823+
onRetryExecution={handleRetrySidebarExecution}
786824
/>
787825
),
788826
[
@@ -791,6 +829,7 @@ export default function Logs() {
791829
handleCloseSidebar,
792830
handleNavigateNext,
793831
handleNavigatePrev,
832+
handleRetrySidebarExecution,
794833
selectedLogIndex,
795834
sortedLogs.length,
796835
]
@@ -1191,6 +1230,7 @@ export default function Logs() {
11911230
onOpenWorkflow={handleOpenWorkflow}
11921231
onOpenPreview={handleOpenPreview}
11931232
onCancelExecution={handleCancelExecution}
1233+
onRetryExecution={handleRetryExecution}
11941234
onToggleWorkflowFilter={handleToggleWorkflowFilter}
11951235
onClearAllFilters={handleClearAllFilters}
11961236
isFilteredByThisWorkflow={isFilteredByThisWorkflow}

apps/sim/app/workspace/[workspaceId]/logs/utils.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Badge } from '@/components/emcn'
44
import { formatDuration } from '@/lib/core/utils/formatting'
55
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
66
import { getBlock } from '@/blocks/registry'
7+
import type { WorkflowLog } from '@/stores/logs/filters/types'
78
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
89

910
export const LOG_COLUMNS = {
@@ -422,3 +423,30 @@ export const formatDate = (dateString: string) => {
422423
})(),
423424
}
424425
}
426+
427+
/**
428+
* Extracts the original workflow input from a log entry for retry.
429+
* Prefers the persisted `workflowInput` field (new logs), falls back to
430+
* reconstructing from `executionState.blockStates` (old logs).
431+
*/
432+
export function extractRetryInput(log: WorkflowLog): unknown | undefined {
433+
const execData = log.executionData as Record<string, unknown> | undefined
434+
if (!execData) return undefined
435+
436+
if (execData.workflowInput !== undefined) {
437+
return execData.workflowInput
438+
}
439+
440+
const executionState = execData.executionState as
441+
| { blockStates?: Record<string, { output?: unknown }> }
442+
| undefined
443+
if (!executionState?.blockStates) return undefined
444+
445+
for (const state of Object.values(executionState.blockStates)) {
446+
if (state.output && typeof state.output === 'object' && 'input' in state.output) {
447+
return state.output
448+
}
449+
}
450+
451+
return undefined
452+
}

apps/sim/hooks/queries/logs.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ async function fetchLogsPage(
120120
}
121121
}
122122

123-
async function fetchLogDetail(logId: string, signal?: AbortSignal): Promise<WorkflowLog> {
123+
export async function fetchLogDetail(logId: string, signal?: AbortSignal): Promise<WorkflowLog> {
124124
const response = await fetch(`/api/logs/${logId}`, { signal })
125125

126126
if (!response.ok) {
@@ -331,3 +331,34 @@ export function useCancelExecution() {
331331
},
332332
})
333333
}
334+
335+
export function useRetryExecution() {
336+
const queryClient = useQueryClient()
337+
return useMutation({
338+
mutationFn: async ({ workflowId, input }: { workflowId: string; input?: unknown }) => {
339+
const res = await fetch(`/api/workflows/${workflowId}/execute`, {
340+
method: 'POST',
341+
headers: { 'Content-Type': 'application/json' },
342+
body: JSON.stringify({ input, triggerType: 'manual', stream: true }),
343+
})
344+
if (!res.ok) {
345+
const data = await res.json().catch(() => ({}))
346+
throw new Error(data.error || 'Failed to retry execution')
347+
}
348+
// The ReadableStream is lazy — start() only runs when read.
349+
// Read one chunk to trigger execution, then cancel.
350+
// Execution continues server-side after client disconnect.
351+
const reader = res.body?.getReader()
352+
if (reader) {
353+
await reader.read()
354+
reader.cancel()
355+
}
356+
return { started: true }
357+
},
358+
onSettled: () => {
359+
queryClient.invalidateQueries({ queryKey: logKeys.lists() })
360+
queryClient.invalidateQueries({ queryKey: logKeys.details() })
361+
queryClient.invalidateQueries({ queryKey: [...logKeys.all, 'stats'] })
362+
},
363+
})
364+
}

apps/sim/lib/logs/execution/logger.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
8585
models: NonNullable<WorkflowExecutionLog['executionData']['models']>
8686
}
8787
executionState?: SerializableExecutionState
88+
workflowInput?: unknown
8889
}): WorkflowExecutionLog['executionData'] {
8990
const {
9091
existingExecutionData,
@@ -94,6 +95,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
9495
completionFailure,
9596
executionCost,
9697
executionState,
98+
workflowInput,
9799
} = params
98100
const traceSpanCount = countTraceSpans(traceSpans)
99101

@@ -129,6 +131,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
129131
},
130132
models: executionCost.models,
131133
...(executionState ? { executionState } : {}),
134+
...(workflowInput !== undefined ? { workflowInput } : {}),
132135
}
133136
}
134137

@@ -377,6 +380,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
377380
completionFailure,
378381
executionCost,
379382
executionState,
383+
workflowInput,
380384
})
381385

382386
const [updatedLog] = await db

apps/sim/lib/logs/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ export interface WorkflowExecutionLog {
149149
>
150150
executionState?: SerializableExecutionState
151151
finalOutput?: any
152+
workflowInput?: unknown
152153
errorDetails?: {
153154
blockId: string
154155
blockName: string

0 commit comments

Comments
 (0)