Skip to content

Commit 8b9f593

Browse files
fix(workflow, custom): billing attribution passthrough (#5657)
1 parent 3e97589 commit 8b9f593

4 files changed

Lines changed: 77 additions & 4 deletions

File tree

apps/sim/executor/execution/executor.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,9 @@ export class DAGExecutor {
427427
blockLogs: overrides?.runFromBlockContext ? [] : (snapshotState?.blockLogs ?? []),
428428
metadata: {
429429
...this.contextExtensions.metadata,
430+
...(this.contextExtensions.billingAttribution
431+
? { billingAttribution: this.contextExtensions.billingAttribution }
432+
: {}),
430433
startTime: new Date().toISOString(),
431434
duration: 0,
432435
useDraftState:

apps/sim/executor/execution/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,13 @@ export interface ContextExtensions {
168168
fileKeys?: string[]
169169
allowLargeValueWorkflowScope?: boolean
170170
userId?: string
171+
/**
172+
* Immutable actor/payer decision for this execution. Child workflow
173+
* executions receive it here (they carry no full metadata), so internal
174+
* tool calls inside the child still attach the billing attribution header.
175+
* Takes precedence over `metadata.billingAttribution` when both are set.
176+
*/
177+
billingAttribution?: BillingAttributionSnapshot
171178
stream?: boolean
172179
selectedOutputs?: string[]
173180
edges?: Array<{ source: string; target: string }>

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

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,27 @@ import {
99
import type { ExecutionContext } from '@/executor/types'
1010
import type { SerializedBlock } from '@/serializer/types'
1111

12-
const { mockExecutorExecute, mockCreateSnapshot } = vi.hoisted(() => ({
13-
mockExecutorExecute: vi.fn(),
14-
mockCreateSnapshot: vi.fn(),
15-
}))
12+
const { mockExecutorExecute, mockCreateSnapshot, mockResolveBillingAttribution, executorOptions } =
13+
vi.hoisted(() => ({
14+
mockExecutorExecute: vi.fn(),
15+
mockCreateSnapshot: vi.fn(),
16+
mockResolveBillingAttribution: vi.fn(),
17+
executorOptions: [] as Array<Record<string, any>>,
18+
}))
1619

1720
vi.mock('@/executor', () => ({
1821
Executor: class {
22+
constructor(options: Record<string, any>) {
23+
executorOptions.push(options)
24+
}
1925
execute = mockExecutorExecute
2026
},
2127
}))
2228

29+
vi.mock('@/lib/billing/core/billing-attribution', () => ({
30+
resolveBillingAttribution: mockResolveBillingAttribution,
31+
}))
32+
2333
vi.mock('@/lib/logs/execution/snapshot/service', () => ({
2434
snapshotService: { createSnapshotWithDeduplication: mockCreateSnapshot },
2535
}))
@@ -92,6 +102,7 @@ describe('WorkflowBlockHandler', () => {
92102

93103
// Reset all mocks
94104
vi.clearAllMocks()
105+
executorOptions.length = 0
95106

96107
// Setup default fetch mock
97108
mockFetch.mockResolvedValue({
@@ -273,6 +284,43 @@ describe('WorkflowBlockHandler', () => {
273284
expect(mockExecutorExecute).toHaveBeenCalledWith('child-workflow-id')
274285
})
275286

287+
it('threads the parent billing attribution into the child execution context', async () => {
288+
const billingAttribution = {
289+
actorUserId: 'actor-1',
290+
workspaceId: 'workspace-parent',
291+
organizationId: 'org-1',
292+
billedAccountUserId: 'owner-1',
293+
billingEntity: { type: 'organization', id: 'org-1' },
294+
billingPeriod: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' },
295+
payerSubscription: null,
296+
}
297+
const ctx = {
298+
...mockContext,
299+
workspaceId: 'workspace-parent',
300+
metadata: { ...mockContext.metadata, billingAttribution },
301+
} as ExecutionContext
302+
303+
mockFetch.mockResolvedValueOnce({
304+
ok: true,
305+
json: () =>
306+
Promise.resolve({
307+
data: {
308+
name: 'Child Workflow',
309+
workspaceId: 'workspace-parent',
310+
state: { blocks: {}, edges: [], loops: {}, parallels: {} },
311+
},
312+
}),
313+
})
314+
mockCreateSnapshot.mockResolvedValue({ snapshot: { id: 'snapshot-1' } })
315+
mockExecutorExecute.mockResolvedValue({ success: true, output: { data: 'ok' } })
316+
317+
await handler.execute(ctx, mockBlock, inputs)
318+
319+
expect(executorOptions).toHaveLength(1)
320+
expect(executorOptions[0].contextExtensions.billingAttribution).toBe(billingAttribution)
321+
expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
322+
})
323+
276324
it('should fail closed when the executing context has no workspace', async () => {
277325
mockFetch.mockResolvedValueOnce({
278326
ok: true,

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
33
import { generateId } from '@sim/utils/id'
4+
import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution'
45
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
56
import { buildNextCallChain, validateCallChain } from '@/lib/execution/call-chain'
67
import { calculateCostSummary } from '@/lib/logs/execution/logging-factory'
@@ -360,6 +361,7 @@ export class WorkflowBlockHandler implements BlockHandler {
360361
let childUserId = ctx.userId
361362
let childWorkspaceId = ctx.workspaceId
362363
let childEnvVarValues = ctx.environmentVariables
364+
let childBillingAttribution = ctx.metadata.billingAttribution
363365
if (isCustomBlock) {
364366
if (!loadUserId) {
365367
throw new Error('Custom block source workflow has no owner')
@@ -371,6 +373,15 @@ export class WorkflowBlockHandler implements BlockHandler {
371373
childWorkspaceId = childWorkflow.workspaceId
372374
const ownerEnv = await getPersonalAndWorkspaceEnv(loadUserId, childWorkflow.workspaceId)
373375
childEnvVarValues = { ...ownerEnv.personalDecrypted, ...ownerEnv.workspaceDecrypted }
376+
// Custom-block children authenticate internal tool calls as the source
377+
// owner in the source workspace, so the consumer's snapshot would fail
378+
// the internal routes' actor/workspace scope match. Resolve the
379+
// source-scoped payer instead — the same decision those routes made
380+
// themselves before attribution headers became required.
381+
childBillingAttribution = await resolveBillingAttribution({
382+
actorUserId: loadUserId,
383+
workspaceId: childWorkflow.workspaceId,
384+
})
374385
}
375386

376387
const subExecutor = new Executor({
@@ -388,6 +399,10 @@ export class WorkflowBlockHandler implements BlockHandler {
388399
workspaceId: childWorkspaceId,
389400
userId: childUserId,
390401
executionId: ctx.executionId,
402+
// Same-workspace children share the parent's frozen payer decision so
403+
// internal tool calls (knowledge, guardrails, MCP, Mothership) can
404+
// attach the required billing attribution header.
405+
billingAttribution: childBillingAttribution,
391406
abortSignal: ctx.abortSignal,
392407
// Propagate in-flight block-output redaction into child workflows so
393408
// nested blocks mask outputs too (recurses: each child forwards it).

0 commit comments

Comments
 (0)