Skip to content

Commit bd546e2

Browse files
fix(tables): validate deployed workflow mappings
1 parent 5638886 commit bd546e2

5 files changed

Lines changed: 112 additions & 161 deletions

File tree

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx

Lines changed: 16 additions & 146 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,11 @@ import {
1919
import { ArrowLeft, ChevronDown, SquareArrowUpRight, X } from '@sim/emcn/icons'
2020
import { toError } from '@sim/utils/errors'
2121
import { generateId } from '@sim/utils/id'
22-
import { useMutation, useQueryClient } from '@tanstack/react-query'
2322
import { findValidationIssue, isValidationError } from '@/lib/api/client/errors'
24-
import { requestJson } from '@/lib/api/client/request'
2523
import type {
2624
AddWorkflowGroupBodyInput,
2725
UpdateWorkflowGroupBodyInput,
2826
} from '@/lib/api/contracts/tables'
29-
import {
30-
putWorkflowNormalizedStateContract,
31-
type WorkflowStateContractInput,
32-
} from '@/lib/api/contracts/workflows'
3327
import type {
3428
ColumnDefinition,
3529
WorkflowGroup,
@@ -39,7 +33,6 @@ import type {
3933
} from '@/lib/table'
4034
import { getColumnId } from '@/lib/table/column-keys'
4135
import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming'
42-
import { columnTypeById } from '@/lib/table/column-types'
4336
import {
4437
type FlattenOutputsBlockInput,
4538
type FlattenOutputsEdgeInput,
@@ -55,12 +48,12 @@ import {
5548
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields'
5649
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
5750
import { BlockTile } from '@/blocks/block-tile'
51+
import { useDeployedWorkflowState } from '@/hooks/queries/deployments'
5852
import {
5953
useAddWorkflowGroup,
6054
useUpdateColumn,
6155
useUpdateWorkflowGroup,
6256
} from '@/hooks/queries/tables'
63-
import { useWorkflowState, workflowKeys } from '@/hooks/queries/workflows'
6457
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
6558
import { InputMappingSection } from './input-mapping-section'
6659
import { RunSettingsSection } from './run-settings-section'
@@ -139,25 +132,6 @@ interface BlockOutputGroup {
139132
paths: string[]
140133
}
141134

142-
interface WorkflowStatePayload {
143-
blocks: Record<
144-
string,
145-
{
146-
type: string
147-
subBlocks?: Record<string, { id?: string; type?: string; value?: unknown }>
148-
} & Record<string, unknown>
149-
>
150-
edges: unknown[]
151-
loops: unknown
152-
parallels: unknown
153-
lastSaved?: number
154-
isDeployed?: boolean
155-
}
156-
157-
function tableColumnTypeToInputType(colType: ColumnDefinition['type'] | undefined): string {
158-
return columnTypeById(colType).workflowInputType
159-
}
160-
161135
/**
162136
* Right-edge sidebar for workflow group configuration. Three flows:
163137
* - create a new group (workflow + outputs + deps),
@@ -274,11 +248,6 @@ export function WorkflowSidebarBody({
274248
*/
275249
const otherColumns = anchorIdx >= allColumns.length ? allColumns : allColumns.slice(0, anchorIdx)
276250

277-
// Used by the "missing workflow input" suggestion below — for edit-output
278-
// we exclude the column being edited (you can't suggest it as its own
279-
// input).
280-
const anchorColumnName = config.mode === 'edit-output' ? config.columnName : null
281-
282251
// Every left-of-current column is a valid dep — workflow output columns
283252
// included. Exclude this group's own outputs (you can't depend on yourself).
284253
const ownOutputIds = new Set(existingGroup?.outputs.map((o) => o.columnName) ?? [])
@@ -324,101 +293,21 @@ export function WorkflowSidebarBody({
324293
const [showValidation, setShowValidation] = useState(false)
325294
const [nameError, setNameError] = useState<string | null>(null)
326295

327-
const workflowState = useWorkflowState(selectedWorkflowId || undefined)
296+
const workflowState = useDeployedWorkflowState(selectedWorkflowId || null)
328297

329-
/** Resolves the unified Start block id and its current `inputFormat` field
330-
* names. The "Add inputs" mutation only adds rows for table columns that
331-
* aren't already represented in the start block. */
332-
const startBlockInputs = useMemo<{
333-
blockId: string | null
334-
existingNames: Set<string>
335-
existing: InputFormatField[]
336-
}>(() => {
298+
/** Resolves Start-block inputs from the active deployment used by table runs. */
299+
const startBlockInputs = useMemo<InputFormatField[]>(() => {
337300
const blocks = (workflowState.data as { blocks?: Record<string, { type: string }> } | null)
338301
?.blocks
339-
if (!blocks) return { blockId: null, existingNames: new Set(), existing: [] }
302+
if (!blocks) return []
340303
const candidate = TriggerUtils.findStartBlock(blocks, 'manual')
341-
if (!candidate) return { blockId: null, existingNames: new Set(), existing: [] }
304+
if (!candidate) return []
342305
const block = blocks[candidate.blockId] as
343306
| { subBlocks?: Record<string, { value?: unknown }> }
344307
| undefined
345-
const existing = normalizeInputFormatValue(block?.subBlocks?.inputFormat?.value)
346-
return {
347-
blockId: candidate.blockId,
348-
existingNames: new Set(existing.map((f) => f.name).filter((n): n is string => !!n)),
349-
existing,
350-
}
308+
return normalizeInputFormatValue(block?.subBlocks?.inputFormat?.value)
351309
}, [workflowState.data])
352310

353-
const missingInputColumnNames = useMemo<string[]>(() => {
354-
if (!startBlockInputs.blockId) return []
355-
const anchor = anchorColumnName
356-
return allColumns
357-
.filter(
358-
(c) =>
359-
getColumnId(c) !== anchor &&
360-
!c.workflowGroupId &&
361-
!startBlockInputs.existingNames.has(c.name)
362-
)
363-
.map((c) => c.name)
364-
}, [allColumns, anchorColumnName, startBlockInputs])
365-
366-
const queryClient = useQueryClient()
367-
const addInputsMutation = useMutation({
368-
mutationFn: async () => {
369-
const wfId = selectedWorkflowId
370-
const startBlockId = startBlockInputs.blockId
371-
const state = workflowState.data as WorkflowStatePayload | null | undefined
372-
if (!wfId || !startBlockId || !state || missingInputColumnNames.length === 0) {
373-
throw new Error('Nothing to add')
374-
}
375-
const startBlock = state.blocks[startBlockId]
376-
if (!startBlock) throw new Error('Start block missing from workflow')
377-
378-
const newFields: InputFormatField[] = missingInputColumnNames.map((name) => {
379-
const col = allColumns.find((c) => c.name === name)
380-
return {
381-
id: generateId(),
382-
name,
383-
type: tableColumnTypeToInputType(col?.type),
384-
value: '',
385-
collapsed: false,
386-
} as InputFormatField & { id: string; collapsed: boolean }
387-
})
388-
389-
const updatedSubBlock = {
390-
...(startBlock.subBlocks?.inputFormat ?? { id: 'inputFormat', type: 'input-format' }),
391-
value: [...startBlockInputs.existing, ...newFields],
392-
}
393-
const updatedBlocks = {
394-
...state.blocks,
395-
[startBlockId]: {
396-
...startBlock,
397-
subBlocks: { ...startBlock.subBlocks, inputFormat: updatedSubBlock },
398-
},
399-
}
400-
401-
const rawBody = {
402-
blocks: updatedBlocks,
403-
edges: state.edges,
404-
loops: state.loops,
405-
parallels: state.parallels,
406-
lastSaved: state.lastSaved ?? Date.now(),
407-
isDeployed: state.isDeployed ?? false,
408-
}
409-
// double-cast-allowed: WorkflowStatePayload is the loose local view of
410-
// useWorkflowState; round-trip back to the strict PUT body shape.
411-
const body = rawBody as unknown as WorkflowStateContractInput
412-
await requestJson(putWorkflowNormalizedStateContract, { params: { id: wfId }, body })
413-
},
414-
onError: (err) => {
415-
toast.error(toError(err).message)
416-
},
417-
onSettled: () => {
418-
return queryClient.invalidateQueries({ queryKey: workflowKeys.state(selectedWorkflowId) })
419-
},
420-
})
421-
422311
const blockOutputGroups = useMemo<BlockOutputGroup[]>(() => {
423312
const state = workflowState.data as
424313
| {
@@ -509,13 +398,13 @@ export function WorkflowSidebarBody({
509398
// Once the Start block's input fields resolve, auto-fill any field that has no
510399
// persisted mapping yet but matches a table column by name. Runs once; never
511400
// overrides a persisted or user-picked mapping.
512-
if (!inputMappingsHydrated && startBlockInputs.existing.length > 0) {
401+
if (!inputMappingsHydrated && startBlockInputs.length > 0) {
513402
// Map a Start input field to the column sharing its name, storing the
514403
// column id (the value the dropdowns and persisted mappings key on).
515404
const idByColumnName = new Map(depOptions.map((c) => [c.name, getColumnId(c)]))
516405
const next = { ...inputMappings }
517406
let changed = false
518-
for (const field of startBlockInputs.existing) {
407+
for (const field of startBlockInputs) {
519408
if (!field.name || next[field.name]) continue
520409
const colId = idByColumnName.get(field.name)
521410
if (colId) {
@@ -805,29 +694,6 @@ export function WorkflowSidebarBody({
805694
<div className='flex flex-col gap-[9.5px]'>
806695
<div className='flex min-w-0 items-center justify-between gap-2 pl-0.5'>
807696
<Label>Workflow preview</Label>
808-
{!isEnrichment &&
809-
startBlockInputs.blockId &&
810-
missingInputColumnNames.length > 0 && (
811-
<Tooltip.Root>
812-
<Tooltip.Trigger asChild>
813-
<Button
814-
type='button'
815-
variant='default'
816-
size='sm'
817-
className='flex-none whitespace-nowrap'
818-
onClick={() => addInputsMutation.mutate()}
819-
disabled={addInputsMutation.isPending}
820-
>
821-
{addInputsMutation.isPending
822-
? 'Adding…'
823-
: `Add column inputs (${missingInputColumnNames.length})`}
824-
</Button>
825-
</Tooltip.Trigger>
826-
<Tooltip.Content side='top'>
827-
Adds {missingInputColumnNames.join(', ')} to the workflow's Start block
828-
</Tooltip.Content>
829-
</Tooltip.Root>
830-
)}
831697
</div>
832698
<div className='relative h-[160px] overflow-hidden rounded-sm border border-[var(--border)]'>
833699
{workflowState.isLoading ? (
@@ -886,12 +752,16 @@ export function WorkflowSidebarBody({
886752
<div className='flex flex-col gap-[9.5px]'>
887753
<RequiredLabel>Workflow</RequiredLabel>
888754
<ChipCombobox
889-
options={workflows?.map((wf) => ({ label: wf.name, value: wf.id })) ?? []}
755+
options={
756+
workflows
757+
?.filter((workflow) => workflow.isDeployed)
758+
.map((workflow) => ({ label: workflow.name, value: workflow.id })) ?? []
759+
}
890760
value={selectedWorkflowId}
891761
onChange={(v) => setSelectedWorkflowId(v)}
892762
placeholder='Select a workflow'
893763
disabled={!workflows || workflows.length === 0 || isEditOutputMode || isEnrichment}
894-
emptyMessage='No manual triggers configured'
764+
emptyMessage='No deployed workflows available'
895765
maxHeight={260}
896766
searchable
897767
searchPlaceholder='Search workflows...'
@@ -984,7 +854,7 @@ export function WorkflowSidebarBody({
984854
{showAdvanced && (
985855
<>
986856
<InputMappingSection
987-
inputFields={startBlockInputs.existing}
857+
inputFields={startBlockInputs}
988858
columnOptions={depOptions}
989859
value={inputMappings}
990860
onChange={setInputMappings}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ vi.mock('@/lib/workflows/application/context', () => ({
7272
resolveActiveWorkflowApplicationContext: mocks.resolveWorkflowContext,
7373
}))
7474
vi.mock('@/lib/workflows/application/resolve-workflow-outputs', () => ({
75-
loadResolvedWorkflowOutputs: mocks.loadWorkflowOutputs,
75+
loadResolvedDeployedWorkflowOutputs: mocks.loadWorkflowOutputs,
7676
}))
7777

7878
import { v2WorkflowGroupSchema } from '@/lib/api/contracts/v2/tables'

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import {
3535
} from '@/lib/table/workflow-groups/service'
3636
import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context'
3737
import type { ResolveWorkflowOutputsResult } from '@/lib/workflows/application/resolve-workflow-outputs'
38-
import { loadResolvedWorkflowOutputs } from '@/lib/workflows/application/resolve-workflow-outputs'
38+
import { loadResolvedDeployedWorkflowOutputs } from '@/lib/workflows/application/resolve-workflow-outputs'
3939
import { getEnrichment } from '@/enrichments/registry'
4040
import type { EnrichmentConfig } from '@/enrichments/types'
4141

@@ -64,7 +64,7 @@ async function resolveWorkflowForAuthorizedTableCommand(
6464
workflowId,
6565
assertedWorkspaceId: workspaceId,
6666
})
67-
return loadResolvedWorkflowOutputs(workflowContext)
67+
return loadResolvedDeployedWorkflowOutputs(workflowContext)
6868
}
6969

7070
async function resolveRelatedWorkflowForTableRoute(

apps/sim/lib/workflows/application/resolve-workflow-outputs.test.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import { OrchestrationError } from '@/lib/core/orchestration/types'
77

88
const mocks = vi.hoisted(() => ({
99
flatten: vi.fn(),
10+
loadDeployed: vi.fn(),
1011
load: vi.fn(),
12+
NoActiveDeploymentError: class NoActiveDeploymentError extends Error {},
1113
order: vi.fn(),
1214
resolveContext: vi.fn(),
1315
resolvePermission: vi.fn(),
@@ -33,10 +35,15 @@ vi.mock('@/lib/workflows/blocks/flatten-outputs', () => ({
3335
}))
3436

3537
vi.mock('@/lib/workflows/persistence/utils', () => ({
38+
NoActiveDeploymentError: mocks.NoActiveDeploymentError,
39+
loadDeployedWorkflowState: mocks.loadDeployed,
3640
loadWorkflowFromNormalizedTables: mocks.load,
3741
}))
3842

39-
import { resolveWorkflowOutputs } from '@/lib/workflows/application/resolve-workflow-outputs'
43+
import {
44+
loadResolvedDeployedWorkflowOutputs,
45+
resolveWorkflowOutputs,
46+
} from '@/lib/workflows/application/resolve-workflow-outputs'
4047

4148
const principal = {
4249
kind: 'delegated' as const,
@@ -59,12 +66,16 @@ describe('resolveWorkflowOutputs', () => {
5966
workspaceOrganizationId: null,
6067
allowPersonalApiKeys: true,
6168
billedAccountUserId: 'billing-owner-1',
62-
workflow: { id: 'workflow-1' },
69+
workflow: { id: 'workflow-1', isDeployed: true },
6370
})
6471
mocks.load.mockResolvedValue({
6572
blocks: { block1: { id: 'block-1', type: 'agent', name: 'Agent', subBlocks: {} } },
6673
edges: [],
6774
})
75+
mocks.loadDeployed.mockResolvedValue({
76+
blocks: { block1: { id: 'block-1', type: 'agent', name: 'Agent', subBlocks: {} } },
77+
edges: [],
78+
})
6879
mocks.flatten.mockReturnValue([
6980
{
7081
blockId: 'block-1',
@@ -110,6 +121,42 @@ describe('resolveWorkflowOutputs', () => {
110121
expect(mocks.load).not.toHaveBeenCalled()
111122
})
112123

124+
it('resolves table mappings from the active deployment state', async () => {
125+
const context = await mocks.resolveContext()
126+
127+
await expect(loadResolvedDeployedWorkflowOutputs(context)).resolves.toMatchObject({
128+
workflowId: 'workflow-1',
129+
outputs: [{ blockId: 'block-1', path: 'content' }],
130+
})
131+
132+
expect(mocks.loadDeployed).toHaveBeenCalledWith('workflow-1', 'workspace-1')
133+
expect(mocks.load).not.toHaveBeenCalled()
134+
})
135+
136+
it('rejects a workflow without an active deployment before resolving mappings', async () => {
137+
const context = {
138+
...(await mocks.resolveContext()),
139+
workflow: { id: 'workflow-1', isDeployed: false },
140+
}
141+
142+
await expect(loadResolvedDeployedWorkflowOutputs(context)).rejects.toMatchObject({
143+
code: 'validation',
144+
message: 'Workflow must have an active deployment',
145+
})
146+
expect(mocks.loadDeployed).not.toHaveBeenCalled()
147+
})
148+
149+
it('rejects inconsistent deployment metadata without returning draft mappings', async () => {
150+
const context = await mocks.resolveContext()
151+
mocks.loadDeployed.mockRejectedValueOnce(new mocks.NoActiveDeploymentError())
152+
153+
await expect(loadResolvedDeployedWorkflowOutputs(context)).rejects.toMatchObject({
154+
code: 'validation',
155+
message: 'Workflow must have an active deployment',
156+
})
157+
expect(mocks.load).not.toHaveBeenCalled()
158+
})
159+
113160
it('rejects expired delegated scope before loading workflow state', async () => {
114161
await expect(
115162
resolveWorkflowOutputs.execute({

0 commit comments

Comments
 (0)