Skip to content

Commit 8d2ffb4

Browse files
committed
fix(network): preserve transport and background execution behavior
1 parent 9fd04ad commit 8d2ffb4

19 files changed

Lines changed: 367 additions & 200 deletions

‎apps/docs/content/docs/platform/enterprise/security.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ Dedicated routing supports public IPv4 destinations over HTTPS on port 443. It a
2525

2626
Native database connections, AWS SDK integrations, remote sandbox traffic, and browser requests keep their existing network paths. Other provider SDKs and OAuth authorization or token refresh calls require separate coverage confirmation. Dedicated IPs do not change access permissions in connected services.
2727

28+
Google Drive, Fireflies, Google Workspace user and group discovery, and Atlassian OAuth site discovery currently use their existing network paths.
29+
2830
If dedicated routing is unavailable, affected requests fail instead of using shared IPs. Organizations without dedicated routing keep their existing behavior.
2931

3032
## Availability

‎apps/sim/background/enrichment-capability-subject.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import { resetDbChainMock } from '@sim/testing'
55
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
66

77
const mocks = vi.hoisted(() => ({
8+
loadWorkspaceApplicationContext: vi.fn(),
9+
resolveOutboundRoute: vi.fn(async (_organizationId: string | null | undefined) => ({
10+
kind: 'direct',
11+
})),
812
getTableById: vi.fn(),
913
getRowById: vi.fn(),
1014
updateRow: vi.fn(),
@@ -22,6 +26,14 @@ const mocks = vi.hoisted(() => ({
2226
loadTableRowSecretProvenance: vi.fn(async () => ({ scope: null, entries: [] })),
2327
}))
2428

29+
vi.mock('@/lib/core/network/config.server', () => ({
30+
isOutboundRoutingEnabled: () => true,
31+
resolveOutboundRoute: mocks.resolveOutboundRoute,
32+
}))
33+
vi.mock('@/lib/workspaces/application/workspace-context', () => ({
34+
loadWorkspaceApplicationContext: mocks.loadWorkspaceApplicationContext,
35+
}))
36+
2537
vi.mock('@/lib/table/service', () => ({ getTableById: mocks.getTableById }))
2638
vi.mock('@/lib/table/rows/service', () => ({
2739
getRowById: mocks.getRowById,
@@ -69,6 +81,7 @@ vi.mock('@/lib/core/rate-limiter/rate-limiter', () => ({
6981
},
7082
}))
7183

84+
import { resolveCurrentOutboundRoute } from '@/lib/core/network/context.server'
7285
import { runRowCascadeLoop } from '@/background/workflow-column-execution'
7386

7487
const GROUP = {
@@ -143,6 +156,7 @@ describe('enrichment cell capability subject', () => {
143156
beforeEach(() => {
144157
vi.clearAllMocks()
145158
resetDbChainMock()
159+
mocks.loadWorkspaceApplicationContext.mockResolvedValue({ workspaceOrganizationId: null })
146160
mocks.getTableById.mockResolvedValue(TABLE)
147161
mocks.getRowById.mockResolvedValue({
148162
id: 'row-1',
@@ -162,6 +176,24 @@ describe('enrichment cell capability subject', () => {
162176
mocks.runEnrichment.mockResolvedValue({ result: {}, cost: 0, detail: {} })
163177
})
164178

179+
it.each(['org_reserved', 'org_other', null])(
180+
'restores the current workspace owner %s before running a queued enrichment',
181+
async (organizationId) => {
182+
mocks.loadWorkspaceApplicationContext.mockResolvedValue({
183+
workspaceOrganizationId: organizationId,
184+
})
185+
mocks.runEnrichment.mockImplementationOnce(async () => {
186+
await resolveCurrentOutboundRoute()
187+
return { result: {}, cost: 0, detail: {} }
188+
})
189+
190+
await runRowCascadeLoop(payload(null, 'billing-owner') as never)
191+
192+
expect(mocks.resolveOutboundRoute).toHaveBeenCalledExactlyOnceWith(organizationId)
193+
expect(mocks.loadWorkspaceApplicationContext).toHaveBeenCalledWith('workspace-1', {})
194+
}
195+
)
196+
165197
/**
166198
* A workspace-key write is actorless: nobody's permission group governs it,
167199
* and the billing owner beside it on the payload is a bystander. Handing that

‎apps/sim/background/webhook-execution.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
2020

2121
const {
22+
mockWithResourceOutboundScope,
2223
mockResolveWebhookRecordProviderConfig,
2324
mockExecuteWorkflowCore,
2425
mockWasExecutionFinalizedByCore,
@@ -32,6 +33,7 @@ const {
3233
} = vi.hoisted(() => {
3334
const mockEnqueue = vi.fn()
3435
return {
36+
mockWithResourceOutboundScope: vi.fn(),
3537
mockResolveWebhookRecordProviderConfig: vi.fn(),
3638
mockExecuteWorkflowCore: vi.fn(),
3739
mockWasExecutionFinalizedByCore: vi.fn(),
@@ -62,6 +64,10 @@ const mockGetExecutionEnvironment = environmentUtilsMockFns.mockGetExecutionEnvi
6264

6365
afterAll(resetEnvironmentUtilsMock)
6466

67+
vi.mock('@/lib/core/network/resource-scope.server', () => ({
68+
withResourceOutboundScope: mockWithResourceOutboundScope,
69+
}))
70+
6571
vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock)
6672
vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock)
6773

@@ -285,6 +291,7 @@ describe('executeWebhookJob fault vs error handling', () => {
285291
projectDiagnosticError: loggingSessionMockFns.mockProjectDiagnosticError,
286292
}
287293
})
294+
mockWithResourceOutboundScope.mockReset().mockImplementation((_owner, run) => run())
288295
mockRefreshExecutionSlotExpiry.mockReset().mockResolvedValue(true)
289296
mockReleaseExecutionSlot.mockReset().mockResolvedValue(undefined)
290297
mockGetProviderHandler.mockReturnValue({})
@@ -856,6 +863,19 @@ describe('executeWebhookJob fault vs error handling', () => {
856863
expect(mockExecuteWorkflowCore).not.toHaveBeenCalled()
857864
})
858865

866+
it('requeues transient organization ownership failures before any workflow block starts', async () => {
867+
mockWithResourceOutboundScope.mockRejectedValueOnce(
868+
Object.assign(new Error('Connection terminated unexpectedly'), { code: 'ECONNRESET' })
869+
)
870+
871+
await expect(executeWebhookJob(payload)).resolves.toMatchObject({
872+
requeued: true,
873+
})
874+
expect(mockExecuteWorkflowCore).not.toHaveBeenCalled()
875+
expect(mockEnqueue).toHaveBeenCalledOnce()
876+
expect(loggingSessionMockFns.mockSafeCompleteWithError).not.toHaveBeenCalled()
877+
})
878+
859879
it('requeues on retryable infrastructure errors thrown by setup reads', async () => {
860880
dbChainMockFns.limit.mockRejectedValueOnce(
861881
Object.assign(new Error('write CONNECT_TIMEOUT'), { code: 'CONNECT_TIMEOUT' })

‎apps/sim/background/webhook-execution.ts‎

Lines changed: 103 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -818,21 +818,21 @@ async function executeWebhookJobInternal(
818818
throw new Error(`Workflow ${payload.workflowId} has no associated workspace`)
819819
}
820820

821-
return withResourceOutboundScope({ workspaceId }, async () => {
822-
const workflowVariables = (workflowRecord.variables as Record<string, unknown>) || {}
821+
const workflowVariables = (workflowRecord.variables as Record<string, unknown>) || {}
823822

824-
let deploymentVersionId: string | undefined
825-
/**
826-
* Flipped immediately before `executeWorkflowCore` is invoked. While false,
827-
* no block has run and no execution effect exists, so a retryable
828-
* infrastructure error may be surfaced as a `RetryableSetupError` and the
829-
* whole delivery safely re-attempted. Once true, errors are never
830-
* reclassified as retryable — retrying after the executor started could
831-
* double-run the workflow.
832-
*/
833-
let workflowCoreStarted = false
823+
let deploymentVersionId: string | undefined
824+
/**
825+
* Flipped immediately before `executeWorkflowCore` is invoked. While false,
826+
* no block has run and no execution effect exists, so a retryable
827+
* infrastructure error may be surfaced as a `RetryableSetupError` and the
828+
* whole delivery safely re-attempted. Once true, errors are never
829+
* reclassified as retryable — retrying after the executor started could
830+
* double-run the workflow.
831+
*/
832+
let workflowCoreStarted = false
834833

835-
try {
834+
try {
835+
return await withResourceOutboundScope({ workspaceId }, async () => {
836836
const workflowStatePromise = payload.deploymentVersionId
837837
? loadWorkflowDeploymentVersionState(
838838
payload.workflowId,
@@ -1175,105 +1175,105 @@ async function executeWebhookJobInternal(
11751175
executedAt: new Date().toISOString(),
11761176
provider: payload.provider,
11771177
}
1178-
} catch (error: unknown) {
1179-
const errorMessage = toError(error).message
1180-
const errorStack = error instanceof Error ? error.stack : undefined
1178+
})
1179+
} catch (error: unknown) {
1180+
const errorMessage = toError(error).message
1181+
const errorStack = error instanceof Error ? error.stack : undefined
11811182

1182-
/**
1183-
* Mirrors the schedule executor's setup boundary: an infrastructure error
1184-
* raised before the workflow core started left no execution effect, so it
1185-
* is surfaced as a `RetryableSetupError` — releasing the idempotency claim
1186-
* and, while attempts remain, requeueing without recording a terminal
1187-
* failed row for an attempt that will be retried. Exhausted retries fall
1188-
* through to normal failure handling but still throw typed so a provider
1189-
* redelivery is not rejected for a run that never happened.
1190-
*/
1191-
const retryableSetupCause =
1192-
!workflowCoreStarted && isRetryableInfrastructureError(error)
1193-
? describeRetryableInfrastructureError(error)
1194-
: undefined
1195-
if (retryableSetupCause && hasRemainingWebhookInfraRetry(payload)) {
1196-
logger.warn(`[${requestId}] Retryable setup failure before webhook workflow started`, {
1197-
workflowId: payload.workflowId,
1198-
provider: payload.provider,
1199-
cause: retryableSetupCause,
1200-
})
1201-
throw new RetryableSetupError(errorMessage, { cause: retryableSetupCause })
1202-
}
1183+
/**
1184+
* Mirrors the schedule executor's setup boundary: an infrastructure error
1185+
* raised before the workflow core started left no execution effect, so it
1186+
* is surfaced as a `RetryableSetupError` — releasing the idempotency claim
1187+
* and, while attempts remain, requeueing without recording a terminal
1188+
* failed row for an attempt that will be retried. Exhausted retries fall
1189+
* through to normal failure handling but still throw typed so a provider
1190+
* redelivery is not rejected for a run that never happened.
1191+
*/
1192+
const retryableSetupCause =
1193+
!workflowCoreStarted && isRetryableInfrastructureError(error)
1194+
? describeRetryableInfrastructureError(error)
1195+
: undefined
1196+
if (retryableSetupCause && hasRemainingWebhookInfraRetry(payload)) {
1197+
logger.warn(`[${requestId}] Retryable setup failure before webhook workflow started`, {
1198+
workflowId: payload.workflowId,
1199+
provider: payload.provider,
1200+
cause: retryableSetupCause,
1201+
})
1202+
throw new RetryableSetupError(errorMessage, { cause: retryableSetupCause })
1203+
}
12031204

1204-
logger.error(
1205-
`[${requestId}] Webhook execution failed`,
1206-
loggingSession.projectDiagnosticError(error, {
1207-
workflowId: payload.workflowId,
1208-
provider: payload.provider,
1209-
})
1210-
)
1205+
logger.error(
1206+
`[${requestId}] Webhook execution failed`,
1207+
loggingSession.projectDiagnosticError(error, {
1208+
workflowId: payload.workflowId,
1209+
provider: payload.provider,
1210+
})
1211+
)
12111212

1212-
// The finalized flag is set inside a fire-and-forget post-execution promise; await it so the
1213-
// signal is reliable and the failure is fully persisted before we decide fault vs error.
1214-
await loggingSession.waitForPostExecution()
1213+
// The finalized flag is set inside a fire-and-forget post-execution promise; await it so the
1214+
// signal is reliable and the failure is fully persisted before we decide fault vs error.
1215+
await loggingSession.waitForPostExecution()
12151216

1216-
// A failure inside workflow execution (block error, provider 4xx, missing required field, etc.)
1217-
// is finalized by core and already recorded in the execution logs. That is a user/workflow error,
1218-
// not a trigger.dev job fault — complete the run normally so we don't fire a false alert. Errors
1219-
// that were not finalized came from the webhook pipeline itself, so we re-throw to fault below.
1220-
if (wasExecutionFinalizedByCore(error, executionId)) {
1221-
return {
1222-
success: false,
1223-
workflowId: payload.workflowId,
1224-
executionId,
1225-
output: hasExecutionResult(error) ? error.executionResult.output : {},
1226-
executedAt: new Date().toISOString(),
1227-
provider: payload.provider,
1228-
}
1217+
// A failure inside workflow execution (block error, provider 4xx, missing required field, etc.)
1218+
// is finalized by core and already recorded in the execution logs. That is a user/workflow error,
1219+
// not a trigger.dev job fault — complete the run normally so we don't fire a false alert. Errors
1220+
// that were not finalized came from the webhook pipeline itself, so we re-throw to fault below.
1221+
if (wasExecutionFinalizedByCore(error, executionId)) {
1222+
return {
1223+
success: false,
1224+
workflowId: payload.workflowId,
1225+
executionId,
1226+
output: hasExecutionResult(error) ? error.executionResult.output : {},
1227+
executedAt: new Date().toISOString(),
1228+
provider: payload.provider,
12291229
}
1230+
}
12301231

1231-
try {
1232-
await loggingSession.safeStart({
1233-
userId: actorUserId,
1234-
actorUserId,
1235-
billingAttribution,
1236-
workspaceId,
1237-
variables: {},
1238-
triggerData: {
1239-
isTest: false,
1240-
correlation,
1241-
},
1242-
deploymentVersionId,
1243-
})
1244-
1245-
const executionResult = hasExecutionResult(error)
1246-
? error.executionResult
1247-
: {
1248-
success: false,
1249-
output: {},
1250-
logs: [],
1251-
}
1252-
const { traceSpans } = buildTraceSpans(executionResult)
1232+
try {
1233+
await loggingSession.safeStart({
1234+
userId: actorUserId,
1235+
actorUserId,
1236+
billingAttribution,
1237+
workspaceId,
1238+
variables: {},
1239+
triggerData: {
1240+
isTest: false,
1241+
correlation,
1242+
},
1243+
deploymentVersionId,
1244+
})
12531245

1254-
await loggingSession.safeCompleteWithError({
1255-
endedAt: new Date().toISOString(),
1256-
totalDurationMs: 0,
1257-
error: {
1258-
message: errorMessage || 'Webhook execution failed',
1259-
stackTrace: errorStack,
1260-
},
1261-
traceSpans,
1262-
executionState: executionResult.executionState,
1263-
})
1264-
} catch (loggingError) {
1265-
logger.error(
1266-
`[${requestId}] Failed to complete logging session`,
1267-
loggingSession.projectDiagnosticError(loggingError)
1268-
)
1269-
}
1246+
const executionResult = hasExecutionResult(error)
1247+
? error.executionResult
1248+
: {
1249+
success: false,
1250+
output: {},
1251+
logs: [],
1252+
}
1253+
const { traceSpans } = buildTraceSpans(executionResult)
1254+
1255+
await loggingSession.safeCompleteWithError({
1256+
endedAt: new Date().toISOString(),
1257+
totalDurationMs: 0,
1258+
error: {
1259+
message: errorMessage || 'Webhook execution failed',
1260+
stackTrace: errorStack,
1261+
},
1262+
traceSpans,
1263+
executionState: executionResult.executionState,
1264+
})
1265+
} catch (loggingError) {
1266+
logger.error(
1267+
`[${requestId}] Failed to complete logging session`,
1268+
loggingSession.projectDiagnosticError(loggingError)
1269+
)
1270+
}
12701271

1271-
if (retryableSetupCause) {
1272-
throw new RetryableSetupError(errorMessage, { cause: retryableSetupCause })
1273-
}
1274-
throw error
1272+
if (retryableSetupCause) {
1273+
throw new RetryableSetupError(errorMessage, { cause: retryableSetupCause })
12751274
}
1276-
})
1275+
throw error
1276+
}
12771277
}
12781278

12791279
export const webhookExecution = task({

0 commit comments

Comments
 (0)