diff --git a/apps/sim/app/api/table/[tableId]/groups/route.ts b/apps/sim/app/api/table/[tableId]/groups/route.ts index 197a1722b1b..5b9f960896a 100644 --- a/apps/sim/app/api/table/[tableId]/groups/route.ts +++ b/apps/sim/app/api/table/[tableId]/groups/route.ts @@ -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 } : {}), }, diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx index 065385a9f05..c4c7904a711 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx @@ -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 } @@ -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' } } @@ -394,6 +398,15 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle ) + case 'no-output': + return ( + + + No output + + + ) + case 'empty': return null diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx index 219a3376e78..c6b1703d0f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx @@ -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 ( onContextMenu(e, row)}> + {isLeftEdgeSelected && ( +
+ )}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx index 463927819f7..f6218e2bac2 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx @@ -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 ( {column.workflowGroupId ? column.headerLabel : column.name} @@ -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} /> {column.workflowGroupId ? column.headerLabel : column.name} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx index e6a5a015f30..d8c7bbded1e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx @@ -1,6 +1,7 @@ 'use client' import type React from 'react' +import { Tooltip } from '@/components/emcn' import { Calendar as CalendarIcon, PlayOutline, @@ -8,6 +9,7 @@ import { TypeJson, TypeNumber, TypeText, + WorkflowX, } from '@/components/emcn/icons' import type { BlockIconInfo } from '../types' @@ -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 ( + + + + + + + + This column's source block no longer exists in the workflow. + + + ) + } const Icon = blockIconInfo?.icon ?? PlayOutline return } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts index 431cfc48789..af5cceea88c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts @@ -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 } /** diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/input-mapping-section.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/input-mapping-section.tsx index c667fc04c08..e255938c0be 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/input-mapping-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/input-mapping-section.tsx @@ -39,7 +39,6 @@ export function InputMappingSection({
{namedFields.length === 0 ? (

diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx index f36cb0ac0ae..fa7c0f8cc30 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx @@ -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, @@ -34,6 +36,7 @@ import type { ColumnDefinition, WorkflowGroup, WorkflowGroupDependencies, + WorkflowGroupDeploymentMode, WorkflowGroupInputMapping, WorkflowGroupOutput, } from '@/lib/table' @@ -347,6 +350,11 @@ export function WorkflowSidebarBody({ const [autoRun, setAutoRun] = useState(() => 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( + () => 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. @@ -709,6 +717,7 @@ export function WorkflowSidebarBody({ outputs: fullOutputs, ...(newOutputColumns.length > 0 ? { newOutputColumns } : {}), inputMappings: inputMappingsList, + deploymentMode, autoRun, }) toast.success(`Saved "${existingGroup.name ?? 'Workflow'}"`) @@ -740,6 +749,7 @@ export function WorkflowSidebarBody({ dependencies, outputs: groupOutputs, inputMappings: inputMappingsList, + deploymentMode, autoRun, } await addWorkflowGroup.mutateAsync({ group, outputColumns: newOutputColumns }) @@ -1027,12 +1037,31 @@ export function WorkflowSidebarBody({

{showAdvanced && ( - + <> + {!isEnrichment && ( + <> +
+ + + setDeploymentMode(v === 'deployed' ? 'deployed' : 'live') + } + > + Live + Deployed + +
+ + + )} + + )} )} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts index 211c623f2a6..844c87a7c3c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts @@ -195,6 +195,10 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams) if (group.type === 'enrichment') continue const state = workflowStates.get(group.workflowId) const blocks = (state as { blocks?: Record } | 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 @@ -202,7 +206,10 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams) ? { 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 }) } } return map diff --git a/apps/sim/background/workflow-column-execution.ts b/apps/sim/background/workflow-column-execution.ts index 9f617cd5144..25a3f707f2a 100644 --- a/apps/sim/background/workflow-column-execution.ts +++ b/apps/sim/background/workflow-column-execution.ts @@ -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') @@ -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> + 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' + } + } else { + normalizedData = await loadWorkflowFromNormalizedTables(workflowId) + } const startBlock = normalizedData ? Object.values(normalizedData.blocks).find((b) => b?.type === 'start_trigger') : undefined @@ -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, diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 6d20d1d1e95..aeb56b2351d 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -1692,6 +1692,7 @@ interface UpdateWorkflowGroupVariables { newOutputColumns?: UpdateWorkflowGroupBodyInput['newOutputColumns'] mappingUpdates?: UpdateWorkflowGroupBodyInput['mappingUpdates'] inputMappings?: UpdateWorkflowGroupBodyInput['inputMappings'] + deploymentMode?: UpdateWorkflowGroupBodyInput['deploymentMode'] type?: UpdateWorkflowGroupBodyInput['type'] autoRun?: boolean } diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index dcd78e1c471..a686181df33 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -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'), @@ -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 @@ -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. */ diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index a6e77164dd2..16e36a6c4fb 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -54,6 +54,7 @@ import type { TableDefinition, WorkflowGroup, WorkflowGroupDependencies, + WorkflowGroupDeploymentMode, WorkflowGroupInputMapping, WorkflowGroupOutput, } from '@/lib/table/types' @@ -139,6 +140,16 @@ function validateOutputsAgainstWorkflow( return `Invalid output(s) for workflow ${workflowId}:\n${invalidList}\n\nValid options${flattened.length > 12 ? ' (first 12)' : ''}:\n${sample}\n\nCall list_workflow_outputs with workflowId="${workflowId}" to see all valid (blockId, path) picks.` } +/** + * Narrows a raw `deploymentMode` arg to the `'live' | 'deployed'` union, or + * `undefined` when absent/invalid (leaving the group's existing value — which + * itself defaults to `'live'`). Lets Mothership choose whether a group's + * per-cell runs execute the live draft or the latest active deployment. + */ +function parseDeploymentMode(value: unknown): WorkflowGroupDeploymentMode | undefined { + return value === 'live' || value === 'deployed' ? value : undefined +} + async function batchInsertAll( tableId: string, rows: RowData[], @@ -1186,11 +1197,13 @@ export const userTableServerTool: BaseServerTool } const dependencies = args.dependencies as WorkflowGroupDependencies | undefined const name = args.name as string | undefined + const deploymentMode = parseDeploymentMode(args.deploymentMode) const group: WorkflowGroup = { id: groupId, workflowId, ...(name ? { name } : {}), ...(dependencies ? { dependencies } : {}), + ...(deploymentMode ? { deploymentMode } : {}), outputs, } const requestId = generateId().slice(0, 8) @@ -1269,6 +1282,7 @@ export const userTableServerTool: BaseServerTool mappingUpdates: args.mappingUpdates as | Array<{ columnName: string; blockId: string; path: string }> | undefined, + deploymentMode: parseDeploymentMode(args.deploymentMode), autoRun: typeof args.autoRun === 'boolean' ? args.autoRun : undefined, }, requestId diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 964bf037b64..156bad960c6 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -3869,6 +3869,7 @@ export async function updateWorkflowGroup( dependencies: data.dependencies ?? group.dependencies, outputs: newOutputs, ...(data.inputMappings !== undefined ? { inputMappings: data.inputMappings } : {}), + ...(data.deploymentMode !== undefined ? { deploymentMode: data.deploymentMode } : {}), ...(data.type !== undefined ? { type: data.type } : {}), ...(data.autoRun !== undefined ? { autoRun: data.autoRun } : {}), } diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 63f56e292f5..4ae7fabae9c 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -63,6 +63,13 @@ export interface WorkflowGroupDependencies { */ export type WorkflowGroupType = 'manual' | 'enrichment' +/** + * Which workflow state a group's per-cell runs execute against: `'live'` runs + * the editable draft (current behavior); `'deployed'` runs the workflow's + * latest active deployment. Defaults to `'live'` when absent. + */ +export type WorkflowGroupDeploymentMode = 'live' | 'deployed' + /** One workflow Start-block input field ← one table column. */ export interface WorkflowGroupInputMapping { /** `inputFormat` field name on the workflow's Start block. */ @@ -88,6 +95,12 @@ export interface WorkflowGroup { * supply each per-row value. Absent / empty means no mapping configured yet. */ inputMappings?: WorkflowGroupInputMapping[] + /** + * Which workflow state per-cell runs execute against. Defaults to `'live'` + * (editable draft) when absent. `'deployed'` runs the workflow's latest + * active deployment. Only meaningful for `manual` groups. + */ + deploymentMode?: WorkflowGroupDeploymentMode /** * When `false`, the group never auto-fires from the scheduler — it can only * be triggered manually via the "Run" actions. Defaults to `true` so @@ -473,6 +486,8 @@ export interface UpdateWorkflowGroupData { mappingUpdates?: Array<{ columnName: string; blockId: string; path: string }> /** Replace the group's input mappings. Omit to leave them unchanged. */ inputMappings?: WorkflowGroupInputMapping[] + /** Change which workflow state the group runs against. Omit to leave unchanged. */ + deploymentMode?: WorkflowGroupDeploymentMode /** Update the group's provenance. Omit to leave it unchanged. */ type?: WorkflowGroupType /** Toggle the group's auto-run flag. Omit to leave it unchanged. */