Skip to content
Merged
3 changes: 3 additions & 0 deletions apps/sim/app/api/table/[tableId]/groups/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R
...(validated.inputMappings !== undefined
? { inputMappings: validated.inputMappings }
: {}),
...(validated.deploymentMode !== undefined
? { deploymentMode: validated.deploymentMode }
: {}),
...(validated.type !== undefined ? { type: validated.type } : {}),
...(validated.autoRun !== undefined ? { autoRun: validated.autoRun } : {}),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type CellRenderKind =
| { kind: 'error' }
| { kind: 'waiting'; labels: string[] }
| { kind: 'not-found' }
| { kind: 'no-output' }
// Plain typed cells
| { kind: 'boolean'; checked: boolean }
| { kind: 'json'; text: string }
Expand Down Expand Up @@ -106,6 +107,9 @@ export function resolveCellRender({
if (exec?.status === 'error') return { kind: 'error' }
// Enrichment ran to completion but matched nothing → "Not found".
if (isEnrichmentOutput && exec?.status === 'completed') return { kind: 'not-found' }
// Workflow output: the group's run completed but this block produced no
// value for the cell → grey "No output" (distinct from a never-run blank).
if (exec?.status === 'completed') return { kind: 'no-output' }
return { kind: 'empty' }
}

Expand Down Expand Up @@ -394,6 +398,15 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
</Wrap>
)

case 'no-output':
return (
<Wrap isEditing={isEditing}>
<Badge variant='gray' dot size='sm'>
No output
</Badge>
</Wrap>
)

case 'empty':
return null

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,10 +194,28 @@ export const DataRow = React.memo(function DataRow({
}, [workflowGroups, row])
const isMultiCell = sel !== null && (sel.startRow !== sel.endRow || sel.startCol !== sel.endCol)
const isRowSelected = isRowChecked
/**
* Whether the selection's left edge sits at column 0 for this row. The blue
* edge is drawn inside the sticky checkbox cell — over its gray right
* border — rather than as the col-0 overlay's `border-l`, so the sticky
* cell can never paint over it and the gray/blue lines never double up at
* the column boundary. The strip overlaps the row gridlines (`-top-px` /
* `-bottom-px`) so consecutive selected rows form one continuous line.
*/
const rowInRange = sel !== null && rowIndex >= sel.startRow && rowIndex <= sel.endRow
const isLeftEdgeSelected = isRowChecked || (isMultiCell && rowInRange && sel!.startCol === 0)

return (
<tr onContextMenu={(e) => onContextMenu(e, row)}>
<td className={cn(CELL_CHECKBOX, 'cursor-pointer')}>
{isLeftEdgeSelected && (
<div
className={cn(
'-right-px -bottom-px pointer-events-none absolute w-px bg-[var(--selection)]',
isFirstRow ? 'top-0' : '-top-px'
)}
/>
)}
<div
className={cn(
'flex items-center',
Expand Down Expand Up @@ -322,7 +340,7 @@ export const DataRow = React.memo(function DataRow({
isFirstRow && isTopEdge && 'top-0',
isTopEdge && 'border-t border-t-[var(--selection)]',
isBottomEdge && 'border-b border-b-[var(--selection)]',
isLeftEdge && 'border-l border-l-[var(--selection)]',
isLeftEdge && colIndex !== 0 && 'border-l border-l-[var(--selection)]',
isRightEdge && 'border-r border-r-[var(--selection)]'
)}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
setMenuOpen(true)
}

// Column whose workflow source block was deleted — the header icon swaps to
// `WorkflowX` with an explanatory tooltip.
const blockMissing = Boolean(sourceInfo?.blockMissing)

return (
<th
className={cn(
Expand Down Expand Up @@ -268,6 +272,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
type={column.type}
isWorkflowColumn={!!column.workflowGroupId && ownGroup?.type !== 'enrichment'}
blockIconInfo={sourceInfo?.blockIconInfo}
blockMissing={blockMissing}
/>
<input
ref={renameInputRef}
Expand All @@ -288,6 +293,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
type={column.type}
isWorkflowColumn={!!column.workflowGroupId && ownGroup?.type !== 'enrichment'}
blockIconInfo={sourceInfo?.blockIconInfo}
blockMissing={blockMissing}
/>
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[13px] text-[var(--text-primary)]'>
{column.workflowGroupId ? column.headerLabel : column.name}
Expand All @@ -305,6 +311,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
type={column.type}
isWorkflowColumn={!!column.workflowGroupId && ownGroup?.type !== 'enrichment'}
blockIconInfo={sourceInfo?.blockIconInfo}
blockMissing={blockMissing}
/>
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[var(--text-primary)] text-small'>
{column.workflowGroupId ? column.headerLabel : column.name}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
'use client'

import type React from 'react'
import { Tooltip } from '@/components/emcn'
import {
Calendar as CalendarIcon,
PlayOutline,
TypeBoolean,
TypeJson,
TypeNumber,
TypeText,
WorkflowX,
} from '@/components/emcn/icons'
import type { BlockIconInfo } from '../types'

Expand All @@ -32,16 +34,39 @@ interface ColumnTypeIconProps {
* ignored — icons render in the plain `text-[var(--text-icon)]` tone like
* every other column-type icon, no per-block tint. */
blockIconInfo?: BlockIconInfo
/** Workflow-output column whose source block no longer exists in the
* workflow — renders the `WorkflowX` "not found" icon with a tooltip. */
blockMissing?: boolean
}

/**
* Tiny icon shown next to a column header. Workflow-output columns get the
* producing block's icon (falling back to `PlayOutline`); plain columns get
* their scalar type icon. Both render in the same `text-[var(--text-icon)]`
* tone — no per-workflow color, no colored swatch.
* tone — no per-workflow color, no colored swatch. A workflow column whose
* source block was deleted renders a `WorkflowX` with an explanatory tooltip.
*/
export function ColumnTypeIcon({ type, isWorkflowColumn, blockIconInfo }: ColumnTypeIconProps) {
export function ColumnTypeIcon({
type,
isWorkflowColumn,
blockIconInfo,
blockMissing,
}: ColumnTypeIconProps) {
if (isWorkflowColumn) {
if (blockMissing) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span className='flex shrink-0 items-center'>
<WorkflowX className='size-3 shrink-0 text-[var(--text-icon)]' />
</span>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
This column's source block no longer exists in the workflow.
</Tooltip.Content>
</Tooltip.Root>
)
}
const Icon = blockIconInfo?.icon ?? PlayOutline
return <Icon className='size-3 shrink-0 text-[var(--text-icon)]' />
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export interface BlockIconInfo {
export interface ColumnSourceInfo {
blockIconInfo?: BlockIconInfo
blockName?: string
/** Workflow loaded but the column's source block no longer exists — the
* header renders a "Not found" badge. Only set for loaded states. */
blockMissing?: boolean
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ export function InputMappingSection({
<div className='flex flex-col gap-[9.5px]'>
<Label className='flex items-baseline gap-1.5 whitespace-nowrap pl-0.5'>
Workflow inputs
<span className='ml-0.5'>*</span>
</Label>
{namedFields.length === 0 ? (
<p className='pl-0.5 text-[var(--text-tertiary)] text-caption'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ExternalLink, RepeatIcon, SplitIcon, X } from 'lucide-react'
import {
Button,
ButtonGroup,
ButtonGroupItem,
Combobox,
type ComboboxOptionGroup,
FieldDivider,
Expand All @@ -34,6 +36,7 @@ import type {
ColumnDefinition,
WorkflowGroup,
WorkflowGroupDependencies,
WorkflowGroupDeploymentMode,
WorkflowGroupInputMapping,
WorkflowGroupOutput,
} from '@/lib/table'
Expand Down Expand Up @@ -347,6 +350,11 @@ export function WorkflowSidebarBody({
const [autoRun, setAutoRun] = useState<boolean>(() =>
existingGroup ? existingGroup.autoRun !== false : false
)
// Which workflow state per-cell runs execute against. Defaults to `'live'`
// (the editable draft) for both new and pre-feature groups.
const [deploymentMode, setDeploymentMode] = useState<WorkflowGroupDeploymentMode>(
() => existingGroup?.deploymentMode ?? 'live'
)
// Deps default to none selected. With auto-run on, at least one is required
// (enforced via `depsValid` below); a legacy group with empty deps will
// surface the error on first open until the user picks at least one column.
Expand Down Expand Up @@ -709,6 +717,7 @@ export function WorkflowSidebarBody({
outputs: fullOutputs,
...(newOutputColumns.length > 0 ? { newOutputColumns } : {}),
inputMappings: inputMappingsList,
deploymentMode,
autoRun,
})
toast.success(`Saved "${existingGroup.name ?? 'Workflow'}"`)
Expand Down Expand Up @@ -740,6 +749,7 @@ export function WorkflowSidebarBody({
dependencies,
outputs: groupOutputs,
inputMappings: inputMappingsList,
deploymentMode,
autoRun,
}
await addWorkflowGroup.mutateAsync({ group, outputColumns: newOutputColumns })
Expand Down Expand Up @@ -1027,12 +1037,31 @@ export function WorkflowSidebarBody({
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
</div>
{showAdvanced && (
<InputMappingSection
inputFields={startBlockInputs.existing}
columnOptions={depOptions}
value={inputMappings}
onChange={setInputMappings}
/>
<>
{!isEnrichment && (
<>
<div className='flex items-center justify-between pl-0.5'>
<Label>Workflow version</Label>
<ButtonGroup
value={deploymentMode}
onValueChange={(v) =>
setDeploymentMode(v === 'deployed' ? 'deployed' : 'live')
}
>
<ButtonGroupItem value='live'>Live</ButtonGroupItem>
<ButtonGroupItem value='deployed'>Deployed</ButtonGroupItem>
</ButtonGroup>
</div>
<FieldDivider />
</>
)}
<InputMappingSection
inputFields={startBlockInputs.existing}
columnOptions={depOptions}
value={inputMappings}
onChange={setInputMappings}
/>
</>
)}
</>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,14 +195,21 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams)
if (group.type === 'enrichment') continue
const state = workflowStates.get(group.workflowId)
const blocks = (state as { blocks?: Record<string, FlattenOutputsBlockInput> } | null)?.blocks
// `useWorkflowStates` only fetches the live draft, so we can only judge
// "block missing" for live-mode groups. A deployed-mode group runs a
// different graph we don't load client-side — don't risk a false badge.
const isLiveMode = group.deploymentMode !== 'deployed'
for (const out of group.outputs) {
const block = blocks?.[out.blockId]
const blockConfig = block?.type ? getBlock(block.type) : undefined
const blockIconInfo: BlockIconInfo | undefined = blockConfig?.icon
? { icon: blockConfig.icon, color: blockConfig.bgColor || '#2F55FF' }
: undefined
const blockName = block?.name?.trim() || undefined
map.set(out.columnName, { blockIconInfo, blockName })
// Flag a missing source block only once the workflow state has loaded
// (truthy `blocks`), so a still-loading workflow never flashes the badge.
const blockMissing = Boolean(isLiveMode && blocks && out.blockId && !block)
map.set(out.columnName, { blockIconInfo, blockName, blockMissing })
Comment thread
cursor[bot] marked this conversation as resolved.
}
}
return map
Expand Down
36 changes: 33 additions & 3 deletions apps/sim/background/workflow-column-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,12 +165,18 @@ async function runWorkflowAndWriteTerminal(
): Promise<'completed' | 'error' | 'paused' | 'blocked'> {
const { tableId, tableName, rowId, groupId, workflowId, workspaceId, executionId, dispatchId } =
payload
// Read from the live `group`, not the payload: in a cascade the payload is the
// first group's snapshot, so a downstream group with a different version must
// use its own setting (same reason `workflowId` is re-derived per iteration).
const deploymentMode = group.deploymentMode
const requestId = `wfgrp-${executionId}`

return runWithRequestContext({ requestId }, async () => {
const { getRowById } = await import('@/lib/table/service')
const { executeWorkflow } = await import('@/lib/workflows/executor/execute-workflow')
const { loadWorkflowFromNormalizedTables } = await import('@/lib/workflows/persistence/utils')
const { loadWorkflowFromNormalizedTables, loadDeployedWorkflowState } = await import(
'@/lib/workflows/persistence/utils'
)
const { writeWorkflowGroupState, markWorkflowGroupPickedUp, buildOutputsByBlockId } =
await import('@/lib/table/cell-write')
const { stashCellContextForResume } = await import('@/lib/table/workflow-columns')
Expand Down Expand Up @@ -382,7 +388,28 @@ async function runWorkflowAndWriteTerminal(
return 'error'
}

const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
// `deployed` groups run the workflow's latest active deployment; `live`
// (default) runs the editable draft. A `deployed` group whose workflow
// has never been deployed fails the cell — no silent fallback to draft.
let normalizedData: Awaited<ReturnType<typeof loadWorkflowFromNormalizedTables>>
if (deploymentMode === 'deployed') {
try {
normalizedData = await loadDeployedWorkflowState(workflowId, workspaceId)
} catch (err) {
// Surface the real reason (missing deployment vs. transient DB/migration
// failure) rather than always claiming the workflow isn't deployed.
await writeState({
status: 'error',
executionId,
jobId: null,
workflowId,
error: toError(err).message,
})
return 'error'
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
} else {
normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
}
const startBlock = normalizedData
? Object.values(normalizedData.blocks).find((b) => b?.type === 'start_trigger')
: undefined
Expand Down Expand Up @@ -665,7 +692,10 @@ async function runWorkflowAndWriteTerminal(
executionMode: 'sync',
workflowTriggerType: 'table',
triggerBlockId: startBlock.id,
useDraftState: true,
// `deployed` groups execute the latest active deployment; everything
// else runs the editable draft (the table default). Matches the
// state loaded above for start-block / output-block resolution.
useDraftState: deploymentMode !== 'deployed',
abortSignal,
onBlockStart,
onBlockComplete,
Expand Down
1 change: 1 addition & 0 deletions apps/sim/hooks/queries/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1692,6 +1692,7 @@ interface UpdateWorkflowGroupVariables {
newOutputColumns?: UpdateWorkflowGroupBodyInput['newOutputColumns']
mappingUpdates?: UpdateWorkflowGroupBodyInput['mappingUpdates']
inputMappings?: UpdateWorkflowGroupBodyInput['inputMappings']
deploymentMode?: UpdateWorkflowGroupBodyInput['deploymentMode']
type?: UpdateWorkflowGroupBodyInput['type']
autoRun?: boolean
}
Expand Down
9 changes: 9 additions & 0 deletions apps/sim/lib/api/contracts/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,11 @@ const workflowGroupDependenciesSchema = z.object({

const workflowGroupTypeSchema = z.enum(['manual', 'enrichment'])

/** Which workflow state a group's per-cell runs execute against: `'live'` (the
* editable draft) or `'deployed'` (the latest active deployment). Defaults to
* `'live'` when omitted. */
const workflowGroupDeploymentModeSchema = z.enum(['live', 'deployed'])

/** One workflow Start-block input field ← one table column. */
const workflowGroupInputMappingSchema = z.object({
inputName: z.string().min(1, 'inputName cannot be empty'),
Expand Down Expand Up @@ -824,6 +829,8 @@ export const addWorkflowGroupBodySchema = z.object({
outputs: z.array(workflowGroupOutputSchema).min(1),
/** Maps the workflow's Start-block inputs to table columns. */
inputMappings: z.array(workflowGroupInputMappingSchema).optional(),
/** Which workflow state per-cell runs execute against. Defaults to `'live'`. */
deploymentMode: workflowGroupDeploymentModeSchema.optional(),
/** When `false`, the group never auto-fires from the scheduler — it can
* only be triggered manually. Defaults to `true`. Persisted on the
* group; distinct from the top-level `autoRun` below which is a
Expand Down Expand Up @@ -868,6 +875,8 @@ export const updateWorkflowGroupBodySchema = z.object({
mappingUpdates: z.array(workflowGroupMappingUpdateSchema).optional(),
/** Replace the group's input mappings. Omit to leave unchanged. */
inputMappings: z.array(workflowGroupInputMappingSchema).optional(),
/** Change which workflow state the group runs against. Omit to leave unchanged. */
deploymentMode: workflowGroupDeploymentModeSchema.optional(),
/** Update the group's provenance. Omit to leave unchanged. */
type: workflowGroupTypeSchema.optional(),
/** Toggle the group's persisted auto-run flag. Omit to leave unchanged. */
Expand Down
Loading
Loading