Skip to content

Commit bba9627

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(logs): retry failed runs from failed block
1 parent d99fe97 commit bba9627

8 files changed

Lines changed: 295 additions & 11 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -828,7 +828,8 @@ export const LogDetails = memo(function LogDetails({
828828
<div className='flex items-center justify-between'>
829829
<h2 className='text-[var(--text-primary)] text-sm'>Log Details</h2>
830830
<div className='flex items-center gap-[1px]'>
831-
{log.status === 'failed' &&
831+
{onRetryExecution &&
832+
log.status === 'failed' &&
832833
(log.workflow?.id || log.workflowId) &&
833834
log.trigger !== 'mothership' && (
834835
<Tooltip.Root>

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ function renderMenu(
8989
props: Partial<{
9090
log: WorkflowLogSummary
9191
canCancelExecution: boolean
92+
canRetryExecution: boolean
9293
isCancelPending: boolean
9394
cancelPendingExecutionId: string
9495
}> = {}
@@ -100,6 +101,7 @@ function renderMenu(
100101
position={{ x: 0, y: 0 }}
101102
log={props.log ?? LOG}
102103
canCancelExecution={props.canCancelExecution ?? true}
104+
canRetryExecution={props.canRetryExecution ?? true}
103105
isCancelPending={props.isCancelPending}
104106
cancelPendingExecutionId={props.cancelPendingExecutionId}
105107
isFilteredByThisWorkflow={false}
@@ -152,3 +154,11 @@ describe('LogRowContextMenu cancellation action', () => {
152154
expect(findButton('Stopping…')?.disabled).toBe(true)
153155
})
154156
})
157+
158+
describe('LogRowContextMenu retry action', () => {
159+
it('hides Retry without edit permission', () => {
160+
renderMenu({ log: { ...LOG, status: 'failed' }, canRetryExecution: false })
161+
162+
expect(findButton('Retry')).toBeUndefined()
163+
})
164+
})

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ interface LogRowContextMenuProps {
3232
onCancelExecution: () => void
3333
onRetryExecution: () => void
3434
canCancelExecution: boolean
35+
canRetryExecution: boolean
3536
isCancelPending?: boolean
3637
cancelPendingExecutionId?: string
3738
isRetryPending?: boolean
@@ -57,6 +58,7 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
5758
onCancelExecution,
5859
onRetryExecution,
5960
canCancelExecution,
61+
canRetryExecution,
6062
isCancelPending = false,
6163
cancelPendingExecutionId,
6264
isRetryPending = false,
@@ -78,7 +80,8 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
7880
(isCancelPending && cancelPendingExecutionId === log?.executionId)
7981
const showCancelAction =
8082
canCancelExecution && hasExecutionId && hasWorkflow && (isCancellable || isStopping)
81-
const isRetryable = log?.status === 'failed' && hasWorkflow && log?.trigger !== 'mothership'
83+
const isRetryable =
84+
canRetryExecution && log?.status === 'failed' && hasWorkflow && log?.trigger !== 'mothership'
8285

8386
return (
8487
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>

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

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -597,7 +597,7 @@ export default function Logs() {
597597
}, [contextMenuLog])
598598

599599
const cancelExecution = useCancelExecution(workspaceId)
600-
const retryExecution = useRetryExecution()
600+
const retryExecution = useRetryExecution(workspaceId)
601601

602602
const handleCancelExecution = useCallback(async () => {
603603
const workflowId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
@@ -617,17 +617,17 @@ export default function Logs() {
617617
async (log: WorkflowLogRow | null) => {
618618
const workflowId = log?.workflow?.id || log?.workflowId
619619
const executionId = log?.executionId
620-
if (!workflowId || !executionId) return
620+
if (!userPermissions.canEdit || !workflowId || !executionId) return
621621

622622
try {
623623
await retryExecution.mutateAsync({ workflowId, executionId })
624624
toast.success('Retry started')
625-
} catch {
626-
toast.error('Failed to retry execution')
625+
} catch (error) {
626+
toast.error(getErrorMessage(error, 'Failed to retry execution'))
627627
}
628628
},
629629
// eslint-disable-next-line react-hooks/exhaustive-deps
630-
[]
630+
[userPermissions.canEdit]
631631
)
632632

633633
const handleRetryExecution = useCallback(() => {
@@ -862,7 +862,7 @@ export default function Logs() {
862862
onNavigatePrev={handleNavigatePrev}
863863
hasNext={selectedLogIndex >= 0 && selectedLogIndex < logs.length - 1}
864864
hasPrev={selectedLogIndex > 0}
865-
onRetryExecution={handleRetrySidebarExecution}
865+
onRetryExecution={userPermissions.canEdit ? handleRetrySidebarExecution : undefined}
866866
isRetryPending={retryExecution.isPending}
867867
onActiveTabChange={handleActiveTabChange}
868868
/>
@@ -1270,6 +1270,7 @@ export default function Logs() {
12701270
onCancelExecution={handleCancelExecution}
12711271
onRetryExecution={handleRetryExecution}
12721272
canCancelExecution={userPermissions.canEdit}
1273+
canRetryExecution={userPermissions.canEdit}
12731274
isCancelPending={cancelExecution.isPending}
12741275
cancelPendingExecutionId={cancelExecution.variables?.executionId}
12751276
isRetryPending={retryExecution.isPending}

apps/sim/hooks/queries/logs.test.tsx

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
66
import { createRoot, type Root } from 'react-dom/client'
77
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
88

9-
const { mockRequestJson } = vi.hoisted(() => ({
9+
const { mockFetch, mockRequestJson } = vi.hoisted(() => ({
10+
mockFetch: vi.fn(),
1011
mockRequestJson: vi.fn(),
1112
}))
1213

@@ -16,7 +17,7 @@ vi.mock('@/lib/api/client/request', () => ({
1617

1718
import { getLogByExecutionIdContract } from '@/lib/api/contracts/logs'
1819
import { cancelWorkflowExecutionContract } from '@/lib/api/contracts/workflows'
19-
import { useCancelExecution } from '@/hooks/queries/logs'
20+
import { useCancelExecution, useRetryExecution } from '@/hooks/queries/logs'
2021

2122
function renderHookWithClient<T>(useHook: () => T): {
2223
result: () => T
@@ -198,3 +199,99 @@ describe('useCancelExecution', () => {
198199
unmount()
199200
})
200201
})
202+
203+
function failedLogDetail(
204+
children = [
205+
{
206+
id: 'failed-span',
207+
name: 'Failed block',
208+
type: 'function',
209+
status: 'error',
210+
blockId: 'failed-block',
211+
},
212+
]
213+
) {
214+
return {
215+
data: {
216+
executionData: {
217+
workflowInput: { prompt: 'original input' },
218+
traceSpans: [
219+
{
220+
id: 'workflow-execution',
221+
name: 'Workflow Execution',
222+
type: 'workflow',
223+
status: 'error',
224+
children,
225+
},
226+
],
227+
},
228+
},
229+
}
230+
}
231+
232+
describe('useRetryExecution', () => {
233+
beforeEach(() => {
234+
vi.clearAllMocks()
235+
vi.stubGlobal('fetch', mockFetch)
236+
})
237+
238+
afterEach(() => {
239+
vi.unstubAllGlobals()
240+
})
241+
242+
it('starts the retry from the failed block using the source execution state', async () => {
243+
const cancel = vi.fn()
244+
mockRequestJson.mockResolvedValue(failedLogDetail())
245+
mockFetch.mockResolvedValue({
246+
ok: true,
247+
body: {
248+
getReader: () => ({ read: vi.fn().mockResolvedValue({ done: false }), cancel }),
249+
},
250+
})
251+
252+
const { result, unmount } = renderHookWithClient(() => useRetryExecution('workspace-1'))
253+
254+
await act(async () => {
255+
await result().mutateAsync({ workflowId: 'workflow-1', executionId: 'execution-1' })
256+
})
257+
258+
expect(mockRequestJson).toHaveBeenCalledWith(getLogByExecutionIdContract, {
259+
params: { executionId: 'execution-1' },
260+
query: { workspaceId: 'workspace-1' },
261+
signal: undefined,
262+
})
263+
expect(mockFetch).toHaveBeenCalledWith('/api/workflows/workflow-1/execute', {
264+
method: 'POST',
265+
headers: { 'Content-Type': 'application/json' },
266+
body: JSON.stringify({
267+
inputFromExecutionId: 'execution-1',
268+
triggerType: 'manual',
269+
stream: true,
270+
runFromBlock: { startBlockId: 'failed-block', executionId: 'execution-1' },
271+
}),
272+
})
273+
expect(cancel).toHaveBeenCalled()
274+
275+
unmount()
276+
})
277+
278+
it('does not execute when the source run has multiple terminating failures', async () => {
279+
mockRequestJson.mockResolvedValue(
280+
failedLogDetail([
281+
{ id: 'failure-1', name: 'One', type: 'function', status: 'error', blockId: 'one' },
282+
{ id: 'failure-2', name: 'Two', type: 'function', status: 'error', blockId: 'two' },
283+
])
284+
)
285+
286+
const { result, unmount } = renderHookWithClient(() => useRetryExecution('workspace-1'))
287+
288+
await act(async () => {
289+
await expect(
290+
result().mutateAsync({ workflowId: 'workflow-1', executionId: 'execution-1' })
291+
).rejects.toThrow('multiple terminating failures')
292+
})
293+
expect(mockFetch).not.toHaveBeenCalled()
294+
295+
unmount()
296+
})
297+
})

apps/sim/hooks/queries/logs.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
import { cancelWorkflowExecutionContract } from '@/lib/api/contracts/workflows'
2727
import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters'
2828
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
29+
import { resolveRetryTarget } from '@/lib/logs/retry'
2930
import type { TimeRange } from '@/stores/logs/filters/types'
3031

3132
export type { DashboardStatsResponse, WorkflowStats }
@@ -430,7 +431,7 @@ export function useCancelExecution(workspaceId: string) {
430431
})
431432
}
432433

433-
export function useRetryExecution() {
434+
export function useRetryExecution(workspaceId: string) {
434435
const queryClient = useQueryClient()
435436
return useMutation({
436437
mutationFn: async ({
@@ -440,6 +441,12 @@ export function useRetryExecution() {
440441
workflowId: string
441442
executionId: string
442443
}) => {
444+
const detail = await fetchLogByExecutionId(workspaceId, executionId)
445+
const retryTarget = resolveRetryTarget(detail.executionData)
446+
if (!retryTarget.success) {
447+
throw new Error(retryTarget.error)
448+
}
449+
443450
// boundary-raw-fetch: stream response, body is a ReadableStream consumed one chunk at a time
444451
const res = await fetch(`/api/workflows/${workflowId}/execute`, {
445452
method: 'POST',
@@ -448,6 +455,10 @@ export function useRetryExecution() {
448455
inputFromExecutionId: executionId,
449456
triggerType: 'manual',
450457
stream: true,
458+
runFromBlock: {
459+
startBlockId: retryTarget.startBlockId,
460+
executionId,
461+
},
451462
}),
452463
})
453464
if (!res.ok) {

apps/sim/lib/logs/retry.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, expect, it } from 'vitest'
2+
import type { LogTraceSpan, WorkflowLogDetail } from '@/lib/api/contracts/logs'
3+
import { resolveRetryTarget } from '@/lib/logs/retry'
4+
5+
function span(overrides: Partial<LogTraceSpan> = {}): LogTraceSpan {
6+
return {
7+
id: 'span-1',
8+
name: 'Block',
9+
type: 'function',
10+
...overrides,
11+
}
12+
}
13+
14+
function workflowTrace(children: LogTraceSpan[]): LogTraceSpan[] {
15+
return [
16+
span({
17+
id: 'workflow-execution',
18+
name: 'Workflow Execution',
19+
type: 'workflow',
20+
status: 'error',
21+
children,
22+
}),
23+
]
24+
}
25+
26+
describe('resolveRetryTarget', () => {
27+
it('selects the sole top-level unhandled failure and ignores handled errors', () => {
28+
const executionData: WorkflowLogDetail['executionData'] = {
29+
traceSpans: workflowTrace([
30+
span({ id: 'success', status: 'success', blockId: 'upstream' }),
31+
span({ id: 'handled', status: 'error', errorHandled: true, blockId: 'handled-block' }),
32+
span({ id: 'failure', status: 'error', blockId: 'failed-block' }),
33+
]),
34+
}
35+
36+
expect(resolveRetryTarget(executionData)).toEqual({
37+
success: true,
38+
startBlockId: 'failed-block',
39+
})
40+
41+
expect(
42+
resolveRetryTarget({
43+
workflowInput: { prompt: 'original input' },
44+
traceSpans: workflowTrace([
45+
span({ id: 'trigger-failure', type: 'starter', status: 'error', blockId: 'trigger' }),
46+
]),
47+
})
48+
).toEqual({ success: true, startBlockId: 'trigger' })
49+
})
50+
51+
it('rejects unsupported retry targets', () => {
52+
const unsupportedCases: {
53+
name: string
54+
executionData: WorkflowLogDetail['executionData']
55+
error: string
56+
}[] = [
57+
{
58+
name: 'missing trace history',
59+
executionData: {},
60+
error: 'This run does not include enough execution history to retry from the failed block.',
61+
},
62+
{
63+
name: 'multiple terminating failures',
64+
executionData: {
65+
traceSpans: workflowTrace([
66+
span({ id: 'failure-1', status: 'error', blockId: 'failed-1' }),
67+
span({ id: 'failure-2', status: 'error', blockId: 'failed-2' }),
68+
]),
69+
},
70+
error:
71+
'This run has multiple terminating failures and cannot be retried from a single block.',
72+
},
73+
{
74+
name: 'a grouped parallel failure',
75+
executionData: {
76+
traceSpans: workflowTrace([
77+
span({ id: 'parallel-execution', type: 'parallel', status: 'error' }),
78+
]),
79+
},
80+
error: 'Retrying failures inside loops or parallel groups is not supported yet.',
81+
},
82+
{
83+
name: 'a trigger failure without its original input',
84+
executionData: {
85+
traceSpans: workflowTrace([
86+
span({ id: 'trigger-failure', type: 'starter', status: 'error', blockId: 'trigger' }),
87+
]),
88+
},
89+
error:
90+
'The original input for this failed trigger is unavailable, so it cannot be retried safely.',
91+
},
92+
]
93+
94+
for (const { name, executionData, error } of unsupportedCases) {
95+
expect(resolveRetryTarget(executionData), name).toEqual({ success: false, error })
96+
}
97+
})
98+
})

0 commit comments

Comments
 (0)