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>
473 lines
15 KiB
TypeScript
473 lines
15 KiB
TypeScript
import { describe, expect, test } from 'vitest'
|
|
|
|
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
|
import { createWorkflowModuleBundleInsertion, getWorkflowModuleBundles } from '../../components/workflows/workflowModuleBundles'
|
|
import {
|
|
createWorkflowReferenceBundleInsertion,
|
|
getWorkflowReferenceBundles,
|
|
} from '../../components/workflows/workflowReferenceBundles'
|
|
|
|
const definitions: WorkflowNodeDefinition[] = [
|
|
{
|
|
step: 'order_line_setup',
|
|
label: 'Order Line Setup',
|
|
family: 'order_line',
|
|
module_key: 'order_line.prepare_render_context',
|
|
category: 'input',
|
|
description: 'Prepare render context.',
|
|
node_type: 'inputNode',
|
|
icon: 'refresh-cw',
|
|
defaults: {},
|
|
fields: [],
|
|
execution_kind: 'bridge',
|
|
legacy_compatible: true,
|
|
input_contract: { context: 'order_line' },
|
|
output_contract: { context: 'order_line', provides: ['order_line'] },
|
|
artifact_roles_produced: [],
|
|
artifact_roles_consumed: [],
|
|
legacy_source: 'legacy.order_line_setup',
|
|
},
|
|
{
|
|
step: 'resolve_template',
|
|
label: 'Resolve Template',
|
|
family: 'order_line',
|
|
module_key: 'rendering.resolve_template',
|
|
category: 'processing',
|
|
description: 'Resolve template.',
|
|
node_type: 'processNode',
|
|
icon: 'layers',
|
|
defaults: {},
|
|
fields: [],
|
|
execution_kind: 'bridge',
|
|
legacy_compatible: true,
|
|
input_contract: { context: 'order_line', requires: ['order_line'] },
|
|
output_contract: { context: 'order_line', provides: ['render_template'] },
|
|
artifact_roles_produced: [],
|
|
artifact_roles_consumed: [],
|
|
legacy_source: 'legacy.resolve_template',
|
|
},
|
|
{
|
|
step: 'auto_populate_materials',
|
|
label: 'Auto Populate Materials',
|
|
family: 'order_line',
|
|
module_key: 'materials.auto_populate',
|
|
category: 'processing',
|
|
description: 'Populate materials.',
|
|
node_type: 'processNode',
|
|
icon: 'layers',
|
|
defaults: {},
|
|
fields: [],
|
|
execution_kind: 'bridge',
|
|
legacy_compatible: true,
|
|
input_contract: { context: 'order_line', requires: ['order_line'] },
|
|
output_contract: { context: 'order_line', provides: ['cad_materials'] },
|
|
artifact_roles_produced: [],
|
|
artifact_roles_consumed: [],
|
|
legacy_source: 'legacy.auto_populate_materials',
|
|
},
|
|
{
|
|
step: 'glb_bbox',
|
|
label: 'Compute Bounding Box',
|
|
family: 'order_line',
|
|
module_key: 'geometry.compute_bbox',
|
|
category: 'processing',
|
|
description: 'Compute bbox.',
|
|
node_type: 'processNode',
|
|
icon: 'layers',
|
|
defaults: {},
|
|
fields: [],
|
|
execution_kind: 'native',
|
|
legacy_compatible: false,
|
|
input_contract: { context: 'order_line', requires: ['order_line'] },
|
|
output_contract: { context: 'order_line', provides: ['bbox'] },
|
|
artifact_roles_produced: [],
|
|
artifact_roles_consumed: [],
|
|
legacy_source: null,
|
|
},
|
|
{
|
|
step: 'material_map_resolve',
|
|
label: 'Resolve Material Map',
|
|
family: 'order_line',
|
|
module_key: 'materials.resolve_map',
|
|
category: 'processing',
|
|
description: 'Resolve material map.',
|
|
node_type: 'processNode',
|
|
icon: 'layers',
|
|
defaults: {},
|
|
fields: [],
|
|
execution_kind: 'native',
|
|
legacy_compatible: false,
|
|
input_contract: { context: 'order_line', requires: ['order_line'] },
|
|
output_contract: { context: 'order_line', provides: ['material_map'] },
|
|
artifact_roles_produced: [],
|
|
artifact_roles_consumed: [],
|
|
legacy_source: null,
|
|
},
|
|
{
|
|
step: 'blender_still',
|
|
label: 'Blender Still',
|
|
family: 'order_line',
|
|
module_key: 'render.production.still',
|
|
category: 'rendering',
|
|
description: 'Render still image.',
|
|
node_type: 'renderNode',
|
|
icon: 'camera',
|
|
defaults: {},
|
|
fields: [],
|
|
execution_kind: 'native',
|
|
legacy_compatible: false,
|
|
input_contract: { context: 'order_line', requires: ['order_line'] },
|
|
output_contract: { context: 'order_line', provides: ['rendered_image'] },
|
|
artifact_roles_produced: [],
|
|
artifact_roles_consumed: [],
|
|
legacy_source: null,
|
|
},
|
|
{
|
|
step: 'output_save',
|
|
label: 'Save Output',
|
|
family: 'order_line',
|
|
module_key: 'media.save_output',
|
|
category: 'output',
|
|
description: 'Save output.',
|
|
node_type: 'outputNode',
|
|
icon: 'download',
|
|
defaults: {},
|
|
fields: [],
|
|
execution_kind: 'bridge',
|
|
legacy_compatible: true,
|
|
input_contract: { context: 'order_line', requires: ['rendered_image'] },
|
|
output_contract: { context: 'order_line', provides: ['saved_output'] },
|
|
artifact_roles_produced: [],
|
|
artifact_roles_consumed: [],
|
|
legacy_source: 'legacy.output_save',
|
|
},
|
|
{
|
|
step: 'notify',
|
|
label: 'Notify Result',
|
|
family: 'order_line',
|
|
module_key: 'notifications.emit',
|
|
category: 'output',
|
|
description: 'Notify result.',
|
|
node_type: 'outputNode',
|
|
icon: 'bell',
|
|
defaults: {},
|
|
fields: [],
|
|
execution_kind: 'bridge',
|
|
legacy_compatible: true,
|
|
input_contract: { context: 'order_line', requires: ['saved_output'] },
|
|
output_contract: { context: 'order_line', provides: ['notification'] },
|
|
artifact_roles_produced: [],
|
|
artifact_roles_consumed: [],
|
|
legacy_source: 'legacy.notify',
|
|
},
|
|
{
|
|
step: 'export_blend',
|
|
label: 'Export Blend',
|
|
family: 'order_line',
|
|
module_key: 'media.export_blend',
|
|
category: 'output',
|
|
description: 'Export blend.',
|
|
node_type: 'outputNode',
|
|
icon: 'download',
|
|
defaults: {},
|
|
fields: [],
|
|
execution_kind: 'bridge',
|
|
legacy_compatible: true,
|
|
input_contract: { context: 'order_line', requires: ['render_template'] },
|
|
output_contract: { context: 'order_line', provides: ['blend_asset'] },
|
|
artifact_roles_produced: [],
|
|
artifact_roles_consumed: [],
|
|
legacy_source: 'legacy.export_blend',
|
|
},
|
|
]
|
|
|
|
const cadDefinitions: WorkflowNodeDefinition[] = [
|
|
{
|
|
step: 'resolve_step_path',
|
|
label: 'Resolve STEP Path',
|
|
family: 'cad_file',
|
|
module_key: 'cad.resolve_step_path',
|
|
category: 'input',
|
|
description: 'Resolve the STEP path.',
|
|
node_type: 'inputNode',
|
|
icon: 'file-code-2',
|
|
defaults: {},
|
|
fields: [],
|
|
execution_kind: 'bridge',
|
|
legacy_compatible: true,
|
|
input_contract: { context: 'cad_file' },
|
|
output_contract: { context: 'cad_file', provides: ['cad_file_record'] },
|
|
artifact_roles_produced: [],
|
|
artifact_roles_consumed: [],
|
|
legacy_source: 'legacy.resolve_step_path',
|
|
},
|
|
{
|
|
step: 'occ_object_extract',
|
|
label: 'Extract STEP Objects',
|
|
family: 'cad_file',
|
|
module_key: 'cad.extract_objects',
|
|
category: 'processing',
|
|
description: 'Extract objects.',
|
|
node_type: 'processNode',
|
|
icon: 'boxes',
|
|
defaults: {},
|
|
fields: [],
|
|
execution_kind: 'native',
|
|
legacy_compatible: false,
|
|
input_contract: { context: 'cad_file', requires: ['cad_file_record'] },
|
|
output_contract: { context: 'cad_file', provides: ['occ_scene'] },
|
|
artifact_roles_produced: [],
|
|
artifact_roles_consumed: [],
|
|
legacy_source: null,
|
|
},
|
|
{
|
|
step: 'occ_glb_export',
|
|
label: 'Export GLB',
|
|
family: 'cad_file',
|
|
module_key: 'cad.export_glb',
|
|
category: 'processing',
|
|
description: 'Export GLB.',
|
|
node_type: 'processNode',
|
|
icon: 'package',
|
|
defaults: {},
|
|
fields: [],
|
|
execution_kind: 'native',
|
|
legacy_compatible: false,
|
|
input_contract: { context: 'cad_file', requires: ['occ_scene'] },
|
|
output_contract: { context: 'cad_file', provides: ['glb_preview'] },
|
|
artifact_roles_produced: [],
|
|
artifact_roles_consumed: [],
|
|
legacy_source: null,
|
|
},
|
|
{
|
|
step: 'stl_cache_generate',
|
|
label: 'Generate STL Cache',
|
|
family: 'cad_file',
|
|
module_key: 'cad.generate_stl_cache',
|
|
category: 'processing',
|
|
description: 'Generate STL cache.',
|
|
node_type: 'processNode',
|
|
icon: 'database',
|
|
defaults: {},
|
|
fields: [],
|
|
execution_kind: 'bridge',
|
|
legacy_compatible: true,
|
|
input_contract: { context: 'cad_file', requires: ['glb_preview'] },
|
|
output_contract: { context: 'cad_file', provides: ['stl_cache'] },
|
|
artifact_roles_produced: [],
|
|
artifact_roles_consumed: [],
|
|
legacy_source: 'legacy.stl_cache_generate',
|
|
},
|
|
{
|
|
step: 'blender_render',
|
|
label: 'Render Thumbnail (Blender)',
|
|
family: 'cad_file',
|
|
module_key: 'cad.thumbnail.blender',
|
|
category: 'rendering',
|
|
description: 'Render Blender thumbnail.',
|
|
node_type: 'renderNode',
|
|
icon: 'camera',
|
|
defaults: {},
|
|
fields: [],
|
|
execution_kind: 'bridge',
|
|
legacy_compatible: true,
|
|
input_contract: { context: 'cad_file', requires: ['glb_preview'] },
|
|
output_contract: { context: 'cad_file', provides: ['rendered_image'] },
|
|
artifact_roles_produced: [],
|
|
artifact_roles_consumed: [],
|
|
legacy_source: 'legacy.blender_render',
|
|
},
|
|
{
|
|
step: 'threejs_render',
|
|
label: 'Render Thumbnail (Three.js)',
|
|
family: 'cad_file',
|
|
module_key: 'cad.thumbnail.threejs',
|
|
category: 'rendering',
|
|
description: 'Render Three.js thumbnail.',
|
|
node_type: 'renderNode',
|
|
icon: 'box',
|
|
defaults: {},
|
|
fields: [],
|
|
execution_kind: 'bridge',
|
|
legacy_compatible: true,
|
|
input_contract: { context: 'cad_file', requires: ['glb_preview'] },
|
|
output_contract: { context: 'cad_file', provides: ['rendered_image'] },
|
|
artifact_roles_produced: [],
|
|
artifact_roles_consumed: [],
|
|
legacy_source: 'legacy.threejs_render',
|
|
},
|
|
{
|
|
step: 'thumbnail_save',
|
|
label: 'Save Thumbnail',
|
|
family: 'cad_file',
|
|
module_key: 'cad.thumbnail.save',
|
|
category: 'output',
|
|
description: 'Persist thumbnail.',
|
|
node_type: 'outputNode',
|
|
icon: 'download',
|
|
defaults: {},
|
|
fields: [],
|
|
execution_kind: 'bridge',
|
|
legacy_compatible: true,
|
|
input_contract: { context: 'cad_file', requires: ['rendered_image'] },
|
|
output_contract: { context: 'cad_file', provides: ['saved_thumbnail'] },
|
|
artifact_roles_produced: [],
|
|
artifact_roles_consumed: [],
|
|
legacy_source: 'legacy.thumbnail_save',
|
|
},
|
|
]
|
|
|
|
describe('workflowModuleBundles', () => {
|
|
test('exposes family-scoped bundles when required steps exist', () => {
|
|
const bundles = getWorkflowModuleBundles(definitions, 'order_line')
|
|
|
|
expect(bundles.map(bundle => bundle.id)).toEqual([
|
|
'scene_prep_core',
|
|
'materials_core',
|
|
'still_render_core',
|
|
'output_publish_notify',
|
|
'blend_export_publish',
|
|
])
|
|
})
|
|
|
|
test('creates a connected bundle insertion graph for scene-prep authoring', () => {
|
|
const insertion = createWorkflowModuleBundleInsertion({
|
|
bundleId: 'scene_prep_core',
|
|
graphFamily: 'order_line',
|
|
nodeDefinitionsByStep: Object.fromEntries(definitions.map(definition => [definition.step, definition])),
|
|
existingNodes: [],
|
|
preferredPosition: { x: 200, y: 320 },
|
|
})
|
|
|
|
expect(insertion.ok).toBe(true)
|
|
if (!insertion.ok) return
|
|
|
|
expect(insertion.nodes).toHaveLength(3)
|
|
expect(insertion.edges).toHaveLength(2)
|
|
expect(insertion.nodes[0].position).toEqual({ x: 200, y: 320 })
|
|
expect(insertion.nodes[1].position).toEqual({ x: 420, y: 320 })
|
|
expect(insertion.nodes[0].data).toMatchObject({ step: 'order_line_setup', label: 'Order Line Setup' })
|
|
expect(insertion.nodes[2].data).toMatchObject({ step: 'glb_bbox', label: 'Compute Bounding Box' })
|
|
expect(insertion.edges[0]).toMatchObject({
|
|
source: insertion.nodes[0].id,
|
|
target: insertion.nodes[1].id,
|
|
})
|
|
})
|
|
|
|
test('creates a single-node still-render module for stage-level insertion', () => {
|
|
const insertion = createWorkflowModuleBundleInsertion({
|
|
bundleId: 'still_render_core',
|
|
graphFamily: 'order_line',
|
|
nodeDefinitionsByStep: Object.fromEntries(definitions.map(definition => [definition.step, definition])),
|
|
existingNodes: [],
|
|
preferredPosition: { x: 640, y: 180 },
|
|
})
|
|
|
|
expect(insertion.ok).toBe(true)
|
|
if (!insertion.ok) return
|
|
|
|
expect(insertion.nodes).toHaveLength(1)
|
|
expect(insertion.edges).toHaveLength(0)
|
|
expect(insertion.nodes[0].position).toEqual({ x: 640, y: 180 })
|
|
expect(insertion.nodes[0].data).toMatchObject({ step: 'blender_still', label: 'Blender Still' })
|
|
})
|
|
|
|
test('exposes full reference paths for complete non-legacy authoring flows', () => {
|
|
const bundles = getWorkflowReferenceBundles(definitions, 'order_line')
|
|
|
|
expect(bundles.map(bundle => bundle.id)).toEqual([
|
|
'still_render_reference',
|
|
'still_render_alpha_reference',
|
|
'still_render_blend_reference',
|
|
])
|
|
})
|
|
|
|
test('creates the canonical still-render reference graph with branched edges', () => {
|
|
const insertion = createWorkflowReferenceBundleInsertion({
|
|
bundleId: 'still_render_reference',
|
|
graphFamily: 'order_line',
|
|
nodeDefinitionsByStep: Object.fromEntries(definitions.map(definition => [definition.step, definition])),
|
|
existingNodes: [],
|
|
preferredPosition: { x: 200, y: 320 },
|
|
})
|
|
|
|
expect(insertion.ok).toBe(true)
|
|
if (!insertion.ok) return
|
|
|
|
expect(insertion.nodes).toHaveLength(8)
|
|
expect(insertion.edges).toHaveLength(10)
|
|
expect(insertion.nodes[0].position).toEqual({ x: 200, y: 440 })
|
|
expect(insertion.nodes[1].position).toEqual({ x: 420, y: 440 })
|
|
expect(insertion.nodes[5].data).toMatchObject({
|
|
step: 'blender_still',
|
|
label: 'Still Render',
|
|
params: {
|
|
use_custom_render_settings: false,
|
|
render_engine: 'cycles',
|
|
samples: 256,
|
|
width: 1920,
|
|
height: 1080,
|
|
},
|
|
})
|
|
expect(insertion.edges).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
source: insertion.nodes[0].id,
|
|
target: insertion.nodes[1].id,
|
|
}),
|
|
expect.objectContaining({
|
|
source: insertion.nodes[5].id,
|
|
target: insertion.nodes[6].id,
|
|
}),
|
|
expect.objectContaining({
|
|
source: insertion.nodes[5].id,
|
|
target: insertion.nodes[7].id,
|
|
}),
|
|
]),
|
|
)
|
|
})
|
|
|
|
test('creates the canonical CAD intake reference graph from the shared blueprint', () => {
|
|
const insertion = createWorkflowReferenceBundleInsertion({
|
|
bundleId: 'cad_intake_reference',
|
|
graphFamily: 'cad_file',
|
|
nodeDefinitionsByStep: Object.fromEntries(cadDefinitions.map(definition => [definition.step, definition])),
|
|
existingNodes: [],
|
|
preferredPosition: { x: 120, y: 240 },
|
|
})
|
|
|
|
expect(insertion.ok).toBe(true)
|
|
if (!insertion.ok) return
|
|
|
|
expect(insertion.nodes).toHaveLength(9)
|
|
expect(insertion.edges).toHaveLength(9)
|
|
expect(insertion.nodes.map(node => node.data.step)).toEqual([
|
|
'resolve_step_path',
|
|
'occ_object_extract',
|
|
'occ_glb_export',
|
|
'glb_bbox',
|
|
'stl_cache_generate',
|
|
'blender_render',
|
|
'threejs_render',
|
|
'thumbnail_save',
|
|
'thumbnail_save',
|
|
])
|
|
expect(insertion.edges).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
source: insertion.nodes[2].id,
|
|
target: insertion.nodes[5].id,
|
|
}),
|
|
expect.objectContaining({
|
|
source: insertion.nodes[2].id,
|
|
target: insertion.nodes[6].id,
|
|
}),
|
|
expect.objectContaining({
|
|
source: insertion.nodes[3].id,
|
|
target: insertion.nodes[6].id,
|
|
}),
|
|
]),
|
|
)
|
|
})
|
|
})
|