Skip to content

Commit 466dac0

Browse files
committed
feat(executor): opt-in per-block retry
Adds a per-block retry policy, off by default, surfaced in the editor's additional-fields disclosure alongside the block's other advanced settings. A block that opts in replays its handler while tries remain, then rethrows the final error so the error port behaves exactly as it does for a block that never retried — retrying only delays the existing outcome, never changes it. Retry is deliberately indiscriminate about the failure, since there is no reliable way to tell a transient error from a permanent one and classifying would silently do nothing for the generic errors people turn it on for. Only throws that are not failures are excluded: a deliberate stop, a child workflow whose own blocks already ran their policies, and the block types whose throw is control flow (human-in-the-loop, sentinels, subflow containers, notes, triggers). Eligibility lives in one predicate read by both the editor and the executor, so a block can never keep retrying after an edit that hides its control. `retry` is a nullable jsonb column; NULL means "runs once", which is how every existing block already behaves, so the change is inert until someone opts in. Bounds are clamped on read rather than rejected, so a value written before a bound moved still resolves to something runnable. Also decouples the additional-fields disclosure from `block.advancedMode`. That flag decides which member of a canonical pair serializes, so opening the disclosure used to be able to drop a block's configured credential. Expansion is now view state; the stored flag is no longer written by the editor. Retried blocks report their try count on the trace span, shown in log details.
1 parent b6c007e commit 466dac0

31 files changed

Lines changed: 19784 additions & 16 deletions

File tree

apps/realtime/src/database/operations.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,35 @@ async function handleBlockOperationTx(
768768
break
769769
}
770770

771+
case BLOCK_OPERATIONS.UPDATE_RETRY: {
772+
if (!payload.id || payload.retry === undefined) {
773+
throw new Error('Missing required fields for update retry operation')
774+
}
775+
776+
const updateResult = await tx
777+
.update(workflowBlocks)
778+
.set({
779+
/**
780+
* Persisted verbatim, including a disabled policy, so the numbers a
781+
* builder configured survive switching retry off and back on. NULL stays
782+
* reserved for a block that never had a policy at all; whether a stored
783+
* policy actually runs is decided by `resolveBlockRetryConfig` at
784+
* execution time, never by the column being present.
785+
*/
786+
retry: payload.retry,
787+
updatedAt: new Date(),
788+
})
789+
.where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId)))
790+
.returning({ id: workflowBlocks.id })
791+
792+
if (updateResult.length === 0) {
793+
throw new Error(`Block ${payload.id} not found in workflow ${workflowId}`)
794+
}
795+
796+
logger.debug(`Updated block retry: ${payload.id} -> ${payload.retry.enabled}`)
797+
break
798+
}
799+
771800
case BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE: {
772801
if (!payload.id || !payload.canonicalId || !payload.canonicalMode) {
773802
throw new Error('Missing required fields for update canonical mode operation')
@@ -962,6 +991,7 @@ async function handleBlocksOperationTx(
962991
advancedMode: (block.advancedMode as boolean) ?? false,
963992
triggerMode: (block.triggerMode as boolean) ?? false,
964993
errorEnabled: (block.errorEnabled as boolean) ?? false,
994+
retry: (block.retry as Record<string, unknown> | undefined) ?? null,
965995
height: (block.height as number) || 0,
966996
locked: (block.locked as boolean) ?? false,
967997
}
@@ -981,6 +1011,7 @@ async function handleBlocksOperationTx(
9811011
horizontalHandles: sql`excluded.horizontal_handles`,
9821012
advancedMode: sql`excluded.advanced_mode`,
9831013
triggerMode: sql`excluded.trigger_mode`,
1014+
retry: sql`excluded.retry`,
9841015
locked: sql`excluded.locked`,
9851016
height: sql`excluded.height`,
9861017
subBlocks: sql`excluded.sub_blocks`,
@@ -2172,6 +2203,7 @@ async function handleWorkflowOperationTx(
21722203
positionX: block.position.x,
21732204
positionY: block.position.y,
21742205
errorEnabled: block.errorEnabled ?? false,
2206+
retry: block.retry ?? null,
21752207
data: block.data || {},
21762208
subBlocks: block.subBlocks || {},
21772209
outputs: block.outputs || {},

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,7 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa
673673
value: isCustomBlockType(span.type) ? 'custom block' : span.type,
674674
})
675675
metaEntries.push({ label: 'Duration', value: formatDuration(duration, { precision: 2 }) || '—' })
676+
if (span.tries !== undefined) metaEntries.push({ label: 'Tries', value: String(span.tries) })
676677
if (span.provider) metaEntries.push({ label: 'Provider', value: span.provider })
677678
if (span.model) metaEntries.push({ label: 'Model', value: span.model })
678679
if (span.finishReason) metaEntries.push({ label: 'Finish reason', value: span.finishReason })
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { ConnectionBlocks } from './connection-blocks/connection-blocks'
2+
export { RetrySettings } from './retry-settings/retry-settings'
23
export { SubBlock } from './sub-block/sub-block'
34
export { SubflowEditor } from './subflow-editor/subflow-editor'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import { ChipInput, FieldDivider, Label, Switch } from '@sim/emcn'
5+
import {
6+
BLOCK_RETRY_DEFAULT_TRIES,
7+
BLOCK_RETRY_DEFAULT_WAIT_MS,
8+
type BlockRetryConfig,
9+
normalizeBlockRetryTries,
10+
normalizeBlockRetryWaitMs,
11+
} from '@sim/workflow-types/workflow'
12+
13+
interface RetrySettingsProps {
14+
retry: BlockRetryConfig | undefined
15+
disabled: boolean
16+
onChange: (retry: BlockRetryConfig) => void
17+
}
18+
19+
interface RetryNumberFieldProps {
20+
id: string
21+
title: string
22+
value: number
23+
disabled: boolean
24+
normalize: (value: unknown) => number
25+
onCommit: (value: number) => void
26+
}
27+
28+
/**
29+
* A bounded number field that commits on blur.
30+
*
31+
* Typed as text with a numeric input mode rather than `type='number'`: the
32+
* native spinner is all that buys, and it does not fit the field chrome the rest
33+
* of the panel uses. Bounds are applied on commit through the same normalizer
34+
* execution uses, so the field cannot clamp differently from the executor.
35+
*
36+
* The draft exists only while the field is being edited; clearing it on commit
37+
* lets an external change — a collaborator's edit, or an undo — flow straight
38+
* through on the next render with no resync.
39+
*/
40+
function RetryNumberField({
41+
id,
42+
title,
43+
value,
44+
disabled,
45+
normalize,
46+
onCommit,
47+
}: RetryNumberFieldProps) {
48+
const [draft, setDraft] = useState<string | null>(null)
49+
50+
const commit = () => {
51+
const next = normalize(draft)
52+
setDraft(null)
53+
if (next !== value) onCommit(next)
54+
}
55+
56+
return (
57+
<div className='subblock-content flex flex-col gap-2.5'>
58+
<Label htmlFor={id}>{title}</Label>
59+
<ChipInput
60+
id={id}
61+
type='text'
62+
inputMode='numeric'
63+
value={draft ?? String(value)}
64+
onChange={(event) => setDraft(event.target.value)}
65+
onBlur={commit}
66+
disabled={disabled}
67+
/>
68+
</div>
69+
)
70+
}
71+
72+
/**
73+
* Per-block retry, rendered as ordinary rows among the block's other additional
74+
* fields so it carries the same label, spacing, and dividers.
75+
*
76+
* The numbers stay mounted only while retry is on, but the policy is written
77+
* with `enabled: false` when it is switched off, so turning it back on restores
78+
* what was configured rather than snapping to the defaults.
79+
*/
80+
export function RetrySettings({ retry, disabled, onChange }: RetrySettingsProps) {
81+
const enabled = retry?.enabled === true
82+
const maxTries = retry?.maxTries ?? BLOCK_RETRY_DEFAULT_TRIES
83+
const waitBetweenTriesMs = retry?.waitBetweenTriesMs ?? BLOCK_RETRY_DEFAULT_WAIT_MS
84+
85+
const setPolicy = (next: Partial<BlockRetryConfig>) =>
86+
onChange({ enabled, maxTries, waitBetweenTriesMs, ...next })
87+
88+
return (
89+
<>
90+
<div className='subblock-row'>
91+
<div className='subblock-content flex items-center gap-x-3'>
92+
<Switch
93+
id='block-retry-enabled'
94+
checked={enabled}
95+
onCheckedChange={(next) => setPolicy({ enabled: next })}
96+
disabled={disabled}
97+
/>
98+
<Label htmlFor='block-retry-enabled'>Retry on fail</Label>
99+
</div>
100+
{enabled && <FieldDivider subblockMarker />}
101+
</div>
102+
103+
{enabled && (
104+
<>
105+
<div className='subblock-row'>
106+
<RetryNumberField
107+
id='block-retry-max-tries'
108+
title='Max tries'
109+
value={maxTries}
110+
disabled={disabled}
111+
normalize={normalizeBlockRetryTries}
112+
onCommit={(next) => setPolicy({ maxTries: next })}
113+
/>
114+
<FieldDivider subblockMarker />
115+
</div>
116+
<div className='subblock-row'>
117+
<RetryNumberField
118+
id='block-retry-wait'
119+
title='Wait between tries (ms)'
120+
value={waitBetweenTriesMs}
121+
disabled={disabled}
122+
normalize={normalizeBlockRetryWaitMs}
123+
onCommit={(next) => setPolicy({ waitBetweenTriesMs: next })}
124+
/>
125+
</div>
126+
</>
127+
)}
128+
</>
129+
)
130+
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ import {
1313
Unlock,
1414
} from '@sim/emcn/icons'
1515
import { getWorkflowTypeAccent } from '@sim/workflow-renderer'
16+
import type { BlockRetryConfig } from '@sim/workflow-types/workflow'
1617
import { isEqual } from 'es-toolkit'
1718
import { useParams } from 'next/navigation'
1819
import { usePostHog } from 'posthog-js/react'
1920
import { useShallow } from 'zustand/react/shallow'
2021
import { useStoreWithEqualityFn } from 'zustand/traditional'
2122
import { captureEvent } from '@/lib/posthog/client'
23+
import { isRetryEligibleBlock } from '@/lib/workflows/blocks/retry-eligibility'
2224
import {
2325
buildCanonicalIndex,
2426
evaluateSubBlockCondition,
@@ -31,6 +33,7 @@ import {
3133
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
3234
import {
3335
ConnectionBlocks,
36+
RetrySettings,
3437
SubBlock,
3538
SubflowEditor,
3639
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components'
@@ -194,9 +197,24 @@ export function Editor() {
194197
() => hasAdvancedValues(subBlocksForCanonical, blockSubBlockValues, canonicalIndex),
195198
[subBlocksForCanonical, blockSubBlockValues, canonicalIndex]
196199
)
200+
/**
201+
* Whether the additional-fields disclosure is open, held as view state only.
202+
*
203+
* Deliberately not written back to `block.advancedMode`: that flag also decides
204+
* which member of a canonical pair serializes, so driving it from this control
205+
* would change the credential a block sends just because someone opened a
206+
* disclosure. Seeded from the stored flag so a block saved while it was on
207+
* still opens expanded.
208+
*/
209+
const [additionalFieldsExpanded, setAdditionalFieldsExpanded] = useState(advancedMode)
210+
211+
useEffect(() => {
212+
setAdditionalFieldsExpanded(advancedMode)
213+
}, [advancedMode, currentBlockId])
214+
197215
const displayAdvancedOptions = canEditBlock
198-
? advancedMode || activeSearchTargetNeedsAdvanced
199-
: advancedMode || advancedValuesPresent || activeSearchTargetNeedsAdvanced
216+
? additionalFieldsExpanded || activeSearchTargetNeedsAdvanced
217+
: additionalFieldsExpanded || advancedValuesPresent || activeSearchTargetNeedsAdvanced
200218

201219
const hasAdvancedOnlyFields = useMemo(() => {
202220
for (const subBlock of subBlocksForCanonical) {
@@ -257,14 +275,32 @@ export function Editor() {
257275
const {
258276
collaborativeSetBlockCanonicalMode,
259277
collaborativeUpdateBlockName,
260-
collaborativeToggleBlockAdvancedMode,
278+
collaborativeSetBlockRetry,
261279
collaborativeBatchToggleLocked,
262280
} = useCollaborativeWorkflow()
263281

264-
const handleToggleAdvancedMode = useCallback(() => {
265-
if (!currentBlockId || !canEditBlock) return
266-
collaborativeToggleBlockAdvancedMode(currentBlockId)
267-
}, [currentBlockId, canEditBlock, collaborativeToggleBlockAdvancedMode])
282+
const handleToggleAdditionalFields = useCallback(() => {
283+
if (!canEditBlock) return
284+
setAdditionalFieldsExpanded((expanded) => !expanded)
285+
}, [canEditBlock])
286+
287+
const supportsRetry = isRetryEligibleBlock({
288+
blockType: currentBlock?.type,
289+
category: blockConfig?.category,
290+
triggerMode,
291+
})
292+
const showRetrySettings = supportsRetry && displayAdvancedOptions
293+
294+
/** Retry lives in the additional-fields disclosure, which a block may otherwise have no reason to show. */
295+
const hasAdditionalFields = hasAdvancedOnlyFields || supportsRetry
296+
297+
const handleChangeRetry = useCallback(
298+
(retry: BlockRetryConfig) => {
299+
if (!currentBlockId) return
300+
collaborativeSetBlockRetry(currentBlockId, retry)
301+
},
302+
[currentBlockId, collaborativeSetBlockRetry]
303+
)
268304

269305
const [isRenaming, setIsRenaming] = useState(false)
270306
const [editedName, setEditedName] = useState('')
@@ -648,7 +684,7 @@ export function Editor() {
648684

649685
const showDivider =
650686
index < regularSubBlocks.length - 1 ||
651-
(!hasAdvancedOnlyFields && index < subBlocks.length - 1)
687+
(!hasAdditionalFields && index < subBlocks.length - 1)
652688

653689
return (
654690
<div key={stableKey} className='subblock-row'>
@@ -698,12 +734,12 @@ export function Editor() {
698734
)
699735
})}
700736

701-
{hasAdvancedOnlyFields && canEditBlock && (
737+
{hasAdditionalFields && canEditBlock && (
702738
<div className='flex items-center gap-2.5 px-0.5 pt-3.5 pb-3'>
703739
<DashedDividerLine className='flex-1' />
704740
<button
705741
type='button'
706-
onClick={handleToggleAdvancedMode}
742+
onClick={handleToggleAdditionalFields}
707743
className='flex items-center gap-1.5 whitespace-nowrap text-[var(--text-secondary)] text-small hover-hover:text-[var(--text-primary)]'
708744
>
709745
{displayAdvancedOptions
@@ -716,7 +752,7 @@ export function Editor() {
716752
<DashedDividerLine className='flex-1' />
717753
</div>
718754
)}
719-
{hasAdvancedOnlyFields && !canEditBlock && displayAdvancedOptions && (
755+
{hasAdditionalFields && !canEditBlock && displayAdvancedOptions && (
720756
<div className='flex items-center gap-2.5 px-0.5 pt-3.5 pb-3'>
721757
<DashedDividerLine className='flex-1' />
722758
<span className='whitespace-nowrap text-[var(--text-secondary)] text-small'>
@@ -749,7 +785,7 @@ export function Editor() {
749785
(subBlock.canonicalParamId ?? subBlock.id))
750786
}
751787
/>
752-
{index < advancedOnlySubBlocks.length - 1 && (
788+
{(index < advancedOnlySubBlocks.length - 1 || showRetrySettings) && (
753789
<FieldDivider
754790
subblockMarker
755791
className={
@@ -762,6 +798,14 @@ export function Editor() {
762798
</div>
763799
)
764800
})}
801+
802+
{showRetrySettings && (
803+
<RetrySettings
804+
retry={currentBlock?.retry}
805+
disabled={!canEditBlock}
806+
onChange={handleChangeRetry}
807+
/>
808+
)}
765809
</div>
766810
)}
767811
</div>

0 commit comments

Comments
 (0)