Skip to content

Commit 4a180c0

Browse files
feat(wait): add Suspend Workflow toggle, restore 5-min in-process default
1 parent 91f88a7 commit 4a180c0

4 files changed

Lines changed: 107 additions & 32 deletions

File tree

apps/sim/blocks/blocks/wait.ts

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,13 @@ const WaitIcon = (props: SVGProps<SVGSVGElement>) => createElement(PauseCircle,
88
export const WaitBlock: BlockConfig = {
99
type: 'wait',
1010
name: 'Wait',
11-
description: 'Pause workflow execution for up to 30 days',
11+
description: 'Pause workflow execution for a time interval',
1212
longDescription:
13-
'Pauses workflow execution for a specified time interval. Waits up to five minutes are held in-process; longer waits suspend the workflow and resume automatically once the configured duration elapses.',
13+
'Pauses workflow execution for a specified time interval. By default the wait runs in-process for up to 5 minutes. Enable Suspend Workflow to pause the run on disk and resume automatically for waits up to 30 days.',
1414
bestPractices: `
15-
- Configure the wait amount and unit (seconds, minutes, hours, or days)
16-
- Maximum wait duration is 30 days
17-
- Waits up to 5 minutes execute in-process and are interruptible via workflow cancellation
18-
- Longer waits suspend the workflow; the execution resumes automatically when the timer fires
15+
- Configure the wait amount and unit
16+
- Default mode runs in-process and caps at 5 minutes
17+
- Enable Suspend Workflow for longer waits (up to 30 days); seconds are not available in this mode
1918
- Enter a positive number for the wait amount
2019
`,
2120
category: 'blocks',
@@ -27,7 +26,6 @@ export const WaitBlock: BlockConfig = {
2726
id: 'timeValue',
2827
title: 'Wait Amount',
2928
type: 'short-input',
30-
description: 'Max: 30 days',
3129
placeholder: '10',
3230
value: () => '10',
3331
required: true,
@@ -39,24 +37,51 @@ export const WaitBlock: BlockConfig = {
3937
options: [
4038
{ label: 'Seconds', id: 'seconds' },
4139
{ label: 'Minutes', id: 'minutes' },
40+
],
41+
value: () => 'seconds',
42+
required: true,
43+
condition: { field: 'suspend', value: true, not: true },
44+
},
45+
{
46+
id: 'timeUnitLong',
47+
title: 'Unit',
48+
type: 'dropdown',
49+
options: [
50+
{ label: 'Minutes', id: 'minutes' },
4251
{ label: 'Hours', id: 'hours' },
4352
{ label: 'Days', id: 'days' },
4453
],
45-
value: () => 'seconds',
54+
value: () => 'minutes',
4655
required: true,
56+
condition: { field: 'suspend', value: true },
57+
},
58+
{
59+
id: 'suspend',
60+
title: 'Suspend Workflow',
61+
type: 'switch',
62+
tooltip:
63+
'By default, the workflow pauses in memory and can wait up to 5 minutes. Turn this on to suspend the run to disk so it can wait much longer (up to 30 days) — execution resumes automatically when the timer fires. Seconds aren’t available while suspended.',
4764
},
4865
],
4966
tools: {
5067
access: [],
5168
},
5269
inputs: {
70+
suspend: {
71+
type: 'boolean',
72+
description: 'Suspend the workflow to allow waits up to 30 days',
73+
},
5374
timeValue: {
5475
type: 'string',
5576
description: 'Wait duration value',
5677
},
5778
timeUnit: {
5879
type: 'string',
59-
description: 'Wait duration unit (seconds, minutes, hours, or days)',
80+
description: 'Wait duration unit when suspend is off (seconds or minutes)',
81+
},
82+
timeUnitLong: {
83+
type: 'string',
84+
description: 'Wait duration unit when suspend is on (minutes, hours, or days)',
6085
},
6186
},
6287
outputs: {

apps/sim/executor/handlers/wait/wait-handler.test.ts

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -120,12 +120,32 @@ describe('WaitBlockHandler', () => {
120120
).rejects.toThrow('Unknown wait unit: fortnights')
121121
})
122122

123-
it('should reject waits longer than the 30-day ceiling', async () => {
123+
it('should reject suspending waits longer than the 30-day ceiling', async () => {
124124
await expect(
125-
handler.execute(mockContext, mockBlock, { timeValue: '31', timeUnit: 'days' })
125+
handler.execute(mockContext, mockBlock, {
126+
suspend: true,
127+
timeValue: '31',
128+
timeUnitLong: 'days',
129+
})
126130
).rejects.toThrow('Wait time exceeds maximum of 30 days')
127131
})
128132

133+
it('should reject non-suspending waits longer than 5 minutes', async () => {
134+
await expect(
135+
handler.execute(mockContext, mockBlock, { timeValue: '10', timeUnit: 'minutes' })
136+
).rejects.toThrow('Wait time exceeds maximum of 5 minutes')
137+
})
138+
139+
it('should reject seconds as a unit when Suspend Workflow is enabled', async () => {
140+
await expect(
141+
handler.execute(mockContext, mockBlock, {
142+
suspend: true,
143+
timeValue: '30',
144+
timeUnitLong: 'seconds',
145+
})
146+
).rejects.toThrow('Seconds are not allowed when Suspend Workflow is enabled')
147+
})
148+
129149
it('should still execute in-process at the 5-minute boundary', async () => {
130150
const inputs = { timeValue: '5', timeUnit: 'minutes' }
131151

@@ -144,7 +164,7 @@ describe('WaitBlockHandler', () => {
144164
it('should suspend the workflow when wait exceeds the in-process threshold', async () => {
145165
vi.setSystemTime(new Date('2026-04-28T00:00:00.000Z'))
146166

147-
const inputs = { timeValue: '10', timeUnit: 'minutes' }
167+
const inputs = { suspend: true, timeValue: '10', timeUnitLong: 'minutes' }
148168

149169
const result = (await handler.execute(mockContext, mockBlock, inputs)) as Record<string, any>
150170

@@ -167,7 +187,7 @@ describe('WaitBlockHandler', () => {
167187
it('should suspend the workflow for multi-day waits', async () => {
168188
vi.setSystemTime(new Date('2026-04-28T00:00:00.000Z'))
169189

170-
const inputs = { timeValue: '2', timeUnit: 'days' }
190+
const inputs = { suspend: true, timeValue: '2', timeUnitLong: 'days' }
171191

172192
const result = (await handler.execute(mockContext, mockBlock, inputs)) as Record<string, any>
173193

@@ -185,8 +205,9 @@ describe('WaitBlockHandler', () => {
185205
vi.setSystemTime(new Date('2026-04-28T00:00:00.000Z'))
186206

187207
const result = (await handler.execute(mockContext, mockBlock, {
208+
suspend: true,
188209
timeValue: '3',
189-
timeUnit: 'hours',
210+
timeUnitLong: 'hours',
190211
})) as Record<string, any>
191212

192213
const waitMs = 3 * 60 * 60 * 1000
@@ -237,8 +258,9 @@ describe('WaitBlockHandler', () => {
237258
mockContext.abortSignal = abortController.signal
238259

239260
const result = (await handler.execute(mockContext, mockBlock, {
261+
suspend: true,
240262
timeValue: '1',
241-
timeUnit: 'hours',
263+
timeUnitLong: 'hours',
242264
})) as Record<string, any>
243265

244266
expect(result.status).toBe('waiting')
@@ -264,13 +286,33 @@ describe('WaitBlockHandler', () => {
264286
vi.setSystemTime(new Date('2026-04-28T00:00:00.000Z'))
265287

266288
const result = (await handler.execute(mockContext, mockBlock, {
289+
suspend: true,
267290
timeValue: '1.5',
268-
timeUnit: 'days',
291+
timeUnitLong: 'days',
269292
})) as Record<string, any>
270293

271294
const waitMs = 1.5 * 24 * 60 * 60 * 1000
272295
expect(result.waitDuration).toBe(waitMs)
273296
expect(result.status).toBe('waiting')
274297
expect(result._pauseMetadata.pauseKind).toBe('time')
275298
})
299+
300+
it('should always suspend when suspend is enabled, even for short waits', async () => {
301+
vi.setSystemTime(new Date('2026-04-28T00:00:00.000Z'))
302+
303+
const result = (await handler.execute(mockContext, mockBlock, {
304+
suspend: true,
305+
timeValue: '2',
306+
timeUnitLong: 'minutes',
307+
})) as Record<string, any>
308+
309+
const waitMs = 2 * 60 * 1000
310+
const expectedResumeAt = new Date(Date.now() + waitMs).toISOString()
311+
312+
expect(result.status).toBe('waiting')
313+
expect(result.waitDuration).toBe(waitMs)
314+
expect(result.resumeAt).toBe(expectedResumeAt)
315+
expect(result._pauseMetadata.pauseKind).toBe('time')
316+
expect(result._pauseMetadata.resumeAt).toBe(expectedResumeAt)
317+
})
276318
})

apps/sim/executor/handlers/wait/wait-handler.ts

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { env, envNumber } from '@/lib/core/config/env'
21
import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation'
32
import type { BlockOutput } from '@/blocks/types'
43
import { BlockType } from '@/executor/constants'
@@ -11,11 +10,11 @@ import type { SerializedBlock } from '@/serializer/types'
1110

1211
const CANCELLATION_CHECK_INTERVAL_MS = 500
1312

14-
/** Threshold below which we hold the wait in-process; above, we suspend via PauseMetadata. */
15-
const inprocessMaxMs = (): number => envNumber(env.WAIT_INPROCESS_MAX_MS, 5 * 60 * 1000)
13+
/** Hard ceiling for in-process (non-suspending) waits. */
14+
const MAX_INPROCESS_WAIT_MS = 5 * 60 * 1000
1615

17-
/** Hard ceiling on configurable wait duration. */
18-
const MAX_WAIT_MS = 30 * 24 * 60 * 60 * 1000
16+
/** Hard ceiling for suspending waits. */
17+
const MAX_SUSPEND_WAIT_MS = 30 * 24 * 60 * 60 * 1000
1918

2019
interface SleepOptions {
2120
signal?: AbortSignal
@@ -92,10 +91,10 @@ function isWaitUnit(value: string): value is WaitUnit {
9291
/**
9392
* Handler for Wait blocks that pause workflow execution for a time delay.
9493
*
95-
* Waits up to `WAIT_INPROCESS_MAX_MS` (default 5 minutes) are held in-process via an interruptible sleep.
96-
* Longer waits suspend the workflow by returning {@link PauseMetadata} with
97-
* `pauseKind: 'time'`; the cron-driven resume poller (see `/api/resume/poll`) picks
98-
* the execution back up once `resumeAt` is reached.
94+
* Default (suspend=false) waits are held in-process via an interruptible sleep and capped at 5 minutes.
95+
* When suspend=true is set, the workflow is always suspended by returning {@link PauseMetadata} with
96+
* `pauseKind: 'time'`; the cron-driven resume poller (see `/api/resume/poll`) picks the execution back
97+
* up once `resumeAt` is reached. Suspend caps at 30 days.
9998
*/
10099
export class WaitBlockHandler implements BlockHandler {
101100
canHandle(block: SerializedBlock): boolean {
@@ -125,8 +124,9 @@ export class WaitBlockHandler implements BlockHandler {
125124
executionOrder?: number
126125
}
127126
): Promise<BlockOutput> {
127+
const suspend = inputs.suspend === true || inputs.suspend === 'true'
128128
const timeValue = Number.parseFloat(inputs.timeValue || '10')
129-
const timeUnit = inputs.timeUnit || 'seconds'
129+
const timeUnit = (suspend ? inputs.timeUnitLong : inputs.timeUnit) || 'seconds'
130130

131131
if (!Number.isFinite(timeValue) || timeValue <= 0) {
132132
throw new Error('Wait amount must be a positive number')
@@ -135,13 +135,24 @@ export class WaitBlockHandler implements BlockHandler {
135135
if (!isWaitUnit(timeUnit)) {
136136
throw new Error(`Unknown wait unit: ${timeUnit}`)
137137
}
138+
139+
if (suspend && timeUnit === 'seconds') {
140+
throw new Error('Seconds are not allowed when Suspend Workflow is enabled')
141+
}
142+
138143
const waitMs = Math.round(timeValue * UNIT_TO_MS[timeUnit])
139144

140-
if (waitMs > MAX_WAIT_MS) {
141-
throw new Error('Wait time exceeds maximum of 30 days')
145+
if (suspend) {
146+
if (waitMs > MAX_SUSPEND_WAIT_MS) {
147+
throw new Error('Wait time exceeds maximum of 30 days')
148+
}
149+
} else if (waitMs > MAX_INPROCESS_WAIT_MS) {
150+
throw new Error(
151+
'Wait time exceeds maximum of 5 minutes; enable Suspend Workflow to wait up to 30 days'
152+
)
142153
}
143154

144-
if (waitMs <= inprocessMaxMs()) {
155+
if (!suspend) {
145156
const completed = await sleep(waitMs, {
146157
signal: ctx.abortSignal,
147158
executionId: ctx.executionId,

apps/sim/lib/core/config/env.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,9 +231,6 @@ export const env = createEnv({
231231
EXECUTION_TIMEOUT_ASYNC_TEAM: z.string().optional().default('5400'), // 90 minutes
232232
EXECUTION_TIMEOUT_ASYNC_ENTERPRISE: z.string().optional().default('5400'), // 90 minutes
233233

234-
// Wait block configuration
235-
WAIT_INPROCESS_MAX_MS: z.string().optional().default('300000'), // Threshold below which a wait runs in-process; above it, the wait suspends and the cron poller resumes. Default 5 min. Lower this locally (e.g. '5000' = 5s) to test the suspend path with short waits.
236-
237234
// Isolated-VM Worker Pool Configuration
238235
IVM_POOL_SIZE: z.string().optional().default('4'), // Max worker processes in pool
239236
IVM_MAX_CONCURRENT: z.string().optional().default('10000'), // Max concurrent executions globally

0 commit comments

Comments
 (0)