Skip to content

Commit 2ad58b6

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

8 files changed

Lines changed: 384 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: 133 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,133 @@ 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: 'failed-block' } },
260+
]),
261+
})
262+
263+
const { result, unmount } = renderHookWithClient(() => useRetryExecution('workspace-1'))
264+
265+
await act(async () => {
266+
await result().mutateAsync({ workflowId: 'workflow-1', executionId: 'execution-1' })
267+
})
268+
269+
expect(mockRequestJson).toHaveBeenCalledWith(getLogByExecutionIdContract, {
270+
params: { executionId: 'execution-1' },
271+
query: { workspaceId: 'workspace-1' },
272+
signal: undefined,
273+
})
274+
expect(mockFetch).toHaveBeenCalledWith('/api/workflows/workflow-1/execute', {
275+
method: 'POST',
276+
headers: { 'Content-Type': 'application/json' },
277+
body: JSON.stringify({
278+
inputFromExecutionId: 'execution-1',
279+
triggerType: 'manual',
280+
stream: true,
281+
runFromBlock: { startBlockId: 'failed-block', executionId: 'execution-1' },
282+
}),
283+
})
284+
285+
unmount()
286+
})
287+
288+
it('surfaces a streamed run-from-block validation error', async () => {
289+
mockRequestJson.mockResolvedValue(failedLogDetail())
290+
mockFetch.mockResolvedValue({
291+
ok: true,
292+
body: executionStream([
293+
{ type: 'execution:started', data: { startTime: '2026-08-31T00:00:00.000Z' } },
294+
{
295+
type: 'execution:error',
296+
data: { error: 'The failed block no longer exists in the current workflow' },
297+
},
298+
]),
299+
})
300+
301+
const { result, unmount } = renderHookWithClient(() => useRetryExecution('workspace-1'))
302+
303+
await act(async () => {
304+
await expect(
305+
result().mutateAsync({ workflowId: 'workflow-1', executionId: 'execution-1' })
306+
).rejects.toThrow('The failed block no longer exists in the current workflow')
307+
})
308+
309+
unmount()
310+
})
311+
312+
it('does not execute when the source run has multiple terminating failures', async () => {
313+
mockRequestJson.mockResolvedValue(
314+
failedLogDetail([
315+
{ id: 'failure-1', name: 'One', type: 'function', status: 'error', blockId: 'one' },
316+
{ id: 'failure-2', name: 'Two', type: 'function', status: 'error', blockId: 'two' },
317+
])
318+
)
319+
320+
const { result, unmount } = renderHookWithClient(() => useRetryExecution('workspace-1'))
321+
322+
await act(async () => {
323+
await expect(
324+
result().mutateAsync({ workflowId: 'workflow-1', executionId: 'execution-1' })
325+
).rejects.toThrow('multiple terminating failures')
326+
})
327+
expect(mockFetch).not.toHaveBeenCalled()
328+
329+
unmount()
330+
})
331+
})

apps/sim/hooks/queries/logs.ts

Lines changed: 43 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,45 @@ 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 (
483+
event.type === 'block:started' ||
484+
event.type === 'execution:completed' ||
485+
event.type === 'execution:paused'
486+
) {
487+
retryStarted = true
488+
return true
489+
}
490+
},
491+
})
492+
} finally {
493+
await reader.cancel().catch(() => undefined)
494+
reader.releaseLock()
495+
}
496+
497+
if (!retryStarted) {
498+
throw new Error('Retry execution ended before the failed block could start')
461499
}
462500
return { started: true }
463501
},

0 commit comments

Comments
 (0)