Skip to content

Commit 53518d0

Browse files
icecrasher321claude
andcommitted
feat(webhooks): prepareSyncDispatch provider hook on the ingest path
Optional provider hook invoked after event filters, the deployment-block check, and admission — immediately before the execution payload is assembled — for work that must happen synchronously at ingest (e.g. opening a Slack loading modal inside the 3-second trigger_id window). The result rides the payload as syncInteraction (identifiers only, never token material) and surfaces to formatInput. A hook failure or throw never blocks dispatch. All three Slack ingest doors converge on this call site; no route changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0f99b4e commit 53518d0

3 files changed

Lines changed: 141 additions & 1 deletion

File tree

apps/sim/lib/webhooks/processor.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,80 @@ describe('webhook processor execution identity', () => {
568568
})
569569
})
570570

571+
describe('webhook processor prepareSyncDispatch', () => {
572+
beforeEach(() => {
573+
vi.clearAllMocks()
574+
mockPreprocessExecution.mockResolvedValue({
575+
success: true,
576+
actorUserId: 'actor-user-1',
577+
billingAttribution,
578+
executionTimeout: { sync: 0, async: 120_000 },
579+
})
580+
mockEnqueue.mockResolvedValue('job-1')
581+
mockGetInlineJobQueue.mockResolvedValue({ enqueue: mockEnqueue })
582+
mockGetJobQueue.mockResolvedValue({ enqueue: mockEnqueue })
583+
mockProviderHandler.current = {}
584+
mockShouldExecuteInline.mockReturnValue(false)
585+
mockGenerateId.mockReturnValue('generated-execution-id')
586+
workflowsPersistenceUtilsMockFns.mockBlockExistsInDeployment.mockResolvedValue(true)
587+
})
588+
589+
const dispatch = () =>
590+
dispatchResolvedWebhookTarget(
591+
makeWebhookRecord({
592+
path: 'incoming/slack',
593+
provider: 'slack',
594+
providerConfig: { openLoadingModal: true },
595+
}),
596+
makeWorkflowRecord({}),
597+
{ type: 'block_actions', trigger_id: 'trigger-1' },
598+
createMockRequest('POST', { type: 'block_actions' }) as NextRequest,
599+
{ requestId: 'request-1', path: 'incoming/slack' }
600+
)
601+
602+
it('puts the hook result on the enqueued payload', async () => {
603+
const prepareSyncDispatch = vi
604+
.fn()
605+
.mockResolvedValue({ syncInteraction: { loadingViewId: 'V1' } })
606+
mockProviderHandler.current = { prepareSyncDispatch }
607+
608+
const result = await dispatch()
609+
610+
expect(result.outcome).toBe('queued')
611+
expect(prepareSyncDispatch).toHaveBeenCalledWith(
612+
expect.objectContaining({
613+
body: { type: 'block_actions', trigger_id: 'trigger-1' },
614+
workflow: expect.objectContaining({ id: 'workflow-1' }),
615+
providerConfig: { openLoadingModal: true },
616+
requestId: 'request-1',
617+
})
618+
)
619+
expect(mockEnqueue.mock.calls[0]?.[1]).toMatchObject({
620+
syncInteraction: { loadingViewId: 'V1' },
621+
})
622+
})
623+
624+
it('omits syncInteraction when the hook resolves null', async () => {
625+
mockProviderHandler.current = { prepareSyncDispatch: vi.fn().mockResolvedValue(null) }
626+
627+
const result = await dispatch()
628+
629+
expect(result.outcome).toBe('queued')
630+
expect(mockEnqueue.mock.calls[0]?.[1]).not.toHaveProperty('syncInteraction')
631+
})
632+
633+
it('still queues when the hook throws', async () => {
634+
mockProviderHandler.current = {
635+
prepareSyncDispatch: vi.fn().mockRejectedValue(new Error('slack down')),
636+
}
637+
638+
const result = await dispatch()
639+
640+
expect(result.outcome).toBe('queued')
641+
expect(mockEnqueue.mock.calls[0]?.[1]).not.toHaveProperty('syncInteraction')
642+
})
643+
})
644+
571645
describe('polled webhook reservation ownership', () => {
572646
const foundWebhook = {
573647
id: 'webhook-1',

apps/sim/lib/webhooks/processor.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
requiresPendingWebhookVerification,
3333
} from '@/lib/webhooks/pending-verification'
3434
import { getProviderHandler } from '@/lib/webhooks/providers'
35-
import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types'
35+
import type { SyncInteractionContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types'
3636
import { normalizeWebhookRegistrationPath } from '@/lib/webhooks/registration-identity'
3737
import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils'
3838
import { SIM_TRIGGER_PROVIDER } from '@/lib/workspace-events/constants'
@@ -723,6 +723,35 @@ async function queueWebhookExecutionWithResult(
723723
provider: foundWebhook.provider,
724724
triggerType: 'webhook',
725725
} satisfies AsyncExecutionCorrelation)
726+
727+
/**
728+
* Runs after event filters, the deployment-block check, and admission — so a
729+
* filtered or rejected delivery never produces a side effect — and before the
730+
* payload is assembled so the result rides into execution. A hook failure
731+
* never blocks dispatch.
732+
*/
733+
let syncInteraction: SyncInteractionContext | undefined
734+
if (handler.prepareSyncDispatch) {
735+
try {
736+
const prepared = await handler.prepareSyncDispatch({
737+
webhook: foundWebhook,
738+
workflow: {
739+
id: foundWorkflow.id,
740+
userId: foundWorkflow.userId,
741+
workspaceId: foundWorkflow.workspaceId,
742+
},
743+
body,
744+
requestId: options.requestId,
745+
providerConfig,
746+
})
747+
syncInteraction = prepared?.syncInteraction
748+
} catch (error) {
749+
logger.warn(`[${options.requestId}] prepareSyncDispatch failed; continuing dispatch`, {
750+
provider: foundWebhook.provider,
751+
error: toError(error).message,
752+
})
753+
}
754+
}
726755
const payload = {
727756
webhookId: foundWebhook.id,
728757
workflowId: foundWorkflow.id,
@@ -750,6 +779,7 @@ async function queueWebhookExecutionWithResult(
750779
...(options.executionTimeoutMs !== undefined
751780
? { executionTimeoutMs: options.executionTimeoutMs }
752781
: {}),
782+
...(syncInteraction ? { syncInteraction } : {}),
753783
} satisfies WebhookExecutionPayload
754784

755785
const shouldUseQueue = shouldUseDurableQueue(payload.provider, handler)

apps/sim/lib/webhooks/providers/types.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export interface FormatInputContext {
4444
* credential → account → owner chain; absent on paths that never resolved it.
4545
*/
4646
credentialOwnerUserId?: string
47+
/** Interaction context created synchronously at ingest (see {@link SyncInteractionContext}). */
48+
syncInteraction?: SyncInteractionContext
4749
}
4850

4951
/** Result of custom input preparation. */
@@ -52,6 +54,31 @@ export interface FormatInputResult {
5254
skip?: { message: string }
5355
}
5456

57+
/**
58+
* Interaction context created synchronously at ingest before enqueue, e.g. a
59+
* Slack loading modal opened while the interaction's trigger_id was still
60+
* fresh. Persisted with the job payload, so it carries identifiers only —
61+
* never token material.
62+
*/
63+
export interface SyncInteractionContext {
64+
/** Slack view id of the loading modal opened via views.open on the ingest path. */
65+
loadingViewId: string
66+
}
67+
68+
/** Context for synchronous pre-enqueue dispatch preparation. */
69+
export interface PrepareSyncDispatchContext {
70+
webhook: Record<string, unknown>
71+
workflow: { id: string; userId: string; workspaceId?: string | null }
72+
body: unknown
73+
requestId: string
74+
providerConfig: Record<string, unknown>
75+
}
76+
77+
/** Result of synchronous pre-enqueue dispatch preparation. */
78+
export interface PrepareSyncDispatchResult {
79+
syncInteraction?: SyncInteractionContext
80+
}
81+
5582
/** Context for provider-specific file processing before execution. */
5683
export interface ProcessFilesContext {
5784
input: Record<string, unknown>
@@ -171,6 +198,15 @@ export interface WebhookProviderHandler {
171198
/** Custom error response when queuing fails. Return null for default 500. */
172199
formatQueueErrorResponse?(): NextResponse | null
173200

201+
/**
202+
* Provider work that must run synchronously on the ingest path after admission
203+
* and immediately before the execution is enqueued — e.g. opening a Slack
204+
* loading modal while the interaction's 3-second trigger_id is still fresh.
205+
* Implementations must swallow provider-side failures and return null; the
206+
* processor additionally guards the call so a throw can never fail dispatch.
207+
*/
208+
prepareSyncDispatch?(ctx: PrepareSyncDispatchContext): Promise<PrepareSyncDispatchResult | null>
209+
174210
/** Custom input preparation. When defined, replaces the default pass-through of the raw body. */
175211
formatInput?(ctx: FormatInputContext): Promise<FormatInputResult>
176212

0 commit comments

Comments
 (0)