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:
2026-07-21 19:09:54 +02:00
co-authored by Claude Sonnet 4.6
parent c51dd8cd67
commit d2e4934cca
63 changed files with 7035 additions and 2140 deletions
@@ -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>