Skip to content

Commit 17760cb

Browse files
fix(tables): follow latest workflow deployment
1 parent 66b5951 commit 17760cb

10 files changed

Lines changed: 158 additions & 349 deletions

‎apps/sim/background/workflow-column-execution.test.ts‎

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
77
import { createTimeoutAbortController, getExecutionDeadlineAt } from '@/lib/core/execution-limits'
88
import { abortManualExecution } from '@/lib/execution/manual-cancellation'
99
import {
10+
assertWorkflowGroupMatchesLatestDeployment,
1011
buildTableAbortState,
1112
buildTableUsageLimitClear,
1213
createWorkflowGroupAttemptTimeoutController,
1314
createWorkflowGroupCarrierTimeoutController,
1415
terminalizeAbortedQueuedCarrierMarker,
1516
} from '@/background/workflow-column-execution'
1617

17-
const { appendTableEventMock } = vi.hoisted(() => ({ appendTableEventMock: vi.fn() }))
18+
const { appendTableEventMock, flattenWorkflowOutputsMock } = vi.hoisted(() => ({
19+
appendTableEventMock: vi.fn(),
20+
flattenWorkflowOutputsMock: vi.fn(),
21+
}))
1822

1923
vi.mock('@/lib/table/events', () => ({ appendTableEvent: appendTableEventMock }))
24+
vi.mock('@/lib/workflows/blocks/flatten-outputs', () => ({
25+
flattenWorkflowOutputs: flattenWorkflowOutputsMock,
26+
}))
2027

2128
beforeEach(() => {
2229
vi.clearAllMocks()
@@ -50,6 +57,90 @@ const QUEUED_PAYLOAD = {
5057
},
5158
}
5259

60+
function latestDeployment(): Parameters<typeof assertWorkflowGroupMatchesLatestDeployment>[1] {
61+
return {
62+
blocks: {
63+
start: {
64+
id: 'start',
65+
type: 'start_trigger',
66+
subBlocks: {
67+
inputFormat: { value: [{ name: 'company', type: 'string' }] },
68+
},
69+
},
70+
agent: {
71+
id: 'agent',
72+
type: 'agent',
73+
subBlocks: {},
74+
},
75+
},
76+
edges: [{ id: 'start-agent', source: 'start', target: 'agent' }],
77+
loops: {},
78+
parallels: {},
79+
variables: {},
80+
isFromNormalizedTables: false,
81+
deploymentVersionId: 'deployment-version-latest',
82+
} as Parameters<typeof assertWorkflowGroupMatchesLatestDeployment>[1]
83+
}
84+
85+
describe('latest table workflow deployment mappings', () => {
86+
beforeEach(() => {
87+
flattenWorkflowOutputsMock.mockReturnValue([
88+
{
89+
blockId: 'agent',
90+
blockName: 'Agent',
91+
blockType: 'agent',
92+
path: 'content',
93+
leafType: 'string',
94+
},
95+
])
96+
})
97+
98+
it('accepts saved mappings that the latest active deployment still supports', () => {
99+
expect(() =>
100+
assertWorkflowGroupMatchesLatestDeployment(
101+
{
102+
id: 'group-1',
103+
workflowId: 'workflow-1',
104+
outputs: [{ blockId: 'agent', path: 'content', columnName: 'column-output' }],
105+
inputMappings: [{ inputName: 'company', columnName: 'column-company' }],
106+
},
107+
latestDeployment()
108+
)
109+
).not.toThrow()
110+
})
111+
112+
it('rejects an output mapping removed by the latest active deployment', () => {
113+
expect(() =>
114+
assertWorkflowGroupMatchesLatestDeployment(
115+
{
116+
id: 'group-1',
117+
workflowId: 'workflow-1',
118+
outputs: [{ blockId: 'agent', path: 'score', columnName: 'column-output' }],
119+
},
120+
latestDeployment()
121+
)
122+
).toThrow(
123+
'Workflow group group-1 output agent::score is not available in the latest active deployment'
124+
)
125+
})
126+
127+
it('rejects an input mapping removed by the latest active deployment', () => {
128+
expect(() =>
129+
assertWorkflowGroupMatchesLatestDeployment(
130+
{
131+
id: 'group-1',
132+
workflowId: 'workflow-1',
133+
outputs: [{ blockId: 'agent', path: 'content', columnName: 'column-output' }],
134+
inputMappings: [{ inputName: 'website', columnName: 'column-website' }],
135+
},
136+
latestDeployment()
137+
)
138+
).toThrow(
139+
'Workflow group group-1 input website is not available in the latest active deployment'
140+
)
141+
})
142+
})
143+
53144
describe('table workflow carrier deadline', () => {
54145
it('preserves one absolute deadline when a later cascade group creates its controller', () => {
55146
vi.useFakeTimers()

‎apps/sim/background/workflow-column-execution.ts‎

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,57 @@ import {
5454
type QueuedWorkflowGroupCellPayload,
5555
type WorkflowGroupCellPayload,
5656
} from '@/lib/table/workflow-columns'
57+
import { flattenWorkflowOutputs } from '@/lib/workflows/blocks/flatten-outputs'
58+
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
59+
import type { DeployedWorkflowData } from '@/lib/workflows/persistence/utils'
5760
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
5861

5962
export type { WorkflowGroupCellPayload }
6063

6164
const logger = createLogger('TriggerWorkflowGroupCell')
6265

66+
/**
67+
* Fails before workflow execution when saved table mappings are incompatible
68+
* with the latest active deployment loaded for this cell run.
69+
*/
70+
export function assertWorkflowGroupMatchesLatestDeployment(
71+
group: WorkflowGroup,
72+
deployment: DeployedWorkflowData
73+
): void {
74+
const validOutputs = new Set(
75+
flattenWorkflowOutputs(Object.values(deployment.blocks), deployment.edges).map(
76+
(output) => `${output.blockId}::${output.path}`
77+
)
78+
)
79+
const invalidOutput = group.outputs.find(
80+
(output) => !validOutputs.has(`${output.blockId}::${output.path}`)
81+
)
82+
if (invalidOutput) {
83+
throw new Error(
84+
`Workflow group ${group.id} output ${invalidOutput.blockId}::${invalidOutput.path} is not available in the latest active deployment`
85+
)
86+
}
87+
88+
const startBlock = Object.values(deployment.blocks).find(
89+
(block) => block.type === 'start_trigger'
90+
)
91+
if (!startBlock) {
92+
throw new Error('Workflow is missing a Start trigger')
93+
}
94+
95+
const validInputNames = new Set(
96+
normalizeInputFormatValue(startBlock.subBlocks?.inputFormat?.value).map((input) => input.name)
97+
)
98+
const invalidInput = (group.inputMappings ?? []).find(
99+
(mapping) => !validInputNames.has(mapping.inputName)
100+
)
101+
if (invalidInput) {
102+
throw new Error(
103+
`Workflow group ${group.id} input ${invalidInput.inputName} is not available in the latest active deployment`
104+
)
105+
}
106+
}
107+
63108
function requirePayloadBillingAttribution(
64109
payload: WorkflowGroupCellPayload
65110
): BillingAttributionSnapshot {
@@ -395,9 +440,7 @@ async function runWorkflowAndWriteTerminal(
395440
return await runWithRequestContext({ requestId }, async () => {
396441
const { getRowById } = await import('@/lib/table/rows/service')
397442
const { executeWorkflow } = await import('@/lib/workflows/executor/execute-workflow')
398-
const { loadWorkflowDeploymentVersionState } = await import(
399-
'@/lib/workflows/persistence/utils'
400-
)
443+
const { loadDeployedWorkflowState } = await import('@/lib/workflows/persistence/utils')
401444
const {
402445
buildCancelledExecution,
403446
createWorkflowCellProgressWriter,
@@ -689,18 +732,10 @@ async function runWorkflowAndWriteTerminal(
689732
return 'error'
690733
}
691734

692-
let normalizedData: Awaited<ReturnType<typeof loadWorkflowDeploymentVersionState>>
735+
let normalizedData: Awaited<ReturnType<typeof loadDeployedWorkflowState>>
693736
try {
694-
if (!group.deploymentVersionId) {
695-
throw new Error(
696-
`Workflow group ${group.id} has no pinned deployment version; run the table workflow deployment backfill`
697-
)
698-
}
699-
normalizedData = await loadWorkflowDeploymentVersionState(
700-
workflowId,
701-
group.deploymentVersionId,
702-
workspaceId
703-
)
737+
normalizedData = await loadDeployedWorkflowState(workflowId, workspaceId)
738+
assertWorkflowGroupMatchesLatestDeployment(group, normalizedData)
704739
} catch (err) {
705740
await writeState({
706741
status: 'error',

‎apps/sim/lib/table/application/groups.test.ts‎

Lines changed: 0 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,6 @@ import {
8989
const group: WorkflowGroup = {
9090
id: 'group-1',
9191
workflowId: 'workflow-1',
92-
deploymentVersionId: 'deployment-version-1',
9392
outputs: [{ blockId: 'block-1', path: 'content', columnName: 'column-result' }],
9493
}
9594
const table: TableDefinition = {
@@ -148,7 +147,6 @@ const principal = {
148147
}
149148
const resolvedWorkflow = {
150149
workflowId: 'workflow-1',
151-
deploymentVersionId: 'deployment-version-1',
152150
outputs: [
153151
{
154152
blockId: 'block-1',
@@ -239,9 +237,6 @@ describe('workflow and enrichment Table application commands', () => {
239237
...(input.workflowId ? { workflowId: input.workflowId } : {}),
240238
...(input.name ? { name: input.name } : {}),
241239
...(input.outputs ? { outputs: input.outputs } : {}),
242-
...(input.resolvedDeployment
243-
? { deploymentVersionId: input.resolvedDeployment.deploymentVersionId }
244-
: {}),
245240
...(input.autoRun !== undefined ? { autoRun: input.autoRun } : {}),
246241
})
247242
)
@@ -276,7 +271,6 @@ describe('workflow and enrichment Table application commands', () => {
276271
group: expect.objectContaining({
277272
id: 'generated-id',
278273
workflowId: 'workflow-1',
279-
deploymentVersionId: 'deployment-version-1',
280274
name: 'Scoring',
281275
autoRun: false,
282276
outputs: [{ blockId: 'block-2', path: 'score', columnName: 'score' }],
@@ -538,7 +532,6 @@ describe('workflow and enrichment Table application commands', () => {
538532
'request-1'
539533
)
540534
expect(result.group.workflowId).toBe('workflow-1')
541-
expect(result.group.deploymentVersionId).toBe('deployment-version-1')
542535
})
543536

544537
it('still creates an enrichment-template group that carries a backing workflow', async () => {
@@ -582,64 +575,6 @@ describe('workflow and enrichment Table application commands', () => {
582575
expect(mocks.audit).not.toHaveBeenCalled()
583576
})
584577

585-
it('refuses to repin mappings that the new active deployment cannot produce', async () => {
586-
mocks.loadWorkflowOutputs.mockResolvedValueOnce({
587-
...resolvedWorkflow,
588-
deploymentVersionId: 'deployment-version-2',
589-
outputs: resolvedWorkflow.outputs.filter((output) => output.blockId !== 'block-1'),
590-
})
591-
592-
await expect(
593-
updateTableGroupUseCase.execute({
594-
principal,
595-
input: {
596-
tableId: table.id,
597-
workspaceId: table.workspaceId,
598-
groupId: group.id,
599-
workflowId: group.workflowId,
600-
outputs: group.outputs,
601-
},
602-
})
603-
).rejects.toMatchObject({
604-
code: 'validation',
605-
message: expect.stringContaining('Invalid output(s) for workflow workflow-1'),
606-
})
607-
608-
expect(mocks.updateGroup).not.toHaveBeenCalled()
609-
})
610-
611-
it('pins a compatible active deployment when workflow mappings are saved', async () => {
612-
mocks.loadWorkflowOutputs.mockResolvedValueOnce({
613-
...resolvedWorkflow,
614-
deploymentVersionId: 'deployment-version-2',
615-
})
616-
617-
await updateTableGroupUseCase.execute({
618-
principal,
619-
input: {
620-
tableId: table.id,
621-
workspaceId: table.workspaceId,
622-
groupId: group.id,
623-
workflowId: group.workflowId,
624-
outputs: group.outputs,
625-
},
626-
})
627-
628-
expect(mocks.updateGroup).toHaveBeenCalledWith(
629-
expect.objectContaining({
630-
resolvedDeployment: {
631-
workflowId: 'workflow-1',
632-
deploymentVersionId: 'deployment-version-2',
633-
validOutputCoordinates: [
634-
{ blockId: 'block-1', path: 'content' },
635-
{ blockId: 'block-2', path: 'score' },
636-
],
637-
},
638-
}),
639-
'request-1'
640-
)
641-
})
642-
643578
it('preserves an existing output coordinate that is no longer pickable', async () => {
644579
mocks.loadWorkflowOutputs.mockResolvedValueOnce({
645580
...resolvedWorkflow,
@@ -1130,7 +1065,6 @@ describe('workflow and enrichment Table application commands', () => {
11301065
expect.objectContaining({
11311066
resolvedOutput: expect.objectContaining({
11321067
workflowId: 'workflow-1',
1133-
deploymentVersionId: 'deployment-version-1',
11341068
columnType: 'number',
11351069
order: expect.arrayContaining([
11361070
expect.objectContaining({ blockId: 'block-2', executionDistance: 2 }),

0 commit comments

Comments
 (0)