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>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { ArrowRight, Plus, Search } from 'lucide-react'
|
||||
import { ArrowRight, Plus } from 'lucide-react'
|
||||
|
||||
import type { StepCategory, WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import {
|
||||
AUTHORING_STAGE_DESCRIPTIONS,
|
||||
AUTHORING_STAGE_LABELS,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
getDefinitionFamily,
|
||||
getDefinitionModuleLabel,
|
||||
getDefinitionModuleNamespace,
|
||||
type WorkflowAuthoringStage,
|
||||
type WorkflowGraphFamily,
|
||||
type WorkflowNodeFamilyFilter,
|
||||
type WorkflowNodeKindFilter,
|
||||
@@ -29,6 +30,94 @@ import {
|
||||
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[]
|
||||
@@ -42,76 +131,6 @@ type WorkflowNodeCatalogBrowserProps = {
|
||||
autoFocusSearch?: boolean
|
||||
}
|
||||
|
||||
type WorkflowNodeCategoryFilter = 'all' | StepCategory
|
||||
|
||||
const CATEGORY_FILTERS: WorkflowNodeCategoryFilter[] = ['all', 'input', 'processing', 'rendering', 'output']
|
||||
|
||||
const CATEGORY_FILTER_LABELS: Record<WorkflowNodeCategoryFilter, string> = {
|
||||
all: 'All Categories',
|
||||
input: 'Input',
|
||||
processing: 'Processing',
|
||||
rendering: 'Rendering',
|
||||
output: 'Output',
|
||||
}
|
||||
|
||||
type FilterPillOption<T extends string> = {
|
||||
value: T
|
||||
label: string
|
||||
}
|
||||
|
||||
function readContractList(contract: Record<string, unknown>, key: string) {
|
||||
const value = contract[key]
|
||||
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0) : []
|
||||
}
|
||||
|
||||
function readContractContext(contract: Record<string, unknown>) {
|
||||
return typeof contract.context === 'string' ? contract.context : null
|
||||
}
|
||||
|
||||
function formatContractLabel(value: string) {
|
||||
return value
|
||||
.split(/[_\s]+/)
|
||||
.filter(Boolean)
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function FilterPillGroup<T extends string>({
|
||||
title,
|
||||
options,
|
||||
activeValue,
|
||||
onChange,
|
||||
}: {
|
||||
title: string
|
||||
options: FilterPillOption<T>[]
|
||||
activeValue: T
|
||||
onChange: (value: T) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-content-secondary">
|
||||
{title}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
|
||||
activeValue === option.value
|
||||
? 'bg-accent text-white'
|
||||
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkflowNodeCatalogBrowser({
|
||||
definitions,
|
||||
graphFamily,
|
||||
@@ -179,6 +198,7 @@ export function WorkflowNodeCatalogBrowser({
|
||||
|
||||
const catalogModel = useMemo(() => buildWorkflowNodeCatalogModel(visibleDefinitions), [visibleDefinitions])
|
||||
const moduleFilters = catalogModel.moduleFilters
|
||||
const stageSections = catalogModel.stageSections
|
||||
|
||||
useEffect(() => {
|
||||
if (moduleFilter !== 'all' && !moduleFilters.some(module => module.namespace === moduleFilter)) {
|
||||
@@ -189,6 +209,7 @@ export function WorkflowNodeCatalogBrowser({
|
||||
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)
|
||||
@@ -219,190 +240,98 @@ export function WorkflowNodeCatalogBrowser({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-content-muted">
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
|
||||
{visibleDefinitions.length} nodes
|
||||
</span>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
|
||||
{totalModuleCount} modules
|
||||
</span>
|
||||
{graphFamily !== 'mixed' && (
|
||||
<span className={`rounded-full px-2 py-0.5 font-medium ${FAMILY_FILTER_STYLES[graphFamily]}`}>
|
||||
{FAMILY_FILTER_LABELS[graphFamily]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(moduleFilter !== 'all' || moduleQuery) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setModuleFilter('all')
|
||||
setModuleQuery('')
|
||||
}}
|
||||
className="text-[11px] font-medium text-accent hover:text-accent-hover"
|
||||
>
|
||||
Show all modules
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" />
|
||||
<input
|
||||
value={query}
|
||||
autoFocus={autoFocusSearch}
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' && firstVisibleDefinition && onSelectStep) {
|
||||
event.preventDefault()
|
||||
onSelectStep(firstVisibleDefinition.step)
|
||||
}
|
||||
}}
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full rounded-xl border border-border-default bg-surface px-8 py-2 text-sm text-content focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FilterPillGroup
|
||||
title="Runtime"
|
||||
options={runtimeFilterOptions}
|
||||
activeValue={kindFilter}
|
||||
onChange={setKindFilter}
|
||||
/>
|
||||
|
||||
<FilterPillGroup
|
||||
title="Family"
|
||||
options={familyFilterOptions}
|
||||
activeValue={familyFilter}
|
||||
onChange={setFamilyFilter}
|
||||
/>
|
||||
|
||||
<FilterPillGroup
|
||||
title="Category"
|
||||
options={categoryFilterOptions}
|
||||
activeValue={categoryFilter}
|
||||
onChange={setCategoryFilter}
|
||||
/>
|
||||
|
||||
{moduleFilters.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Modules
|
||||
</p>
|
||||
<span className="text-[11px] text-content-muted">
|
||||
family + runtime scoped
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" />
|
||||
<input
|
||||
value={moduleQuery}
|
||||
onChange={event => setModuleQuery(event.target.value)}
|
||||
placeholder="Search modules"
|
||||
className="w-full rounded-xl border border-border-default bg-surface px-8 py-2 text-xs text-content focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModuleFilter('all')}
|
||||
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
|
||||
moduleFilter === 'all'
|
||||
? 'bg-accent text-white'
|
||||
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
All Modules
|
||||
</button>
|
||||
{moduleFilters.map(module => (
|
||||
<button
|
||||
key={module.namespace}
|
||||
type="button"
|
||||
onClick={() => setModuleFilter(module.namespace)}
|
||||
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
|
||||
moduleFilter === module.namespace
|
||||
? 'bg-accent text-white'
|
||||
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
|
||||
}`}
|
||||
title={`${module.label} · ${module.stages.map(stage => AUTHORING_STAGE_LABELS[stage]).join(' / ')}`}
|
||||
>
|
||||
{module.label}
|
||||
<span className="ml-1 opacity-70">{module.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
{(['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[]).map(group => {
|
||||
const count = catalogModel.runtimeCounts[group] ?? 0
|
||||
if (count === 0) return null
|
||||
return (
|
||||
<span
|
||||
key={group}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`}
|
||||
title={NODE_LIBRARY_GROUP_LABELS[group]}
|
||||
>
|
||||
<span>{NODE_LIBRARY_GROUP_LABELS[group]}</span>
|
||||
<span>{count}</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
<RuntimeCoverageBadges runtimeCounts={catalogModel.runtimeCounts} />
|
||||
</div>
|
||||
|
||||
{quickInsertDefinitions.length > 0 && (
|
||||
<div className="rounded-xl border border-border-default bg-surface-hover/35 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{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">
|
||||
Quick Insert
|
||||
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>
|
||||
<p className="mt-1 text-xs text-content-muted">{quickInsertTitle}</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{quickInsertDefinitions.length} picks
|
||||
{stageSections.length} active stages
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{quickInsertDefinitions.map(definition => (
|
||||
<button
|
||||
key={`quick-${definition.step}`}
|
||||
type="button"
|
||||
onClick={() => onSelectStep?.(definition.step)}
|
||||
disabled={!onSelectStep}
|
||||
className="rounded-full border border-border-default bg-surface px-3 py-1.5 text-xs font-medium text-content transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-60"
|
||||
title={definition.description}
|
||||
</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"
|
||||
>
|
||||
{definition.label}
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<WorkflowNodeCatalogQuickInsert
|
||||
title={quickInsertTitle}
|
||||
definitions={quickInsertDefinitions}
|
||||
onSelectStep={onSelectStep}
|
||||
/>
|
||||
|
||||
{visibleDefinitions.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-border-default bg-surface-hover/40 px-4 py-8 text-center">
|
||||
<p className="text-sm font-medium text-content">No matching nodes</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Adjust search, runtime, family, or module filters to bring nodes back into view.
|
||||
</p>
|
||||
{onEmptyAction && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEmptyAction}
|
||||
className="mt-3 rounded-lg border border-border-default px-3 py-1.5 text-xs font-medium text-content hover:bg-surface-hover"
|
||||
>
|
||||
{emptyActionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<WorkflowNodeCatalogEmptyState
|
||||
onResetFilters={onEmptyAction}
|
||||
resetLabel={emptyActionLabel}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
@@ -433,27 +362,16 @@ export function WorkflowNodeCatalogBrowser({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[]).map(group => {
|
||||
const count = familySection.runtimeCounts[group]
|
||||
if (count === 0) return null
|
||||
return (
|
||||
<span
|
||||
key={`${familySection.family}-${group}`}
|
||||
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`}
|
||||
>
|
||||
{NODE_KIND_FILTER_LABELS[group]} {count}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
<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
|
||||
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">
|
||||
@@ -463,29 +381,10 @@ export function WorkflowNodeCatalogBrowser({
|
||||
<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>
|
||||
{moduleGroup.stages.map(stage => (
|
||||
<span
|
||||
key={`${moduleGroup.namespace}-${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>
|
||||
))}
|
||||
<StageCoverageBadges stages={moduleGroup.stages} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[]).map(group => {
|
||||
const count = moduleGroup.runtimeCounts[group]
|
||||
if (count === 0) return null
|
||||
return (
|
||||
<span
|
||||
key={`${moduleGroup.namespace}-${group}`}
|
||||
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`}
|
||||
>
|
||||
{NODE_KIND_FILTER_LABELS[group]} {count}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
<RuntimeCoverageBadges runtimeCounts={moduleGroup.runtimeCounts} size="xs" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-content-muted">{moduleGroup.definitions.length}</span>
|
||||
@@ -529,10 +428,20 @@ export function WorkflowNodeCatalogBrowser({
|
||||
<div className="space-y-1">
|
||||
{categoryDefinitions.map(definition => {
|
||||
const family = getDefinitionFamily(definition)
|
||||
const requiredInputs = readContractList(definition.input_contract, 'requires')
|
||||
const providedOutputs = readContractList(definition.output_contract, 'provides')
|
||||
const inputContext = readContractContext(definition.input_contract)
|
||||
const outputContext = readContractContext(definition.output_contract)
|
||||
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 (
|
||||
@@ -609,49 +518,46 @@ export function WorkflowNodeCatalogBrowser({
|
||||
{definition.description}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 text-[10px]">
|
||||
{inputContext && (
|
||||
<span className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary">
|
||||
In {formatContractLabel(inputContext)}
|
||||
</span>
|
||||
)}
|
||||
{outputContext && (
|
||||
<span className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary">
|
||||
Out {formatContractLabel(outputContext)}
|
||||
</span>
|
||||
)}
|
||||
{requiredInputs.slice(0, 2).map(input => (
|
||||
<span
|
||||
key={`${definition.step}-requires-${input}`}
|
||||
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
|
||||
>
|
||||
Requires {formatContractLabel(input)}
|
||||
</span>
|
||||
))}
|
||||
{providedOutputs.slice(0, 2).map(output => (
|
||||
<span
|
||||
key={`${definition.step}-provides-${output}`}
|
||||
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
|
||||
>
|
||||
Provides {formatContractLabel(output)}
|
||||
</span>
|
||||
))}
|
||||
{definition.artifact_roles_consumed.slice(0, 1).map(artifact => (
|
||||
<span
|
||||
key={`${definition.step}-consumes-${artifact}`}
|
||||
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
|
||||
>
|
||||
Consumes {formatContractLabel(artifact)}
|
||||
</span>
|
||||
))}
|
||||
{definition.artifact_roles_produced.slice(0, 1).map(artifact => (
|
||||
<span
|
||||
key={`${definition.step}-produces-${artifact}`}
|
||||
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
|
||||
>
|
||||
Produces {formatContractLabel(artifact)}
|
||||
</span>
|
||||
))}
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user