mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-01 14:59:19 +08:00
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:
@@ -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 } })
|
||||
})
|
||||
+2
-26
@@ -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()
|
||||
|
||||
+389
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+1
@@ -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'
|
||||
|
||||
+33
-5
@@ -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'
|
||||
|
||||
@@ -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
@@ -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,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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user