Skip to content

Commit 471be26

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

8 files changed

Lines changed: 409 additions & 15 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: 155 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,155 @@ 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+
function executionStream(events: object[]): ReadableStream<Uint8Array> {
233+
return new ReadableStream({
234+
start(controller) {
235+
for (const event of events) {
236+
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`))
237+
}
238+
controller.close()
239+
},
240+
})
241+
}
242+
243+
describe('useRetryExecution', () => {
244+
beforeEach(() => {
245+
vi.clearAllMocks()
246+
vi.stubGlobal('fetch', mockFetch)
247+
})
248+
249+
afterEach(() => {
250+
vi.unstubAllGlobals()
251+
})
252+
253+
it('starts the retry from the failed block using the source execution state', async () => {
254+
mockRequestJson.mockResolvedValue(failedLogDetail())
255+
mockFetch.mockResolvedValue({
256+
ok: true,
257+
body: executionStream([
258+
{ type: 'execution:started', data: { startTime: '2026-08-31T00:00:00.000Z' } },
259+
{ type: 'block:started', data: { blockId: 'different-block' } },
260+
{ type: 'block:started', data: { blockId: 'failed-block' } },
261+
]),
262+
})
263+
264+
const { result, unmount } = renderHookWithClient(() => useRetryExecution('workspace-1'))
265+
266+
await act(async () => {
267+
await result().mutateAsync({ workflowId: 'workflow-1', executionId: 'execution-1' })
268+
})
269+
270+
expect(mockRequestJson).toHaveBeenCalledWith(getLogByExecutionIdContract, {
271+
params: { executionId: 'execution-1' },
272+
query: { workspaceId: 'workspace-1' },
273+
signal: undefined,
274+
})
275+
expect(mockFetch).toHaveBeenCalledWith('/api/workflows/workflow-1/execute', {
276+
method: 'POST',
277+
headers: { 'Content-Type': 'application/json' },
278+
body: JSON.stringify({
279+
inputFromExecutionId: 'execution-1',
280+
triggerType: 'manual',
281+
stream: true,
282+
runFromBlock: { startBlockId: 'failed-block', executionId: 'execution-1' },
283+
}),
284+
})
285+
286+
unmount()
287+
})
288+
289+
it('surfaces a streamed run-from-block validation error', async () => {
290+
mockRequestJson.mockResolvedValue(failedLogDetail())
291+
mockFetch.mockResolvedValue({
292+
ok: true,
293+
body: executionStream([
294+
{ type: 'execution:started', data: { startTime: '2026-08-31T00:00:00.000Z' } },
295+
{
296+
type: 'execution:error',
297+
data: { error: 'The failed block no longer exists in the current workflow' },
298+
},
299+
]),
300+
})
301+
302+
const { result, unmount } = renderHookWithClient(() => useRetryExecution('workspace-1'))
303+
304+
await act(async () => {
305+
await expect(
306+
result().mutateAsync({ workflowId: 'workflow-1', executionId: 'execution-1' })
307+
).rejects.toThrow('The failed block no longer exists in the current workflow')
308+
})
309+
310+
unmount()
311+
})
312+
313+
it('does not report success when the selected failed block never starts', async () => {
314+
mockRequestJson.mockResolvedValue(failedLogDetail())
315+
mockFetch.mockResolvedValue({
316+
ok: true,
317+
body: executionStream([
318+
{ type: 'execution:started', data: { startTime: '2026-08-31T00:00:00.000Z' } },
319+
{ type: 'execution:completed', data: { success: true } },
320+
]),
321+
})
322+
323+
const { result, unmount } = renderHookWithClient(() => useRetryExecution('workspace-1'))
324+
325+
await act(async () => {
326+
await expect(
327+
result().mutateAsync({ workflowId: 'workflow-1', executionId: 'execution-1' })
328+
).rejects.toThrow('Retry execution ended before the failed block could start')
329+
})
330+
331+
unmount()
332+
})
333+
334+
it('does not execute when the source run has multiple terminating failures', async () => {
335+
mockRequestJson.mockResolvedValue(
336+
failedLogDetail([
337+
{ id: 'failure-1', name: 'One', type: 'function', status: 'error', blockId: 'one' },
338+
{ id: 'failure-2', name: 'Two', type: 'function', status: 'error', blockId: 'two' },
339+
])
340+
)
341+
342+
const { result, unmount } = renderHookWithClient(() => useRetryExecution('workspace-1'))
343+
344+
await act(async () => {
345+
await expect(
346+
result().mutateAsync({ workflowId: 'workflow-1', executionId: 'execution-1' })
347+
).rejects.toThrow('multiple terminating failures')
348+
})
349+
expect(mockFetch).not.toHaveBeenCalled()
350+
351+
unmount()
352+
})
353+
})

apps/sim/hooks/queries/logs.ts

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,11 @@ import {
2424
type WorkflowStats,
2525
} from '@/lib/api/contracts/logs'
2626
import { cancelWorkflowExecutionContract } from '@/lib/api/contracts/workflows'
27+
import { readSSEEvents } from '@/lib/core/utils/sse'
2728
import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters'
2829
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
30+
import { resolveRetryTarget } from '@/lib/logs/retry'
31+
import type { ExecutionEvent } from '@/lib/workflows/executor/execution-events'
2932
import type { TimeRange } from '@/stores/logs/filters/types'
3033

3134
export type { DashboardStatsResponse, WorkflowStats }
@@ -430,7 +433,7 @@ export function useCancelExecution(workspaceId: string) {
430433
})
431434
}
432435

433-
export function useRetryExecution() {
436+
export function useRetryExecution(workspaceId: string) {
434437
const queryClient = useQueryClient()
435438
return useMutation({
436439
mutationFn: async ({
@@ -440,6 +443,12 @@ export function useRetryExecution() {
440443
workflowId: string
441444
executionId: string
442445
}) => {
446+
const detail = await fetchLogByExecutionId(workspaceId, executionId)
447+
const retryTarget = resolveRetryTarget(detail.executionData)
448+
if (!retryTarget.success) {
449+
throw new Error(retryTarget.error)
450+
}
451+
443452
// boundary-raw-fetch: stream response, body is a ReadableStream consumed one chunk at a time
444453
const res = await fetch(`/api/workflows/${workflowId}/execute`, {
445454
method: 'POST',
@@ -448,16 +457,44 @@ export function useRetryExecution() {
448457
inputFromExecutionId: executionId,
449458
triggerType: 'manual',
450459
stream: true,
460+
runFromBlock: {
461+
startBlockId: retryTarget.startBlockId,
462+
executionId,
463+
},
451464
}),
452465
})
453466
if (!res.ok) {
454467
const data = await res.json().catch(() => ({}))
455468
throw new Error(data.error || 'Failed to retry execution')
456469
}
457-
const reader = res.body?.getReader()
458-
if (reader) {
459-
await reader.read()
460-
reader.cancel()
470+
if (!res.body) {
471+
throw new Error('Retry execution did not return a stream')
472+
}
473+
474+
const reader = res.body.getReader()
475+
let retryStarted = false
476+
try {
477+
await readSSEEvents<ExecutionEvent>(reader, {
478+
onEvent: (event) => {
479+
if (event.type === 'execution:error') {
480+
throw new Error(event.data.error)
481+
}
482+
if (event.type === 'block:started' && event.data.blockId === retryTarget.startBlockId) {
483+
retryStarted = true
484+
return true
485+
}
486+
if (event.type === 'execution:completed' || event.type === 'execution:paused') {
487+
return true
488+
}
489+
},
490+
})
491+
} finally {
492+
await reader.cancel().catch(() => undefined)
493+
reader.releaseLock()
494+
}
495+
496+
if (!retryStarted) {
497+
throw new Error('Retry execution ended before the failed block could start')
461498
}
462499
return { started: true }
463500
},

0 commit comments

Comments
 (0)