Skip to content

Commit a70f63b

Browse files
committed
fix(triggers): v2 schedules match the dispatcher, guard poll backoff, atomic poll auto-disable
The v2 list and block target read schedules with the dispatcher's own predicate, so a versionless schedule that never fires is not reported; the legacy row-id route keeps it for the editor. Enabling clears poll backoff only on polling rows with an object config. An unknown schedule status reads as active instead of failing the list. markWebhookFailed increments and auto-disables in one statement, so a concurrent enable is never switched off by a decision made on the old count. Documents that a disable lasts until the next deploy or activation, and that workspace keys are not governed by the webhook capability.
1 parent d07faa9 commit a70f63b

11 files changed

Lines changed: 130 additions & 48 deletions

File tree

‎apps/docs/content/docs/cli/reference.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8273,7 +8273,7 @@ sim workflows triggers update <workflowId> <blockId> [options]
82738273

82748274
| Option | Required | Description |
82758275
| --- | --- | --- |
8276-
| `--enabled <true\|false>` | Yes | Whether the trigger should fire. Accepted values: `true`, `false`. |
8276+
| `--enabled <true\|false>` | Yes | Whether the trigger should fire. A disable lasts until the next deploy or version activation, which re-enables every trigger. Accepted values: `true`, `false`. |
82778277

82788278
</CommandTable>
82798279

‎apps/docs/content/docs/cli/workflows.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,7 @@ sim workflows triggers update <workflowId> <blockId> [options]
472472

473473
| Option | Required | Description |
474474
| --- | --- | --- |
475-
| `--enabled <true\|false>` | Yes | Whether the trigger should fire. Accepted values: `true`, `false`. |
475+
| `--enabled <true\|false>` | Yes | Whether the trigger should fire. A disable lasts until the next deploy or version activation, which re-enables every trigger. Accepted values: `true`, `false`. |
476476

477477
</CommandTable>
478478

‎apps/docs/openapi-v2-workflows.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2669,7 +2669,7 @@
26692669
"patch": {
26702670
"operationId": "updateWorkflowTrigger",
26712671
"summary": "Update workflow trigger",
2672-
"description": "Enable or disable one trigger of the live deployment without redeploying. Requires workspace write access. Enabling resets `failedCount`, schedules the next run, and clears any polling backoff; a schedule whose cron expression is invalid or has no future runs returns `400`. A permission group that blocks webhook triggers returns `403` when enabling a polling, internal-event, or webhook trigger. Disabling a completed schedule marks it disabled. A block with several live rows switches them together. Requesting the current state changes nothing.\n\nDisabling only stops Sim from accepting the trigger; nothing is removed at the provider. Deliveries to a disabled webhook receive `404`, and some providers disable their subscription after repeated failures, which requires a redeploy to restore.\n\nOAuth scope: `api:write`.",
2672+
"description": "Enable or disable one trigger of the live deployment without redeploying. Requires workspace write access. Enabling resets `failedCount`, schedules the next run, and clears any polling backoff; a schedule whose cron expression is invalid or has no future runs returns `400`. A permission group that blocks webhook triggers returns `403` when a member it governs enables a polling, internal-event, or webhook trigger; a workspace API key is not governed by permission groups, as with every other capability. Disabling a completed schedule marks it disabled. A block with several live rows switches them together. Requesting the current state changes nothing.\n\nDisabling only stops Sim from accepting the trigger; nothing is removed at the provider. A disable lasts until the next deploy or version activation, which re-enables every trigger, the same way it clears a trigger that disabled itself after repeated failures. Deliveries to a disabled webhook receive `404`, and some providers disable their subscription after repeated failures, which requires a redeploy to restore.\n\nOAuth scope: `api:write`.",
26732673
"x-sim-operation": "workflows.triggers.set_enabled",
26742674
"x-oauth-scope": "api:write",
26752675
"tags": ["Workflows"],
@@ -13032,7 +13032,7 @@
1303213032
"properties": {
1303313033
"enabled": {
1303413034
"type": "boolean",
13035-
"description": "Whether the trigger should fire."
13035+
"description": "Whether the trigger should fire. A disable lasts until the next deploy or version activation, which re-enables every trigger."
1303613036
}
1303713037
},
1303813038
"required": ["enabled"],

‎apps/sim/lib/api/contracts/v2/openapi/workflows.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -949,7 +949,7 @@ const declaredRoutes = [
949949
applicationOperation: workflowOperations.setTriggerEnabled,
950950
operationId: 'updateWorkflowTrigger',
951951
summary: 'Update workflow trigger',
952-
description: `Enable or disable one trigger of the live deployment without redeploying. Requires workspace write access. Enabling resets \`failedCount\`, schedules the next run, and clears any polling backoff; a schedule whose cron expression is invalid or has no future runs returns \`400\`. A permission group that blocks webhook triggers returns \`403\` when enabling a polling, internal-event, or webhook trigger. Disabling a completed schedule marks it disabled. A block with several live rows switches them together. Requesting the current state changes nothing.\n\nDisabling only stops Sim from accepting the trigger; nothing is removed at the provider. Deliveries to a disabled webhook receive \`404\`, and some providers disable their subscription after repeated failures, which requires a redeploy to restore.`,
952+
description: `Enable or disable one trigger of the live deployment without redeploying. Requires workspace write access. Enabling resets \`failedCount\`, schedules the next run, and clears any polling backoff; a schedule whose cron expression is invalid or has no future runs returns \`400\`. A permission group that blocks webhook triggers returns \`403\` when a member it governs enables a polling, internal-event, or webhook trigger; a workspace API key is not governed by permission groups, as with every other capability. Disabling a completed schedule marks it disabled. A block with several live rows switches them together. Requesting the current state changes nothing.\n\nDisabling only stops Sim from accepting the trigger; nothing is removed at the provider. A disable lasts until the next deploy or version activation, which re-enables every trigger, the same way it clears a trigger that disabled itself after repeated failures. Deliveries to a disabled webhook receive \`404\`, and some providers disable their subscription after repeated failures, which requires a redeploy to restore.`,
953953
errors: [...RESOURCE_MUTATION_ERRORS, 'PayloadTooLarge'],
954954
success: jsonSuccess('The trigger after the update.'),
955955
}),

‎apps/sim/lib/api/contracts/v2/workflows.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1395,7 +1395,9 @@ const v2UpdateWorkflowTriggerBodySchema = z
13951395
.object({
13961396
enabled: z
13971397
.boolean({ error: 'enabled must be a boolean' })
1398-
.describe('Whether the trigger should fire.'),
1398+
.describe(
1399+
'Whether the trigger should fire. A disable lasts until the next deploy or version activation, which re-enables every trigger.'
1400+
),
13991401
})
14001402
.strict()
14011403
.meta({

‎apps/sim/lib/api/mcp/generated/v2-operations.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2990,7 +2990,7 @@ export const V2_MCP_OPERATIONS = {
29902990
contract: v2UpdateWorkflowTriggerContract,
29912991
summary: 'Update workflow trigger',
29922992
description:
2993-
'Enable or disable one trigger of the live deployment without redeploying. Requires workspace write access. Enabling resets `failedCount`, schedules the next run, and clears any polling backoff; a schedule with no future runs returns `409` and an invalid cron expression returns `400`. A permission group that blocks webhook triggers returns `403` when enabling a polling, internal-event, or webhook trigger. Requesting the current state changes nothing.\n\nDisabling only stops Sim from accepting the trigger; nothing is removed at the provider. Deliveries to a disabled webhook receive `404`, and some providers disable their subscription after repeated failures, which requires a redeploy to restore.\n\nOAuth scope: `api:write`.',
2993+
'Enable or disable one trigger of the live deployment without redeploying. Requires workspace write access. Enabling resets `failedCount`, schedules the next run, and clears any polling backoff; a schedule whose cron expression is invalid or has no future runs returns `400`. A permission group that blocks webhook triggers returns `403` when a member it governs enables a polling, internal-event, or webhook trigger; a workspace API key is not governed by permission groups, as with every other capability. Disabling a completed schedule marks it disabled. A block with several live rows switches them together. Requesting the current state changes nothing.\n\nDisabling only stops Sim from accepting the trigger; nothing is removed at the provider. A disable lasts until the next deploy or version activation, which re-enables every trigger, the same way it clears a trigger that disabled itself after repeated failures. Deliveries to a disabled webhook receive `404`, and some providers disable their subscription after repeated failures, which requires a redeploy to restore.\n\nOAuth scope: `api:write`.',
29942994
handler: () =>
29952995
import('@/app/api/v2/workflows/[workflowId]/triggers/[blockId]/route').then(
29962996
(route) => route.PATCH

‎apps/sim/lib/webhooks/polling/utils.ts‎

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -145,29 +145,27 @@ export async function recordPollSourceFailure(
145145
await markWebhookFailed(webhookData.id, logger)
146146
}
147147

148-
/** Increment the webhook's failure count. Auto-disables after MAX_CONSECUTIVE_FAILURES. */
148+
/**
149+
* Increment the webhook's failure count, auto-disabling it once the count
150+
* reaches MAX_CONSECUTIVE_FAILURES. One statement, so an enable that resets the
151+
* count concurrently is never followed by a disable decided on the old count.
152+
*/
149153
export async function markWebhookFailed(webhookId: string, logger: Logger): Promise<void> {
150154
try {
151-
const result = await db
155+
const now = new Date()
156+
const reachesLimit = sql`COALESCE(${webhook.failedCount}, 0) + 1 >= ${MAX_CONSECUTIVE_FAILURES}`
157+
const [result] = await db
152158
.update(webhook)
153159
.set({
154160
failedCount: sql`COALESCE(${webhook.failedCount}, 0) + 1`,
155-
lastFailedAt: new Date(),
156-
updatedAt: new Date(),
161+
lastFailedAt: now,
162+
isActive: sql`CASE WHEN ${reachesLimit} THEN false ELSE ${webhook.isActive} END`,
163+
updatedAt: now,
157164
})
158165
.where(eq(webhook.id, webhookId))
159166
.returning({ failedCount: webhook.failedCount })
160167

161-
const newFailedCount = result[0]?.failedCount || 0
162-
if (newFailedCount >= MAX_CONSECUTIVE_FAILURES) {
163-
await db
164-
.update(webhook)
165-
.set({
166-
isActive: false,
167-
updatedAt: new Date(),
168-
})
169-
.where(eq(webhook.id, webhookId))
170-
168+
if ((result?.failedCount ?? 0) >= MAX_CONSECUTIVE_FAILURES) {
171169
logger.warn(
172170
`Webhook ${webhookId} auto-disabled after ${MAX_CONSECUTIVE_FAILURES} consecutive failures`
173171
)

‎apps/sim/lib/workflows/application/triggers.integration.ts‎

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
workspace,
1818
} from '@sim/db/schema'
1919
import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
20+
import { createLogger } from '@sim/logger'
2021
import {
2122
createSessionPrincipal,
2223
createWorkspaceApiKeyPrincipal,
@@ -26,6 +27,7 @@ import { getErrorMessage } from '@sim/utils/errors'
2627
import { generateId } from '@sim/utils/id'
2728
import { and, eq, inArray } from 'drizzle-orm'
2829
import { afterAll, beforeAll, expect, it, vi } from 'vitest'
30+
import { markWebhookFailed } from '@/lib/webhooks/polling/utils'
2931
import { v2WorkflowErrorPolicies } from '@/lib/workflows/api'
3032
import {
3133
listWorkflowTriggers,
@@ -69,6 +71,8 @@ const pollingWebhookId = generateId()
6971
const eventWebhookId = generateId()
7072
const twinWebhookIds = [generateId(), generateId()]
7173
const versionlessWebhookId = generateId()
74+
const scalarConfigWebhookId = generateId()
75+
const flakyWebhookId = generateId()
7276
const hookPath = `trigger-health-${generateId()}`
7377

7478
const writer = createSessionPrincipal({ userId: writerId })
@@ -386,7 +390,25 @@ beforeAll(async () => {
386390
blockId: 'twin',
387391
path: `${hookPath}-twin-${index}`,
388392
failedCount: 100,
393+
providerConfig: { pollSourceFailures: 1 },
389394
})),
395+
{
396+
...dormantWebhook,
397+
id: scalarConfigWebhookId,
398+
blockId: 'scalar',
399+
provider: 'gmail',
400+
path: `${hookPath}-scalar`,
401+
providerConfig: 'legacy-scalar-config',
402+
},
403+
{
404+
...dormantWebhook,
405+
id: flakyWebhookId,
406+
blockId: 'flaky',
407+
provider: 'gmail',
408+
path: `${hookPath}-flaky`,
409+
isActive: true,
410+
failedCount: 98,
411+
},
390412
{
391413
...dormantWebhook,
392414
id: versionlessWebhookId,
@@ -418,8 +440,10 @@ check('lists only the live deployment’s rows, disabled ones included', async (
418440
['badcron', 'schedule', 'disabled'],
419441
['done', 'schedule', 'completed'],
420442
['evt', 'event', 'active'],
443+
['flaky', 'polling', 'active'],
421444
['hook', 'webhook', 'disabled'],
422445
['poll', 'polling', 'disabled'],
446+
['scalar', 'polling', 'disabled'],
423447
['sched', 'schedule', 'disabled'],
424448
['twin', 'webhook', 'disabled'],
425449
['twin', 'webhook', 'disabled'],
@@ -509,6 +533,7 @@ check('the write itself refuses a row that is no longer live', async () => {
509533
enabled: true,
510534
schedules: [{ id: oldScheduleId, nextRunAt: new Date(Date.now() + 60_000) }],
511535
webhookIds: [oldWebhookId, candidateWebhookId],
536+
scheduleLiveness: 'editor',
512537
now: new Date(),
513538
})
514539
expect(updated).toEqual([])
@@ -517,15 +542,23 @@ check('the write itself refuses a row that is no longer live', async () => {
517542
expect((await webhookRow(candidateWebhookId)).isActive).toBe(false)
518543
})
519544

520-
check('a versionless deploy is switchable exactly as delivery serves it', async () => {
545+
check('a versionless schedule is hidden from v2 but kept for the editor row id', async () => {
521546
const { triggers } = await listWorkflowTriggers.execute({
522547
principal: reader,
523548
input: { workflowId: versionlessWorkflowId },
524549
})
525-
expect(triggers.map((trigger) => [trigger.blockId, trigger.kind])).toEqual([
526-
['hook', 'webhook'],
527-
['sched', 'schedule'],
528-
])
550+
expect(triggers.map((trigger) => [trigger.blockId, trigger.kind])).toEqual([['hook', 'webhook']])
551+
await expect(
552+
setWorkflowTriggerEnabled.execute({
553+
principal: writer,
554+
input: {
555+
target: { kind: 'block', workflowId: versionlessWorkflowId, blockId: 'sched' },
556+
enabled: true,
557+
},
558+
})
559+
).rejects.toMatchObject({ code: 'not_found' })
560+
expect((await scheduleRow(versionlessScheduleId)).status).toBe('disabled')
561+
529562
const schedule = await setWorkflowTriggerEnabled.execute({
530563
principal: writer,
531564
input: { target: { kind: 'row', table: 'schedule', id: versionlessScheduleId }, enabled: true },
@@ -555,7 +588,11 @@ check('a block switches every live row together; a row id switches only that row
555588
[...twinWebhookIds].sort()
556589
)
557590
for (const id of twinWebhookIds) {
558-
expect(await webhookRow(id)).toMatchObject({ isActive: true, failedCount: 0 })
591+
expect(await webhookRow(id)).toMatchObject({
592+
isActive: true,
593+
failedCount: 0,
594+
providerConfig: { pollSourceFailures: 1 },
595+
})
559596
}
560597
await expect.poll(async () => (await auditRows(second)).length).toBe(1)
561598
})
@@ -644,6 +681,22 @@ check('enabling a poller clears its failures and backoff but keeps its config',
644681
const row = await webhookRow(pollingWebhookId)
645682
expect(row).toMatchObject({ isActive: true, failedCount: 0 })
646683
expect(row.providerConfig).toEqual({ labelIds: ['INBOX'] })
684+
685+
const scalar = await setBlock('scalar', true)
686+
expect(scalar.changes).toHaveLength(1)
687+
expect((await webhookRow(scalarConfigWebhookId)).providerConfig).toBe('legacy-scalar-config')
688+
})
689+
690+
check('a poll failure auto-disables at the threshold in the same write', async () => {
691+
const logger = createLogger('TriggerHealthIntegration')
692+
await markWebhookFailed(flakyWebhookId, logger)
693+
expect(await webhookRow(flakyWebhookId)).toMatchObject({ isActive: true, failedCount: 99 })
694+
await markWebhookFailed(flakyWebhookId, logger)
695+
expect(await webhookRow(flakyWebhookId)).toMatchObject({ isActive: false, failedCount: 100 })
696+
697+
await setBlock('flaky', true)
698+
await markWebhookFailed(flakyWebhookId, logger)
699+
expect(await webhookRow(flakyWebhookId)).toMatchObject({ isActive: true, failedCount: 1 })
647700
})
648701

649702
check('an unknown block is not found', async () => {

‎apps/sim/lib/workflows/application/triggers.ts‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
isInRequestedState,
2222
type LiveWorkflowTriggerRecord,
2323
listLiveWorkflowTriggers,
24+
type ScheduleLiveness,
2425
setLiveTriggersEnabled,
2526
toWorkflowTriggerHealth,
2627
type WorkflowTriggerHealth,
@@ -152,16 +153,18 @@ async function assertWebhookEnableAllowed(
152153
* Enabling resets the failure counters, re-arms a schedule's next run, and
153154
* clears a poller's backoff. Disabling only closes Sim's gate: nothing is torn
154155
* down at the provider. A request for the state the trigger is already in
155-
* changes nothing and records no audit.
156+
* changes nothing and records no audit. A disable lasts until the next deploy
157+
* or version activation, which re-registers every trigger enabled, exactly as
158+
* it already clears an automatic disable.
156159
*/
157160
export const setWorkflowTriggerEnabled = defineAuthorizedWorkflowUseCase({
158161
operation: workflowOperations.setTriggerEnabled,
159162
resolveContext: resolveTriggerTargetContext,
160163
async execute({ principal, input, context }): Promise<SetWorkflowTriggerEnabledResult> {
161-
let [, current] = await Promise.all([
162-
requireMutableWorkflow(context.workflowId),
163-
findLiveBlockTriggers(context),
164-
])
164+
// A legacy row id keeps the versionless schedules the editor shows; a block target sees only what fires.
165+
const scheduleLiveness: ScheduleLiveness = context.row ? 'editor' : 'dispatched'
166+
const readLive = () => findLiveBlockTriggers({ ...context, scheduleLiveness })
167+
let [, current] = await Promise.all([requireMutableWorkflow(context.workflowId), readLive()])
165168
if (current.length === 0) throw new OrchestrationError('not_found', context.notFoundMessage)
166169
const pending = current.filter((record) => !isInRequestedState(record, input.enabled))
167170
if (pending.length === 0) {
@@ -187,10 +190,11 @@ export const setWorkflowTriggerEnabled = defineAuthorizedWorkflowUseCase({
187190
enabled: input.enabled,
188191
schedules,
189192
webhookIds,
193+
scheduleLiveness,
190194
now: new Date(),
191195
})
192196
if (updated.length < pending.length) {
193-
current = await findLiveBlockTriggers(context)
197+
current = await readLive()
194198
if (current.length === 0) throw new OrchestrationError('not_found', context.notFoundMessage)
195199
if (current.some((record) => !isInRequestedState(record, input.enabled))) {
196200
throw new OrchestrationError('conflict', 'Trigger changed during the update; retry')

0 commit comments

Comments
 (0)