feat(enrichment): add enrichment details sidebar with cost + provider cascade (#5139)

* feat(enrichment): add enrichment details sidebar with cost + provider cascade

* fix(enrichment): address review — persist detail on cancel/skip, exclude not_run from ran count, refetch on panel open

* fix(enrichment): keep cascade detail sticky on upsert; mark unattempted providers not_run on abort

* fix(enrichment): show Cancelled in details panel for aborted runs
This commit is contained in:
Theodore Li
2026-06-19 14:19:13 -07:00
committed by GitHub
parent 9d2a6ef043
commit 208d135dac
22 changed files with 17879 additions and 45 deletions
@@ -0,0 +1,118 @@
/**
* @vitest-environment node
*/
import { hybridAuthMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { EnrichmentRunDetail, TableDefinition } from '@/lib/table'
const { mockCheckAccess, mockLoadEnrichmentDetail } = vi.hoisted(() => ({
mockCheckAccess: vi.fn(),
mockLoadEnrichmentDetail: vi.fn(),
}))
vi.mock('@/lib/table/rows/executions', () => ({
loadEnrichmentDetail: mockLoadEnrichmentDetail,
}))
vi.mock('@/app/api/table/utils', async () => {
const { NextResponse } = await import('next/server')
return {
checkAccess: mockCheckAccess,
accessError: (result: { status: number }) =>
NextResponse.json({ error: 'denied' }, { status: result.status }),
}
})
import { GET } from '@/app/api/table/[tableId]/rows/[rowId]/enrichment/[groupId]/route'
function buildTable(): TableDefinition {
return {
id: 'tbl_1',
name: 'People',
description: null,
schema: { columns: [] },
metadata: null,
rowCount: 1,
maxRows: 1_000_000,
workspaceId: 'workspace-1',
createdBy: 'user-1',
archivedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
}
}
function makeRequest(tableId = 'tbl_1', rowId = 'row_1', groupId = 'grp_1') {
const req = new NextRequest(
`http://localhost:3000/api/table/${tableId}/rows/${rowId}/enrichment/${groupId}`
)
return GET(req, { params: Promise.resolve({ tableId, rowId, groupId }) })
}
const detail: EnrichmentRunDetail = {
startedAt: '2026-06-18T00:00:00.000Z',
completedAt: '2026-06-18T00:00:01.000Z',
durationMs: 1000,
totalCost: 0.05,
matchedProvider: 'hunter',
aborted: false,
providers: [
{
id: 'hunter',
label: 'Hunter',
toolId: 'hunter_find_email',
status: 'matched',
cost: 0.05,
durationMs: 1000,
error: null,
},
],
}
describe('GET /api/table/[tableId]/rows/[rowId]/enrichment/[groupId]', () => {
beforeEach(() => {
vi.clearAllMocks()
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
authType: 'session',
})
mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() })
})
it('returns the enrichment detail', async () => {
mockLoadEnrichmentDetail.mockResolvedValue(detail)
const res = await makeRequest()
expect(res.status).toBe(200)
const json = await res.json()
expect(json).toEqual({ success: true, data: { detail } })
expect(mockLoadEnrichmentDetail).toHaveBeenCalledWith(
expect.anything(),
'tbl_1',
'row_1',
'grp_1'
)
})
it('returns null when there is no recorded run', async () => {
mockLoadEnrichmentDetail.mockResolvedValue(null)
const res = await makeRequest()
expect(res.status).toBe(200)
const json = await res.json()
expect(json).toEqual({ success: true, data: { detail: null } })
})
it('401s when unauthenticated', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false })
const res = await makeRequest()
expect(res.status).toBe(401)
expect(mockLoadEnrichmentDetail).not.toHaveBeenCalled()
})
it('denies when access check fails', async () => {
mockCheckAccess.mockResolvedValue({ ok: false, status: 403 })
const res = await makeRequest()
expect(res.status).toBe(403)
expect(mockLoadEnrichmentDetail).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,51 @@
import { db } from '@sim/db'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { getEnrichmentDetailContract } from '@/lib/api/contracts/tables'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { loadEnrichmentDetail } from '@/lib/table/rows/executions'
import { accessError, checkAccess } from '@/app/api/table/utils'
const logger = createLogger('EnrichmentDetailAPI')
interface RouteParams {
params: Promise<{ tableId: string; rowId: string; groupId: string }>
}
/**
* GET /api/table/[tableId]/rows/[rowId]/enrichment/[groupId]
*
* Returns the enrichment cascade breakdown (provider outcomes, cost, timing)
* for one enrichment cell. Read on demand by the enrichment details panel —
* this data is deliberately kept off the hot grid read. Returns `null` for
* cells with no recorded run or runs that predate the feature.
*/
export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => {
const requestId = generateRequestId()
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const parsed = await parseRequest(getEnrichmentDetailContract, request, { params })
if (!parsed.success) return parsed.response
const { tableId, rowId, groupId } = parsed.data.params
const result = await checkAccess(tableId, authResult.userId, 'read')
if (!result.ok) return accessError(result, requestId, tableId)
const detail = await loadEnrichmentDetail(db, tableId, rowId, groupId)
logger.info(`[${requestId}] Loaded enrichment detail`, {
tableId,
rowId,
groupId,
hasDetail: detail !== null,
})
return NextResponse.json({ success: true, data: { detail } })
})
@@ -32,7 +32,7 @@ import {
import { cn } from '@/lib/core/utils/cn'
import type { TraceSpan } from '@/lib/logs/types'
import {
DEFAULT_BLOCK_COLOR,
adjustBgForContrast,
formatCostAmount,
formatTokenCount,
formatTps,
@@ -41,6 +41,7 @@ import {
getDisplayName,
hasErrorInTree,
hasUnhandledErrorInTree,
iconColorClass,
isIterationType,
parseTime,
} from '@/app/workspace/[workspaceId]/logs/components/log-details/utils'
@@ -119,31 +120,6 @@ function getDisplayChildren(span: TraceSpan): TraceSpan[] {
return kids
}
/** Returns 'text-white' for dark backgrounds, dark text for light ones. */
function iconColorClass(bgColor: string): string {
const hex = bgColor.replace('#', '')
if (hex.length !== 6) return 'text-white'
const r = Number.parseInt(hex.slice(0, 2), 16)
const g = Number.parseInt(hex.slice(2, 4), 16)
const b = Number.parseInt(hex.slice(4, 6), 16)
return r * 299 + g * 587 + b * 114 > 160_000 ? 'text-[#111111]' : 'text-white'
}
/**
* Near-black bgColors disappear against the dark-mode surface (--bg: #1b1b1b).
* Below the luminance threshold we fall back to the neutral block color used
* for blocks with no distinct identity; everything brighter passes through.
*/
function adjustBgForContrast(bgColor: string): string {
const hex = bgColor.replace('#', '')
if (hex.length !== 6) return bgColor
const r = Number.parseInt(hex.slice(0, 2), 16)
const g = Number.parseInt(hex.slice(2, 4), 16)
const b = Number.parseInt(hex.slice(4, 6), 16)
if (r * 299 + g * 587 + b * 114 < 30_000) return DEFAULT_BLOCK_COLOR
return bgColor
}
/**
* Flattens the visible (expanded) span tree into a linear list for keyboard
* navigation, carrying depth, the chain of parent ids for indent drawing, and
@@ -81,6 +81,31 @@ export function getBlockIconAndColor(
return { icon: null, bgColor: DEFAULT_BLOCK_COLOR }
}
/** Returns 'text-white' for dark backgrounds, dark text for light ones. */
export function iconColorClass(bgColor: string): string {
const hex = bgColor.replace('#', '')
if (hex.length !== 6) return 'text-white'
const r = Number.parseInt(hex.slice(0, 2), 16)
const g = Number.parseInt(hex.slice(2, 4), 16)
const b = Number.parseInt(hex.slice(4, 6), 16)
return r * 299 + g * 587 + b * 114 > 160_000 ? 'text-[#111111]' : 'text-white'
}
/**
* Near-black bgColors disappear against the dark-mode surface (--bg: #1b1b1b).
* Below the luminance threshold we fall back to the neutral block color used
* for blocks with no distinct identity; everything brighter passes through.
*/
export function adjustBgForContrast(bgColor: string): string {
const hex = bgColor.replace('#', '')
if (hex.length !== 6) return bgColor
const r = Number.parseInt(hex.slice(0, 2), 16)
const g = Number.parseInt(hex.slice(2, 4), 16)
const b = Number.parseInt(hex.slice(4, 6), 16)
if (r * 299 + g * 587 + b * 114 < 30_000) return DEFAULT_BLOCK_COLOR
return bgColor
}
export function parseTime(value?: string | number | null): number {
if (!value) return 0
const ms = typeof value === 'number' ? value : new Date(value).getTime()
@@ -0,0 +1,389 @@
'use client'
import { useEffect, useState } from 'react'
import { formatDuration } from '@sim/utils/formatting'
import { Badge, Button, ChipModalTabs, X } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { EnrichmentProviderOutcome, EnrichmentRunDetail } from '@/lib/table'
import {
adjustBgForContrast,
getBlockIconAndColor,
iconColorClass,
} from '@/app/workspace/[workspaceId]/logs/components/log-details/utils'
import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks'
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
import { useEnrichmentDetail } from '@/hooks/queries/tables'
import { formatCost } from '@/providers/utils'
import { useLogDetailsUIStore } from '@/stores/logs/store'
import { MAX_LOG_DETAILS_WIDTH_RATIO, MIN_LOG_DETAILS_WIDTH } from '@/stores/logs/utils'
type EnrichmentDetailsTab = 'result' | 'cascade'
type ResultStatus = 'matched' | 'no_match' | 'error' | 'not_run' | 'cancelled'
const RESULT_STATUS_CONFIG: Record<
ResultStatus,
{ variant: React.ComponentProps<typeof Badge>['variant']; label: string }
> = {
matched: { variant: 'green', label: 'Matched' },
no_match: { variant: 'gray', label: 'No match' },
error: { variant: 'red', label: 'Error' },
not_run: { variant: 'gray', label: 'Not run' },
cancelled: { variant: 'orange', label: 'Cancelled' },
}
/** Minimum bar width so a sub-millisecond provider still shows on the timeline. */
const MIN_BAR_PCT = 0.5
const PROVIDER_STATUS_LABEL: Record<EnrichmentProviderOutcome['status'], string> = {
matched: 'Matched',
no_match: 'No match',
skipped: 'Skipped',
error: 'Error',
not_run: 'Not run',
}
interface CascadeRow {
outcome: EnrichmentProviderOutcome
offsetPct: number
widthPct: number
}
/**
* Lays the (sequential) provider attempts on one timeline so the Cascade tab
* reads like the execution trace waterfall: each bar's offset is the time before
* it ran, its width its own duration. Skipped providers (0ms) get no bar.
*/
function buildCascadeRows(providers: EnrichmentProviderOutcome[]): CascadeRow[] {
const total = Math.max(
1,
providers.reduce((sum, p) => sum + p.durationMs, 0)
)
let cursor = 0
return providers.map((outcome) => {
const offsetMs = cursor
cursor += outcome.durationMs
const offsetPct = Math.min(100 - MIN_BAR_PCT, (offsetMs / total) * 100)
const rawWidth = (outcome.durationMs / total) * 100
const widthPct =
outcome.durationMs > 0 ? Math.max(MIN_BAR_PCT, Math.min(100 - offsetPct, rawWidth)) : 0
return { outcome, offsetPct, widthPct }
})
}
/** A provider that actually executed its tool (not skipped / never reached). */
function didRun(p: EnrichmentProviderOutcome): boolean {
return p.status !== 'skipped' && p.status !== 'not_run'
}
/**
* Derives the cell-level outcome from the cascade — mirrors the executor: a
* cancelled run is `cancelled` regardless of how far the cascade got; otherwise
* `error` only when every provider that ran errored, `not_run` when nothing
* executed (missing inputs), else a clean `no_match`.
*/
function deriveResultStatus(detail: EnrichmentRunDetail): ResultStatus {
if (detail.aborted) return 'cancelled'
if (detail.matchedProvider) return 'matched'
const ran = detail.providers.filter(didRun)
if (ran.length === 0) return 'not_run'
if (ran.every((p) => p.status === 'error')) return 'error'
return 'no_match'
}
interface DetailRowProps {
label: string
children: React.ReactNode
}
function DetailRow({ label, children }: DetailRowProps) {
return (
<div className='flex h-10 items-center justify-between gap-4 px-3 transition-colors hover-hover:bg-[var(--surface-2)]'>
<span className='flex-shrink-0 font-medium text-[var(--text-tertiary)] text-caption'>
{label}
</span>
<span className='min-w-0 truncate text-right font-medium text-[var(--text-secondary)] text-caption tabular-nums'>
{children}
</span>
</div>
)
}
interface EnrichmentDetailsContentProps {
tableId: string
rowId: string
groupId: string
groupName?: string
isOpen: boolean
}
function EnrichmentDetailsContent({
tableId,
rowId,
groupId,
groupName,
isOpen,
}: EnrichmentDetailsContentProps) {
const [activeTab, setActiveTab] = useState<EnrichmentDetailsTab>('result')
const [prevKey, setPrevKey] = useState(`${rowId}:${groupId}`)
const key = `${rowId}:${groupId}`
if (prevKey !== key) {
setPrevKey(key)
setActiveTab('result')
}
const { data: detail, isLoading } = useEnrichmentDetail(tableId, rowId, groupId, {
enabled: isOpen,
})
const matchedLabel = detail?.matchedProvider
? (detail.providers.find((p) => p.id === detail.matchedProvider)?.label ??
detail.matchedProvider)
: null
const ranCount = detail ? detail.providers.filter(didRun).length : 0
const lastError = detail
? [...detail.providers].reverse().find((p) => p.status === 'error')?.error
: null
const timestamp = detail ? formatDate(detail.completedAt) : null
return (
<div className='mt-4 flex min-h-0 flex-1 flex-col'>
<ChipModalTabs
tabs={[
{ value: 'result', label: 'Result' },
{ value: 'cascade', label: 'Cascade' },
]}
value={activeTab}
onChange={(v) => setActiveTab(v as EnrichmentDetailsTab)}
/>
{isLoading ? (
<div className='flex h-full items-center justify-center px-4 text-center'>
<span className='font-medium text-[var(--text-tertiary)] text-sm'>Loading</span>
</div>
) : !detail ? (
<div className='flex h-full items-center justify-center px-4 text-center'>
<span className='font-medium text-[var(--text-tertiary)] text-sm'>
No enrichment details for this run
</span>
</div>
) : activeTab === 'result' ? (
<div className='mt-4 min-h-0 flex-1 overflow-y-auto'>
<div className='flex flex-col gap-2.5 pb-4'>
<div className='grid grid-cols-2 gap-x-3 pb-0.5'>
<div className='flex min-w-0 flex-col gap-0.5'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Timestamp
</span>
<span className='font-medium text-[var(--text-secondary)] text-sm tabular-nums'>
{timestamp ? `${timestamp.compactDate} ${timestamp.compactTime}` : '—'}
</span>
</div>
<div className='flex min-w-0 flex-col gap-0.5'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Enrichment
</span>
<span className='min-w-0 truncate font-medium text-[var(--text-secondary)] text-sm'>
{groupName || 'Enrichment'}
</span>
</div>
</div>
<div className='divide-y divide-[var(--border)] overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)] dark:bg-transparent'>
<DetailRow label='Status'>
<Badge
variant={RESULT_STATUS_CONFIG[deriveResultStatus(detail)].variant}
dot
size='sm'
>
{RESULT_STATUS_CONFIG[deriveResultStatus(detail)].label}
</Badge>
</DetailRow>
<DetailRow label='Duration'>
{formatDuration(detail.durationMs, { precision: 2 }) || '—'}
</DetailRow>
<DetailRow label='Total cost'>{formatCost(detail.totalCost)}</DetailRow>
<DetailRow label='Matched provider'>{matchedLabel || '—'}</DetailRow>
<DetailRow label='Providers ran'>{ranCount}</DetailRow>
</div>
{lastError && (
<div className='flex flex-col gap-1.5 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2.5 py-2 dark:bg-transparent'>
<span className='font-medium text-[var(--text-error)] text-caption'>Error</span>
<p className='break-words text-[var(--text-secondary)] text-caption'>{lastError}</p>
</div>
)}
</div>
</div>
) : (
<div className='mt-4 min-h-0 flex-1 overflow-y-auto'>
{/* Summary strip — mirrors the trace header */}
<div className='mb-2 flex items-center gap-2 px-[2px]'>
<Badge variant={RESULT_STATUS_CONFIG[deriveResultStatus(detail)].variant} dot size='sm'>
{RESULT_STATUS_CONFIG[deriveResultStatus(detail)].label}
</Badge>
<span className='font-medium text-[var(--text-secondary)] text-caption tabular-nums'>
{formatDuration(detail.durationMs, { precision: 2 }) || '—'}
</span>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
{ranCount} {ranCount === 1 ? 'provider' : 'providers'}
</span>
{detail.totalCost > 0 && (
<span className='font-medium text-[var(--text-tertiary)] text-caption tabular-nums'>
{formatCost(detail.totalCost)}
</span>
)}
</div>
{/* Provider waterfall — each row is one cascade attempt on a shared timeline */}
<div className='flex flex-col pb-4'>
{buildCascadeRows(detail.providers).map(({ outcome, offsetPct, widthPct }) => {
const ran = didRun(outcome)
const { icon: ProviderIcon, bgColor: rawBgColor } = getBlockIconAndColor(
'tool',
outcome.toolId
)
const bgColor = adjustBgForContrast(rawBgColor)
return (
<div
key={outcome.id}
className={cn(
'relative flex min-w-0 flex-col rounded-md transition-colors',
outcome.status === 'matched'
? 'bg-[var(--surface-2)]'
: 'hover-hover:bg-[var(--surface-2)]',
!ran && 'opacity-60'
)}
>
<div className='flex min-w-0 items-center gap-1.5 px-2 pt-1.5'>
<div
className='flex size-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm'
style={{ background: bgColor }}
>
{ProviderIcon && (
<ProviderIcon className={cn('size-[11px]', iconColorClass(bgColor))} />
)}
</div>
<span className='min-w-0 flex-1 truncate font-medium text-[var(--text-secondary)] text-caption'>
{outcome.label}
</span>
<span className='flex-shrink-0 font-medium text-[var(--text-tertiary)] text-caption'>
{PROVIDER_STATUS_LABEL[outcome.status]}
</span>
{outcome.cost > 0 && (
<span className='flex-shrink-0 font-medium text-[var(--text-tertiary)] text-xs tabular-nums'>
{formatCost(outcome.cost)}
</span>
)}
{ran && (
<span className='flex-shrink-0 font-medium text-[var(--text-tertiary)] text-caption tabular-nums'>
{formatDuration(outcome.durationMs, { precision: 2 }) || '—'}
</span>
)}
</div>
<div className='px-2 pt-[3px] pb-1.5'>
<div className='relative h-[3px] w-full overflow-hidden rounded-full bg-[var(--border)]'>
{widthPct > 0 && (
<div
className='absolute h-full rounded-full bg-[var(--text-tertiary)]'
style={{ left: `${offsetPct}%`, width: `${widthPct}%` }}
/>
)}
</div>
</div>
{outcome.error && (
<p className='break-words px-2 pb-1.5 text-[var(--text-tertiary)] text-xs'>
{outcome.error}
</p>
)}
</div>
)
})}
</div>
</div>
)}
</div>
)
}
interface EnrichmentDetailsProps {
tableId: string
rowId: string | null
groupId: string | null
groupName?: string
isOpen: boolean
onClose: () => void
}
/**
* Right-edge slideout showing an enrichment cell's run: a Result tab (status,
* duration, total cost, matched provider) and a Cascade tab (per-provider
* outcomes). Mirrors the log-details shell — resizable with a shared persisted
* width — minus the prev/next navigation, which is meaningless for a cell.
*/
export function EnrichmentDetails({
tableId,
rowId,
groupId,
groupName,
isOpen,
onClose,
}: EnrichmentDetailsProps) {
const panelWidth = useLogDetailsUIStore((state) => state.panelWidth)
const { handleMouseDown } = useLogDetailsResize()
const maxVw = `${MAX_LOG_DETAILS_WIDTH_RATIO * 100}vw`
const effectiveWidth = `clamp(min(${MIN_LOG_DETAILS_WIDTH}px, ${maxVw}), ${panelWidth}px, ${maxVw})`
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) onClose()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen, onClose])
return (
<>
{isOpen && (
<div
className='absolute top-0 bottom-0 z-[var(--z-dropdown)] w-[8px] cursor-ew-resize'
style={{ right: `calc(${effectiveWidth} - 4px)` }}
onMouseDown={handleMouseDown}
role='separator'
aria-label='Resize enrichment details panel'
aria-orientation='vertical'
/>
)}
<div
className={cn(
'absolute top-0 right-0 bottom-0 z-[var(--z-dropdown)] overflow-hidden border-l bg-[var(--bg)] shadow-md transition-transform duration-200 ease-out',
isOpen ? 'translate-x-0' : 'translate-x-full'
)}
style={{ width: effectiveWidth }}
aria-label='Enrichment details sidebar'
>
{rowId && groupId && (
<div className='flex h-full flex-col px-3.5 pt-3'>
<div className='flex items-center justify-between'>
<h2 className='font-medium text-[var(--text-primary)] text-sm'>Enrichment Details</h2>
<Button variant='ghost' className='!p-1' onClick={onClose} aria-label='Close'>
<X className='size-[14px]' />
</Button>
</div>
<EnrichmentDetailsContent
tableId={tableId}
rowId={rowId}
groupId={groupId}
groupName={groupName}
isOpen={isOpen}
/>
</div>
)}
</div>
</>
)
}
@@ -0,0 +1 @@
export { EnrichmentDetails } from './enrichment-details'
@@ -1,5 +1,6 @@
export * from './column-config-sidebar'
export * from './context-menu'
export * from './enrichment-details'
export * from './enrichments-sidebar'
export * from './new-column-dropdown'
export * from './row-modal'
@@ -127,6 +127,10 @@ export interface SelectionSnapshot {
/** True iff the exec is in a state that produced a server log
* (completed / error / running). Drives the View execution button. */
canViewExecution: boolean
/** True iff this is an enrichment group with a terminal run (completed /
* error) — drives "View execution" opening the enrichment details panel
* instead of a workflow execution log. */
canViewEnrichment: boolean
} | null
}
@@ -153,6 +157,8 @@ interface TableGridProps {
/** Open the enrichments slideout in edit mode for an existing enrichment group. */
onOpenEnrichmentConfig: (group: WorkflowGroup) => void
onOpenExecutionDetails: (executionId: string) => void
/** Open the enrichment details panel (cost + provider cascade) for a cell. */
onOpenEnrichmentDetails: (rowId: string, groupId: string) => void
/** Open the row-edit modal for `row`. Wrapper renders the modal. */
onOpenRowModal: (row: TableRowType) => void
/** Open the row-delete modal for `snapshots`. Wrapper renders the modal. */
@@ -283,6 +289,7 @@ export function TableGrid({
onOpenEnrichments,
onOpenEnrichmentConfig,
onOpenExecutionDetails,
onOpenEnrichmentDetails,
onOpenRowModal,
onRequestDeleteRows,
onRequestDeleteAllByFilter,
@@ -1005,6 +1012,9 @@ export function TableGrid({
let contextMenuExecutionId: string | null = null
let contextMenuIsWorkflowColumn = false
let contextMenuHasStartedRun = false
// The (rowId, groupId) of the right-clicked enrichment cell when it has a
// terminal run — drives "View execution" opening the enrichment details panel.
let contextMenuEnrichment: { rowId: string; groupId: string } | null = null
// The workflow group of the right-clicked cell, when it's a workflow-output
// column. Scopes the run/re-run menu items to just that cell's group (the
// cascade re-runs dependents on its own) instead of every group on the row.
@@ -1025,7 +1035,8 @@ export function TableGrid({
_exec?.status === 'pending' &&
typeof _exec?.jobId === 'string' &&
_exec.jobId.startsWith('paused-')
// Enrichment cells have no workflow execution trace to open.
// Enrichment cells have no workflow execution trace; a terminal run opens
// the enrichment details panel instead.
const _isEnrichmentGroup = workflowGroupById.get(_gid)?.type === 'enrichment'
contextMenuHasStartedRun =
!_isEnrichmentGroup &&
@@ -1034,10 +1045,22 @@ export function TableGrid({
_exec?.status === 'running' ||
_isPaused)
contextMenuExecutionId = _exec?.executionId ?? null
if (
_isEnrichmentGroup &&
(_exec?.status === 'completed' || _exec?.status === 'error') &&
contextMenu.row
) {
contextMenuEnrichment = { rowId: contextMenu.row.id, groupId: _gid }
}
}
}
function handleViewExecution() {
if (contextMenuEnrichment) {
onOpenEnrichmentDetails(contextMenuEnrichment.rowId, contextMenuEnrichment.groupId)
closeContextMenu()
return
}
if (!contextMenuExecutionId) return
onOpenExecutionDetails(contextMenuExecutionId)
closeContextMenu()
@@ -3369,8 +3392,8 @@ export function TableGrid({
// running/completed/error.
const isPaused =
status === 'pending' && typeof exec?.jobId === 'string' && exec.jobId.startsWith('paused-')
// Enrichment groups have no workflow execution to open — never offer "View
// execution" for them.
// Enrichment groups have no workflow execution / trace; instead a terminal
// run exposes the enrichment details panel (cost + provider cascade).
const isEnrichmentGroup = workflowGroupById.get(groupId)?.type === 'enrichment'
return {
rowId: row.id,
@@ -3383,6 +3406,7 @@ export function TableGrid({
!isEnrichmentGroup &&
Boolean(exec?.executionId) &&
(status === 'completed' || status === 'error' || status === 'running' || isPaused),
canViewEnrichment: isEnrichmentGroup && (status === 'completed' || status === 'error'),
}
}, [normalizedSelection, rows, displayColumns, workflowGroupById])
@@ -3474,7 +3498,8 @@ export function TableGrid({
prev.singleWorkflowCell.rowId === singleWorkflowCell.rowId &&
prev.singleWorkflowCell.groupId === singleWorkflowCell.groupId &&
prev.singleWorkflowCell.executionId === singleWorkflowCell.executionId &&
prev.singleWorkflowCell.canViewExecution === singleWorkflowCell.canViewExecution
prev.singleWorkflowCell.canViewExecution === singleWorkflowCell.canViewExecution &&
prev.singleWorkflowCell.canViewEnrichment === singleWorkflowCell.canViewEnrichment
const sameRunScope =
(prev?.selectedRunScope ?? null) === null && selectedRunScope === null
? true
@@ -3879,7 +3904,10 @@ export function TableGrid({
onInsertBelow={handleInsertRowBelow}
onDuplicate={handleDuplicateRow}
onViewExecution={handleViewExecution}
canViewExecution={Boolean(contextMenuExecutionId) && contextMenuHasStartedRun}
canViewExecution={
(Boolean(contextMenuExecutionId) && contextMenuHasStartedRun) ||
Boolean(contextMenuEnrichment)
}
canEditCell={!contextMenuIsWorkflowColumn}
selectedRowCount={selectedRowCount}
onRunWorkflows={
@@ -38,6 +38,7 @@ import type { DeletedRowSnapshot } from '@/stores/table/types'
import {
type ColumnConfig,
ColumnConfigSidebar,
EnrichmentDetails,
EnrichmentsSidebar,
NewColumnDropdown,
RowModal,
@@ -78,12 +79,14 @@ type SlideoutState =
| { kind: 'enrichments'; editGroup?: WorkflowGroup }
| { kind: 'workflow'; config: WorkflowConfig }
| { kind: 'execution'; executionId: string }
| { kind: 'enrichment-details'; rowId: string; groupId: string }
type SlideoutAction =
| { type: 'OPEN_COLUMN'; config: ColumnConfig }
| { type: 'OPEN_ENRICHMENTS'; editGroup?: WorkflowGroup }
| { type: 'OPEN_WORKFLOW'; config: WorkflowConfig }
| { type: 'OPEN_EXECUTION'; executionId: string }
| { type: 'OPEN_ENRICHMENT_DETAILS'; rowId: string; groupId: string }
| { type: 'CLOSE' }
function slideoutReducer(_state: SlideoutState, action: SlideoutAction): SlideoutState {
@@ -96,6 +99,8 @@ function slideoutReducer(_state: SlideoutState, action: SlideoutAction): Slideou
return { kind: 'workflow', config: action.config }
case 'OPEN_EXECUTION':
return { kind: 'execution', executionId: action.executionId }
case 'OPEN_ENRICHMENT_DETAILS':
return { kind: 'enrichment-details', rowId: action.rowId, groupId: action.groupId }
case 'CLOSE':
return { kind: 'none' }
}
@@ -176,6 +181,9 @@ export function Table({
const onOpenExecutionDetails = useCallback((executionId: string) => {
dispatch({ type: 'OPEN_EXECUTION', executionId })
}, [])
const onOpenEnrichmentDetails = useCallback((rowId: string, groupId: string) => {
dispatch({ type: 'OPEN_ENRICHMENT_DETAILS', rowId, groupId })
}, [])
const onCloseSlideout = () => dispatch({ type: 'CLOSE' })
const onOpenRowModal = (row: TableRowType) => setEditingRow(row)
// useCallback because <Resource.Header> is memo-wrapped — these flow into
@@ -565,7 +573,7 @@ export function Table({
const sidebarReservedPx =
slideout.kind === 'column' || slideout.kind === 'workflow' || slideout.kind === 'enrichments'
? COLUMN_SIDEBAR_WIDTH
: slideout.kind === 'execution'
: slideout.kind === 'execution' || slideout.kind === 'enrichment-details'
? logPanelWidth
: 0
@@ -592,6 +600,10 @@ export function Table({
const columnConfig = slideout.kind === 'column' ? slideout.config : null
const workflowConfig = slideout.kind === 'workflow' ? slideout.config : null
const executionId = slideout.kind === 'execution' ? slideout.executionId : null
const enrichmentDetailsTarget = slideout.kind === 'enrichment-details' ? slideout : null
const enrichmentDetailsGroupName =
enrichmentDetailsTarget &&
tableWorkflowGroups.find((g) => g.id === enrichmentDetailsTarget.groupId)?.name
// Fetch the workflow log when the execution-details slideout is open. Reuses
// the logs page's <LogDetails> directly — no intermediate wrapper needed for
// a one-line query forward.
@@ -674,6 +686,7 @@ export function Table({
onOpenEnrichments={onOpenEnrichments}
onOpenEnrichmentConfig={onOpenEnrichmentConfig}
onOpenExecutionDetails={onOpenExecutionDetails}
onOpenEnrichmentDetails={onOpenEnrichmentDetails}
onOpenRowModal={onOpenRowModal}
onRequestDeleteRows={onRequestDeleteRows}
onRequestDeleteAllByFilter={onRequestDeleteAllByFilter}
@@ -746,7 +759,12 @@ export function Table({
const id = selection.singleWorkflowCell?.executionId
if (id) onOpenExecutionDetails(id)
}
: undefined
: selection.singleWorkflowCell?.canViewEnrichment
? () => {
const cell = selection.singleWorkflowCell
if (cell) onOpenEnrichmentDetails(cell.rowId, cell.groupId)
}
: undefined
}
/>
)}
@@ -785,6 +803,14 @@ export function Table({
isOpen={Boolean(executionId)}
onClose={onCloseSlideout}
/>
<EnrichmentDetails
tableId={tableId}
rowId={enrichmentDetailsTarget?.rowId ?? null}
groupId={enrichmentDetailsTarget?.groupId ?? null}
groupName={enrichmentDetailsGroupName ?? undefined}
isOpen={Boolean(enrichmentDetailsTarget)}
onClose={onCloseSlideout}
/>
{tableData && (
<ImportCsvDialog
open={isImportCsvOpen}
@@ -208,7 +208,7 @@ async function runWorkflowAndWriteTerminal(
// workflow path rather than erroring.
if (group.type === 'enrichment' && group.enrichmentId) {
const { getEnrichment } = await import('@/enrichments/registry')
const { runEnrichment } = await import('@/enrichments/run')
const { runEnrichment, skippedEnrichmentDetail } = await import('@/enrichments/run')
const enrichment = getEnrichment(group.enrichmentId)
// `tableRowExecutions.workflowId` is an opaque id for status; use the
// enrichment id for enrichment cells.
@@ -320,6 +320,7 @@ async function runWorkflowAndWriteTerminal(
jobId: null,
workflowId: statusId,
error: null,
enrichmentDetails: skippedEnrichmentDetail(enrichment),
},
clearPatch
)
@@ -334,10 +335,11 @@ async function runWorkflowAndWriteTerminal(
jobId: null,
workflowId: statusId,
error: 'Cancelled',
enrichmentDetails: skippedEnrichmentDetail(enrichment, { aborted: true }),
})
return 'error'
}
const { result, cost, error } = await runEnrichment(enrichment, enrichInputs, {
const { result, cost, error, detail } = await runEnrichment(enrichment, enrichInputs, {
tableId,
rowId,
workspaceId,
@@ -352,6 +354,7 @@ async function runWorkflowAndWriteTerminal(
jobId: null,
workflowId: statusId,
error: 'Cancelled',
enrichmentDetails: detail,
})
return 'error'
}
@@ -365,6 +368,7 @@ async function runWorkflowAndWriteTerminal(
jobId: null,
workflowId: statusId,
error,
enrichmentDetails: detail,
})
return 'error'
}
@@ -410,7 +414,14 @@ async function runWorkflowAndWriteTerminal(
value === undefined || value === null ? '' : (value as RowData[string])
}
await writeState(
{ status: 'completed', executionId, jobId: null, workflowId: statusId, error: null },
{
status: 'completed',
executionId,
jobId: null,
workflowId: statusId,
error: null,
enrichmentDetails: detail,
},
dataPatch
)
return 'completed'
+155
View File
@@ -0,0 +1,155 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockExecuteTool } = vi.hoisted(() => ({ mockExecuteTool: vi.fn() }))
vi.mock('@/tools', () => ({ executeTool: mockExecuteTool }))
import { runEnrichment, skippedEnrichmentDetail } from '@/enrichments/run'
import type { EnrichmentConfig, EnrichmentProvider } from '@/enrichments/types'
const ICON = (() => null) as unknown as EnrichmentConfig['icon']
function prov(
id: string,
opts: {
build?: (inputs: Record<string, unknown>) => Record<string, unknown> | null
map?: (output: Record<string, unknown>) => Record<string, unknown> | null
} = {}
): EnrichmentProvider {
return {
id,
label: id.toUpperCase(),
toolId: `tool_${id}`,
buildParams: opts.build ?? (() => ({ q: 'x' })),
mapOutput: opts.map ?? ((o) => (o.email ? { email: o.email } : null)),
}
}
function config(providers: EnrichmentProvider[]): EnrichmentConfig {
return {
id: 'test',
name: 'Test',
description: '',
icon: ICON,
inputs: [],
outputs: [],
providers,
}
}
const ctx = { workspaceId: 'ws-1' }
beforeEach(() => {
mockExecuteTool.mockReset()
})
describe('runEnrichment cascade detail', () => {
it('records the first match and stops the cascade', async () => {
mockExecuteTool.mockImplementation((toolId: string) => {
if (toolId === 'tool_a') return { success: false, output: { status: 404 } }
if (toolId === 'tool_b')
return { success: true, output: { email: 'j@acme.com', cost: { total: 0.05 } } }
throw new Error('tool_c should never run after a match')
})
const outcome = await runEnrichment(config([prov('a'), prov('b'), prov('c')]), {}, ctx)
expect(outcome.result).toEqual({ email: 'j@acme.com' })
expect(outcome.cost).toBe(0.05)
expect(outcome.error).toBeNull()
expect(outcome.provider).toBe('B')
expect(outcome.detail.matchedProvider).toBe('b')
expect(outcome.detail.totalCost).toBe(0.05)
// The full cascade is recorded; the provider after the match is `not_run`.
expect(outcome.detail.providers.map((p) => p.id)).toEqual(['a', 'b', 'c'])
expect(outcome.detail.providers.map((p) => p.status)).toEqual([
'no_match',
'matched',
'not_run',
])
expect(outcome.detail.providers[1]?.cost).toBe(0.05)
expect(outcome.detail.providers.every((p) => typeof p.durationMs === 'number')).toBe(true)
// The tool is never called for the matched-past provider.
expect(mockExecuteTool).toHaveBeenCalledTimes(2)
})
it('marks providers with insufficient inputs as skipped without calling the tool', async () => {
mockExecuteTool.mockImplementation(() => ({
success: true,
output: { email: 'j@acme.com' },
}))
const outcome = await runEnrichment(
config([prov('a', { build: () => null }), prov('b')]),
{},
ctx
)
expect(outcome.detail.providers[0]).toMatchObject({ id: 'a', status: 'skipped', durationMs: 0 })
expect(outcome.detail.providers[1]?.status).toBe('matched')
// Only provider b actually called the tool.
expect(mockExecuteTool).toHaveBeenCalledTimes(1)
})
it('sets error only when every provider that ran errored', async () => {
mockExecuteTool.mockImplementation(() => ({ success: false, output: { status: 500 } }))
const outcome = await runEnrichment(config([prov('a'), prov('b')]), {}, ctx)
expect(outcome.result).toEqual({})
expect(outcome.error).not.toBeNull()
expect(outcome.provider).toBeNull()
expect(outcome.detail.matchedProvider).toBeNull()
expect(outcome.detail.providers.map((p) => p.status)).toEqual(['error', 'error'])
expect(outcome.detail.providers.every((p) => p.error)).toBe(true)
})
it('treats a clean miss (ran, empty result) as no_match with no error', async () => {
mockExecuteTool.mockImplementation(() => ({ success: true, output: {} }))
const outcome = await runEnrichment(config([prov('a')]), {}, ctx)
expect(outcome.result).toEqual({})
expect(outcome.error).toBeNull()
expect(outcome.detail.providers.map((p) => p.status)).toEqual(['no_match'])
})
it('skippedEnrichmentDetail marks every provider skipped without running', () => {
const detail = skippedEnrichmentDetail(config([prov('a'), prov('b')]))
expect(detail.matchedProvider).toBeNull()
expect(detail.totalCost).toBe(0)
expect(detail.providers.map((p) => p.status)).toEqual(['skipped', 'skipped'])
expect(mockExecuteTool).not.toHaveBeenCalled()
})
it('marks unattempted providers not_run when the signal is already aborted', async () => {
const controller = new AbortController()
controller.abort()
const outcome = await runEnrichment(
config([prov('a'), prov('b')]),
{},
{
...ctx,
signal: controller.signal,
}
)
expect(mockExecuteTool).not.toHaveBeenCalled()
expect(outcome.detail.aborted).toBe(true)
expect(outcome.detail.providers.map((p) => p.status)).toEqual(['not_run', 'not_run'])
})
it('does not error when some providers no-match and only some error', async () => {
mockExecuteTool.mockImplementation((toolId: string) => {
if (toolId === 'tool_a') return { success: false, output: { status: 500 } }
return { success: false, output: { status: 404 } }
})
const outcome = await runEnrichment(config([prov('a'), prov('b')]), {}, ctx)
expect(outcome.error).toBeNull()
expect(outcome.detail.providers.map((p) => p.status)).toEqual(['error', 'no_match'])
})
})
+130 -6
View File
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { EnrichmentProviderOutcome, EnrichmentRunDetail } from '@/lib/table/types'
import type { EnrichmentConfig, EnrichmentRunContext } from '@/enrichments/types'
import { executeTool } from '@/tools'
@@ -19,6 +20,38 @@ export interface EnrichmentRunOutcome {
error: string | null
/** Label of the provider whose result was returned, or `null` on no match. */
provider: string | null
/** Per-provider cascade breakdown + timing for the enrichment details panel. */
detail: EnrichmentRunDetail
}
/**
* Detail for a terminal cell that recorded no provider attempt — missing
* required inputs, or cancelled before any provider ran. Every provider is
* marked `skipped` so the details panel stays informative (shows the configured
* cascade) instead of empty.
*/
export function skippedEnrichmentDetail(
enrichment: EnrichmentConfig,
opts: { aborted?: boolean } = {}
): EnrichmentRunDetail {
const now = new Date().toISOString()
return {
startedAt: now,
completedAt: now,
durationMs: 0,
totalCost: 0,
matchedProvider: null,
aborted: opts.aborted ?? false,
providers: enrichment.providers.map((provider) => ({
id: provider.id,
label: provider.label,
toolId: provider.toolId,
status: 'skipped' as const,
cost: 0,
durationMs: 0,
error: null,
})),
}
}
/** True when at least one output value in the result is non-empty. */
@@ -53,12 +86,29 @@ export async function runEnrichment(
let ranCount = 0
let errorCount = 0
let lastError: string | null = null
let matchedProvider: string | null = null
let winner: { result: Record<string, unknown>; label: string } | null = null
const providers: EnrichmentProviderOutcome[] = []
const startedAt = Date.now()
for (const provider of enrichment.providers) {
for (let i = 0; i < enrichment.providers.length; i++) {
const provider = enrichment.providers[i]
if (ctx.signal?.aborted) break
const params = provider.buildParams(inputs)
if (!params) continue
if (!params) {
providers.push({
id: provider.id,
label: provider.label,
toolId: provider.toolId,
status: 'skipped',
cost: 0,
durationMs: 0,
error: null,
})
continue
}
ranCount++
const providerStart = Date.now()
try {
const response = await executeTool(
provider.toolId,
@@ -72,18 +122,60 @@ export async function runEnrichment(
// found" rather than an error). Other statuses (auth, rate-limit, 5xx)
// are real errors and propagate.
const status = (response.output as { status?: unknown } | undefined)?.status
if (status === 404) continue
if (status === 404) {
providers.push({
id: provider.id,
label: provider.label,
toolId: provider.toolId,
status: 'no_match',
cost: 0,
durationMs: Date.now() - providerStart,
error: null,
})
continue
}
throw new Error(response.error ?? `${provider.toolId} failed`)
}
cost += readCost(response.output)
const providerCost = readCost(response.output)
cost += providerCost
const result = provider.mapOutput(response.output)
if (result && hasResult(result)) {
providers.push({
id: provider.id,
label: provider.label,
toolId: provider.toolId,
status: 'matched',
cost: providerCost,
durationMs: Date.now() - providerStart,
error: null,
})
matchedProvider = provider.id
winner = { result, label: provider.label }
logger.info('Enrichment hit', { enrichmentId: enrichment.id, provider: provider.id })
return { result, cost, error: null, provider: provider.label }
break
}
// Ran cleanly but mapped to nothing — a no-match, fall through to the next.
providers.push({
id: provider.id,
label: provider.label,
toolId: provider.toolId,
status: 'no_match',
cost: providerCost,
durationMs: Date.now() - providerStart,
error: null,
})
} catch (err) {
errorCount++
lastError = getErrorMessage(err)
providers.push({
id: provider.id,
label: provider.label,
toolId: provider.toolId,
status: 'error',
cost: 0,
durationMs: Date.now() - providerStart,
error: lastError,
})
logger.warn('Enrichment provider failed; trying next', {
enrichmentId: enrichment.id,
provider: provider.id,
@@ -92,8 +184,40 @@ export async function runEnrichment(
}
}
// Any provider not represented yet never ran — the cascade short-circuited on
// a match or aborted mid-run. Record them as `not_run` (in registry order) so
// the panel always shows the full configured cascade.
const seen = new Set(providers.map((p) => p.id))
for (const provider of enrichment.providers) {
if (seen.has(provider.id)) continue
providers.push({
id: provider.id,
label: provider.label,
toolId: provider.toolId,
status: 'not_run',
cost: 0,
durationMs: 0,
error: null,
})
}
const completedAt = Date.now()
const detail: EnrichmentRunDetail = {
startedAt: new Date(startedAt).toISOString(),
completedAt: new Date(completedAt).toISOString(),
durationMs: completedAt - startedAt,
totalCost: cost,
matchedProvider,
aborted: Boolean(ctx.signal?.aborted),
providers,
}
if (winner) {
return { result: winner.result, cost, error: null, provider: winner.label, detail }
}
// No provider hit. Surface an error only when every provider that ran errored
// (infra/auth/rate-limit) — a clean miss returns a blank result instead.
const error = ranCount > 0 && errorCount === ranCount ? lastError : null
return { result: {}, cost, error, provider: null }
return { result: {}, cost, error, provider: null, detail }
}
+44
View File
@@ -44,6 +44,7 @@ import {
exportDownloadContract,
exportTableAsyncContract,
findTableRowsContract,
getEnrichmentDetailContract,
getTableContract,
type InsertTableRowBodyInput,
importIntoTableAsyncContract,
@@ -72,6 +73,7 @@ import {
} from '@/lib/api/contracts/tables'
import type {
CsvHeaderMapping,
EnrichmentRunDetail,
Filter,
RowData,
RowExecutionMetadata,
@@ -115,6 +117,10 @@ export const tableKeys = {
[...tableKeys.rowsRoot(tableId), 'find', paramsKey] as const,
activeDispatches: (tableId: string) =>
[...tableKeys.detail(tableId), 'active-dispatches'] as const,
enrichmentDetails: (tableId: string) =>
[...tableKeys.detail(tableId), 'enrichment-detail'] as const,
enrichmentDetail: (tableId: string, rowId: string, groupId: string) =>
[...tableKeys.enrichmentDetails(tableId), rowId, groupId] as const,
}
type TableRowsParams = Omit<TableRowsQueryInput, 'filter' | 'sort'> &
@@ -297,6 +303,44 @@ async function fetchTableRunState(tableId: string, signal?: AbortSignal): Promis
}
}
async function fetchEnrichmentDetail(
tableId: string,
rowId: string,
groupId: string,
signal?: AbortSignal
): Promise<EnrichmentRunDetail | null> {
const response = await requestJson(getEnrichmentDetailContract, {
params: { tableId, rowId, groupId },
signal,
})
return response.data.detail
}
/**
* Enrichment cascade breakdown for one cell, fetched on demand when the
* enrichment details panel opens. Kept off the hot grid read — only queried
* while `enabled` (panel open with a selected row + group).
*
* `staleTime: 0` so reopening the panel always refetches: a cell can be re-run
* between opens (the run writes new `enrichmentDetails` in the background with no
* client invalidation), and the panel is opened on demand, so a fresh fetch per
* open keeps the cascade in sync without a cached stale run.
*/
export function useEnrichmentDetail(
tableId: string,
rowId: string | null,
groupId: string | null,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: tableKeys.enrichmentDetail(tableId, rowId ?? '', groupId ?? ''),
queryFn: ({ signal }) =>
fetchEnrichmentDetail(tableId, rowId as string, groupId as string, signal),
enabled: Boolean(tableId && rowId && groupId) && (options?.enabled ?? true),
staleTime: 0,
})
}
/** Count groups flipped to in-flight (`pending`) by an optimistic schedule that
* weren't in-flight before — the delta to add to the run-state counter. */
function countNewlyInFlight(before: RowExecutions, after: RowExecutions): number {
+24
View File
@@ -3,6 +3,7 @@ import { z } from 'zod'
import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
import type {
CsvHeaderMapping,
EnrichmentRunDetail,
Filter,
RowData,
Sort,
@@ -905,6 +906,29 @@ export const deleteTableRowContract = defineRouteContract({
},
})
export const enrichmentDetailParamsSchema = tableRowParamsSchema.extend({
groupId: z.string().min(1),
})
/**
* Per-(row, group) enrichment cascade breakdown. Modeled as a domain object so
* the `EnrichmentRunDetail` TS type stays the single source of truth (matching
* `tableRowSchema` / `tableDefinitionSchema`). `null` when the cell has no
* recorded run or the run predates this feature.
*/
export const getEnrichmentDetailContract = defineRouteContract({
method: 'GET',
path: '/api/table/[tableId]/rows/[rowId]/enrichment/[groupId]',
params: enrichmentDetailParamsSchema,
response: {
mode: 'json',
schema: successResponseSchema(
z.object({ detail: domainObjectSchema<EnrichmentRunDetail>().nullable() })
),
},
})
export type GetEnrichmentDetailResponse = ContractJsonResponse<typeof getEnrichmentDetailContract>
export const deleteTableRowsContract = defineRouteContract({
method: 'DELETE',
path: '/api/table/[tableId]/rows',
+51 -1
View File
@@ -11,6 +11,7 @@ import type { DbOrTx } from '@/lib/db/types'
import { getColumnId } from '@/lib/table/column-keys'
import { areGroupDepsSatisfied } from '@/lib/table/deps'
import type {
EnrichmentRunDetail,
RowData,
RowExecutionMetadata,
RowExecutions,
@@ -29,8 +30,22 @@ export async function loadExecutionsByRow(
const ids = Array.from(new Set(rowIds))
const result = new Map<string, RowExecutions>()
if (ids.length === 0) return result
// Explicit column list, never `select()` — `enrichmentDetails` is large and
// must stay off the hot grid read path (fetched on demand via
// `loadEnrichmentDetail`).
const rows = await trx
.select()
.select({
rowId: tableRowExecutions.rowId,
groupId: tableRowExecutions.groupId,
status: tableRowExecutions.status,
executionId: tableRowExecutions.executionId,
jobId: tableRowExecutions.jobId,
workflowId: tableRowExecutions.workflowId,
error: tableRowExecutions.error,
runningBlockIds: tableRowExecutions.runningBlockIds,
blockErrors: tableRowExecutions.blockErrors,
cancelledAt: tableRowExecutions.cancelledAt,
})
.from(tableRowExecutions)
.where(inArray(tableRowExecutions.rowId, ids))
for (const r of rows) {
@@ -61,6 +76,31 @@ export async function loadExecutionsForRow(trx: DbOrTx, rowId: string): Promise<
return byRow.get(rowId) ?? {}
}
/**
* Loads the enrichment cascade breakdown for one `(tableId, rowId, groupId)`,
* or `null` when there is no exec row or it predates the feature. Read on demand
* by the enrichment details panel — kept off `loadExecutionsByRow`.
*/
export async function loadEnrichmentDetail(
trx: DbOrTx,
tableId: string,
rowId: string,
groupId: string
): Promise<EnrichmentRunDetail | null> {
const [row] = await trx
.select({ enrichmentDetails: tableRowExecutions.enrichmentDetails })
.from(tableRowExecutions)
.where(
and(
eq(tableRowExecutions.tableId, tableId),
eq(tableRowExecutions.rowId, rowId),
eq(tableRowExecutions.groupId, groupId)
) as SQL
)
.limit(1)
return (row?.enrichmentDetails as EnrichmentRunDetail | null | undefined) ?? null
}
/**
* Derive automatic clears + cancellation candidates from a row's data patch.
*
@@ -212,6 +252,7 @@ export async function writeExecutionsPatch(
runningBlockIds: value.runningBlockIds ?? [],
blockErrors: value.blockErrors ?? {},
cancelledAt: value.cancelledAt ? new Date(value.cancelledAt) : null,
enrichmentDetails: value.enrichmentDetails ?? null,
updatedAt: new Date(),
} as const
@@ -235,6 +276,11 @@ export async function writeExecutionsPatch(
runningBlockIds: insertValues.runningBlockIds,
blockErrors: insertValues.blockErrors,
cancelledAt: insertValues.cancelledAt,
// Sticky: preserve a prior cascade breakdown when this write omits
// it (e.g. the running pickup stamp) so only an explicit detail
// overwrites it. Re-runs delete the row first, so this never serves
// stale detail across runs.
enrichmentDetails: sql`coalesce(excluded.enrichment_details, ${tableRowExecutions.enrichmentDetails})`,
updatedAt: insertValues.updatedAt,
},
where: and(
@@ -269,6 +315,10 @@ export async function writeExecutionsPatch(
runningBlockIds: insertValues.runningBlockIds,
blockErrors: insertValues.blockErrors,
cancelledAt: insertValues.cancelledAt,
// Sticky: preserve a prior cascade breakdown when this write omits it
// (e.g. the running pickup stamp) so only an explicit detail overwrites
// it. Re-runs delete the row first, so this never serves stale detail.
enrichmentDetails: sql`coalesce(excluded.enrichment_details, ${tableRowExecutions.enrichmentDetails})`,
updatedAt: insertValues.updatedAt,
},
})
+60
View File
@@ -134,6 +134,59 @@ export interface WorkflowGroup {
autoRun?: boolean
}
/**
* State of one provider in an enrichment cascade run. `matched`/`no_match`/
* `error` actually called the tool; `skipped` had insufficient inputs; `not_run`
* was never reached because an earlier provider matched.
*/
export type EnrichmentProviderStatus = 'matched' | 'no_match' | 'skipped' | 'error' | 'not_run'
/**
* Outcome of one provider attempt in an enrichment cascade, for the enrichment
* details panel. The full configured cascade is recorded: `skipped` providers
* had insufficient inputs, `not_run` providers sit after the match.
*/
export interface EnrichmentProviderOutcome {
/** Provider id, e.g. `'hunter'`. */
id: string
/** Human label, e.g. `'Hunter'`. */
label: string
/** Tool id the provider runs, e.g. `'hunter_find_email'` — resolves the block
* icon for the details panel. */
toolId: string
status: EnrichmentProviderStatus
/** Hosted-key cost (USD) this provider incurred; `0` for skip / no_match / error / BYOK. */
cost: number
/** Wall-clock ms this provider's tool call took; `0` for skipped. */
durationMs: number
/** Error message when `status === 'error'`, else `null`. */
error: string | null
}
/**
* Per-(row, group) cascade breakdown for an enrichment run, surfaced in the
* enrichment details panel. Persisted on the `tableRowExecutions` sidecar but
* deliberately kept out of the hot grid read path (fetched on demand) — it can
* carry a dozen provider outcomes per cell.
*/
export interface EnrichmentRunDetail {
/** ISO timestamp when the cascade started. */
startedAt: string
/** ISO timestamp when the cascade finished. */
completedAt: string
/** Wall-clock ms across the whole cascade. */
durationMs: number
/** Sum of per-provider hosted-key cost (USD). */
totalCost: number
/** Provider id that produced the match, or `null` on no match. */
matchedProvider: string | null
/** True when the run was cancelled (stop / signal abort) — drives a
* "Cancelled" result rather than inferring no-match/not-run from the cascade. */
aborted: boolean
/** Every configured provider, in cascade order (including `not_run` ones). */
providers: EnrichmentProviderOutcome[]
}
/**
* Per-row execution state for one workflow group, persisted as a row in the
* `tableRowExecutions` sidecar keyed by `(rowId, groupId)`. Holds run
@@ -163,6 +216,13 @@ export interface RowExecutionMetadata {
* re-runs whose `cancelledAt > dispatch.requestedAt` — a user cancel
* mid-dispatch must not be overridden by `isManualRun`. */
cancelledAt?: string
/**
* Enrichment cascade breakdown for `enrichment`-type groups, written on the
* terminal cell write. Persisted on `tableRowExecutions` but NOT hydrated by
* `loadExecutionsByRow` (kept off the hot grid read) — read it on demand via
* `loadEnrichmentDetail` for the details panel.
*/
enrichmentDetails?: EnrichmentRunDetail | null
}
/** Map of `WorkflowGroup.id` → execution state. Stored on every row. */
+1
View File
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "simstudio",
@@ -0,0 +1 @@
ALTER TABLE "table_row_executions" ADD COLUMN "enrichment_details" jsonb;
File diff suppressed because it is too large Load Diff
@@ -1702,6 +1702,13 @@
"when": 1781895339512,
"tag": "0243_kb_workspace_cascade",
"breakpoints": true
},
{
"idx": 244,
"version": "7",
"when": 1781899910981,
"tag": "0244_table_row_executions_enrichment_details",
"breakpoints": true
}
]
}
+7
View File
@@ -3387,6 +3387,13 @@ export const tableRowExecutions = pgTable(
runningBlockIds: text('running_block_ids').array().notNull().default(sql`'{}'::text[]`),
blockErrors: jsonb('block_errors').notNull().default({}),
cancelledAt: timestamp('cancelled_at'),
/**
* Enrichment cascade breakdown (provider outcomes, cost, timing) for
* `enrichment`-type groups. Null for workflow groups and pre-feature runs.
* Deliberately excluded from the hot grid read (`loadExecutionsByRow`) — read
* on demand for the enrichment details panel.
*/
enrichmentDetails: jsonb('enrichment_details'),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
+2 -2
View File
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
const BASELINE = {
totalRoutes: 856,
zodRoutes: 856,
totalRoutes: 857,
zodRoutes: 857,
nonZodRoutes: 0,
} as const