Skip to content

Commit dc027f2

Browse files
fix(workflows): resume legacy queued jobs
1 parent 58c667c commit dc027f2

4 files changed

Lines changed: 212 additions & 12 deletions

File tree

apps/sim/background/async-preprocessing-correlation.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,98 @@ describe('async preprocessing correlation threading', () => {
227227
)
228228
})
229229

230+
it('restores a legacy authenticated workflow job as its recorded user actor', async () => {
231+
mockPreprocessExecution.mockResolvedValueOnce({
232+
success: true,
233+
actorUserId: 'actor-1',
234+
workflowRecord: {
235+
id: 'workflow-1',
236+
userId: 'owner-1',
237+
workspaceId: 'workspace-1',
238+
variables: {},
239+
},
240+
billingAttribution,
241+
executionTimeout: {},
242+
})
243+
mockExecuteWorkflowCore.mockResolvedValueOnce({
244+
success: true,
245+
status: 'success',
246+
output: { ok: true },
247+
metadata: { duration: 10, userId: 'actor-1' },
248+
})
249+
250+
await executeWorkflowJob({
251+
workflowId: 'workflow-1',
252+
userId: 'actor-1',
253+
workspaceId: 'workspace-1',
254+
billingAttribution,
255+
triggerType: 'api',
256+
executionId: 'legacy-user-execution',
257+
requestId: 'legacy-user-request',
258+
enforceCredentialAccess: true,
259+
})
260+
261+
expect(mockExecutionSnapshot).toHaveBeenCalledWith(
262+
expect.objectContaining({
263+
principal: {
264+
kind: 'session',
265+
userId: 'actor-1',
266+
sessionId: 'legacy-queued-workflow',
267+
},
268+
}),
269+
expect.anything(),
270+
undefined,
271+
expect.any(Object),
272+
expect.any(Array)
273+
)
274+
})
275+
276+
it('restores an identity-ambiguous legacy workflow job as actorless', async () => {
277+
mockPreprocessExecution.mockResolvedValueOnce({
278+
success: true,
279+
actorUserId: 'actor-1',
280+
workflowRecord: {
281+
id: 'workflow-1',
282+
userId: 'owner-1',
283+
workspaceId: 'workspace-1',
284+
variables: {},
285+
},
286+
billingAttribution,
287+
executionTimeout: {},
288+
})
289+
mockExecuteWorkflowCore.mockResolvedValueOnce({
290+
success: true,
291+
status: 'success',
292+
output: { ok: true },
293+
metadata: { duration: 10, userId: 'actor-1' },
294+
})
295+
296+
await executeWorkflowJob({
297+
workflowId: 'workflow-1',
298+
userId: 'actor-1',
299+
workspaceId: 'workspace-1',
300+
billingAttribution,
301+
triggerType: 'api',
302+
executionId: 'legacy-actorless-execution',
303+
requestId: 'legacy-actorless-request',
304+
})
305+
306+
expect(mockExecutionSnapshot).toHaveBeenCalledWith(
307+
expect.objectContaining({
308+
principal: {
309+
kind: 'system',
310+
serviceId: 'internal',
311+
workspaceId: 'workspace-1',
312+
workflowId: 'workflow-1',
313+
},
314+
}),
315+
expect.anything(),
316+
undefined,
317+
expect.any(Object),
318+
expect.any(Array)
319+
)
320+
})
321+
230322
it('passes validated workflow input provenance from the queued payload into core execution', async () => {
231323
const provenance = {
232324
version: 1 as const,

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

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const {
2525
mockLoadDeploymentVersionState,
2626
mockGetProviderHandler,
2727
mockSetResolvedSecretTraceRegistry,
28+
mockExecutionSnapshot,
2829
} = vi.hoisted(() => ({
2930
mockResolveWebhookRecordProviderConfig: vi.fn(),
3031
mockExecuteWorkflowCore: vi.fn(),
@@ -34,6 +35,7 @@ const {
3435
mockReleaseExecutionSlot: vi.fn(),
3536
mockGetProviderHandler: vi.fn(() => ({})),
3637
mockSetResolvedSecretTraceRegistry: vi.fn(),
38+
mockExecutionSnapshot: vi.fn(),
3739
mockLoadDeploymentVersionState: vi.fn(
3840
async (_workflowId: string, deploymentVersionId: string) => ({
3941
blocks: {},
@@ -120,7 +122,7 @@ vi.mock('@/lib/oauth/credential-service', () => ({
120122
}))
121123

122124
vi.mock('@/executor/execution/snapshot', () => ({
123-
ExecutionSnapshot: class {},
125+
ExecutionSnapshot: mockExecutionSnapshot,
124126
}))
125127

126128
vi.mock('@/tools/safe-assign', () => ({ safeAssign: vi.fn() }))
@@ -281,6 +283,55 @@ describe('executeWebhookJob fault vs error handling', () => {
281283
dbChainMockFns.limit.mockResolvedValue([{ id: 'webhook-1' }])
282284
})
283285

286+
it('restores a legacy queued webhook as its canonical system principal', async () => {
287+
const legacyPayload = {
288+
webhookId: payload.webhookId,
289+
workflowId: payload.workflowId,
290+
userId: payload.userId,
291+
billingAttribution: payload.billingAttribution,
292+
executionId: payload.executionId,
293+
requestId: payload.requestId,
294+
provider: payload.provider,
295+
body: payload.body,
296+
headers: payload.headers,
297+
path: payload.path,
298+
workspaceId: payload.workspaceId,
299+
}
300+
mockExecuteWorkflowCore.mockResolvedValueOnce({
301+
success: true,
302+
status: 'completed',
303+
output: {},
304+
logs: [],
305+
executionState: {
306+
blockStates: {},
307+
executedBlocks: [],
308+
blockLogs: [],
309+
decisions: {},
310+
completedLoops: [],
311+
activeExecutionPath: [],
312+
},
313+
})
314+
315+
await executeWebhookJob(legacyPayload)
316+
317+
expect(mockExecutionSnapshot).toHaveBeenCalledWith(
318+
expect.objectContaining({
319+
principal: {
320+
kind: 'system',
321+
serviceId: 'webhook',
322+
webhookId: 'webhook-1',
323+
workflowId: 'workflow-1',
324+
workspaceId: 'workspace-1',
325+
provider: 'gmail',
326+
},
327+
}),
328+
expect.anything(),
329+
expect.anything(),
330+
expect.any(Object),
331+
expect.any(Array)
332+
)
333+
})
334+
284335
it('completes the run (does not throw) when the failure was finalized by core', async () => {
285336
mockExecuteWorkflowCore.mockRejectedValue(
286337
new Error('Gmail 2 is missing required fields: Label')

apps/sim/background/webhook-execution.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,10 @@ import {
4949
resolveWebhookRecordProviderConfig,
5050
type WebhookEnvResolutionOptions,
5151
} from '@/lib/webhooks/env-resolver'
52-
import { assertWebhookExecutionPrincipal } from '@/lib/webhooks/execution-principal'
52+
import {
53+
assertWebhookExecutionPrincipal,
54+
createWebhookExecutionPrincipal,
55+
} from '@/lib/webhooks/execution-principal'
5356
import { getProviderHandler } from '@/lib/webhooks/providers'
5457
import {
5558
executeWorkflowCore,
@@ -165,7 +168,7 @@ function normalizeWebhookAttachments(value: unknown): WebhookAttachment[] {
165168
}
166169

167170
export function buildWebhookCorrelation(
168-
payload: WebhookExecutionPayload
171+
payload: WebhookExecutionJobPayload
169172
): AsyncExecutionCorrelation {
170173
const executionId = payload.executionId || generateId()
171174
const requestId = payload.requestId || payload.correlation?.requestId || executionId.slice(0, 8)
@@ -298,8 +301,26 @@ export type WebhookExecutionPayload = {
298301
executionTimeoutMs?: number
299302
}
300303

304+
type LegacyWebhookExecutionPayload = Omit<WebhookExecutionPayload, 'principal'> & {
305+
/** Jobs queued before execution principals were introduced omit this field. */
306+
principal?: undefined
307+
}
308+
309+
type WebhookExecutionJobPayload = WebhookExecutionPayload | LegacyWebhookExecutionPayload
310+
311+
/** Reconstructs the exact system authority recorded by the pre-principal webhook payload. */
312+
function parseWebhookJobPrincipal(payload: WebhookExecutionJobPayload): WorkflowExecutionPrincipal {
313+
if (payload.principal !== undefined) return parsePrincipal(payload.principal)
314+
return createWebhookExecutionPrincipal({
315+
webhookId: payload.webhookId,
316+
workflowId: payload.workflowId,
317+
workspaceId: payload.workspaceId,
318+
provider: payload.provider,
319+
})
320+
}
321+
301322
export async function executeWebhookJob(
302-
payload: WebhookExecutionPayload,
323+
payload: WebhookExecutionJobPayload,
303324
externalAbortSignal?: AbortSignal
304325
) {
305326
const correlation = buildWebhookCorrelation(payload)
@@ -308,7 +329,7 @@ export async function executeWebhookJob(
308329
let payloadBillingAttribution: BillingAttributionSnapshot
309330
let principal: WorkflowExecutionPrincipal
310331
try {
311-
principal = parsePrincipal(payload.principal)
332+
principal = parseWebhookJobPrincipal(payload)
312333
assertWebhookExecutionPrincipal(principal, payload)
313334
payloadBillingAttribution = assertBillingAttributionSnapshot(payload.billingAttribution)
314335
if (
@@ -490,7 +511,7 @@ async function handleExecutionResult(
490511
}
491512

492513
async function executeWebhookJobInternal(
493-
payload: WebhookExecutionPayload,
514+
payload: WebhookExecutionJobPayload,
494515
principal: WorkflowExecutionPrincipal,
495516
correlation: AsyncExecutionCorrelation,
496517
timeoutController: ReturnType<typeof createTimeoutAbortController>,
@@ -933,6 +954,6 @@ export const webhookExecution = task({
933954
queue: {
934955
concurrencyLimit: WEBHOOK_EXECUTION_CONCURRENCY_LIMIT,
935956
},
936-
run: async (payload: WebhookExecutionPayload, { signal }: { signal: AbortSignal }) =>
957+
run: async (payload: WebhookExecutionJobPayload, { signal }: { signal: AbortSignal }) =>
937958
executeWebhookJob(payload, signal),
938959
})

apps/sim/background/workflow-execution.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { parsePrincipal, type SerializedPrincipalV1 } from '@sim/auth/principal'
1+
import {
2+
parsePrincipal,
3+
type SerializedPrincipalV1,
4+
type WorkflowExecutionPrincipal,
5+
} from '@sim/auth/principal'
26
import { createLogger, runWithRequestContext } from '@sim/logger'
37
import { toError } from '@sim/utils/errors'
48
import { generateId } from '@sim/utils/id'
@@ -38,9 +42,10 @@ import type { ResolvedSecretTraceProvenanceV1 } from '@/executor/utils/resolved-
3842
import type { CoreTriggerType } from '@/stores/logs/filters/types'
3943

4044
const logger = createLogger('TriggerWorkflowExecution')
45+
const LEGACY_WORKFLOW_JOB_SESSION_ID = 'legacy-queued-workflow'
4146

4247
export function buildWorkflowCorrelation(
43-
payload: WorkflowExecutionPayload
48+
payload: WorkflowExecutionJobPayload
4449
): AsyncExecutionCorrelation {
4550
const executionId = payload.executionId || generateId()
4651
const requestId = payload.requestId || payload.correlation?.requestId || executionId.slice(0, 8)
@@ -88,13 +93,44 @@ export type WorkflowExecutionPayload = {
8893
isPublicApiAccess?: boolean
8994
}
9095

96+
type LegacyWorkflowExecutionPayload = Omit<WorkflowExecutionPayload, 'principal'> & {
97+
/** Jobs queued before execution principals were introduced omit this field. */
98+
principal?: undefined
99+
}
100+
101+
type WorkflowExecutionJobPayload = WorkflowExecutionPayload | LegacyWorkflowExecutionPayload
102+
103+
function requireLegacyWorkflowJobString(value: unknown, field: string): string {
104+
if (typeof value !== 'string' || !value.trim()) {
105+
throw new Error(`Legacy workflow job ${field} must be a non-empty string`)
106+
}
107+
return value
108+
}
109+
110+
/** Restores only the actor decision recorded by the pre-principal queue payload. */
111+
function parseWorkflowJobPrincipal(
112+
payload: WorkflowExecutionJobPayload
113+
): WorkflowExecutionPrincipal {
114+
if (payload.principal !== undefined) return parsePrincipal(payload.principal)
115+
const workflowId = requireLegacyWorkflowJobString(payload.workflowId, 'workflowId')
116+
const workspaceId = requireLegacyWorkflowJobString(payload.workspaceId, 'workspaceId')
117+
if (payload.enforceCredentialAccess === true) {
118+
return {
119+
kind: 'session',
120+
userId: requireLegacyWorkflowJobString(payload.userId, 'userId'),
121+
sessionId: LEGACY_WORKFLOW_JOB_SESSION_ID,
122+
}
123+
}
124+
return { kind: 'system', serviceId: 'internal', workspaceId, workflowId }
125+
}
126+
91127
/**
92128
* Background workflow execution job
93129
* @see preprocessExecution For detailed information on preprocessing checks
94130
* @see executeWorkflowCore For the core workflow execution logic
95131
*/
96132
export async function executeWorkflowJob(
97-
payload: WorkflowExecutionPayload,
133+
payload: WorkflowExecutionJobPayload,
98134
externalAbortSignal?: AbortSignal
99135
) {
100136
const workflowId = payload.workflowId
@@ -104,7 +140,7 @@ export async function executeWorkflowJob(
104140
let principal
105141
let billingAttribution: BillingAttributionSnapshot
106142
try {
107-
principal = parsePrincipal(payload.principal)
143+
principal = parseWorkflowJobPrincipal(payload)
108144
billingAttribution = assertBillingAttributionSnapshot(payload.billingAttribution)
109145
if (
110146
billingAttribution.actorUserId !== payload.userId ||
@@ -309,5 +345,5 @@ export const workflowExecutionTask = task({
309345
queue: {
310346
concurrencyLimit: WORKFLOW_EXECUTION_CONCURRENCY_LIMIT,
311347
},
312-
run: (payload: WorkflowExecutionPayload, { signal }) => executeWorkflowJob(payload, signal),
348+
run: (payload: WorkflowExecutionJobPayload, { signal }) => executeWorkflowJob(payload, signal),
313349
})

0 commit comments

Comments
 (0)