Skip to content

Commit eefbb20

Browse files
fix(tables): run workflow groups from deployments (#7240)
* fix(tables): run workflow groups from deployments * fix(tables): validate deployed workflow mappings * fix(tables): pin table workflow deployment state * fix(tables): pin deployed workflow variables * fix(tables): pin workflow group deployment versions * fix(tables): follow latest workflow deployment * fix(tables): remove copilot deployment mode * fix(tables): resolve canonical workflow starts * fix(scripts): simplify staging workflow backfill * fix(scripts): skip deleted table workflows * fix(scripts): avoid private Redis in staging backfill * fix(scripts): skip locked table workflows * fix(scripts): support production workflow backfill * fix(scripts): skip undeployable table workflows
1 parent f41e27a commit eefbb20

19 files changed

Lines changed: 1284 additions & 268 deletions

File tree

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

Lines changed: 16 additions & 173 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33
import { useMemo, useState } from 'react'
44
import {
55
Button,
6-
ButtonGroup,
7-
ButtonGroupItem,
86
ChipCombobox,
97
ChipInput,
108
type ComboboxOptionGroup,
@@ -21,28 +19,20 @@ import {
2119
import { ArrowLeft, ChevronDown, SquareArrowUpRight, X } from '@sim/emcn/icons'
2220
import { toError } from '@sim/utils/errors'
2321
import { generateId } from '@sim/utils/id'
24-
import { useMutation, useQueryClient } from '@tanstack/react-query'
2522
import { findValidationIssue, isValidationError } from '@/lib/api/client/errors'
26-
import { requestJson } from '@/lib/api/client/request'
2723
import type {
2824
AddWorkflowGroupBodyInput,
2925
UpdateWorkflowGroupBodyInput,
3026
} from '@/lib/api/contracts/tables'
31-
import {
32-
putWorkflowNormalizedStateContract,
33-
type WorkflowStateContractInput,
34-
} from '@/lib/api/contracts/workflows'
3527
import type {
3628
ColumnDefinition,
3729
WorkflowGroup,
3830
WorkflowGroupDependencies,
39-
WorkflowGroupDeploymentMode,
4031
WorkflowGroupInputMapping,
4132
WorkflowGroupOutput,
4233
} from '@/lib/table'
4334
import { getColumnId } from '@/lib/table/column-keys'
4435
import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming'
45-
import { columnTypeById } from '@/lib/table/column-types'
4636
import {
4737
type FlattenOutputsBlockInput,
4838
type FlattenOutputsEdgeInput,
@@ -58,12 +48,12 @@ import {
5848
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields'
5949
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
6050
import { BlockTile } from '@/blocks/block-tile'
51+
import { useDeployedWorkflowState } from '@/hooks/queries/deployments'
6152
import {
6253
useAddWorkflowGroup,
6354
useUpdateColumn,
6455
useUpdateWorkflowGroup,
6556
} from '@/hooks/queries/tables'
66-
import { useWorkflowState, workflowKeys } from '@/hooks/queries/workflows'
6757
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
6858
import { InputMappingSection } from './input-mapping-section'
6959
import { RunSettingsSection } from './run-settings-section'
@@ -142,25 +132,6 @@ interface BlockOutputGroup {
142132
paths: string[]
143133
}
144134

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

280-
// Used by the "missing workflow input" suggestion below — for edit-output
281-
// we exclude the column being edited (you can't suggest it as its own
282-
// input).
283-
const anchorColumnName = config.mode === 'edit-output' ? config.columnName : null
284-
285251
// Every left-of-current column is a valid dep — workflow output columns
286252
// included. Exclude this group's own outputs (you can't depend on yourself).
287253
const ownOutputIds = new Set(existingGroup?.outputs.map((o) => o.columnName) ?? [])
@@ -312,11 +278,6 @@ export function WorkflowSidebarBody({
312278
const [autoRun, setAutoRun] = useState<boolean>(() =>
313279
existingGroup ? existingGroup.autoRun !== false : false
314280
)
315-
// Which workflow state per-cell runs execute against. Defaults to `'live'`
316-
// (the editable draft) for both new and pre-feature groups.
317-
const [deploymentMode, setDeploymentMode] = useState<WorkflowGroupDeploymentMode>(
318-
() => existingGroup?.deploymentMode ?? 'live'
319-
)
320281
// Deps default to none selected. With auto-run on, at least one is required
321282
// (enforced via `depsValid` below); a legacy group with empty deps will
322283
// surface the error on first open until the user picks at least one column.
@@ -332,101 +293,21 @@ export function WorkflowSidebarBody({
332293
const [showValidation, setShowValidation] = useState(false)
333294
const [nameError, setNameError] = useState<string | null>(null)
334295

335-
const workflowState = useWorkflowState(selectedWorkflowId || undefined)
296+
const workflowState = useDeployedWorkflowState(selectedWorkflowId || null)
336297

337-
/** Resolves the unified Start block id and its current `inputFormat` field
338-
* names. The "Add inputs" mutation only adds rows for table columns that
339-
* aren't already represented in the start block. */
340-
const startBlockInputs = useMemo<{
341-
blockId: string | null
342-
existingNames: Set<string>
343-
existing: InputFormatField[]
344-
}>(() => {
298+
/** Resolves Start-block inputs from the active deployment used by table runs. */
299+
const startBlockInputs = useMemo<InputFormatField[]>(() => {
345300
const blocks = (workflowState.data as { blocks?: Record<string, { type: string }> } | null)
346301
?.blocks
347-
if (!blocks) return { blockId: null, existingNames: new Set(), existing: [] }
302+
if (!blocks) return []
348303
const candidate = TriggerUtils.findStartBlock(blocks, 'manual')
349-
if (!candidate) return { blockId: null, existingNames: new Set(), existing: [] }
304+
if (!candidate) return []
350305
const block = blocks[candidate.blockId] as
351306
| { subBlocks?: Record<string, { value?: unknown }> }
352307
| undefined
353-
const existing = normalizeInputFormatValue(block?.subBlocks?.inputFormat?.value)
354-
return {
355-
blockId: candidate.blockId,
356-
existingNames: new Set(existing.map((f) => f.name).filter((n): n is string => !!n)),
357-
existing,
358-
}
308+
return normalizeInputFormatValue(block?.subBlocks?.inputFormat?.value)
359309
}, [workflowState.data])
360310

361-
const missingInputColumnNames = useMemo<string[]>(() => {
362-
if (!startBlockInputs.blockId) return []
363-
const anchor = anchorColumnName
364-
return allColumns
365-
.filter(
366-
(c) =>
367-
getColumnId(c) !== anchor &&
368-
!c.workflowGroupId &&
369-
!startBlockInputs.existingNames.has(c.name)
370-
)
371-
.map((c) => c.name)
372-
}, [allColumns, anchorColumnName, startBlockInputs])
373-
374-
const queryClient = useQueryClient()
375-
const addInputsMutation = useMutation({
376-
mutationFn: async () => {
377-
const wfId = selectedWorkflowId
378-
const startBlockId = startBlockInputs.blockId
379-
const state = workflowState.data as WorkflowStatePayload | null | undefined
380-
if (!wfId || !startBlockId || !state || missingInputColumnNames.length === 0) {
381-
throw new Error('Nothing to add')
382-
}
383-
const startBlock = state.blocks[startBlockId]
384-
if (!startBlock) throw new Error('Start block missing from workflow')
385-
386-
const newFields: InputFormatField[] = missingInputColumnNames.map((name) => {
387-
const col = allColumns.find((c) => c.name === name)
388-
return {
389-
id: generateId(),
390-
name,
391-
type: tableColumnTypeToInputType(col?.type),
392-
value: '',
393-
collapsed: false,
394-
} as InputFormatField & { id: string; collapsed: boolean }
395-
})
396-
397-
const updatedSubBlock = {
398-
...(startBlock.subBlocks?.inputFormat ?? { id: 'inputFormat', type: 'input-format' }),
399-
value: [...startBlockInputs.existing, ...newFields],
400-
}
401-
const updatedBlocks = {
402-
...state.blocks,
403-
[startBlockId]: {
404-
...startBlock,
405-
subBlocks: { ...startBlock.subBlocks, inputFormat: updatedSubBlock },
406-
},
407-
}
408-
409-
const rawBody = {
410-
blocks: updatedBlocks,
411-
edges: state.edges,
412-
loops: state.loops,
413-
parallels: state.parallels,
414-
lastSaved: state.lastSaved ?? Date.now(),
415-
isDeployed: state.isDeployed ?? false,
416-
}
417-
// double-cast-allowed: WorkflowStatePayload is the loose local view of
418-
// useWorkflowState; round-trip back to the strict PUT body shape.
419-
const body = rawBody as unknown as WorkflowStateContractInput
420-
await requestJson(putWorkflowNormalizedStateContract, { params: { id: wfId }, body })
421-
},
422-
onError: (err) => {
423-
toast.error(toError(err).message)
424-
},
425-
onSettled: () => {
426-
return queryClient.invalidateQueries({ queryKey: workflowKeys.state(selectedWorkflowId) })
427-
},
428-
})
429-
430311
const blockOutputGroups = useMemo<BlockOutputGroup[]>(() => {
431312
const state = workflowState.data as
432313
| {
@@ -517,13 +398,13 @@ export function WorkflowSidebarBody({
517398
// Once the Start block's input fields resolve, auto-fill any field that has no
518399
// persisted mapping yet but matches a table column by name. Runs once; never
519400
// overrides a persisted or user-picked mapping.
520-
if (!inputMappingsHydrated && startBlockInputs.existing.length > 0) {
401+
if (!inputMappingsHydrated && startBlockInputs.length > 0) {
521402
// Map a Start input field to the column sharing its name, storing the
522403
// column id (the value the dropdowns and persisted mappings key on).
523404
const idByColumnName = new Map(depOptions.map((c) => [c.name, getColumnId(c)]))
524405
const next = { ...inputMappings }
525406
let changed = false
526-
for (const field of startBlockInputs.existing) {
407+
for (const field of startBlockInputs) {
527408
if (!field.name || next[field.name]) continue
528409
const colId = idByColumnName.get(field.name)
529410
if (colId) {
@@ -676,7 +557,6 @@ export function WorkflowSidebarBody({
676557
outputs: fullOutputs,
677558
...(newOutputColumns.length > 0 ? { newOutputColumns } : {}),
678559
inputMappings: inputMappingsList,
679-
deploymentMode,
680560
autoRun,
681561
})
682562
toast.success(`Saved "${existingGroup.name ?? 'Workflow'}"`)
@@ -708,7 +588,6 @@ export function WorkflowSidebarBody({
708588
dependencies,
709589
outputs: groupOutputs,
710590
inputMappings: inputMappingsList,
711-
deploymentMode,
712591
autoRun,
713592
}
714593
await addWorkflowGroup.mutateAsync({ group, outputColumns: newOutputColumns })
@@ -815,29 +694,6 @@ export function WorkflowSidebarBody({
815694
<div className='flex flex-col gap-[9.5px]'>
816695
<div className='flex min-w-0 items-center justify-between gap-2 pl-0.5'>
817696
<Label>Workflow preview</Label>
818-
{!isEnrichment &&
819-
startBlockInputs.blockId &&
820-
missingInputColumnNames.length > 0 && (
821-
<Tooltip.Root>
822-
<Tooltip.Trigger asChild>
823-
<Button
824-
type='button'
825-
variant='default'
826-
size='sm'
827-
className='flex-none whitespace-nowrap'
828-
onClick={() => addInputsMutation.mutate()}
829-
disabled={addInputsMutation.isPending}
830-
>
831-
{addInputsMutation.isPending
832-
? 'Adding…'
833-
: `Add column inputs (${missingInputColumnNames.length})`}
834-
</Button>
835-
</Tooltip.Trigger>
836-
<Tooltip.Content side='top'>
837-
Adds {missingInputColumnNames.join(', ')} to the workflow's Start block
838-
</Tooltip.Content>
839-
</Tooltip.Root>
840-
)}
841697
</div>
842698
<div className='relative h-[160px] overflow-hidden rounded-sm border border-[var(--border)]'>
843699
{workflowState.isLoading ? (
@@ -896,12 +752,16 @@ export function WorkflowSidebarBody({
896752
<div className='flex flex-col gap-[9.5px]'>
897753
<RequiredLabel>Workflow</RequiredLabel>
898754
<ChipCombobox
899-
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+
}
900760
value={selectedWorkflowId}
901761
onChange={(v) => setSelectedWorkflowId(v)}
902762
placeholder='Select a workflow'
903763
disabled={!workflows || workflows.length === 0 || isEditOutputMode || isEnrichment}
904-
emptyMessage='No manual triggers configured'
764+
emptyMessage='No deployed workflows available'
905765
maxHeight={260}
906766
searchable
907767
searchPlaceholder='Search workflows...'
@@ -993,25 +853,8 @@ export function WorkflowSidebarBody({
993853
</div>
994854
{showAdvanced && (
995855
<>
996-
{!isEnrichment && (
997-
<>
998-
<div className='flex items-center justify-between pl-0.5'>
999-
<Label>Workflow version</Label>
1000-
<ButtonGroup
1001-
value={deploymentMode}
1002-
onValueChange={(v) =>
1003-
setDeploymentMode(v === 'deployed' ? 'deployed' : 'live')
1004-
}
1005-
>
1006-
<ButtonGroupItem value='live'>Live</ButtonGroupItem>
1007-
<ButtonGroupItem value='deployed'>Deployed</ButtonGroupItem>
1008-
</ButtonGroup>
1009-
</div>
1010-
<FieldDivider />
1011-
</>
1012-
)}
1013856
<InputMappingSection
1014-
inputFields={startBlockInputs.existing}
857+
inputFields={startBlockInputs}
1015858
columnOptions={depOptions}
1016859
value={inputMappings}
1017860
onChange={setInputMappings}

0 commit comments

Comments
 (0)