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>
297 lines
9.7 KiB
TypeScript
297 lines
9.7 KiB
TypeScript
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
|
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
|
|
import { getWorkflowModuleBundles, type WorkflowModuleBundleDefinition, type WorkflowModuleBundleId } from './workflowModuleBundles'
|
|
import {
|
|
getWorkflowReferenceBundles,
|
|
type WorkflowReferenceBundleDefinition,
|
|
type WorkflowReferenceBundleId,
|
|
} from './workflowReferenceBundles'
|
|
|
|
export const STARTER_NODE_STEP_ORDER: Record<WorkflowGraphFamily, string[]> = {
|
|
cad_file: [
|
|
'resolve_step_path',
|
|
'occ_object_extract',
|
|
'occ_glb_export',
|
|
'stl_cache_generate',
|
|
'thumbnail_save',
|
|
],
|
|
order_line: [
|
|
'order_line_setup',
|
|
'resolve_template',
|
|
'auto_populate_materials',
|
|
'glb_bbox',
|
|
'material_map_resolve',
|
|
'blender_still',
|
|
'output_save',
|
|
'notify',
|
|
],
|
|
mixed: [],
|
|
}
|
|
|
|
export const STARTER_PATH_TITLES: Record<WorkflowGraphFamily, string> = {
|
|
cad_file: 'CAD intake assembly',
|
|
order_line: 'Still-render assembly',
|
|
mixed: 'Starter path',
|
|
}
|
|
|
|
export const STARTER_PATH_DESCRIPTIONS: Record<WorkflowGraphFamily, string> = {
|
|
cad_file: 'Use this module sequence to take a CAD file through extraction, preview generation, and published output.',
|
|
order_line: 'Use this module sequence to keep the non-legacy still graph parallel to the legacy render path.',
|
|
mixed: 'Reference sequence for assembling a workflow graph.',
|
|
}
|
|
|
|
export type WorkflowAuthoringPriority = {
|
|
title: string
|
|
description: string
|
|
}
|
|
|
|
export type WorkflowAuthoringFlowStep = {
|
|
index: number
|
|
title: string
|
|
description: string
|
|
}
|
|
|
|
export type WorkflowAuthoringBundleStatus<TBundle> = TBundle & {
|
|
presentCount: number
|
|
totalCount: number
|
|
}
|
|
|
|
export type WorkflowStarterPathItem = {
|
|
index: number
|
|
definition: WorkflowNodeDefinition
|
|
isPresent: boolean
|
|
}
|
|
|
|
export type WorkflowAuthoringStageProgress = {
|
|
id: string
|
|
title: string
|
|
description: string
|
|
present: number
|
|
total: number
|
|
actionLabel: string
|
|
actionKind: 'reference' | 'module' | 'step'
|
|
bundleId?: WorkflowReferenceBundleId | WorkflowModuleBundleId
|
|
step?: string
|
|
}
|
|
|
|
export type WorkflowAuthoringPlan = {
|
|
title: string
|
|
description: string
|
|
priorities: WorkflowAuthoringPriority[]
|
|
authoringFlow: WorkflowAuthoringFlowStep[]
|
|
referenceBundles: WorkflowAuthoringBundleStatus<WorkflowReferenceBundleDefinition>[]
|
|
moduleBundles: WorkflowAuthoringBundleStatus<WorkflowModuleBundleDefinition>[]
|
|
starterTitle: string
|
|
starterDescription: string
|
|
starterItems: WorkflowStarterPathItem[]
|
|
starterCompletedCount: number
|
|
stageProgress: WorkflowAuthoringStageProgress[]
|
|
gapFillDefinitions: WorkflowNodeDefinition[]
|
|
}
|
|
|
|
function haveSameStepSet(left: string[], right: string[]) {
|
|
if (left.length !== right.length) return false
|
|
const rightSet = new Set(right)
|
|
return left.every(step => rightSet.has(step))
|
|
}
|
|
|
|
function getAuthoringPriorities(graphFamily: WorkflowGraphFamily): WorkflowAuthoringPriority[] {
|
|
if (graphFamily === 'order_line') {
|
|
return [
|
|
{
|
|
title: 'Start with the Still Render Reference path',
|
|
description: 'Insert the full non-legacy still production baseline first, then tune modules or individual nodes.',
|
|
},
|
|
{
|
|
title: 'Swap stages with stage modules',
|
|
description: 'Use scene-prep, materials, render, and publish bundles to change whole production stages without breaking graph-safe sequencing.',
|
|
},
|
|
{
|
|
title: 'Use raw nodes last',
|
|
description: 'Drop to legacy, bridge, or native graph nodes only after the production path already exists on canvas.',
|
|
},
|
|
]
|
|
}
|
|
|
|
if (graphFamily === 'cad_file') {
|
|
return [
|
|
{
|
|
title: 'Start with the CAD intake assembly',
|
|
description: 'Build the import and preview chain first so downstream consumers always receive consistent artifacts.',
|
|
},
|
|
{
|
|
title: 'Extend with stage bundles',
|
|
description: 'Use reusable intake modules before inserting isolated conversion or utility nodes.',
|
|
},
|
|
{
|
|
title: 'Use raw nodes last',
|
|
description: 'Only drop to individual nodes for edge cases or targeted debugging once the intake baseline is present.',
|
|
},
|
|
]
|
|
}
|
|
|
|
return [
|
|
{
|
|
title: 'Split the graph by responsibility',
|
|
description: 'Keep mixed graphs organized by building complete stages first, then add isolated nodes only for orchestration glue.',
|
|
},
|
|
{
|
|
title: 'Prefer reusable modules over hand-wiring',
|
|
description: 'Use grouped bundles whenever a stage can be expressed as a reusable production slice.',
|
|
},
|
|
{
|
|
title: 'Use raw nodes last',
|
|
description: 'Reach for the full catalog only after the baseline path is readable and stable.',
|
|
},
|
|
]
|
|
}
|
|
|
|
export function getWorkflowAuthoringFlow(graphFamily: WorkflowGraphFamily): WorkflowAuthoringFlowStep[] {
|
|
const starterPathLabel = graphFamily === 'mixed' ? 'Starter Path' : STARTER_PATH_TITLES[graphFamily]
|
|
|
|
return [
|
|
{
|
|
index: 1,
|
|
title: 'Reference Path',
|
|
description:
|
|
graphFamily === 'order_line'
|
|
? 'Start from the canonical non-legacy still graph before changing individual stages.'
|
|
: 'Start from the canonical path when you want a full baseline instead of assembling from scratch.',
|
|
},
|
|
{
|
|
index: 2,
|
|
title: 'Stage Modules',
|
|
description: 'Swap or extend whole production stages with reusable graph-safe bundles.',
|
|
},
|
|
{
|
|
index: 3,
|
|
title: starterPathLabel,
|
|
description: 'Fill gaps one required step at a time and verify the minimum viable chain stays intact.',
|
|
},
|
|
{
|
|
index: 4,
|
|
title: 'Raw Node Catalog',
|
|
description: 'Use individual nodes only for advanced authoring, experiments, or edge-case overrides.',
|
|
},
|
|
]
|
|
}
|
|
|
|
export function getWorkflowAuthoringPlan(
|
|
definitions: WorkflowNodeDefinition[],
|
|
graphFamily: WorkflowGraphFamily,
|
|
activeSteps: string[],
|
|
): WorkflowAuthoringPlan {
|
|
const activeStepSet = new Set(activeSteps)
|
|
const definitionsByStep = new Map(definitions.map(definition => [definition.step, definition]))
|
|
const title = graphFamily === 'mixed' ? 'Guided Authoring' : STARTER_PATH_TITLES[graphFamily]
|
|
const description =
|
|
graphFamily === 'mixed'
|
|
? 'Keep the graph readable by preferring complete paths and stage bundles before raw node-level edits.'
|
|
: STARTER_PATH_DESCRIPTIONS[graphFamily]
|
|
const referenceBundles = getWorkflowReferenceBundles(definitions, graphFamily)
|
|
.map(bundle => ({
|
|
...bundle,
|
|
presentCount: bundle.stepIds.filter(step => activeStepSet.has(step)).length,
|
|
totalCount: bundle.stepIds.length,
|
|
}))
|
|
const moduleBundles = getWorkflowModuleBundles(definitions, graphFamily)
|
|
.map(bundle => ({
|
|
...bundle,
|
|
presentCount: bundle.stepIds.filter(step => activeStepSet.has(step)).length,
|
|
totalCount: bundle.stepIds.length,
|
|
}))
|
|
|
|
const starterDefinitions =
|
|
graphFamily === 'mixed'
|
|
? []
|
|
: STARTER_NODE_STEP_ORDER[graphFamily]
|
|
.map(step => definitionsByStep.get(step))
|
|
.filter((definition): definition is WorkflowNodeDefinition => Boolean(definition))
|
|
|
|
const starterItems = starterDefinitions.map((definition, index) => ({
|
|
index: index + 1,
|
|
definition,
|
|
isPresent: activeStepSet.has(definition.step),
|
|
}))
|
|
|
|
const starterCompletedCount = starterItems.filter(item => item.isPresent).length
|
|
const gapFillDefinitions = starterItems
|
|
.filter(item => !item.isPresent)
|
|
.map(item => item.definition)
|
|
.slice(0, 3)
|
|
|
|
const stageProgress = (() => {
|
|
if (graphFamily === 'mixed') return [] as WorkflowAuthoringStageProgress[]
|
|
|
|
const stages: WorkflowAuthoringStageProgress[] = []
|
|
const referenceBundle = referenceBundles[0]
|
|
if (referenceBundle) {
|
|
stages.push({
|
|
id: referenceBundle.id,
|
|
title: referenceBundle.label,
|
|
description: referenceBundle.description,
|
|
present: referenceBundle.presentCount,
|
|
total: referenceBundle.totalCount,
|
|
actionLabel:
|
|
referenceBundle.presentCount === 0
|
|
? `Insert ${referenceBundle.shortLabel}`
|
|
: `Reapply ${referenceBundle.shortLabel}`,
|
|
actionKind: 'reference',
|
|
bundleId: referenceBundle.id,
|
|
})
|
|
}
|
|
|
|
for (const bundle of moduleBundles) {
|
|
if (referenceBundle && haveSameStepSet(bundle.stepIds, referenceBundle.stepIds)) {
|
|
continue
|
|
}
|
|
if (bundle.presentCount === bundle.totalCount) continue
|
|
|
|
stages.push({
|
|
id: bundle.id,
|
|
title: bundle.label,
|
|
description: bundle.description,
|
|
present: bundle.presentCount,
|
|
total: bundle.totalCount,
|
|
actionLabel:
|
|
bundle.presentCount === 0
|
|
? `Insert ${bundle.shortLabel}`
|
|
: `Complete ${bundle.shortLabel}`,
|
|
actionKind: 'module',
|
|
bundleId: bundle.id,
|
|
})
|
|
}
|
|
|
|
const nextMissingStarterDefinition = starterItems.find(item => !item.isPresent)?.definition
|
|
if (nextMissingStarterDefinition) {
|
|
stages.push({
|
|
id: nextMissingStarterDefinition.step,
|
|
title: `Next missing step: ${nextMissingStarterDefinition.label}`,
|
|
description: 'Use a single-step insert only when you need to patch a gap without dropping an entire stage bundle.',
|
|
present: 0,
|
|
total: 1,
|
|
actionLabel: `Add ${nextMissingStarterDefinition.label}`,
|
|
actionKind: 'step',
|
|
step: nextMissingStarterDefinition.step,
|
|
})
|
|
}
|
|
|
|
return stages
|
|
})()
|
|
|
|
return {
|
|
title,
|
|
description,
|
|
priorities: getAuthoringPriorities(graphFamily),
|
|
authoringFlow: getWorkflowAuthoringFlow(graphFamily),
|
|
referenceBundles,
|
|
moduleBundles,
|
|
starterTitle: STARTER_PATH_TITLES[graphFamily],
|
|
starterDescription: STARTER_PATH_DESCRIPTIONS[graphFamily],
|
|
starterItems,
|
|
starterCompletedCount,
|
|
stageProgress,
|
|
gapFillDefinitions,
|
|
}
|
|
}
|