Skip to content

Commit 757033a

Browse files
feat(table): backend running counter, dep-aware retrigger, sidebar polish
Counter (Fix 1): top-right "X running" + per-row badge are now backend-bootstrapped via a count on `user_table_rows.executions ->> 'status' = 'running'` returned alongside active dispatches. SSE `kind: 'cell'` events compute a delta from `prev → next` status to keep the cache live; cell events for rows outside the loaded page slice trigger a run-state refetch. On `pruned` we invalidate the cache. Counts only worker-claimed `running` cells — optimistic queued/pending no longer inflate the badge, and rows outside the loaded page slice are counted too. Sidebar (Fix 2 + 3a): `Run after` no longer ticks every column by default for new groups (empty list). Save is disabled with an inline error when auto-run is on with zero deps. `edit-group` mode anchors the left-of-current filter to the group's leftmost column, so a workflow can only depend on columns to its left. Reorder scrub (Fix 3b): `updateTableMetadata` walks the schema's workflow groups when `columnOrder` is in the patch and drops any dep whose new position lands at or after the group's leftmost column (uses the existing `stripGroupDeps` helper). Metadata + schema updates land atomically. Server returns ordered columns (Fix 3b cont'd): `getTableById` / `listTables` now sort `schema.columns` by `metadata.columnOrder` before returning, via a new `applyColumnOrderToSchema` helper. Every consumer (grid, sidebar, copilot, mothership) gets one ordered list — the sidebar's leftmost-group-column anchor now points at the right index. Dep-aware retrigger (Fix 4): editing a value that a downstream workflow depends on now re-runs that workflow. - `deriveExecClearsForDataPatch` returns `{ executionsPatch, inFlightDownstreamGroups }`. Walks `schema.workflowGroups[].dependencies.columns` for every column in the patch, clears terminal-state downstream entries, and reports in-flight entries. - `updateRow` calls `cancelWorkflowGroupRuns` + `runWorkflowColumn` (`mode: 'incomplete' + isManualRun: true`) for in-flight downstream groups, then always fires `runWorkflowColumn({ mode: 'new' })` for the cleared groups. Skips both when `executionsPatch` is provided by the caller — those are cell-task / cancel writes that would otherwise spawn a recursive flood of dispatches per partial-write. - `cancelWorkflowGroupRuns(tableId, rowId, { groupIds? })` accepts a per-group filter so the cancel only touches the affected groups, not every in-flight cell on the row. - `pickNextEligibleGroupForRow` now treats a dispatcher pre-stamp (`pending` + `executionId: null`) as claimable — the cascade-loop is the real owner. Without this, the dispatcher's pre-stamp of downstream groups made the cascade-loop see them as "in-flight" and skip them, stranding `pending` cells forever. - `optimisticallyScheduleNewlyEligibleGroups` extends the cache patch to flip dep-touched groups to `pending` regardless of their current status, matching the server's cancel-then-rerun behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 563fc37 commit 757033a

11 files changed

Lines changed: 464 additions & 140 deletions

File tree

apps/sim/app/api/table/[tableId]/dispatches/route.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { parseRequest } from '@/lib/api/server'
55
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
66
import { generateRequestId } from '@/lib/core/utils/request'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8-
import { listActiveDispatches } from '@/lib/table/dispatcher'
8+
import { countRunningCells, listActiveDispatches } from '@/lib/table/dispatcher'
99
import { accessError, checkAccess } from '@/app/api/table/utils'
1010

1111
const logger = createLogger('TableDispatchesAPI')
@@ -37,7 +37,10 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
3737
const result = await checkAccess(tableId, authResult.userId, 'read')
3838
if (!result.ok) return accessError(result, requestId, tableId)
3939

40-
const rows = await listActiveDispatches(tableId)
40+
const [rows, running] = await Promise.all([
41+
listActiveDispatches(tableId),
42+
countRunningCells(tableId),
43+
])
4144
const dispatches: ActiveDispatch[] = rows.map((r) => ({
4245
id: r.id,
4346
status: r.status as 'pending' | 'dispatching',
@@ -47,7 +50,14 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
4750
scope: r.scope,
4851
}))
4952

50-
return NextResponse.json({ success: true, data: { dispatches } })
53+
return NextResponse.json({
54+
success: true,
55+
data: {
56+
dispatches,
57+
runningCellCount: running.total,
58+
runningByRowId: running.byRowId,
59+
},
60+
})
5161
} catch (error) {
5262
logger.error(`[${requestId}] list-dispatches failed:`, error)
5363
return NextResponse.json({ error: 'Failed to list active dispatches' }, { status: 500 })

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,15 @@ import { cn } from '@/lib/core/utils/cn'
1212
import { captureEvent } from '@/lib/posthog/client'
1313
import type { ColumnDefinition, TableRow as TableRowType } from '@/lib/table'
1414
import { TABLE_LIMITS } from '@/lib/table/constants'
15-
import { isExecInFlight } from '@/lib/table/deps'
1615
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
1716
import {
1817
useAddTableColumn,
1918
useBatchCreateTableRows,
20-
useActiveDispatches,
2119
useBatchUpdateTableRows,
2220
useCreateTableRow,
2321
useDeleteColumn,
2422
useDeleteWorkflowGroup,
23+
useTableRunState,
2524
useUpdateColumn,
2625
useUpdateTableMetadata,
2726
useUpdateTableRow,
@@ -74,6 +73,8 @@ import {
7473

7574
const logger = createLogger('TableView')
7675

76+
const EMPTY_RUNNING_BY_ROW: Readonly<Record<string, number>> = Object.freeze({})
77+
7778
const COL_WIDTH_MIN = 80
7879
const COL_WIDTH_AUTO_FIT_MAX = 1000
7980
const SKELETON_COL_COUNT = 4
@@ -301,7 +302,10 @@ export function TableGrid({
301302
ensureAllRowsLoaded,
302303
} = useTable({ workspaceId, tableId, queryOptions })
303304

304-
const { data: activeDispatches } = useActiveDispatches(tableId)
305+
const { data: tableRunState } = useTableRunState(tableId)
306+
const activeDispatches = tableRunState?.dispatches
307+
const totalRunning = tableRunState?.runningCellCount ?? 0
308+
const runningByRowId = tableRunState?.runningByRowId ?? EMPTY_RUNNING_BY_ROW
305309

306310
const fetchNextPageRef = useRef(fetchNextPage)
307311
fetchNextPageRef.current = fetchNextPage
@@ -2693,22 +2697,11 @@ export function TableGrid({
26932697
return ids.length > 0 ? ids : null
26942698
}, [rowSelection, rows])
26952699

2696-
const { runningByRowId, totalRunning } = useMemo(() => {
2697-
const byRow = new Map<string, number>()
2698-
let total = 0
2699-
for (const row of rows) {
2700-
let count = 0
2701-
const executions = row.executions ?? {}
2702-
for (const gid in executions) {
2703-
if (isExecInFlight(executions[gid])) count++
2704-
}
2705-
if (count > 0) {
2706-
byRow.set(row.id, count)
2707-
total += count
2708-
}
2709-
}
2710-
return { runningByRowId: byRow, totalRunning: total }
2711-
}, [rows])
2700+
// `runningByRowId` + `totalRunning` come from `useTableRunState` above —
2701+
// backend-bootstrapped via `countRunningCells` and kept live by
2702+
// `applyCell`'s SSE-driven delta. Counts only cells whose worker has
2703+
// actually claimed the cell (`status === 'running'`), ignoring optimistic
2704+
// queued/pending stamps.
27122705

27132706
// Context-menu wrappers: act on `contextMenuRowIds`, then close the menu.
27142707
// Mirror the action bar's Play / Refresh split: Play fills empty/failed,
@@ -2729,7 +2722,7 @@ export function TableGrid({
27292722
// Total running/queued cells across the rows the context menu is acting on;
27302723
// drives the "Stop N running workflows" item, shown only when > 0.
27312724
const runningInContextSelection = contextMenuRowIds.reduce(
2732-
(total, rowId) => total + (runningByRowId.get(rowId) ?? 0),
2725+
(total, rowId) => total + (runningByRowId[rowId] ?? 0),
27332726
0
27342727
)
27352728

@@ -2760,7 +2753,7 @@ export function TableGrid({
27602753
return []
27612754
}, [rowSelection, normalizedSelection, rows])
27622755
const runningInActionBarSelection = actionBarRowIds.reduce(
2763-
(total, rowId) => total + (runningByRowId.get(rowId) ?? 0),
2756+
(total, rowId) => total + (runningByRowId[rowId] ?? 0),
27642757
0
27652758
)
27662759

@@ -3152,7 +3145,7 @@ export function TableGrid({
31523145
onCellMouseEnter={handleCellMouseEnter}
31533146
isRowChecked={rowSelectionIncludes(rowSelection, row.id)}
31543147
onRowToggle={handleRowToggle}
3155-
runningCount={runningByRowId.get(row.id) ?? 0}
3148+
runningCount={runningByRowId[row.id] ?? 0}
31563149
hasWorkflowColumns={hasWorkflowColumns}
31573150
numDivWidth={numDivWidth}
31583151
onStopRow={onStopRow}

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

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,22 @@ interface RunSettingsSectionProps {
1010
/** Column names this group waits on. */
1111
deps: string[]
1212
onChangeDeps: (next: string[]) => void
13+
/** Inline validation error rendered under the picker. */
14+
error?: string | null
1315
}
1416

1517
/**
1618
* "Run after" picker: which upstream columns must be filled before this group
1719
* fires. Workflow output columns count the same as plain columns — once a
18-
* column is non-empty, the dep is satisfied. Empty selection = the group fires
19-
* on any row change.
20+
* column is non-empty, the dep is satisfied. At least one dep is required
21+
* when auto-run is on.
2022
*/
21-
export function RunSettingsSection({ depOptions, deps, onChangeDeps }: RunSettingsSectionProps) {
23+
export function RunSettingsSection({
24+
depOptions,
25+
deps,
26+
onChangeDeps,
27+
error,
28+
}: RunSettingsSectionProps) {
2229
const options = depOptions.map((c) => ({ label: c.name, value: c.name }))
2330

2431
return (
@@ -38,11 +45,12 @@ export function RunSettingsSection({ depOptions, deps, onChangeDeps }: RunSettin
3845
multiSelectValues={deps}
3946
onMultiSelectChange={onChangeDeps}
4047
overlayContent={
41-
<span className='truncate text-[var(--text-primary)]'>
42-
{deps.length === 0 ? 'Any row change' : `${deps.length} selected`}
48+
<span className='truncate text-[var(--text-tertiary)]'>
49+
{deps.length === 0 ? 'Select at least one column' : `${deps.length} selected`}
4350
</span>
4451
}
4552
/>
53+
{error && <p className='pl-0.5 text-[var(--text-danger)] text-xs'>{error}</p>}
4654
</div>
4755
)
4856
}

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

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -241,31 +241,42 @@ function WorkflowSidebarBody({
241241
? (allColumns.find((c) => c.name === config.columnName) ?? null)
242242
: null
243243

244-
// Anchor column for "left of current" filtering. For create + edit-group we
245-
// treat the anchor as missing (group config sits at the right edge of the
246-
// group); for edit-output the anchor is the column being edited.
247-
const anchorColumnName = config.mode === 'edit-output' ? config.columnName : null
244+
// Anchor index for "left of current" filtering.
245+
// - edit-output: the column being edited.
246+
// - edit-group: the leftmost column belonging to this group (deps must be
247+
// reachable from the group's first output column).
248+
// - create: no anchor; new column sits at the right edge, so every
249+
// existing column qualifies.
250+
const anchorIdx = (() => {
251+
if (config.mode === 'edit-output') {
252+
const idx = allColumns.findIndex((c) => c.name === config.columnName)
253+
return idx === -1 ? allColumns.length : idx
254+
}
255+
if (config.mode === 'edit-group' && existingGroup) {
256+
let leftmost = Number.POSITIVE_INFINITY
257+
for (let i = 0; i < allColumns.length; i++) {
258+
if (allColumns[i].workflowGroupId === existingGroup.id && i < leftmost) leftmost = i
259+
}
260+
return Number.isFinite(leftmost) ? leftmost : allColumns.length
261+
}
262+
return allColumns.length
263+
})()
248264

249265
/**
250266
* Columns "left of current" — these are the only valid trigger dependencies.
251-
* For create + edit-group, every existing column qualifies. For edit-output,
252-
* only columns physically before the anchor.
253267
*/
254-
const otherColumns = (() => {
255-
if (anchorColumnName === null) return allColumns
256-
const idx = allColumns.findIndex((c) => c.name === anchorColumnName)
257-
if (idx === -1) return allColumns.filter((c) => c.name !== anchorColumnName)
258-
return allColumns.slice(0, idx)
259-
})()
268+
const otherColumns = anchorIdx >= allColumns.length ? allColumns : allColumns.slice(0, anchorIdx)
269+
270+
// Used by the "missing workflow input" suggestion below — for edit-output
271+
// we exclude the column being edited (you can't suggest it as its own
272+
// input).
273+
const anchorColumnName = config.mode === 'edit-output' ? config.columnName : null
260274

261275
// Every left-of-current column is a valid dep — workflow output columns
262276
// included. Exclude this group's own outputs (you can't depend on yourself).
263277
const ownOutputNames = new Set(existingGroup?.outputs.map((o) => o.columnName) ?? [])
264278
const depOptions = otherColumns.filter((c) => !ownOutputNames.has(c.name))
265279

266-
// Default deps for a brand-new group: tick every left-of-current column.
267-
const defaultDeps = depOptions.map((c) => c.name)
268-
269280
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string>(
270281
() => existingGroup?.workflowId ?? ''
271282
)
@@ -276,8 +287,11 @@ function WorkflowSidebarBody({
276287
const [autoRun, setAutoRun] = useState<boolean>(() =>
277288
existingGroup ? existingGroup.autoRun !== false : false
278289
)
290+
// Deps default to none selected. With auto-run on, at least one is required
291+
// (enforced via `depsValid` below); a legacy group with empty deps will
292+
// surface the error on first open until the user picks at least one column.
279293
const [deps, setDeps] = useState<string[]>(
280-
() => existingGroup?.dependencies?.columns ?? defaultDeps
294+
() => existingGroup?.dependencies?.columns ?? []
281295
)
282296
// `selectedOutputs` is encoded `${blockId}::${path}`. Seeded once `blockOutputGroups`
283297
// resolves (we may not have the workflow blocks loaded at first render); see the
@@ -542,6 +556,7 @@ function WorkflowSidebarBody({
542556
if (!selectedWorkflowId) missing.push('a workflow')
543557
if (selectedWorkflowId && selectedOutputs.length === 0) missing.push('at least one output')
544558
if (isEditOutputMode && !trimmedName) missing.push('a column name')
559+
if (autoRun && deps.length === 0) missing.push('at least one Run after column')
545560
if (missing.length > 0) {
546561
setShowValidation(true)
547562
return
@@ -664,8 +679,15 @@ function WorkflowSidebarBody({
664679
}
665680
}
666681

682+
// Auto-run requires ≥1 dependency column — without one, the dispatcher's
683+
// eligibility predicate would never fire the workflow. Block Save and
684+
// surface an inline error so the user picks a column.
685+
const depsValid = !autoRun || deps.length > 0
667686
const saveDisabled =
668-
addWorkflowGroup.isPending || updateWorkflowGroup.isPending || updateColumn.isPending
687+
addWorkflowGroup.isPending ||
688+
updateWorkflowGroup.isPending ||
689+
updateColumn.isPending ||
690+
!depsValid
669691
const titleByMode = {
670692
create: 'Add workflow',
671693
'edit-group': 'Configure workflow',
@@ -875,7 +897,14 @@ function WorkflowSidebarBody({
875897
{autoRun && (
876898
<>
877899
<FieldDivider />
878-
<RunSettingsSection depOptions={depOptions} deps={deps} onChangeDeps={setDeps} />
900+
<RunSettingsSection
901+
depOptions={depOptions}
902+
deps={deps}
903+
onChangeDeps={setDeps}
904+
error={
905+
showValidation && deps.length === 0 ? 'Select at least one column' : null
906+
}
907+
/>
879908
</>
880909
)}
881910
</>

0 commit comments

Comments
 (0)