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
@@ -160,6 +160,25 @@ const definitions: WorkflowNodeDefinition[] = [
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[] = [
@@ -302,12 +321,18 @@ describe('workflowModuleBundles', () => {
test('exposes family-scoped bundles when required steps exist', () => {
const bundles = getWorkflowModuleBundles(definitions, 'order_line')
expect(bundles.map(bundle => bundle.id)).toEqual(['still_render_core', 'output_publish_notify'])
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 still-render authoring', () => {
test('creates a connected bundle insertion graph for scene-prep authoring', () => {
const insertion = createWorkflowModuleBundleInsertion({
bundleId: 'still_render_core',
bundleId: 'scene_prep_core',
graphFamily: 'order_line',
nodeDefinitionsByStep: Object.fromEntries(definitions.map(definition => [definition.step, definition])),
existingNodes: [],
@@ -317,22 +342,44 @@ describe('workflowModuleBundles', () => {
expect(insertion.ok).toBe(true)
if (!insertion.ok) return
expect(insertion.nodes).toHaveLength(6)
expect(insertion.edges).toHaveLength(5)
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[5].data).toMatchObject({ step: 'blender_still', label: 'Blender Still' })
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'])
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', () => {
@@ -354,7 +401,13 @@ describe('workflowModuleBundles', () => {
expect(insertion.nodes[5].data).toMatchObject({
step: 'blender_still',
label: 'Still Render',
params: { use_custom_render_settings: true },
params: {
use_custom_render_settings: false,
render_engine: 'cycles',
samples: 256,
width: 1920,
height: 1080,
},
})
expect(insertion.edges).toEqual(
expect.arrayContaining([
@@ -386,12 +439,13 @@ describe('workflowModuleBundles', () => {
expect(insertion.ok).toBe(true)
if (!insertion.ok) return
expect(insertion.nodes).toHaveLength(8)
expect(insertion.edges).toHaveLength(7)
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',
@@ -402,11 +456,15 @@ describe('workflowModuleBundles', () => {
expect.arrayContaining([
expect.objectContaining({
source: insertion.nodes[2].id,
target: insertion.nodes[4].id,
target: insertion.nodes[5].id,
}),
expect.objectContaining({
source: insertion.nodes[2].id,
target: insertion.nodes[5].id,
target: insertion.nodes[6].id,
}),
expect.objectContaining({
source: insertion.nodes[3].id,
target: insertion.nodes[6].id,
}),
]),
)