Skip to content

Commit 99e623a

Browse files
refactor(table): align optimistic UI with new dispatcher; sticky cancel via 'new' mode
Fix 0: new `DispatchMode = 'new'` for auto-fire callsites. Eligibility skips rows with any prior `executions[gid]` entry — cancelled / errored / completed cells stay sticky until a manual run. Dispatcher's windowed SELECT pushes `NOT jsonb_exists_any(...)` to SQL so CSV imports into mostly-attempted tables don't pay a per-window load+JS-filter. `batchInsertRows` drops its `rowIds` payload (keeps dispatch scope tiny on big imports). Fix A/B/D: client optimistic patches now mirror the backend's actual invariants. `useCreateTableRow.onSuccess` stamps eligible groups via `optimisticallyScheduleNewlyEligibleGroups` so newly-inserted rows show `Queued` instantly. `useCancelTableRuns.onMutate` distinguishes optimistic- only pending (`executionId == null` — strip silently) from real worker claims (stamp cancelled; SSE will reconcile). Drop `onSettled` invalidation on `useUpdateTableRow` / `useBatchUpdateTableRows` to kill the delete-cell flicker. Fix C: active-dispatches overlay. New `listActiveDispatches` helper, contract, and `GET /api/table/[tableId]/dispatches` route. `kind:'dispatch'` SSE events carry scope+cursor+mode on every transition. New `useActiveDispatches` hook + `resolveCellExec` synthesize a virtual `pending` exec for cells in an active dispatch's scope ahead of cursor — queued indicators now survive page refresh during long Run-all dispatches. `cancelWorkflowGroupRuns` emits `kind:'dispatch',status:'cancelled'` events so the overlay clears without a refetch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 125ae7e commit 99e623a

12 files changed

Lines changed: 351 additions & 45 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { type ActiveDispatch, listActiveDispatchesContract } from '@/lib/api/contracts/tables'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
6+
import { generateRequestId } from '@/lib/core/utils/request'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { listActiveDispatches } from '@/lib/table/dispatcher'
9+
import { accessError, checkAccess } from '@/app/api/table/utils'
10+
11+
const logger = createLogger('TableDispatchesAPI')
12+
13+
interface RouteParams {
14+
params: Promise<{ tableId: string }>
15+
}
16+
17+
/**
18+
* GET /api/table/[tableId]/dispatches
19+
*
20+
* Returns active (`pending` / `dispatching`) dispatches for the table. Drives
21+
* the client's "about to run" overlay so refresh during a long Run-all keeps
22+
* the queued indicators on rows the dispatcher hasn't reached yet.
23+
*/
24+
export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => {
25+
const requestId = generateRequestId()
26+
27+
try {
28+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
29+
if (!authResult.success || !authResult.userId) {
30+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
31+
}
32+
33+
const parsed = await parseRequest(listActiveDispatchesContract, request, { params })
34+
if (!parsed.success) return parsed.response
35+
const { tableId } = parsed.data.params
36+
37+
const result = await checkAccess(tableId, authResult.userId, 'read')
38+
if (!result.ok) return accessError(result, requestId, tableId)
39+
40+
const rows = await listActiveDispatches(tableId)
41+
const dispatches: ActiveDispatch[] = rows.map((r) => ({
42+
id: r.id,
43+
status: r.status as 'pending' | 'dispatching',
44+
mode: r.mode,
45+
isManualRun: r.isManualRun,
46+
cursor: r.cursor,
47+
scope: r.scope,
48+
}))
49+
50+
return NextResponse.json({ success: true, data: { dispatches } })
51+
} catch (error) {
52+
logger.error(`[${requestId}] list-dispatches failed:`, error)
53+
return NextResponse.json({ error: 'Failed to list active dispatches' }, { status: 500 })
54+
}
55+
})

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import React from 'react'
44
import { Button, Checkbox } from '@/components/emcn'
55
import { PlayOutline, Square } from '@/components/emcn/icons'
6+
import type { ActiveDispatch } from '@/lib/api/contracts/tables'
67
import { cn } from '@/lib/core/utils/cn'
78
import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
89
import type { TableRow as TableRowType, WorkflowGroup } from '@/lib/table'
@@ -17,7 +18,7 @@ import {
1718
SELECTION_TINT_BG,
1819
} from './constants'
1920
import type { DisplayColumn } from './types'
20-
import { type NormalizedSelection, readExecution } from './utils'
21+
import { type NormalizedSelection, resolveCellExec } from './utils'
2122

2223
export interface DataRowProps {
2324
row: TableRowType
@@ -50,6 +51,12 @@ export interface DataRowProps {
5051
* for empty workflow-output cells whose group has unmet dependencies.
5152
*/
5253
workflowGroups: WorkflowGroup[]
54+
/**
55+
* Active dispatches on the table — rows in scope ahead of the dispatcher's
56+
* cursor render as `Queued` until the dispatcher pre-stamps them. Preserves
57+
* queued indicators across page refresh during long Run-all dispatches.
58+
*/
59+
activeDispatches: ActiveDispatch[] | undefined
5360
}
5461

5562
function cellRangeRowChanged(
@@ -105,7 +112,8 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean {
105112
prev.numDivWidth !== next.numDivWidth ||
106113
prev.onStopRow !== next.onStopRow ||
107114
prev.onRunRow !== next.onRunRow ||
108-
prev.workflowGroups !== next.workflowGroups
115+
prev.workflowGroups !== next.workflowGroups ||
116+
prev.activeDispatches !== next.activeDispatches
109117
) {
110118
return false
111119
}
@@ -148,6 +156,7 @@ export const DataRow = React.memo(function DataRow({
148156
onStopRow,
149157
onRunRow,
150158
workflowGroups,
159+
activeDispatches,
151160
}: DataRowProps) {
152161
const sel = normalizedSelection
153162
/**
@@ -306,7 +315,13 @@ export const DataRow = React.memo(function DataRow({
306315
? pendingCellValue[column.name]
307316
: row.data[column.name]
308317
}
309-
exec={readExecution(row, column.workflowGroupId)}
318+
exec={resolveCellExec(
319+
row,
320+
column.workflowGroupId
321+
? workflowGroups.find((g) => g.id === column.workflowGroupId)
322+
: undefined,
323+
activeDispatches
324+
)}
310325
column={column}
311326
isEditing={isEditing}
312327
initialCharacter={isEditing ? initialCharacter : undefined}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
1717
import {
1818
useAddTableColumn,
1919
useBatchCreateTableRows,
20+
useActiveDispatches,
2021
useBatchUpdateTableRows,
2122
useCreateTableRow,
2223
useDeleteColumn,
@@ -300,6 +301,8 @@ export function TableGrid({
300301
ensureAllRowsLoaded,
301302
} = useTable({ workspaceId, tableId, queryOptions })
302303

304+
const { data: activeDispatches } = useActiveDispatches(tableId)
305+
303306
const fetchNextPageRef = useRef(fetchNextPage)
304307
fetchNextPageRef.current = fetchNextPage
305308
const hasNextPageRef = useRef(hasNextPage)
@@ -3155,6 +3158,7 @@ export function TableGrid({
31553158
onStopRow={onStopRow}
31563159
onRunRow={onRunRow}
31573160
workflowGroups={tableWorkflowGroups}
3161+
activeDispatches={activeDispatches}
31583162
/>
31593163
))}
31603164
</>

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import type { ActiveDispatch } from '@/lib/api/contracts/tables'
12
import type {
23
ColumnDefinition,
34
RowExecutionMetadata,
45
RowExecutions,
56
TableRow as TableRowType,
67
WorkflowGroup,
78
} from '@/lib/table'
9+
import { areOutputsFilled } from '@/lib/table/deps'
810
import type { DeletedRowSnapshot } from '@/stores/table/types'
911
import type { DisplayColumn } from './types'
1012

@@ -166,6 +168,38 @@ export function readExecution(
166168
return row?.executions?.[groupId]
167169
}
168170

171+
/**
172+
* Resolves a cell's execution state with the "about to run" overlay applied:
173+
* for cells in an active dispatch's scope ahead of its cursor, returns a
174+
* synthetic `pending` exec so the renderer shows `Queued`. Cells with a real
175+
* DB exec always win — the overlay only fills the gap between dispatch start
176+
* and the dispatcher's per-row pending stamp.
177+
*/
178+
export function resolveCellExec(
179+
row: TableRowType,
180+
group: WorkflowGroup | undefined,
181+
activeDispatches: ActiveDispatch[] | undefined
182+
): RowExecutionMetadata | undefined {
183+
if (!group) return undefined
184+
const real = row.executions?.[group.id]
185+
if (real) return real
186+
if (!activeDispatches || activeDispatches.length === 0) return undefined
187+
if (areOutputsFilled(group, row)) return undefined
188+
for (const d of activeDispatches) {
189+
if (!d.scope.groupIds.includes(group.id)) continue
190+
if (d.scope.rowIds && !d.scope.rowIds.includes(row.id)) continue
191+
if (row.position <= d.cursor) continue
192+
return {
193+
status: 'pending',
194+
executionId: null,
195+
jobId: null,
196+
workflowId: group.workflowId,
197+
error: null,
198+
}
199+
}
200+
return undefined
201+
}
202+
169203
export interface ExecStatusMix {
170204
hasIncompleteOrFailed: boolean
171205
hasCompleted: boolean

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useEffect } from 'react'
44
import { createLogger } from '@sim/logger'
55
import { useQueryClient } from '@tanstack/react-query'
6+
import type { ActiveDispatch } from '@/lib/api/contracts/tables'
67
import type { RowData, RowExecutionMetadata, RowExecutions } from '@/lib/table'
78
import type { TableEvent, TableEventEntry } from '@/lib/table/events'
89
import { snapshotAndMutateRows, tableKeys } from '@/hooks/queries/tables'
@@ -110,6 +111,41 @@ export function useTableEventStream({
110111
)
111112
}
112113

114+
const applyDispatch = (event: Extract<TableEvent, { kind: 'dispatch' }>): void => {
115+
const { dispatchId, status, scope, cursor, mode } = event
116+
queryClient.setQueryData<ActiveDispatch[]>(
117+
tableKeys.activeDispatches(tableId),
118+
(prev) => {
119+
const list = prev ?? []
120+
// Terminal states drop the dispatch from the overlay; client renders
121+
// the row's authoritative DB exec state from here.
122+
if (status === 'complete' || status === 'cancelled') {
123+
const filtered = list.filter((d) => d.id !== dispatchId)
124+
return filtered.length === list.length ? list : filtered
125+
}
126+
if (scope === undefined || cursor === undefined || mode === undefined) {
127+
// Defensive: a legacy emit without the new fields can't drive the
128+
// overlay. Leave existing cache alone.
129+
return list
130+
}
131+
const next: ActiveDispatch = {
132+
id: dispatchId,
133+
status,
134+
mode,
135+
isManualRun: false,
136+
cursor,
137+
scope,
138+
}
139+
const idx = list.findIndex((d) => d.id === dispatchId)
140+
if (idx === -1) return [...list, next]
141+
const merged = list.slice()
142+
// Preserve isManualRun from the initial fetch — SSE doesn't carry it.
143+
merged[idx] = { ...next, isManualRun: list[idx].isManualRun }
144+
return merged
145+
}
146+
)
147+
}
148+
113149
const handlePrune = (payload: PrunedEvent): void => {
114150
logger.info('Table event buffer pruned — full refetch', { tableId, ...payload })
115151
void queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) })
@@ -152,11 +188,11 @@ export function useTableEventStream({
152188
eventSource.onmessage = (msg: MessageEvent<string>) => {
153189
try {
154190
const entry = JSON.parse(msg.data) as TableEventEntry
155-
if (entry.event?.kind !== 'cell') return
156191
if (entry.eventId <= lastEventId) return
157192
lastEventId = entry.eventId
158193
savePointer(tableId, lastEventId)
159-
applyCell(entry.event)
194+
if (entry.event?.kind === 'cell') applyCell(entry.event)
195+
else if (entry.event?.kind === 'dispatch') applyDispatch(entry.event)
160196
} catch (err) {
161197
logger.warn('Failed to parse table event', { tableId, err })
162198
}

0 commit comments

Comments
 (0)