Skip to content

Commit 6c54fa6

Browse files
fix(enrichment): store manual attempts in execution metadata
1 parent a3017e0 commit 6c54fa6

13 files changed

Lines changed: 124 additions & 41 deletions

File tree

apps/sim/app/api/table/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ function makeRequest(tableId = 'tbl_1', rowId = 'row_1', groupId = 'grp_1') {
5050
}
5151

5252
const detail: EnrichmentRunDetail = {
53+
isManualRun: true,
5354
startedAt: '2026-06-18T00:00:00.000Z',
5455
completedAt: '2026-06-18T00:00:01.000Z',
5556
durationMs: 1000,

apps/sim/app/api/tools/enrichment/run/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4848

4949
const { result, cost, error, provider } = await runEnrichment(enrichment, inputs, {
5050
workspaceId,
51+
isManualRun: false,
5152
signal: request.signal,
5253
})
5354

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { sleep } from '@sim/utils/helpers'
66
import { generateId } from '@sim/utils/id'
77
import { backoffWithJitter } from '@sim/utils/retry'
88
import { task, timeout } from '@trigger.dev/sdk'
9-
import { and, eq, isNull, or } from 'drizzle-orm'
9+
import { and, eq, isNull, or, sql } from 'drizzle-orm'
1010
import {
1111
assertBillingAttributionSnapshot,
1212
type BillingAttributionSnapshot,
@@ -134,9 +134,9 @@ export async function terminalizeAbortedQueuedCarrierMarker(
134134
executionId: executionState.executionId,
135135
jobId: executionState.jobId,
136136
workflowId: executionState.workflowId,
137-
isManualRun: payload.isManualRun,
138137
error: executionState.error,
139138
runningBlockIds: executionState.runningBlockIds,
139+
enrichmentDetails: sql`coalesce(${tableRowExecutions.enrichmentDetails}, '{}'::jsonb) || jsonb_build_object('isManualRun', ${payload.isManualRun})`,
140140
updatedAt: new Date(),
141141
})
142142
.where(
@@ -555,7 +555,9 @@ async function runWorkflowAndWriteTerminal(
555555
jobId: null,
556556
workflowId: statusId,
557557
error: null,
558-
enrichmentDetails: skippedEnrichmentDetail(enrichment),
558+
enrichmentDetails: skippedEnrichmentDetail(enrichment, {
559+
isManualRun: payload.isManualRun,
560+
}),
559561
},
560562
clearPatch,
561563
undefined,
@@ -573,7 +575,10 @@ async function runWorkflowAndWriteTerminal(
573575
timedOut: timeoutController.isTimedOut(),
574576
timeoutMs: timeoutController.timeoutMs,
575577
}),
576-
enrichmentDetails: skippedEnrichmentDetail(enrichment, { aborted: true }),
578+
enrichmentDetails: skippedEnrichmentDetail(enrichment, {
579+
isManualRun: payload.isManualRun,
580+
aborted: true,
581+
}),
577582
})
578583
return 'error'
579584
}
@@ -600,6 +605,7 @@ async function runWorkflowAndWriteTerminal(
600605
tableId,
601606
rowId,
602607
workspaceId,
608+
isManualRun: payload.isManualRun,
603609
signal: attemptSignal,
604610
resolvedSecretTraceRegistry: enrichmentRegistry,
605611
})

apps/sim/enrichments/run.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ function config(providers: EnrichmentProvider[]): EnrichmentConfig {
4040
}
4141
}
4242

43-
const ctx = { workspaceId: 'ws-1' }
43+
const ctx = { workspaceId: 'ws-1', isManualRun: false }
4444

4545
beforeEach(() => {
4646
mockExecuteTool.mockReset()
@@ -63,6 +63,7 @@ describe('runEnrichment cascade detail', () => {
6363
expect(outcome.provider).toBe('B')
6464

6565
expect(outcome.detail.matchedProvider).toBe('b')
66+
expect(outcome.detail.isManualRun).toBe(false)
6667
expect(outcome.detail.totalCost).toBe(0.05)
6768
// The full cascade is recorded; the provider after the match is `not_run`.
6869
expect(outcome.detail.providers.map((p) => p.id)).toEqual(['a', 'b', 'c'])
@@ -139,7 +140,10 @@ describe('runEnrichment cascade detail', () => {
139140
})
140141

141142
it('skippedEnrichmentDetail marks every provider skipped without running', () => {
142-
const detail = skippedEnrichmentDetail(config([prov('a'), prov('b')]))
143+
const detail = skippedEnrichmentDetail(config([prov('a'), prov('b')]), {
144+
isManualRun: true,
145+
})
146+
expect(detail.isManualRun).toBe(true)
143147
expect(detail.matchedProvider).toBeNull()
144148
expect(detail.totalCost).toBe(0)
145149
expect(detail.providers.map((p) => p.status)).toEqual(['skipped', 'skipped'])

apps/sim/enrichments/run.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,11 @@ export interface EnrichmentRunOutcome {
3232
*/
3333
export function skippedEnrichmentDetail(
3434
enrichment: EnrichmentConfig,
35-
opts: { aborted?: boolean } = {}
35+
opts: { isManualRun: boolean; aborted?: boolean }
3636
): EnrichmentRunDetail {
3737
const now = new Date().toISOString()
3838
return {
39+
isManualRun: opts.isManualRun,
3940
startedAt: now,
4041
completedAt: now,
4142
durationMs: 0,
@@ -206,6 +207,7 @@ export async function runEnrichment(
206207

207208
const completedAt = Date.now()
208209
const detail: EnrichmentRunDetail = {
210+
isManualRun: ctx.isManualRun,
209211
startedAt: new Date(startedAt).toISOString(),
210212
completedAt: new Date(completedAt).toISOString(),
211213
durationMs: completedAt - startedAt,

apps/sim/enrichments/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export interface EnrichmentRunContext {
3131
tableId?: string
3232
rowId?: string
3333
workspaceId: string
34+
/** Whether a user explicitly requested this table attempt. */
35+
isManualRun: boolean
3436
signal?: AbortSignal
3537
/** Isolated provenance for the exact mapped row inputs used by this run. */
3638
resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry

apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export const enrichmentRunServerTool: BaseServerTool<EnrichmentRunParams, Enrich
4444

4545
const { result, cost, error, provider } = await runEnrichment(enrichment, inputs ?? {}, {
4646
workspaceId,
47+
isManualRun: false,
4748
signal: context?.abortSignal,
4849
})
4950

apps/sim/lib/table/dispatcher.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,19 @@ export async function dispatcherStep(
507507
// out into one query per row. Returns `Map<rowId, RowExecutions>`.
508508
const chunkRowIds = chunk.map((r) => r.id)
509509
const execRows = await db
510-
.select()
510+
.select({
511+
rowId: tableRowExecutions.rowId,
512+
groupId: tableRowExecutions.groupId,
513+
status: tableRowExecutions.status,
514+
executionId: tableRowExecutions.executionId,
515+
jobId: tableRowExecutions.jobId,
516+
workflowId: tableRowExecutions.workflowId,
517+
isManualRun: sql<boolean>`coalesce((${tableRowExecutions.enrichmentDetails} ->> 'isManualRun')::boolean, false)`,
518+
error: tableRowExecutions.error,
519+
runningBlockIds: tableRowExecutions.runningBlockIds,
520+
blockErrors: tableRowExecutions.blockErrors,
521+
cancelledAt: tableRowExecutions.cancelledAt,
522+
})
511523
.from(tableRowExecutions)
512524
.where(inArray(tableRowExecutions.rowId, chunkRowIds))
513525
const executionsByRow = new Map<string, RowExecutions>()

apps/sim/lib/table/rows/executions.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,22 @@ describe('writeExecutionsPatch guards', () => {
129129
)
130130
).resolves.toBe('wrote')
131131
})
132+
133+
it('stores manual-run provenance in the existing execution JSON', async () => {
134+
await expect(
135+
writeExecutionsPatch(
136+
dbChainMock.db as unknown as Parameters<typeof writeExecutionsPatch>[0],
137+
'table-1',
138+
'row-1',
139+
{ 'group-1': { ...EXECUTION_STATE, isManualRun: true } }
140+
)
141+
).resolves.toBe('wrote')
142+
143+
expect(dbChainMockFns.values).toHaveBeenCalledWith(
144+
expect.objectContaining({ enrichmentDetails: { isManualRun: true } })
145+
)
146+
expect(dbChainMockFns.values.mock.calls[0]?.[0]).not.toHaveProperty('isManualRun')
147+
})
132148
})
133149

134150
describe('loadExecutionsByRow', () => {

apps/sim/lib/table/rows/executions.ts

Lines changed: 51 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,31 @@ import type {
1919
TableSchema,
2020
} from '@/lib/table/types'
2121

22+
function readExecutionIsManualRun(value: Record<string, unknown>): boolean {
23+
const isManualRun = value.isManualRun
24+
if (isManualRun === undefined) return false
25+
if (typeof isManualRun !== 'boolean') {
26+
throw new Error('Execution metadata isManualRun must be a boolean')
27+
}
28+
return isManualRun
29+
}
30+
31+
function readEnrichmentRunDetail(value: unknown): EnrichmentRunDetail | null {
32+
if (value === null || value === undefined) return null
33+
if (typeof value !== 'object' || Array.isArray(value)) {
34+
throw new Error('Enrichment run detail must be a JSON object')
35+
}
36+
const metadata = value as Record<string, unknown>
37+
if (metadata.providers === undefined) return null
38+
if (!Array.isArray(metadata.providers)) {
39+
throw new Error('Enrichment run detail providers must be an array')
40+
}
41+
return {
42+
...(value as Omit<EnrichmentRunDetail, 'isManualRun'>),
43+
isManualRun: readExecutionIsManualRun(metadata),
44+
}
45+
}
46+
2247
/**
2348
* Loads `tableRowExecutions` rows for the given row ids and groups them into a
2449
* `Map<rowId, RowExecutions>` suitable for plugging into `TableRow.executions`.
@@ -30,9 +55,9 @@ export async function loadExecutionsByRow(
3055
const ids = Array.from(new Set(rowIds))
3156
const result = new Map<string, RowExecutions>()
3257
if (ids.length === 0) return result
33-
// Explicit column list, never `select()` — `enrichmentDetails` is large and
34-
// must stay off the hot grid read path (fetched on demand via
35-
// `loadEnrichmentDetail`).
58+
// Explicit column list, never `select()` — project only the small provenance
59+
// scalar from `enrichmentDetails`; the provider cascade stays off the hot
60+
// grid read path and is fetched on demand via `loadEnrichmentDetail`.
3661
const rows = await trx
3762
.select({
3863
rowId: tableRowExecutions.rowId,
@@ -41,7 +66,7 @@ export async function loadExecutionsByRow(
4166
executionId: tableRowExecutions.executionId,
4267
jobId: tableRowExecutions.jobId,
4368
workflowId: tableRowExecutions.workflowId,
44-
isManualRun: tableRowExecutions.isManualRun,
69+
isManualRun: sql<boolean>`coalesce((${tableRowExecutions.enrichmentDetails} ->> 'isManualRun')::boolean, false)`,
4570
error: tableRowExecutions.error,
4671
runningBlockIds: tableRowExecutions.runningBlockIds,
4772
blockErrors: tableRowExecutions.blockErrors,
@@ -100,7 +125,20 @@ export async function loadEnrichmentDetail(
100125
) as SQL
101126
)
102127
.limit(1)
103-
return (row?.enrichmentDetails as EnrichmentRunDetail | null | undefined) ?? null
128+
return readEnrichmentRunDetail(row?.enrichmentDetails)
129+
}
130+
131+
function buildStoredExecutionMetadata(
132+
value: RowExecutionMetadata
133+
): EnrichmentRunDetail | { isManualRun: boolean } | null {
134+
const detail = value.enrichmentDetails
135+
if (detail) {
136+
if (value.isManualRun !== undefined && detail.isManualRun !== value.isManualRun) {
137+
throw new Error('Execution state and enrichment detail disagree on manual-run provenance')
138+
}
139+
return detail
140+
}
141+
return value.isManualRun === undefined ? null : { isManualRun: value.isManualRun }
104142
}
105143

106144
/**
@@ -265,14 +303,18 @@ export async function writeExecutionsPatch(
265303
executionId: value.executionId,
266304
jobId: value.jobId,
267305
workflowId: value.workflowId,
268-
isManualRun: value.isManualRun ?? false,
269306
error: value.error,
270307
runningBlockIds: value.runningBlockIds ?? [],
271308
blockErrors: value.blockErrors ?? {},
272309
cancelledAt: value.cancelledAt ? new Date(value.cancelledAt) : null,
273-
enrichmentDetails: value.enrichmentDetails ?? null,
310+
enrichmentDetails: buildStoredExecutionMetadata(value),
274311
updatedAt: new Date(),
275312
} as const
313+
const enrichmentDetailsUpdate = value.enrichmentDetails
314+
? insertValues.enrichmentDetails
315+
: value.isManualRun !== undefined
316+
? sql`coalesce(${tableRowExecutions.enrichmentDetails}, '{}'::jsonb) || excluded.enrichment_details`
317+
: tableRowExecutions.enrichmentDetails
276318

277319
if (isGuarded) {
278320
// Gate by guard semantics. The original JSONB guard had two AND'd
@@ -303,19 +345,11 @@ export async function writeExecutionsPatch(
303345
executionId: insertValues.executionId,
304346
jobId: insertValues.jobId,
305347
workflowId: insertValues.workflowId,
306-
isManualRun:
307-
value.isManualRun === undefined
308-
? tableRowExecutions.isManualRun
309-
: insertValues.isManualRun,
310348
error: insertValues.error,
311349
runningBlockIds: insertValues.runningBlockIds,
312350
blockErrors: insertValues.blockErrors,
313351
cancelledAt: insertValues.cancelledAt,
314-
// Sticky: preserve a prior cascade breakdown when this write omits
315-
// it (e.g. the running pickup stamp) so only an explicit detail
316-
// overwrites it. Re-runs delete the row first, so this never serves
317-
// stale detail across runs.
318-
enrichmentDetails: sql`coalesce(excluded.enrichment_details, ${tableRowExecutions.enrichmentDetails})`,
352+
enrichmentDetails: enrichmentDetailsUpdate,
319353
updatedAt: insertValues.updatedAt,
320354
},
321355
where: guardCondition as SQL,
@@ -335,18 +369,11 @@ export async function writeExecutionsPatch(
335369
executionId: insertValues.executionId,
336370
jobId: insertValues.jobId,
337371
workflowId: insertValues.workflowId,
338-
isManualRun:
339-
value.isManualRun === undefined
340-
? tableRowExecutions.isManualRun
341-
: insertValues.isManualRun,
342372
error: insertValues.error,
343373
runningBlockIds: insertValues.runningBlockIds,
344374
blockErrors: insertValues.blockErrors,
345375
cancelledAt: insertValues.cancelledAt,
346-
// Sticky: preserve a prior cascade breakdown when this write omits it
347-
// (e.g. the running pickup stamp) so only an explicit detail overwrites
348-
// it. Re-runs delete the row first, so this never serves stale detail.
349-
enrichmentDetails: sql`coalesce(excluded.enrichment_details, ${tableRowExecutions.enrichmentDetails})`,
376+
enrichmentDetails: enrichmentDetailsUpdate,
350377
updatedAt: insertValues.updatedAt,
351378
},
352379
})

0 commit comments

Comments
 (0)