Files
HartOMat/frontend/src/components/workflows/WorkflowNodeCatalogBrowser.tsx
T
HartmutandClaude Sonnet 4.6 d2e4934cca feat: workflow graph system — blocks 1-20 complete checkpoint
Full graph-based workflow execution engine with:
- WorkflowGraphRuntime with two-phase dispatch (collect → fire Celery tasks)
- Node registry with contract validation, socket types, execution kinds
- WorkflowRuntimeServices: preflight, context resolution, shadow-mode A/B
- WorkflowRouter: CRUD + dispatch/preflight/run-history API endpoints
- OutputTypeContracts: workflow binding, rollout-mode resolution
- Admin router: output-type workflow binding endpoints
- Frontend: complete workflow editor with drag-drop canvas, node inspector,
  module bundles, reference bundles, preflight panel, validation banner,
  authoring guidance, blueprint templates, shadow/rollout gate UI
- Tests: comprehensive coverage for all workflow modules
- Docs: NODE_CONTRACT_AUDIT and VALIDATION_ERROR_INVENTORY

All 20 blocks from NEXT_20_BLOCK_BATCH_PLAN completed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 19:09:54 +02:00

586 lines
28 KiB
TypeScript

import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { ArrowRight, Plus } from 'lucide-react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import {
AUTHORING_STAGE_DESCRIPTIONS,
AUTHORING_STAGE_LABELS,
AUTHORING_STAGE_STYLES,
CATEGORY_COLORS,
CATEGORY_LABELS,
FAMILY_FILTER_DESCRIPTIONS,
FAMILY_FILTER_LABELS,
FAMILY_FILTER_STYLES,
NODE_KIND_FILTER_LABELS,
NODE_LIBRARY_GROUP_LABELS,
NODE_LIBRARY_GROUP_STYLES,
getDefinitionBadges,
getDefinitionFamily,
getDefinitionModuleLabel,
getDefinitionModuleNamespace,
type WorkflowAuthoringStage,
type WorkflowGraphFamily,
type WorkflowNodeFamilyFilter,
type WorkflowNodeKindFilter,
type WorkflowNodeLibraryGroup,
} from './workflowNodeLibrary'
import {
buildWorkflowNodeCatalogModel,
filterWorkflowNodeDefinitions,
getAvailableFamilyFilters,
} from './workflowNodeCatalog'
import { STARTER_NODE_STEP_ORDER, STARTER_PATH_TITLES } from './workflowAuthoringGuidance'
import {
CATEGORY_FILTER_LABELS,
CATEGORY_FILTERS,
type FilterPillOption,
type WorkflowNodeCategoryFilter,
WorkflowNodeCatalogFilterControls,
} from './WorkflowNodeCatalogFilterControls'
import { WorkflowNodeCatalogEmptyState } from './WorkflowNodeCatalogEmptyState'
import { WorkflowNodeCatalogQuickInsert } from './WorkflowNodeCatalogQuickInsert'
import {
getWorkflowNodeContractPresentation,
getWorkflowNodeContractSignals,
getWorkflowNodeContractSummary,
} from './workflowNodeContracts'
import { WorkflowNodeContractSignalList } from './WorkflowNodeContractSignalList'
function ContractSummaryMetric({
label,
value,
title,
}: {
label: string
value: string
title?: string
}) {
return (
<div
className="rounded-lg border border-border-default bg-surface-hover/50 px-2 py-1"
title={title}
>
<p className="text-[9px] font-semibold uppercase tracking-wide text-content-muted">{label}</p>
<p className="mt-0.5 text-[11px] font-medium text-content">{value}</p>
</div>
)
}
function RuntimeCoverageBadges({
runtimeCounts,
size = 'sm',
}: {
runtimeCounts: Partial<Record<WorkflowNodeLibraryGroup, number>>
size?: 'sm' | 'xs'
}) {
return (
<>
{(['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[]).map(group => {
const count = runtimeCounts[group] ?? 0
if (count === 0) return null
const label = size === 'sm' ? NODE_LIBRARY_GROUP_LABELS[group] : NODE_KIND_FILTER_LABELS[group]
return (
<span
key={group}
className={
size === 'xs'
? `rounded-full px-1.5 py-0.5 text-[10px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`
: `inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`
}
title={size === 'sm' ? NODE_LIBRARY_GROUP_LABELS[group] : undefined}
>
<span>{label}</span>
<span>{count}</span>
</span>
)
})}
</>
)
}
function StageCoverageBadges({
stages,
}: {
stages: WorkflowAuthoringStage[]
}) {
return (
<>
{stages.map(stage => (
<span
key={stage}
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${AUTHORING_STAGE_STYLES[stage]}`}
title={AUTHORING_STAGE_DESCRIPTIONS[stage]}
>
{AUTHORING_STAGE_LABELS[stage]}
</span>
))}
</>
)
}
type WorkflowNodeCatalogBrowserProps = {
definitions: WorkflowNodeDefinition[]
graphFamily: WorkflowGraphFamily
variant?: 'menu' | 'panel'
onSelectStep?: (step: string) => void
onEmptyAction?: () => void
emptyActionLabel?: string
renderIcon?: (iconName?: string, size?: number) => ReactNode
searchPlaceholder?: string
autoFocusSearch?: boolean
}
export function WorkflowNodeCatalogBrowser({
definitions,
graphFamily,
variant = 'panel',
onSelectStep,
onEmptyAction,
emptyActionLabel = 'Clear Filters',
renderIcon,
searchPlaceholder = 'Search node label, step, or capability',
autoFocusSearch = false,
}: WorkflowNodeCatalogBrowserProps) {
const [query, setQuery] = useState('')
const [familyFilter, setFamilyFilter] = useState<WorkflowNodeFamilyFilter>(
graphFamily === 'mixed' ? 'all' : graphFamily,
)
const [kindFilter, setKindFilter] = useState<WorkflowNodeKindFilter>('all')
const [categoryFilter, setCategoryFilter] = useState<WorkflowNodeCategoryFilter>('all')
const [moduleFilter, setModuleFilter] = useState<string>('all')
const [moduleQuery, setModuleQuery] = useState('')
useEffect(() => {
setFamilyFilter(graphFamily === 'mixed' ? 'all' : graphFamily)
setCategoryFilter('all')
setModuleFilter('all')
setModuleQuery('')
}, [graphFamily])
const availableFamilyFilters = useMemo(
() => getAvailableFamilyFilters(graphFamily),
[graphFamily],
)
const filteredDefinitions = useMemo(() => {
return filterWorkflowNodeDefinitions(definitions, {
graphFamily,
familyFilter,
kindFilter,
query,
})
}, [definitions, familyFilter, graphFamily, kindFilter, query])
const normalizedModuleQuery = moduleQuery.trim().toLowerCase()
const visibleDefinitions = useMemo(() => {
const scopedByModule =
moduleFilter === 'all'
? filteredDefinitions
: filteredDefinitions.filter(definition => getDefinitionModuleNamespace(definition) === moduleFilter)
const scopedByModuleQuery = !normalizedModuleQuery
? scopedByModule
: scopedByModule.filter(definition => {
const namespace = getDefinitionModuleNamespace(definition).toLowerCase()
return (
namespace.includes(normalizedModuleQuery) ||
getDefinitionModuleLabel(definition).toLowerCase().includes(normalizedModuleQuery) ||
definition.module_key.toLowerCase().includes(normalizedModuleQuery) ||
definition.label.toLowerCase().includes(normalizedModuleQuery)
)
})
if (categoryFilter === 'all') return scopedByModuleQuery
return scopedByModuleQuery.filter(definition => definition.category === categoryFilter)
}, [categoryFilter, filteredDefinitions, moduleFilter, normalizedModuleQuery])
const catalogModel = useMemo(() => buildWorkflowNodeCatalogModel(visibleDefinitions), [visibleDefinitions])
const moduleFilters = catalogModel.moduleFilters
const stageSections = catalogModel.stageSections
useEffect(() => {
if (moduleFilter !== 'all' && !moduleFilters.some(module => module.namespace === moduleFilter)) {
setModuleFilter('all')
}
}, [moduleFilter, moduleFilters])
const familySections = catalogModel.familySections
const firstVisibleDefinition = visibleDefinitions[0]
const totalModuleCount = moduleFilters.length
const shouldExpandStageCoverage = variant === 'panel' && query.trim().length === 0 && moduleFilter === 'all'
const quickInsertDefinitions = useMemo(() => {
const prioritizedSteps = STARTER_NODE_STEP_ORDER[graphFamily]
if (prioritizedSteps.length === 0) return visibleDefinitions.slice(0, 4)
const byStep = new Map(visibleDefinitions.map(definition => [definition.step, definition]))
return prioritizedSteps
.map(step => byStep.get(step))
.filter((definition): definition is WorkflowNodeDefinition => Boolean(definition))
.slice(0, variant === 'menu' ? 5 : 6)
}, [graphFamily, variant, visibleDefinitions])
const quickInsertTitle = graphFamily === 'mixed' ? 'Suggested nodes' : STARTER_PATH_TITLES[graphFamily]
const runtimeFilterOptions: FilterPillOption<WorkflowNodeKindFilter>[] = [
{ value: 'all', label: NODE_KIND_FILTER_LABELS.all },
{ value: 'legacy', label: NODE_KIND_FILTER_LABELS.legacy },
{ value: 'bridge', label: NODE_KIND_FILTER_LABELS.bridge },
{ value: 'graph', label: NODE_KIND_FILTER_LABELS.graph },
]
const familyFilterOptions = availableFamilyFilters.map(filter => ({
value: filter,
label: FAMILY_FILTER_LABELS[filter],
}))
const categoryFilterOptions: FilterPillOption<WorkflowNodeCategoryFilter>[] = CATEGORY_FILTERS.map(
filter => ({
value: filter,
label: CATEGORY_FILTER_LABELS[filter],
}),
)
return (
<div className="space-y-3">
<WorkflowNodeCatalogFilterControls
visibleDefinitionCount={visibleDefinitions.length}
totalModuleCount={totalModuleCount}
graphFamily={graphFamily}
familyFilterOptions={familyFilterOptions}
runtimeFilterOptions={runtimeFilterOptions}
categoryFilterOptions={categoryFilterOptions}
familyFilter={familyFilter}
onFamilyFilterChange={setFamilyFilter}
kindFilter={kindFilter}
onKindFilterChange={setKindFilter}
categoryFilter={categoryFilter}
onCategoryFilterChange={setCategoryFilter}
query={query}
onQueryChange={setQuery}
onQueryEnter={() => {
if (firstVisibleDefinition && onSelectStep) {
onSelectStep(firstVisibleDefinition.step)
}
}}
searchPlaceholder={searchPlaceholder}
autoFocusSearch={autoFocusSearch}
moduleFilters={moduleFilters}
moduleFilter={moduleFilter}
onModuleFilterChange={setModuleFilter}
moduleQuery={moduleQuery}
onModuleQueryChange={setModuleQuery}
onClearModuleScope={() => {
setModuleFilter('all')
setModuleQuery('')
}}
/>
<div className="flex flex-wrap gap-2">
<RuntimeCoverageBadges runtimeCounts={catalogModel.runtimeCounts} />
</div>
{stageSections.length > 0 && (
<details
className="rounded-xl border border-border-default bg-surface-hover/35 p-3"
open={shouldExpandStageCoverage}
>
<summary className="flex cursor-pointer list-none flex-wrap items-start justify-between gap-2">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Stage Coverage
</p>
<p className="mt-1 text-xs text-content-muted">
Use Quick Insert for the first canonical steps, then use stage coverage to inspect which modules can fill the rest of the production path.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{stageSections.length} active stages
</span>
</summary>
<div className="mt-3 grid gap-2 md:grid-cols-2 xl:grid-cols-3">
{stageSections.map(section => (
<div
key={`stage-summary-${section.stage}`}
className="rounded-lg border border-border-default bg-surface px-3 py-2"
>
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${AUTHORING_STAGE_STYLES[section.stage]}`}>
{AUTHORING_STAGE_LABELS[section.stage]}
</span>
<span className="rounded-full border border-border-default bg-surface-hover px-1.5 py-0.5 text-[10px] text-content-muted">
{section.modules.length} modules
</span>
<span className="rounded-full border border-border-default bg-surface-hover px-1.5 py-0.5 text-[10px] text-content-muted">
{section.definitions.length} nodes
</span>
</div>
<p className="mt-2 text-xs text-content-muted">
{AUTHORING_STAGE_DESCRIPTIONS[section.stage]}
</p>
</div>
))}
</div>
</details>
)}
<WorkflowNodeCatalogQuickInsert
title={quickInsertTitle}
definitions={quickInsertDefinitions}
onSelectStep={onSelectStep}
/>
{visibleDefinitions.length === 0 && (
<WorkflowNodeCatalogEmptyState
onResetFilters={onEmptyAction}
resetLabel={emptyActionLabel}
/>
)}
<div className="space-y-3">
{familySections.map(familySection => {
const familyLabel = FAMILY_FILTER_LABELS[familySection.family]
const familyDescription = FAMILY_FILTER_DESCRIPTIONS[familySection.family]
const familyStyle = FAMILY_FILTER_STYLES[familySection.family]
return (
<div
key={familySection.family}
className="rounded-xl border border-border-default bg-surface-hover/35 p-3"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${familyStyle}`}>
{familyLabel}
</span>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{familySection.modules.length} modules
</span>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{familySection.definitions.length} nodes
</span>
</div>
<p className="text-xs text-content-muted">{familyDescription}</p>
</div>
<div className="flex flex-wrap gap-2">
<RuntimeCoverageBadges runtimeCounts={familySection.runtimeCounts} size="xs" />
</div>
</div>
<div className="mt-3 space-y-3">
{familySection.modules.map(moduleGroup => (
<div
key={`${familySection.family}:${moduleGroup.namespace}`}
className="rounded-lg border border-border-default bg-surface/80 p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-xs font-medium text-content">
{moduleGroup.label}
</span>
<span className="truncate rounded-full border border-border-default bg-surface px-2 py-0.5 font-mono text-[10px] text-content-muted">
{moduleGroup.namespace}
</span>
<StageCoverageBadges stages={moduleGroup.stages} />
</div>
<div className="flex flex-wrap gap-1.5">
<RuntimeCoverageBadges runtimeCounts={moduleGroup.runtimeCounts} size="xs" />
</div>
</div>
<span className="text-xs text-content-muted">{moduleGroup.definitions.length}</span>
</div>
<div className="mt-3 space-y-3">
{moduleGroup.stageSections.map(stageSection => {
const stageLabel = AUTHORING_STAGE_LABELS[stageSection.stage]
const stageDescription = AUTHORING_STAGE_DESCRIPTIONS[stageSection.stage]
const stageStyle = AUTHORING_STAGE_STYLES[stageSection.stage]
return (
<div
key={`${familySection.family}:${moduleGroup.namespace}:${stageSection.stage}`}
className="rounded-md border border-border-default bg-surface-hover/35 p-2"
>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${stageStyle}`}>
{stageLabel}
</span>
<p className="text-xs text-content-muted">{stageDescription}</p>
</div>
<span className="text-[10px] text-content-muted">
{stageSection.definitions.length}
</span>
</div>
<div className="space-y-2">
{stageSection.categories.map(categorySection => {
const { category, definitions: categoryDefinitions } = categorySection
return (
<div key={`${moduleGroup.namespace}:${stageSection.stage}:${category}`}>
<div className="mb-1 flex items-center justify-between gap-2">
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_COLORS[category]}`}>
{CATEGORY_LABELS[category]}
</span>
<span className="text-[10px] text-content-muted">{categoryDefinitions.length}</span>
</div>
<div className="space-y-1">
{categoryDefinitions.map(definition => {
const family = getDefinitionFamily(definition)
const contractSummary = getWorkflowNodeContractSummary(definition)
const contractPresentation =
getWorkflowNodeContractPresentation(contractSummary)
const {
inputMetric,
variableMetric,
outputMetric,
socketRequirementDescription,
variableSummaryText,
} = contractPresentation
const contractSignals = getWorkflowNodeContractSignals(
definition,
contractSummary,
)
const isActionable = Boolean(onSelectStep)
return (
<div
key={definition.step}
className={`rounded-lg border border-border-default bg-surface px-3 py-2 ${
isActionable ? 'transition-colors hover:bg-surface-hover' : ''
}`}
title={definition.description}
>
<div className="flex items-start gap-2">
{renderIcon && (
<span className="mt-0.5 text-content-secondary">
{renderIcon(definition.icon, variant === 'menu' ? 14 : 13)}
</span>
)}
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<p className="truncate text-sm font-medium text-content">
{definition.label}
</p>
<span
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${FAMILY_FILTER_STYLES[family]}`}
>
{FAMILY_FILTER_LABELS[family]}
</span>
{getDefinitionBadges(definition).map(badge => (
<span
key={`${definition.step}-${badge.label}`}
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${badge.className}`}
>
{badge.label}
</span>
))}
</div>
<p className="mt-0.5 truncate font-mono text-[11px] text-content-muted">
{definition.step}
</p>
</div>
{isActionable && (
<button
type="button"
onClick={() => onSelectStep?.(definition.step)}
aria-label={
variant === 'panel'
? `Insert ${definition.label}`
: `Use ${definition.label}`
}
className={`shrink-0 rounded-lg px-2 py-1 text-xs font-medium ${
variant === 'panel'
? 'border border-border-default text-content hover:bg-surface-hover'
: 'bg-accent text-white hover:bg-accent-hover'
}`}
>
{variant === 'panel' ? (
<span className="inline-flex items-center gap-1">
<Plus size={12} />
Insert
</span>
) : (
<span className="inline-flex items-center gap-1">
Use
<ArrowRight size={12} />
</span>
)}
</button>
)}
</div>
<p className="mt-1 line-clamp-2 text-xs text-content-muted">
{definition.description}
</p>
<WorkflowNodeContractSignalList
signals={contractSignals}
className="mt-2 flex flex-wrap gap-1.5 text-[10px]"
/>
<div className="mt-2 grid gap-1.5 sm:grid-cols-3">
<ContractSummaryMetric
label="Inputs"
value={inputMetric.value}
title={inputMetric.title}
/>
<ContractSummaryMetric
label="Variables"
value={variableMetric.value}
title={variableMetric.title}
/>
<ContractSummaryMetric
label="Outputs"
value={outputMetric.value}
title={outputMetric.title}
/>
</div>
<div className="mt-2 grid gap-1.5 lg:grid-cols-2">
<div className="rounded-lg border border-border-default bg-surface-hover/50 px-2 py-1.5">
<p className="text-[9px] font-semibold uppercase tracking-wide text-content-muted">
Wiring
</p>
<p className="mt-1 text-[11px] text-content-secondary">
{socketRequirementDescription}
</p>
</div>
<div className="rounded-lg border border-border-default bg-surface-hover/50 px-2 py-1.5">
<p className="text-[9px] font-semibold uppercase tracking-wide text-content-muted">
Variables
</p>
<p className="mt-1 text-[11px] text-content-muted">
{variableSummaryText}
</p>
</div>
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
</div>
))}
</div>
</div>
)
})}
</div>
</div>
)
}