fix(tables): resource-cell icons, embedded filters, run-state + UI fixes (#4789)

* fix(tables): resource-cell icons, embedded filters, run-count + queued fixes

- table-grid: render in-workspace resource URLs (workflow/table/KB/file) as
  tagged-resource cells reusing ContextMentionIcon (colored square for
  workflows), matching @-mention chips; only the matching list is fetched.
- table-grid: fix row-number sticky cell overflow — reserve the full run/stop
  button area (30px, not 16px) so wide row indices don't clip.
- table-grid: show an infinite-scroll loading spinner while the next page
  loads instead of looking like the end of the table.
- table: surface sort + filter (and run/stop via the options-bar extras slot)
  in the embedded mothership table resource view.
- table-grid/utils: stop the dispatch overlay from optimistically painting
  autoRun=false cells Queued for auto-fire dispatches — the dispatcher skips
  those groups ('autoRun-off'); manual runs still show Queued (manual-bypass).
- dispatcher: exclude orphan pre-stamps (pending + executionId null) from
  countRunningCells so the "X running" badge doesn't stick above zero.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tables): single run/stop control, right-aligned row numbers, View-execution guard

- table: de-duplicate the run/stop control in the embedded mothership view —
  drop TableGrid's own embedded run-status bar; it now lives only in the
  options bar (left-aligned next to Filter + Sort). Removes the orphaned
  RunStatusControl import + onStopAll/cancelRunsPending props.
- data-row: right-align the row number within its box (hugs the right edge,
  no hover position jump) with a scaled right inset — 2px for ≤3-digit
  indices, 4px for 4+ so narrow columns don't look over-padded.
- table-grid: require a real executionId in the action bar's canViewExecution
  flag so an error that never produced an execution (enqueue failure →
  status 'error', executionId null) doesn't offer "View execution".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tables): address review — drizzle operators for orphan filter, enabled flag for files query

- dispatcher: replace the `not(and(...)) as SQL` cast in countRunningCells with
  `or(ne(status,'pending'), isNotNull(executionId))` — De Morgan equivalent,
  fully type-checked, no cast and no hand-written raw SQL.
- workspace-files: add an `enabled` option to useWorkspaceFiles; sim-resource
  cell now passes the real workspaceId with `enabled` instead of '' so the
  query cache isn't polluted with an empty-key entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tables): "X running" badge counts actual in-flight cells, not dispatch scope

The badge derived from `runningCellCount` (the dispatch-scope estimate =
rows-ahead × groupCount), which over-counts groups that already finished on
rows still inside a dispatch's scope — a cascade where 3 of 4 workflow columns
completed read "4 running" instead of "1". Derive `totalRunning` from the live
`runningByRowId` map instead (the same per-row source the gutter and action-bar
selection already sum), so it reflects cells actually in flight and updates
per-cell via SSE rather than only on dispatch-window events.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Theodore Li
2026-05-29 14:59:22 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent fd77bb4069
commit 8d68c8a0f3
9 changed files with 294 additions and 58 deletions
@@ -10,6 +10,9 @@ interface CellContentProps {
value: unknown
exec?: RowExecutionMetadata
column: DisplayColumn
/** Current workspace id — lets string cells holding an in-workspace resource
* URL render as a tagged-resource chip instead of a plain external link. */
workspaceId: string
isEditing: boolean
initialCharacter?: string | null
onSave: (value: unknown, reason: SaveReason) => void
@@ -34,6 +37,7 @@ export function CellContent({
value,
exec,
column,
workspaceId,
isEditing,
initialCharacter,
onSave,
@@ -41,7 +45,14 @@ export function CellContent({
waitingOnLabels,
isEnrichmentOutput,
}: CellContentProps) {
const kind = resolveCellRender({ value, exec, column, waitingOnLabels, isEnrichmentOutput })
const kind = resolveCellRender({
value,
exec,
column,
waitingOnLabels,
isEnrichmentOutput,
currentWorkspaceId: workspaceId,
})
return (
<>
@@ -9,6 +9,7 @@ import type { RowExecutionMetadata } from '@/lib/table'
import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils'
import { storageToDisplay } from '../../../utils'
import type { DisplayColumn } from '../types'
import { SimResourceCell, type SimResourceType } from './sim-resource-cell'
export type CellRenderKind =
// Workflow-output cells
@@ -26,6 +27,13 @@ export type CellRenderKind =
| { kind: 'json'; text: string }
| { kind: 'date'; text: string }
| { kind: 'url'; text: string; href: string; domain: string }
| {
kind: 'sim-resource'
workspaceId: string
resourceType: SimResourceType
resourceId: string
href: string
}
| { kind: 'text'; text: string }
// Universal fallback
| { kind: 'empty' }
@@ -38,6 +46,9 @@ interface ResolveCellRenderInput {
/** Column is an enrichment-group output — a completed-but-empty cell renders
* "Not found" rather than a blank, since the enrichment ran and matched nothing. */
isEnrichmentOutput?: boolean
/** Current workspace id — a URL pointing to a resource in this workspace
* renders as a tagged-resource chip rather than a plain external link. */
currentWorkspaceId?: string
}
export function resolveCellRender({
@@ -46,6 +57,7 @@ export function resolveCellRender({
column,
waitingOnLabels,
isEnrichmentOutput,
currentWorkspaceId,
}: ResolveCellRenderInput): CellRenderKind {
const isNull = value === null || value === undefined
const isEmpty = isNull || value === ''
@@ -97,6 +109,18 @@ export function resolveCellRender({
if (column.type === 'date') return { kind: 'date', text: String(value) }
if (column.type === 'string') {
const text = stringifyValue(value)
if (currentWorkspaceId) {
const resource = extractSimResourceInfo(text)
if (resource && resource.workspaceId === currentWorkspaceId) {
return {
kind: 'sim-resource',
workspaceId: resource.workspaceId,
resourceType: resource.resourceType,
resourceId: resource.resourceId,
href: resource.href,
}
}
}
const urlInfo = extractUrlInfo(text)
if (urlInfo) return { kind: 'url', text, href: urlInfo.href, domain: urlInfo.domain }
return { kind: 'text', text }
@@ -131,6 +155,43 @@ function extractUrlInfo(text: string): { href: string; domain: string } | null {
return null
}
/** Maps a workspace route section to the sim resource kind it addresses. */
const SIM_RESOURCE_SECTIONS: Record<string, SimResourceType> = {
w: 'workflow',
tables: 'table',
knowledge: 'knowledge',
files: 'file',
}
/**
* Recognizes a `/workspace/{id}/{section}/{resourceId}` URL (absolute or
* relative) pointing to a sim resource and returns its descriptor. The href is
* the pathname so the link stays within the current deployment. Returns null
* for anything that isn't a single-segment resource route.
*/
function extractSimResourceInfo(
text: string
): { workspaceId: string; resourceType: SimResourceType; resourceId: string; href: string } | null {
const trimmed = text.trim()
if (!trimmed) return null
let pathname: string
if (/^https?:\/\//i.test(trimmed)) {
try {
pathname = new URL(trimmed).pathname
} catch {
return null
}
} else if (trimmed.startsWith('/')) {
pathname = trimmed.split(/[?#]/)[0]
} else {
return null
}
const match = pathname.match(/^\/workspace\/([^/]+)\/(w|tables|knowledge|files)\/([^/]+)\/?$/)
if (!match) return null
const [, workspaceId, section, resourceId] = match
return { workspaceId, resourceType: SIM_RESOURCE_SECTIONS[section], resourceId, href: pathname }
}
interface CellRenderProps {
kind: CellRenderKind
isEditing: boolean
@@ -270,6 +331,17 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
</span>
)
case 'sim-resource':
return (
<SimResourceCell
workspaceId={kind.workspaceId}
resourceType={kind.resourceType}
resourceId={kind.resourceId}
href={kind.href}
isEditing={isEditing}
/>
)
case 'text':
return (
<span
@@ -0,0 +1,104 @@
'use client'
import { useMemo } from 'react'
import { cn } from '@/lib/core/utils/cn'
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useTablesList } from '@/hooks/queries/tables'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
/** Sim resource kinds a table cell URL can point to within the current workspace. */
export type SimResourceType = 'workflow' | 'table' | 'knowledge' | 'file'
const FALLBACK_LABEL: Record<SimResourceType, string> = {
workflow: 'Workflow',
table: 'Table',
knowledge: 'Knowledge base',
file: 'File',
}
interface SimResourceCellProps {
/** Always the current workspace — the resolver only emits this kind for same-workspace URLs. */
workspaceId: string
resourceType: SimResourceType
resourceId: string
/** In-app pathname the resource link navigates to. */
href: string
isEditing: boolean
}
/**
* Renders a cell whose value is a URL pointing to a sim resource in the current
* workspace as a tagged-resource chip — the same icon (and per-workflow colored
* square) used for @-style resource mentions, plus the resource's name as a link.
* Only the list matching `resourceType` is fetched; the other queries stay
* disabled so a sim-resource cell subscribes to a single shared list.
*/
export function SimResourceCell({
workspaceId,
resourceType,
resourceId,
href,
isEditing,
}: SimResourceCellProps) {
const { data: workflows = [] } = useWorkflows(
resourceType === 'workflow' ? workspaceId : undefined
)
const { data: tables = [] } = useTablesList(resourceType === 'table' ? workspaceId : undefined)
const { data: knowledgeBases = [] } = useKnowledgeBasesQuery(workspaceId, {
enabled: resourceType === 'knowledge',
})
const { data: files = [] } = useWorkspaceFiles(workspaceId, 'active', {
enabled: resourceType === 'file',
})
const workflow =
resourceType === 'workflow' ? workflows.find((w) => w.id === resourceId) : undefined
const name = useMemo(() => {
switch (resourceType) {
case 'workflow':
return workflow?.name
case 'table':
return tables.find((t) => t.id === resourceId)?.name
case 'knowledge':
return knowledgeBases.find((kb) => kb.id === resourceId)?.name
case 'file':
return files.find((f) => f.id === resourceId)?.name
}
}, [resourceType, resourceId, workflow, tables, knowledgeBases, files])
const label = name ?? FALLBACK_LABEL[resourceType]
const context: ChatMessageContext =
resourceType === 'workflow'
? { kind: 'workflow', label, workflowId: resourceId }
: resourceType === 'table'
? { kind: 'table', label, tableId: resourceId }
: resourceType === 'knowledge'
? { kind: 'knowledge', label, knowledgeId: resourceId }
: { kind: 'file', label, fileId: resourceId }
return (
<span className={cn('flex min-w-0 items-center gap-1.5', isEditing && 'invisible')}>
<ContextMentionIcon
context={context}
workflowColor={workflow?.color ?? null}
className='size-[14px] shrink-0 text-[var(--text-icon)]'
/>
<a
href={href}
className={cn(
'min-w-0 overflow-clip text-ellipsis text-[var(--text-primary)] underline underline-offset-2 hover:opacity-70',
isEditing && 'pointer-events-none'
)}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{label}
</a>
</span>
)
}
@@ -23,6 +23,9 @@ import { type NormalizedSelection, resolveCellExec } from './utils'
export interface DataRowProps {
row: TableRowType
columns: DisplayColumn[]
/** Current workspace id — forwarded to cells so in-workspace resource URLs
* render as tagged-resource chips. */
workspaceId: string
rowIndex: number
isFirstRow: boolean
editingColumnName: string | null
@@ -98,6 +101,7 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean {
if (
prev.row !== next.row ||
prev.columns !== next.columns ||
prev.workspaceId !== next.workspaceId ||
prev.rowIndex !== next.rowIndex ||
prev.isFirstRow !== next.isFirstRow ||
prev.editingColumnName !== next.editingColumnName ||
@@ -141,6 +145,7 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean {
export const DataRow = React.memo(function DataRow({
row,
columns,
workspaceId,
rowIndex,
isFirstRow,
editingColumnName,
@@ -204,7 +209,12 @@ export const DataRow = React.memo(function DataRow({
tabIndex={0}
aria-checked={isRowSelected}
aria-label={`Select row ${rowIndex + 1}`}
className='group/checkbox flex h-[20px] shrink-0 items-center justify-center'
className={cn(
'group/checkbox flex h-[20px] shrink-0 items-center justify-end',
// Lighter right inset for narrow indices (≤3 digits → numDivWidth ≤ 28);
// full 4px once the column widens (4+ digits, numDivWidth ≥ 36).
numDivWidth >= 36 ? 'pr-1' : 'pr-0.5'
)}
style={{ width: numDivWidth }}
onMouseDown={(e) => {
if (e.button !== 0) return
@@ -216,7 +226,7 @@ export const DataRow = React.memo(function DataRow({
>
<span
className={cn(
'text-center text-[var(--text-tertiary)] text-xs tabular-nums',
'text-right text-[var(--text-tertiary)] text-xs tabular-nums',
isRowSelected ? 'hidden' : 'block group-hover/checkbox:hidden'
)}
>
@@ -328,6 +338,7 @@ export const DataRow = React.memo(function DataRow({
)}
<div className={CELL_CONTENT}>
<CellContent
workspaceId={workspaceId}
value={
pendingCellValue && column.name in pendingCellValue
? pendingCellValue[column.name]
@@ -7,7 +7,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'
import { useParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { Skeleton, toast, useToast } from '@/components/emcn'
import { TableX } from '@/components/emcn/icons'
import { Loader, TableX } from '@/components/emcn/icons'
import type { RunLimit, RunMode } from '@/lib/api/contracts/tables'
import { cn } from '@/lib/core/utils/cn'
import { captureEvent } from '@/lib/posthog/client'
@@ -40,7 +40,6 @@ import {
import type { ColumnConfig } from '../column-config-sidebar'
import { ContextMenu } from '../context-menu'
import { NewColumnDropdown } from '../new-column-dropdown'
import { RunStatusControl } from '../run-status-control'
import type { WorkflowConfig } from '../workflow-sidebar'
import { ExpandedCellPopover } from './cells'
import { ADD_COL_WIDTH, CELL_HEADER_CHECKBOX, COL_WIDTH, SELECTION_TINT_BG } from './constants'
@@ -160,10 +159,6 @@ interface TableGridProps {
onStopRows: (rowIds: string[]) => void
/** Single-row stop for the per-row gutter button. */
onStopRow: (rowId: string) => void
/** Wholesale cancel — page-header "Stop all". */
onStopAll: () => void
/** Whether `useCancelTableRuns` is currently in flight. */
cancelRunsPending: boolean
/**
* Fired whenever the action-bar selection or running-count derivations
* change. Wrapper uses this to render <TableActionBar>.
@@ -258,8 +253,6 @@ export function TableGrid({
onRunRows,
onStopRows,
onStopRow,
onStopAll,
cancelRunsPending,
onSelectionChange,
queryOptions,
columnRenameSinkRef,
@@ -333,8 +326,13 @@ export function TableGrid({
const { data: tableRunState } = useTableRunState(tableId)
const activeDispatches = tableRunState?.dispatches
const totalRunning = tableRunState?.runningCellCount ?? 0
const runningByRowId = tableRunState?.runningByRowId ?? EMPTY_RUNNING_BY_ROW
// Actual in-flight cell count = sum of the live per-row map (kept current by
// applyCell's SSE deltas, and the same source the per-row gutter uses). The
// dispatch-scope `runningCellCount` over-counts already-completed groups on
// rows still inside a dispatch's scope — e.g. a cascade where 3 of 4 columns
// finished would read "4 running" instead of "1".
const totalRunning = Object.values(runningByRowId).reduce((sum, n) => sum + n, 0)
const tableRowCountRef = useRef(tableData?.rowCount ?? 0)
tableRowCountRef.current = tableData?.rowCount ?? 0
@@ -3087,8 +3085,12 @@ export function TableGrid({
rowId: row.id,
groupId,
executionId: exec?.executionId ?? null,
// Requires a real executionId: an error that never produced an execution
// (e.g. enqueue failure → status 'error' with executionId null) has no
// trace to open, so "View execution" must not offer it.
canViewExecution:
!isEnrichmentGroup &&
Boolean(exec?.executionId) &&
(status === 'completed' || status === 'error' || status === 'running' || isPaused),
}
}, [normalizedSelection, rows, displayColumns, workflowGroupById])
@@ -3235,16 +3237,6 @@ export function TableGrid({
return (
<div ref={containerRef} className='flex h-full flex-col overflow-hidden'>
{embedded && totalRunning > 0 && (
<div className='flex shrink-0 items-center justify-end border-[var(--border)] border-b px-3 py-1.5'>
<RunStatusControl
running={totalRunning}
onStopAll={onStopAll}
isStopping={cancelRunsPending}
/>
</div>
)}
<div className='relative flex min-h-0 flex-1'>
<div
ref={scrollRef}
@@ -3498,6 +3490,7 @@ export function TableGrid({
key={row.id}
row={row}
columns={displayColumns}
workspaceId={workspaceId}
rowIndex={index}
isFirstRow={index === 0}
editingColumnName={
@@ -3541,6 +3534,18 @@ export function TableGrid({
/>
</tr>
)}
{isFetchingNextPage && (
<tr>
<td colSpan={displayColumns.length + 1} className='h-[35px] p-0'>
<div className='flex items-center justify-center'>
<Loader
animate
className='size-[14px] shrink-0 text-[var(--text-tertiary)]'
/>
</div>
</td>
</tr>
)}
</>
)
})()
@@ -49,7 +49,10 @@ export function checkboxColLayout(
): { colWidth: number; numDivWidth: number } {
const digits = maxRows > 0 ? Math.floor(Math.log10(maxRows)) + 1 : 1
const numDivWidth = Math.max(20, digits * 8 + 4)
const colWidth = Math.max(32, numDivWidth + 8) + (hasWorkflowCols ? 16 : 0)
// When workflow columns are present a 20px run/stop button sits to the right of
// the number, separated by a 6px gap and a 4px right pad — 30px total. Reserving
// only the button width clipped the number on tables with many (wide) row indices.
const colWidth = Math.max(32, numDivWidth + 8) + (hasWorkflowCols ? 30 : 0)
return { colWidth, numDivWidth }
}
@@ -196,6 +199,12 @@ export function resolveCellExec(
// cell SSE) cover the actual rows instead.
if (d.limit) continue
if (!d.scope.groupIds.includes(group.id)) continue
// Auto-fire dispatches (row writes / schema changes) scope every group but
// the dispatcher honors `autoRun: false` per-cell ('autoRun-off'), so those
// cells never actually run — don't optimistically paint them Queued. Manual
// runs (Run all / Run column) bypass autoRun and DO run them, so keep the
// overlay's Queued there.
if (!d.isManualRun && group.autoRun === false) continue
if (d.scope.rowIds && !d.scope.rowIds.includes(row.id)) continue
if (row.position <= d.cursor) continue
return {
@@ -462,36 +462,46 @@ export function Table({
return (
<div className='relative flex h-full flex-col overflow-hidden'>
{!embedded && (
<>
<ResourceHeader
icon={TableIcon}
breadcrumbs={breadcrumbs}
createTrigger={createTrigger}
actions={headerActions}
leadingActions={
selection.totalRunning > 0 ? (
<RunStatusControl
running={selection.totalRunning}
onStopAll={onStopAll}
isStopping={cancelRunsMutation.isPending}
/>
) : null
}
/>
<ResourceOptionsBar
sort={sortConfig}
onFilterToggle={() => setFilterOpen((prev) => !prev)}
filterActive={filterOpen || !!queryOptions.filter}
/>
{filterOpen && (
<TableFilter
columns={columns}
filter={queryOptions.filter}
onApply={handleFilterApply}
onClose={() => setFilterOpen(false)}
<ResourceHeader
icon={TableIcon}
breadcrumbs={breadcrumbs}
createTrigger={createTrigger}
actions={headerActions}
leadingActions={
selection.totalRunning > 0 ? (
<RunStatusControl
running={selection.totalRunning}
onStopAll={onStopAll}
isStopping={cancelRunsMutation.isPending}
/>
) : null
}
/>
)}
{/* Sort + filter render in both modes. In embedded (mothership) mode there's
no ResourceHeader, so the run/stop control rides in the options bar's
`extras` slot — keeping the bar populated whether or not a run is live. */}
<ResourceOptionsBar
sort={sortConfig}
onFilterToggle={() => setFilterOpen((prev) => !prev)}
filterActive={filterOpen || !!queryOptions.filter}
extras={
embedded && selection.totalRunning > 0 ? (
<RunStatusControl
running={selection.totalRunning}
onStopAll={onStopAll}
isStopping={cancelRunsMutation.isPending}
/>
)}
</>
) : undefined
}
/>
{filterOpen && (
<TableFilter
columns={columns}
filter={queryOptions.filter}
onApply={handleFilterApply}
onClose={() => setFilterOpen(false)}
/>
)}
<TableGrid
workspaceId={workspaceId}
@@ -511,8 +521,6 @@ export function Table({
onRunRows={onRunRows}
onStopRows={onStopRows}
onStopRow={onStopRow}
onStopAll={onStopAll}
cancelRunsPending={cancelRunsMutation.isPending}
onSelectionChange={onSelectionChange}
queryOptions={queryOptions}
columnRenameSinkRef={columnRenameSinkRef}
+6 -2
View File
@@ -98,11 +98,15 @@ async function fetchWorkspaceFiles(
/**
* Hook to fetch workspace files
*/
export function useWorkspaceFiles(workspaceId: string, scope: WorkspaceFileQueryScope = 'active') {
export function useWorkspaceFiles(
workspaceId: string,
scope: WorkspaceFileQueryScope = 'active',
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: workspaceFilesKeys.list(workspaceId, scope),
queryFn: ({ signal }) => fetchWorkspaceFiles(workspaceId, scope, signal),
enabled: !!workspaceId,
enabled: !!workspaceId && (options?.enabled ?? true),
staleTime: 30 * 1000, // 30 seconds - files can change frequently
placeholderData: keepPreviousData, // Show cached data immediately
})
+14 -2
View File
@@ -3,7 +3,7 @@ import { tableRowExecutions, tableRunDispatches, userTableRows } from '@sim/db/s
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, asc, eq, gt, inArray, type SQL, sql } from 'drizzle-orm'
import { and, asc, eq, gt, inArray, isNotNull, ne, or, type SQL, sql } from 'drizzle-orm'
import { getJobQueue } from '@/lib/core/async-jobs/config'
import { writeWorkflowGroupState } from '@/lib/table/cell-write'
import { appendTableEvent } from '@/lib/table/events'
@@ -185,6 +185,15 @@ export async function insertDispatch(input: {
* gutter Run/Stop button. All three statuses are user-cancellable, so the
* gutter must surface Stop whenever any of them are present (else clicking
* Play during the queued window would re-run an already-queued cell).
*
* Excludes orphan pre-stamps — `pending` rows with no `executionId` — which
* are dead placeholders left when a dispatcher loop wrote the stamp but no
* cell-task ever picked it up (lock contention, queue failure, crash). The
* cell already shows its prior value and `classifyEligibility` treats these as
* claimable, so counting them stuck the "X running" badge above zero forever
* even though nothing was running. Same `executionId == null` test used by
* {@link classifyEligibility} / {@link pickNextEligibleGroupForRow}.
*
* Hits the `(table_id, status)` partial index on table_row_executions. */
export async function countRunningCells(
tableId: string
@@ -198,7 +207,10 @@ export async function countRunningCells(
.where(
and(
eq(tableRowExecutions.tableId, tableId),
inArray(tableRowExecutions.status, ['queued', 'running', 'pending'])
inArray(tableRowExecutions.status, ['queued', 'running', 'pending']),
// Exclude orphan pre-stamps (`pending` + null executionId). De Morgan of
// NOT(pending AND null) — `status` is NOT NULL so `ne` is well-defined.
or(ne(tableRowExecutions.status, 'pending'), isNotNull(tableRowExecutions.executionId))
)
)
.groupBy(tableRowExecutions.rowId)