feat(tables): workflow version selection (live/deployed) and not-found/no-output badges (#4889)

* feat(tables): workflow version selection (live/deployed) and not-found/no-output badges

* fix(tables): draw row-selection left edge as checkbox cell border so it cannot be cut off

* fix(tables): per-group version in cascade, accurate deploy error, skip not-found for deployed groups

* fix(tables): render selection left edge as continuous strip overlapping row gridlines

* feat(tables): not-found column icon, optional workflow inputs, mothership deploymentMode

---------

Co-authored-by: waleed <walif6@gmail.com>
This commit is contained in:
Theodore Li
2026-06-04 20:52:33 -04:00
committed by GitHub
co-authored by waleed
parent ddab1aaa1c
commit ce7ddd182f
15 changed files with 188 additions and 14 deletions
@@ -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 } : {}),
},
@@ -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
</Wrap>
)
case 'no-output':
return (
<Wrap isEditing={isEditing}>
<Badge variant='gray' dot size='sm'>
No output
</Badge>
</Wrap>
)
case 'empty':
return null
@@ -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',
@@ -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)]'
)}
/>
@@ -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(
@@ -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}
@@ -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}
@@ -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}
@@ -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 (
<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)]' />
}
@@ -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
}
/**
@@ -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'>
@@ -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<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.
@@ -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({
<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}
/>
</>
)}
</>
)}
@@ -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<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
@@ -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
@@ -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<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'
}
} 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,
+1
View File
@@ -1692,6 +1692,7 @@ interface UpdateWorkflowGroupVariables {
newOutputColumns?: UpdateWorkflowGroupBodyInput['newOutputColumns']
mappingUpdates?: UpdateWorkflowGroupBodyInput['mappingUpdates']
inputMappings?: UpdateWorkflowGroupBodyInput['inputMappings']
deploymentMode?: UpdateWorkflowGroupBodyInput['deploymentMode']
type?: UpdateWorkflowGroupBodyInput['type']
autoRun?: boolean
}
+9
View File
@@ -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. */
@@ -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<UserTableArgs, UserTableResult>
}
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<UserTableArgs, UserTableResult>
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
+1
View File
@@ -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 } : {}),
}
+15
View File
@@ -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. */