Skip to content

Commit 3dfa07b

Browse files
fix(enrichment): track manual table attempts
1 parent 346e003 commit 3dfa07b

22 files changed

Lines changed: 19069 additions & 30 deletions

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
SELECTION_TINT_BG,
2020
} from './constants'
2121
import type { DisplayColumn } from './types'
22-
import { type NormalizedSelection, resolveCellExec } from './utils'
22+
import { hasAttemptedManualRun, type NormalizedSelection, resolveCellExec } from './utils'
2323

2424
export interface DataRowProps {
2525
row: TableRowType
@@ -203,9 +203,8 @@ export const DataRow = React.memo(function DataRow({
203203
for (const id of unmet.columns) labels.add(nameByColumnId.get(id) ?? id)
204204
}
205205

206-
const hasAttemptedManualRun =
207-
group.autoRun === false && row.executions?.[group.id] !== undefined
208-
if (group.type === 'enrichment' && (group.autoRun !== false || hasAttemptedManualRun)) {
206+
const manuallyAttempted = hasAttemptedManualRun(row, group)
207+
if (group.type === 'enrichment' && (group.autoRun !== false || manuallyAttempted)) {
209208
const enrichment = getEnrichment(group.enrichmentId)
210209
if (enrichment) {
211210
const readiness = getEnrichmentRowReadiness(enrichment, group, row.data, schemaColumns)

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
canWriteRowsWithChip,
1616
chipRowCount,
1717
drainTargetForChip,
18+
hasAttemptedManualRun,
1819
resolveCellExec,
1920
selectedColumnIds,
2021
} from './utils'
@@ -196,3 +197,63 @@ describe('resolveCellExec', () => {
196197
expect(resolveCellExec(pendingRow, group, [dispatch], true)).toBeUndefined()
197198
})
198199
})
200+
201+
describe('hasAttemptedManualRun', () => {
202+
const group: WorkflowGroup = {
203+
id: 'group-1',
204+
workflowId: '',
205+
type: 'enrichment',
206+
autoRun: false,
207+
outputs: [{ blockId: '', path: '', outputId: 'email', columnName: 'email' }],
208+
}
209+
const row: TableRow = {
210+
id: 'row-1',
211+
data: {},
212+
executions: {},
213+
position: 0,
214+
createdAt: new Date(),
215+
updatedAt: new Date(),
216+
}
217+
218+
it('does not treat an earlier automatic execution as a manual attempt', () => {
219+
expect(
220+
hasAttemptedManualRun(
221+
{
222+
...row,
223+
executions: {
224+
[group.id]: {
225+
status: 'completed',
226+
executionId: 'execution-1',
227+
jobId: null,
228+
workflowId: '',
229+
isManualRun: false,
230+
error: null,
231+
},
232+
},
233+
},
234+
group
235+
)
236+
).toBe(false)
237+
})
238+
239+
it('recognizes an explicit manual attempt', () => {
240+
expect(
241+
hasAttemptedManualRun(
242+
{
243+
...row,
244+
executions: {
245+
[group.id]: {
246+
status: 'completed',
247+
executionId: 'execution-2',
248+
jobId: null,
249+
workflowId: '',
250+
isManualRun: true,
251+
error: null,
252+
},
253+
},
254+
},
255+
group
256+
)
257+
).toBe(true)
258+
})
259+
})

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,13 @@ export function readExecution(
218218
return row?.executions?.[groupId]
219219
}
220220

221+
export function hasAttemptedManualRun(
222+
row: Pick<TableRowType, 'executions'>,
223+
group: WorkflowGroup
224+
): boolean {
225+
return group.autoRun === false && row.executions?.[group.id]?.isManualRun === true
226+
}
227+
221228
/**
222229
* Resolves a cell's execution state with the "about to run" overlay applied:
223230
* for cells in an active dispatch's scope ahead of its cursor whose deps are

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,16 @@ describe('applyCellEventToRow', () => {
106106
}
107107
)
108108

109+
it('carries the authoritative manual-run origin through cell events', () => {
110+
const row = createRow(null, 'pending', { isManualRun: true })
111+
112+
expect(
113+
applyCellEventToRow(row, createCellEvent('running', 'execution-new', { isManualRun: true }))
114+
).toMatchObject({
115+
executions: { [GROUP_ID]: { isManualRun: true } },
116+
})
117+
})
118+
109119
it('ignores a delayed id-less pre-stamp after a terminal state', () => {
110120
const row = createRow('execution-old', 'completed')
111121

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,11 @@ export function applyCellEventToRow(
121121
jobId: event.jobId,
122122
// Preserve workflowId from cache; SSE payload doesn't carry it.
123123
workflowId: prevExec?.workflowId ?? '',
124+
...(event.isManualRun !== undefined
125+
? { isManualRun: event.isManualRun }
126+
: prevExec?.isManualRun !== undefined
127+
? { isManualRun: prevExec.isManualRun }
128+
: {}),
124129
error: event.error,
125130
...(event.runningBlockIds ? { runningBlockIds: event.runningBlockIds } : {}),
126131
...(event.blockErrors ? { blockErrors: event.blockErrors } : {}),

apps/sim/background/resume-execution.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,9 @@ async function continueCascadeAfterResume(
401401
): Promise<void> {
402402
const { getTableById } = await import('@/lib/table/service')
403403
const { getRowById } = await import('@/lib/table/rows/service')
404-
const { pickNextEligibleGroupForRow } = await import('@/lib/table/workflow-columns')
404+
const { isManualQueuedMarker, pickNextEligibleGroupForRow } = await import(
405+
'@/lib/table/workflow-columns'
406+
)
405407
const { runRowCascadeLoop } = await import('@/background/workflow-column-execution')
406408

407409
const freshTable = await getTableById(cellContext.tableId)
@@ -410,6 +412,7 @@ async function continueCascadeAfterResume(
410412
if (!freshRow) return
411413
const next = pickNextEligibleGroupForRow(freshTable, freshRow, cellContext.groupId)
412414
if (!next) return
415+
const nextExec = freshRow.executions?.[next.id]
413416
await runRowCascadeLoop(
414417
{
415418
tableId: cellContext.tableId,
@@ -419,6 +422,7 @@ async function continueCascadeAfterResume(
419422
groupId: next.id,
420423
workflowId: next.workflowId,
421424
executionId: generateId(),
425+
isManualRun: isManualQueuedMarker(nextExec),
422426
billingAttribution,
423427
},
424428
signal

apps/sim/background/workflow-column-execution.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import type {
5151
} from '@/lib/table/types'
5252
import {
5353
buildWorkflowGroupExecutionCorrelation,
54+
isManualQueuedMarker,
5455
type QueuedWorkflowGroupCellPayload,
5556
type WorkflowGroupCellPayload,
5657
} from '@/lib/table/workflow-columns'
@@ -133,6 +134,7 @@ export async function terminalizeAbortedQueuedCarrierMarker(
133134
executionId: executionState.executionId,
134135
jobId: executionState.jobId,
135136
workflowId: executionState.workflowId,
137+
isManualRun: payload.isManualRun,
136138
error: executionState.error,
137139
runningBlockIds: executionState.runningBlockIds,
138140
updatedAt: new Date(),
@@ -162,6 +164,7 @@ export async function terminalizeAbortedQueuedCarrierMarker(
162164
executionId: executionState.executionId ?? null,
163165
jobId: null,
164166
error: executionState.error ?? null,
167+
isManualRun: payload.isManualRun,
165168
})
166169
return true
167170
}
@@ -278,8 +281,8 @@ export async function executeWorkflowGroupCellJob(
278281
if (!freshRow) break
279282
const next = pickNextEligibleGroupForRow(freshTable, freshRow)
280283
if (!next) break
281-
// Only re-drive a genuine queued marker (an explicit run request whose
282-
// cell-task bailed during our release window). The inner cascade loop has
284+
// Only re-drive a genuine queued marker (a dispatched run whose cell-task
285+
// bailed during our release window). The inner cascade loop has
283286
// already drained every auto-eligible group, so re-driving a non-marker
284287
// group here would re-run forever — e.g. a group that completed with empty
285288
// outputs stays auto-eligible (the inner loop excludes it via
@@ -294,6 +297,7 @@ export async function executeWorkflowGroupCellJob(
294297
// Re-derive so a workflow group after an enrichment group doesn't keep a stale enrichmentId.
295298
enrichmentId: next.enrichmentId,
296299
executionId: generateId(),
300+
isManualRun: isManualQueuedMarker(nextExec),
297301
}
298302
}
299303
} finally {
@@ -315,6 +319,7 @@ export async function runRowCascadeLoop(
315319

316320
let currentGroupId = payload.groupId
317321
let currentWorkflowId = payload.workflowId
322+
let currentIsManualRun = payload.isManualRun
318323
// Fresh executionId per iteration: SQL guard rejects writes whose id ≠
319324
// row.executions[gid].executionId, so we need a new claim per group.
320325
let currentExecutionId = payload.executionId
@@ -327,6 +332,7 @@ export async function runRowCascadeLoop(
327332
groupId: currentGroupId,
328333
workflowId: currentWorkflowId,
329334
executionId: currentExecutionId,
335+
isManualRun: currentIsManualRun,
330336
},
331337
signal
332338
)
@@ -350,6 +356,7 @@ export async function runRowCascadeLoop(
350356
groupId: currentGroupId,
351357
workflowId: currentWorkflowId,
352358
executionId: currentExecutionId,
359+
isManualRun: currentIsManualRun,
353360
},
354361
signal,
355362
freshTable,
@@ -366,6 +373,8 @@ export async function runRowCascadeLoop(
366373
if (!freshRow) break
367374
const next = pickNextEligibleGroupForRow(freshTable, freshRow, currentGroupId)
368375
if (!next) break
376+
const nextExec = freshRow.executions?.[next.id]
377+
currentIsManualRun = isManualQueuedMarker(nextExec)
369378
currentGroupId = next.id
370379
currentWorkflowId = next.workflowId
371380
currentExecutionId = generateId()
@@ -411,7 +420,16 @@ async function runWorkflowAndWriteTerminal(
411420
)
412421
const { stashCellContextForResume } = await import('@/lib/table/workflow-columns')
413422

414-
const cellCtx = { tableId, rowId, workspaceId, groupId, executionId, requestId, table }
423+
const cellCtx = {
424+
tableId,
425+
rowId,
426+
workspaceId,
427+
groupId,
428+
executionId,
429+
isManualRun: payload.isManualRun,
430+
requestId,
431+
table,
432+
}
415433
const writeState = (
416434
executionState: RowExecutionMetadata,
417435
dataPatch?: RowData,

apps/sim/hooks/queries/tables.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2333,6 +2333,7 @@ function buildPendingExec(
23332333
executionId: prev?.executionId ?? null,
23342334
jobId: null,
23352335
workflowId: prev?.workflowId ?? workflowIdFallback ?? '',
2336+
isManualRun: true,
23362337
error: null,
23372338
}
23382339
}

apps/sim/lib/table/cell-write.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,25 @@ describe('writeWorkflowGroupState', () => {
130130
})
131131
})
132132

133+
it('persists and emits the attempt origin from the write context', async () => {
134+
const executionState = { ...RUNNING_STATE, isManualRun: true }
135+
136+
await expect(
137+
writeWorkflowGroupState({ ...CONTEXT, isManualRun: true }, { executionState: RUNNING_STATE })
138+
).resolves.toBe('wrote')
139+
140+
expect(mockWriteExecutionsPatch).toHaveBeenCalledWith(
141+
expect.anything(),
142+
TABLE.id,
143+
CONTEXT.rowId,
144+
{ [GROUP.id]: executionState },
145+
{ groupId: GROUP.id, executionId: CONTEXT.executionId }
146+
)
147+
expect(mockAppendTableEvent).toHaveBeenCalledWith(
148+
expect.objectContaining({ isManualRun: true })
149+
)
150+
})
151+
133152
it('writes only changed data while emitting cumulative outputs', async () => {
134153
const secretProvenance = {
135154
complete: true,

apps/sim/lib/table/cell-write.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export interface WriteWorkflowGroupContext {
4040
workspaceId: string
4141
groupId: string
4242
executionId: string
43+
/** Authoritative origin of the current cell attempt. */
44+
isManualRun?: boolean
4345
/** Preloaded, column-bounded table definition used to validate data patches. */
4446
table: TableDefinition
4547
/** Used as the `requestId` passed to `updateRow` for log correlation. */
@@ -68,16 +70,20 @@ export async function writeWorkflowGroupState(
6870
): Promise<'wrote' | 'skipped'> {
6971
const { tableId, rowId, workspaceId, groupId, executionId, table } = ctx
7072
const requestId = ctx.requestId ?? `wfgrp-${executionId}`
71-
const isCancelStamp = payload.executionState.status === 'cancelled'
72-
const isQueuedStamp = payload.executionState.status === 'queued'
73+
const executionState =
74+
ctx.isManualRun === undefined
75+
? payload.executionState
76+
: { ...payload.executionState, isManualRun: ctx.isManualRun }
77+
const isCancelStamp = executionState.status === 'cancelled'
78+
const isQueuedStamp = executionState.status === 'queued'
7379
const cancellationGuard = isCancelStamp
7480
? undefined
7581
: {
7682
groupId,
7783
executionId,
7884
...(isQueuedStamp ? { allowNewExecution: true } : {}),
7985
}
80-
const executionsPatch = { [groupId]: payload.executionState }
86+
const executionsPatch = { [groupId]: executionState }
8187
const dataPatch = payload.dataPatch
8288
const hasDataPatch = Boolean(dataPatch && Object.keys(dataPatch).length > 0)
8389

@@ -141,17 +147,20 @@ export async function writeWorkflowGroupState(
141147
const hasOutputs = rawEventOutputs && Object.keys(rawEventOutputs).length > 0
142148
const eventOutputs = hasOutputs ? { ...rawEventOutputs } : rawEventOutputs
143149
if (hasOutputs && eventOutputs) coerceRowValues(eventOutputs, table.schema)
144-
const runningBlockIds = payload.executionState.runningBlockIds
145-
const blockErrors = payload.executionState.blockErrors
150+
const runningBlockIds = executionState.runningBlockIds
151+
const blockErrors = executionState.blockErrors
146152
void appendTableEvent({
147153
kind: 'cell',
148154
tableId,
149155
rowId,
150156
groupId,
151-
status: payload.executionState.status,
152-
executionId: payload.executionState.executionId ?? null,
153-
jobId: payload.executionState.jobId ?? null,
154-
error: payload.executionState.error ?? null,
157+
status: executionState.status,
158+
executionId: executionState.executionId ?? null,
159+
jobId: executionState.jobId ?? null,
160+
error: executionState.error ?? null,
161+
...(executionState.isManualRun !== undefined
162+
? { isManualRun: executionState.isManualRun }
163+
: {}),
155164
...(hasOutputs ? { outputs: eventOutputs } : {}),
156165
...(runningBlockIds && runningBlockIds.length > 0 ? { runningBlockIds } : {}),
157166
...(blockErrors && Object.keys(blockErrors).length > 0 ? { blockErrors } : {}),
@@ -380,13 +389,14 @@ export async function markWorkflowGroupPickedUp(
380389
* tombstone the dispatcher reads to skip re-runs of cells the user killed
381390
* mid-cascade. */
382391
export function buildCancelledExecution(
383-
prev: Pick<RowExecutionMetadata, 'executionId' | 'workflowId' | 'blockErrors'>
392+
prev: Pick<RowExecutionMetadata, 'executionId' | 'workflowId' | 'isManualRun' | 'blockErrors'>
384393
): RowExecutionMetadata {
385394
return {
386395
status: 'cancelled',
387396
executionId: prev.executionId ?? null,
388397
jobId: null,
389398
workflowId: prev.workflowId,
399+
...(prev.isManualRun !== undefined ? { isManualRun: prev.isManualRun } : {}),
390400
error: 'Cancelled',
391401
cancelledAt: new Date().toISOString(),
392402
...(prev.blockErrors ? { blockErrors: prev.blockErrors } : {}),

0 commit comments

Comments
 (0)