Skip to content

Commit 6a16bfa

Browse files
committed
fix(workflows): preserve concurrent binding and tool failure evidence
1 parent 8a6bd23 commit 6a16bfa

7 files changed

Lines changed: 457 additions & 92 deletions

File tree

‎apps/sim/executor/utils/run-from-block-preview.test.ts‎

Lines changed: 102 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,21 @@
1-
import { describe, expect, it } from 'vitest'
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { DAGExecutor } from '@/executor/execution/executor'
3+
import { ExecutionState } from '@/executor/execution/state'
24
import type { SerializableExecutionState } from '@/executor/execution/types'
35
import { previewRunFromBlock } from '@/executor/utils/run-from-block-preview'
6+
import {
7+
buildClonedSubflowId,
8+
buildParallelSentinelStartId,
9+
buildSentinelStartId,
10+
} from '@/executor/utils/subflow-utils'
411
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
512

13+
vi.mock('@/executor/handlers/registry', () => ({
14+
createBlockHandlers: () => [
15+
{ canHandle: () => true, execute: async () => ({ result: 'value' }) },
16+
],
17+
}))
18+
619
function block(id: string, type = 'function'): SerializedBlock {
720
return {
821
id,
@@ -52,6 +65,26 @@ function workflow(): SerializedWorkflow {
5265
}
5366
}
5467

68+
function containerWorkflow(kind: 'loop' | 'parallel'): SerializedWorkflow {
69+
return {
70+
version: '1',
71+
blocks: [block('start', 'starter'), block('container', kind), block('inner'), block('after')],
72+
connections: [
73+
{ source: 'start', target: 'container' },
74+
{ source: 'container', target: 'inner', sourceHandle: `${kind}-start-source` },
75+
{ source: 'container', target: 'after', sourceHandle: `${kind}-end-source` },
76+
],
77+
loops:
78+
kind === 'loop'
79+
? { container: { id: 'container', nodes: ['inner'], iterations: 1, loopType: 'for' } }
80+
: {},
81+
parallels:
82+
kind === 'parallel'
83+
? { container: { id: 'container', nodes: ['inner'], count: 1, parallelType: 'count' } }
84+
: {},
85+
}
86+
}
87+
5588
describe('partial-run preview with the real DAG builder', () => {
5689
it('includes rerun candidates and sibling cached dependencies without exposing values or mutating state', () => {
5790
const graph = workflow()
@@ -140,26 +173,7 @@ describe('partial-run preview with the real DAG builder', () => {
140173
it.each(['loop', 'parallel'] as const)(
141174
'projects %s sentinels to containers and refuses interior starts',
142175
(kind) => {
143-
const graph: SerializedWorkflow = {
144-
version: '1',
145-
blocks: [
146-
block('start', 'starter'),
147-
block('container', kind),
148-
block('inner'),
149-
block('after'),
150-
],
151-
connections: [
152-
{ source: 'start', target: 'container' },
153-
{ source: 'container', target: 'inner', sourceHandle: `${kind}-start-source` },
154-
{ source: 'container', target: 'after', sourceHandle: `${kind}-end-source` },
155-
],
156-
loops:
157-
kind === 'loop'
158-
? { container: { id: 'container', nodes: ['inner'], iterations: 2 } }
159-
: {},
160-
parallels:
161-
kind === 'parallel' ? { container: { id: 'container', nodes: ['inner'], count: 2 } } : {},
162-
}
176+
const graph = containerWorkflow(kind)
163177
const result = previewRunFromBlock(graph, 'container', snapshot(['start']))
164178
expect(result.validation.valid).toBe(true)
165179
expect(result.rerunBlocks.map((item) => item.blockId)).toEqual([
@@ -182,6 +196,73 @@ describe('partial-run preview with the real DAG builder', () => {
182196
}
183197
)
184198

199+
it.each(['loop', 'parallel'] as const)(
200+
'distinguishes %s control state from a completed container output in real snapshots',
201+
async (kind) => {
202+
const graph = containerWorkflow(kind)
203+
const sentinelStartId =
204+
kind === 'loop'
205+
? buildSentinelStartId('container')
206+
: buildParallelSentinelStartId('container')
207+
const makeExecutor = (stopAfterBlockId?: string) =>
208+
new DAGExecutor({
209+
workflow: graph,
210+
contextExtensions: {
211+
workspaceId: 'workspace-test',
212+
executionId: 'execution-test',
213+
principal: { kind: 'session', userId: 'user-test', sessionId: 'session-test' },
214+
stopAfterBlockId,
215+
},
216+
})
217+
const partial = await makeExecutor(sentinelStartId).execute('workflow-test')
218+
const partialState = partial.executionState!
219+
expect(partialState.blockStates[sentinelStartId].output).toEqual({ sentinelStart: true })
220+
expect(partialState.blockStates.container).toBeUndefined()
221+
expect(
222+
previewRunFromBlock(graph, 'after', partialState).upstreamBlocks.find(
223+
(item) => item.blockId === 'container'
224+
)?.hasCachedOutput
225+
).toBe(false)
226+
227+
const complete = await makeExecutor().execute('workflow-test')
228+
const completeState = complete.executionState!
229+
expect(completeState.blockStates.container.output).toHaveProperty('results')
230+
expect(
231+
previewRunFromBlock(graph, 'after', completeState).upstreamBlocks.find(
232+
(item) => item.blockId === 'container'
233+
)
234+
).toMatchObject({ executedInSource: true, hasCachedOutput: true })
235+
}
236+
)
237+
238+
it.each(['loop', 'parallel'] as const)(
239+
'recognizes cloned %s aggregate outputs without mistaking cloned control state for them',
240+
(kind) => {
241+
const containerId = buildClonedSubflowId('container', 2)
242+
const consumerId = buildClonedSubflowId('consumer', 2)
243+
const sentinelId =
244+
kind === 'loop'
245+
? buildSentinelStartId(containerId)
246+
: buildParallelSentinelStartId(containerId)
247+
const state = new ExecutionState()
248+
state.setBlockOutput(sentinelId, { sentinelStart: true })
249+
const preview = () =>
250+
previewRunFromBlock(containerWorkflow(kind), 'after', {
251+
...snapshot(),
252+
blockStates: Object.fromEntries(state.getBlockStates()),
253+
executedBlocks: [...state.getExecutedBlocks()],
254+
}).upstreamBlocks.find((item) => item.blockId === 'container')
255+
256+
expect(state.getBlockOutput('container', consumerId)).toBeUndefined()
257+
expect(preview()?.hasCachedOutput).toBe(false)
258+
259+
const output = { results: [[{ result: 'value' }]] }
260+
state.setBlockOutput(containerId, output)
261+
expect(state.getBlockOutput('container', consumerId)).toEqual(output)
262+
expect(preview()).toMatchObject({ executedInSource: true, hasCachedOutput: true })
263+
}
264+
)
265+
185266
it('includes both conditional branches as candidates', () => {
186267
const graph: SerializedWorkflow = {
187268
version: '1',

‎apps/sim/lib/workflows/application/replace-workflow-state.test.ts‎

Lines changed: 140 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { db } from '@sim/db'
12
import { WorkflowLockedError } from '@sim/platform-authz/workflow'
2-
import { workflowAuthzMockFns } from '@sim/testing'
3+
import { dbChainMockFns, resetDbChainMock, workflowAuthzMockFns } from '@sim/testing'
34
import { beforeEach, describe, expect, it, vi } from 'vitest'
45

56
const mocks = vi.hoisted(() => ({
@@ -8,12 +9,17 @@ const mocks = vi.hoisted(() => ({
89
resolvePermission: vi.fn(),
910
notify: vi.fn(),
1011
replace: vi.fn(),
12+
replacementResult: vi.fn(),
13+
resolvedState: vi.fn(),
1114
prepare: vi.fn(),
1215
collectGraphIds: vi.fn(),
1316
assertIdsUnclaimed: vi.fn(),
1417
validate: vi.fn(),
1518
needsRedeployment: vi.fn(),
1619
loadNormalized: vi.fn(),
20+
saveNormalized: vi.fn(),
21+
extractCustomTools: vi.fn(),
22+
admitBlockTypes: vi.fn(),
1723
}))
1824

1925
vi.mock('@sim/audit', () => ({
@@ -52,11 +58,21 @@ vi.mock('@/lib/workflows/deployment-status', () => ({
5258
}))
5359
vi.mock('@/lib/workflows/persistence/utils', () => ({
5460
loadWorkflowFromNormalizedTables: mocks.loadNormalized,
61+
saveWorkflowToNormalizedTables: mocks.saveNormalized,
62+
}))
63+
64+
vi.mock('@/lib/workflows/persistence/block-access-guard', () => ({
65+
assertNoWithheldBlockType: mocks.admitBlockTypes,
66+
}))
67+
68+
vi.mock('@/lib/workflows/persistence/custom-tools-persistence', () => ({
69+
extractAndPersistCustomTools: mocks.extractCustomTools,
5570
}))
5671

5772
import { OrchestrationError } from '@/lib/core/orchestration/types'
5873
import { replaceWorkflowState } from '@/lib/workflows/application/replace-workflow-state'
5974
import { validateInputsForBlock } from '@/lib/workflows/editing/validation'
75+
import type { ReplaceWorkflowNormalizedStateInput } from '@/lib/workflows/persistence/replace-normalized-state'
6076
import { AgentBlock } from '@/blocks/blocks/agent'
6177
import { ExaBlock } from '@/blocks/blocks/exa'
6278
import { getBlock } from '@/blocks/registry'
@@ -97,9 +113,14 @@ describe('replaceWorkflowState', () => {
97113
)
98114
mocks.resolveContext.mockResolvedValue(context)
99115
mocks.resolvePermission.mockResolvedValue('write')
116+
mocks.admitBlockTypes.mockResolvedValue(undefined)
100117
workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined)
101118
mocks.validate.mockReturnValue({ valid: true, errors: [], warnings: [] })
102-
mocks.replace.mockResolvedValue({
119+
mocks.replace.mockImplementation(async ({ state }: ReplaceWorkflowNormalizedStateInput) => {
120+
mocks.resolvedState(typeof state === 'function' ? await state(db) : state)
121+
return mocks.replacementResult()
122+
})
123+
mocks.replacementResult.mockReturnValue({
103124
warnings: [],
104125
state: { blocks: { 'block-1': BLOCK }, edges: [], loops: {}, parallels: {} },
105126
})
@@ -152,13 +173,111 @@ describe('replaceWorkflowState', () => {
152173
])
153174
expect(result.warnings.join(' ')).toContain('removes 1 credential/table binding')
154175
expect(mocks.loadNormalized).toHaveBeenCalledTimes(1)
155-
expect(mocks.loadNormalized).toHaveBeenCalledWith(context.workflowId, undefined, {
156-
persistMigrations: false,
176+
expect(mocks.loadNormalized).toHaveBeenCalledWith(
177+
context.workflowId,
178+
dryRun ? undefined : db,
179+
{
180+
persistMigrations: false,
181+
}
182+
)
183+
expect(mocks.replace).toHaveBeenCalledTimes(dryRun ? 0 : 1)
184+
}
185+
)
186+
187+
it('compares removals against the baseline read after the replacement acquires its row lock', async () => {
188+
const { replaceWorkflowNormalizedState } = await vi.importActual<
189+
typeof import('@/lib/workflows/persistence/replace-normalized-state')
190+
>('@/lib/workflows/persistence/replace-normalized-state')
191+
resetDbChainMock()
192+
mocks.saveNormalized.mockResolvedValue({ success: true })
193+
mocks.extractCustomTools.mockResolvedValue({ saved: 0, errors: [] })
194+
mocks.replace.mockImplementationOnce(replaceWorkflowNormalizedState)
195+
vi.mocked(getBlock).mockImplementation((type) =>
196+
type === 'bound-test'
197+
? ({
198+
type: 'bound-test',
199+
subBlocks: [{ id: 'credential', type: 'oauth-input' }],
200+
outputs: {},
201+
} as never)
202+
: defaultGetBlock?.(type)
203+
)
204+
const baseline = (credential: string) => ({
205+
blocks: {
206+
[BLOCK.id]: {
207+
...BLOCK,
208+
type: 'bound-test',
209+
subBlocks: {
210+
credential: { id: 'credential', type: 'oauth-input' as const, value: credential },
211+
},
212+
},
213+
},
214+
edges: [],
215+
loops: {},
216+
parallels: {},
217+
})
218+
let saved = baseline('credential-before-concurrent-save')
219+
mocks.loadNormalized.mockImplementation(async () => saved)
220+
let unlock!: () => void
221+
let started!: () => void
222+
const lockStarted = new Promise<void>((resolve) => {
223+
started = resolve
224+
})
225+
const lockReleased = new Promise<void>((resolve) => {
226+
unlock = resolve
227+
})
228+
dbChainMockFns.for.mockImplementationOnce(async () => {
229+
started()
230+
await lockReleased
231+
return [{ id: context.workflowId }]
232+
})
233+
234+
const replacing = replaceWorkflowState.execute({ principal: sessionPrincipal, input })
235+
await lockStarted
236+
saved = baseline('credential-committed-while-waiting-for-lock')
237+
unlock()
238+
const result = await replacing
239+
240+
expect(result.removedBindings.map((binding) => binding.resourceId)).toEqual([
241+
'credential-committed-while-waiting-for-lock',
242+
])
243+
expect(mocks.loadNormalized).toHaveBeenCalledWith(context.workflowId, expect.anything(), {
244+
persistMigrations: false,
245+
})
246+
expect(mocks.saveNormalized).toHaveBeenCalledTimes(1)
247+
expect(mocks.admitBlockTypes).toHaveBeenCalledBefore(dbChainMockFns.for)
248+
expect(mocks.loadNormalized).toHaveBeenCalledBefore(mocks.saveNormalized)
249+
})
250+
251+
it.each([true, false])(
252+
'allows an initially absent normalized graph (dryRun=%s)',
253+
async (dryRun) => {
254+
mocks.loadNormalized.mockResolvedValue(null)
255+
const result = await replaceWorkflowState.execute({
256+
principal: sessionPrincipal,
257+
input: { ...input, dryRun },
157258
})
259+
expect(result.removedBindings).toEqual([])
260+
expect(result.blocksCount).toBe(1)
158261
expect(mocks.replace).toHaveBeenCalledTimes(dryRun ? 0 : 1)
159262
}
160263
)
161264

265+
it('refuses a withheld block before acquiring the replacement lock', async () => {
266+
mocks.admitBlockTypes.mockRejectedValueOnce(
267+
new OrchestrationError('forbidden', 'Block type is not allowed')
268+
)
269+
await expect(
270+
replaceWorkflowState.execute({ principal: sessionPrincipal, input })
271+
).rejects.toMatchObject({ code: 'forbidden' })
272+
expect(mocks.admitBlockTypes).toHaveBeenCalledWith(
273+
{ workspaceId: context.workspaceId, subjectUserId: sessionPrincipal.userId },
274+
[BLOCK]
275+
)
276+
expect(mocks.replace).not.toHaveBeenCalled()
277+
expect(mocks.loadNormalized).not.toHaveBeenCalled()
278+
expect(mocks.recordAudit).not.toHaveBeenCalled()
279+
})
280+
162281
it('does not read saved bindings before access is authorized', async () => {
163282
mocks.resolvePermission.mockResolvedValue('read')
164283
await expect(
@@ -205,7 +324,12 @@ describe('replaceWorkflowState', () => {
205324
workflowId: 'workflow-1',
206325
workspaceId: 'workspace-1',
207326
attributedUserId: 'user-1',
208-
state: { blocks: { 'block-1': BLOCK }, edges: [], variables: undefined },
327+
state: expect.any(Function),
328+
})
329+
expect(mocks.resolvedState).toHaveBeenCalledWith({
330+
blocks: { 'block-1': BLOCK },
331+
edges: [],
332+
variables: undefined,
209333
})
210334
})
211335

@@ -229,15 +353,13 @@ describe('replaceWorkflowState', () => {
229353
},
230354
})
231355

232-
expect(mocks.replace).toHaveBeenCalledWith(
356+
expect(mocks.resolvedState).toHaveBeenCalledWith(
233357
expect.objectContaining({
234-
state: expect.objectContaining({
235-
variables: {
236-
'var-1': { id: 'var-1', name: 'retries', type: 'number', value: 42 },
237-
'var-2': { id: 'var-2', name: 'enabled', type: 'boolean', value: true },
238-
'var-3': { id: 'var-3', name: 'tags', type: 'array', value: ['a', 'b'] },
239-
},
240-
}),
358+
variables: {
359+
'var-1': { id: 'var-1', name: 'retries', type: 'number', value: 42 },
360+
'var-2': { id: 'var-2', name: 'enabled', type: 'boolean', value: true },
361+
'var-3': { id: 'var-3', name: 'tags', type: 'array', value: ['a', 'b'] },
362+
},
241363
})
242364
)
243365
})
@@ -425,14 +547,15 @@ describe('replaceWorkflowState', () => {
425547
}
426548
const replacement = { ...input, blocks: { [BLOCK.id]: block } }
427549

428-
it.each([false, true])('rejects new aliases before persistence (dryRun=%s)', async (dryRun) => {
550+
it.each([false, true])('rejects new aliases before writing (dryRun=%s)', async (dryRun) => {
429551
await expect(
430552
replaceWorkflowState.execute({
431553
principal: copilotPrincipal,
432554
input: { ...replacement, dryRun },
433555
})
434556
).rejects.toThrow('attachment names are read-only')
435-
expect(mocks.replace).not.toHaveBeenCalled()
557+
expect(mocks.replace).toHaveBeenCalledTimes(dryRun ? 0 : 1)
558+
expect(mocks.resolvedState).not.toHaveBeenCalled()
436559
expect(mocks.notify).not.toHaveBeenCalled()
437560
})
438561

@@ -449,11 +572,9 @@ describe('replaceWorkflowState', () => {
449572
input: { ...replacement, blocks: { [BLOCK.id]: { ...block, name: 'Updated Agent' } } },
450573
})
451574
).resolves.toMatchObject({ dryRun: false })
452-
expect(mocks.replace).toHaveBeenCalledWith(
575+
expect(mocks.resolvedState).toHaveBeenCalledWith(
453576
expect.objectContaining({
454-
state: expect.objectContaining({
455-
blocks: { [BLOCK.id]: { ...block, name: 'Updated Agent' } },
456-
}),
577+
blocks: { [BLOCK.id]: { ...block, name: 'Updated Agent' } },
457578
})
458579
)
459580
})

0 commit comments

Comments
 (0)