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
@@ -71,6 +71,331 @@ const notifyDefinition: WorkflowNodeDefinition = {
legacy_source: 'legacy.notify',
}
const orderLineSetupDefinition: WorkflowNodeDefinition = {
step: 'order_line_setup',
label: 'Order Line Setup',
family: 'order_line',
module_key: 'orders.setup',
category: 'input',
description: 'Resolve order-line context and setup metadata.',
node_type: 'inputNode',
icon: 'package-search',
defaults: {},
fields: [],
execution_kind: 'native',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['order_line_record'] },
output_contract: { context: 'order_line', provides: ['order_line_context'] },
artifact_roles_consumed: [],
artifact_roles_produced: ['order_line_context'],
legacy_source: 'legacy.order_line_setup',
}
const outputSaveDefinition: WorkflowNodeDefinition = {
step: 'output_save',
label: 'Save Output',
family: 'order_line',
module_key: 'media.save_output',
category: 'output',
description: 'Persist the selected render artifact.',
node_type: 'outputNode',
icon: 'download',
defaults: {
expected_artifact_role: '',
require_upstream_artifact: false,
},
fields: [
{
key: 'expected_artifact_role',
label: 'Expected Artifact Role',
type: 'select',
description: 'Restrict the accepted upstream artifact.',
section: 'Output',
default: '',
min: null,
max: null,
step: null,
unit: null,
options: [
{ value: '', label: 'Any Connected Artifact' },
{ value: 'render_output', label: 'Still Output' },
{ value: 'turntable_output', label: 'Turntable Output' },
],
allow_blank: true,
max_length: null,
text_format: null,
},
{
key: 'require_upstream_artifact',
label: 'Require Upstream Artifact',
type: 'boolean',
description: 'Fail when no matching artifact is wired.',
section: 'Output',
default: false,
min: null,
max: null,
step: null,
unit: null,
options: [],
allow_blank: true,
max_length: null,
text_format: null,
},
],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['order_line_context'], requires_any: ['rendered_image'] },
output_contract: { context: 'order_line', provides: ['workflow_result'] },
artifact_roles_consumed: ['rendered_image'],
artifact_roles_produced: ['workflow_result'],
legacy_source: 'legacy.output_save',
}
const exportBlendDefinition: WorkflowNodeDefinition = {
step: 'export_blend',
label: 'Export Blend',
family: 'order_line',
module_key: 'media.export_blend',
category: 'output',
description: 'Persist the generated .blend file.',
node_type: 'outputNode',
icon: 'download',
defaults: {
output_name_suffix: '',
},
fields: [
{
key: 'output_name_suffix',
label: 'Output Name Suffix',
type: 'text',
description: 'Optional suffix appended to the emitted filename.',
section: 'Output',
default: '',
min: null,
max: null,
step: null,
unit: null,
options: [],
allow_blank: true,
max_length: 64,
text_format: 'safe_filename_suffix',
},
],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['order_line_context', 'render_template'] },
output_contract: { context: 'order_line', provides: ['blend_asset'] },
artifact_roles_consumed: ['order_line_context', 'render_template'],
artifact_roles_produced: ['blend_asset'],
legacy_source: 'legacy.export_blend',
}
const blenderStillDefinition: WorkflowNodeDefinition = {
step: 'blender_still',
label: 'Render Still',
family: 'order_line',
module_key: 'render.production.still',
category: 'rendering',
description: 'Render a still image.',
node_type: 'renderNode',
icon: 'camera',
defaults: { use_custom_render_settings: false },
fields: [
{
key: 'use_custom_render_settings',
label: 'Custom Render Settings',
type: 'boolean',
description: 'Enable explicit render overrides.',
section: 'Render',
default: false,
min: null,
max: null,
step: null,
unit: null,
options: [],
allow_blank: true,
max_length: null,
text_format: null,
},
{
key: 'samples',
label: 'Samples',
type: 'number',
description: 'Render samples.',
section: 'Render',
default: 256,
min: 1,
max: 4096,
step: 1,
unit: null,
options: [],
allow_blank: true,
max_length: null,
text_format: null,
},
{
key: 'noise_threshold',
label: 'Noise Threshold',
type: 'text',
description: 'Adaptive sampling threshold.',
section: 'Denoising',
default: '',
min: null,
max: null,
step: null,
unit: null,
options: [],
allow_blank: true,
max_length: null,
text_format: null,
},
{
key: 'focal_length_mm',
label: 'Focal Length',
type: 'number',
description: 'Lens override.',
section: 'Camera',
default: null,
min: 1,
max: 500,
step: 0.1,
unit: 'mm',
options: [],
allow_blank: true,
max_length: null,
text_format: null,
},
{
key: 'target_collection',
label: 'Target Collection',
type: 'text',
description: 'Collection name.',
section: 'Scene',
default: 'Product',
min: null,
max: null,
step: null,
unit: null,
options: [],
allow_blank: true,
max_length: null,
text_format: null,
},
],
execution_kind: 'native',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['order_line_context', 'render_template', 'bbox'] },
output_contract: { context: 'order_line', provides: ['rendered_image'] },
artifact_roles_consumed: ['order_line_context', 'render_template', 'bbox'],
artifact_roles_produced: ['rendered_image'],
legacy_source: 'legacy.blender_still',
}
const blenderTurntableDefinition: WorkflowNodeDefinition = {
step: 'blender_turntable',
label: 'Render Turntable',
family: 'order_line',
module_key: 'render.production.turntable',
category: 'rendering',
description: 'Render a turntable animation.',
node_type: 'renderNode',
icon: 'video',
defaults: { use_custom_render_settings: false },
fields: [
{
key: 'use_custom_render_settings',
label: 'Custom Render Settings',
type: 'boolean',
description: 'Enable explicit render overrides.',
section: 'Render',
default: false,
min: null,
max: null,
step: null,
unit: null,
options: [],
allow_blank: true,
max_length: null,
text_format: null,
},
{
key: 'fps',
label: 'Frames Per Second',
type: 'number',
description: 'Playback speed.',
section: 'Animation',
default: 24,
min: 1,
max: 120,
step: 1,
unit: null,
options: [],
allow_blank: true,
max_length: null,
text_format: null,
},
{
key: 'turntable_axis',
label: 'Turntable Axis',
type: 'select',
description: 'Rotation axis.',
section: 'Animation',
default: 'z',
min: null,
max: null,
step: null,
unit: null,
options: [
{ value: 'x', label: 'X' },
{ value: 'y', label: 'Y' },
{ value: 'z', label: 'Z' },
],
allow_blank: false,
max_length: null,
text_format: null,
},
{
key: 'bg_color',
label: 'Background Color',
type: 'text',
description: 'Background override.',
section: 'Render',
default: '#ffffff',
min: null,
max: null,
step: null,
unit: null,
options: [],
allow_blank: true,
max_length: null,
text_format: null,
},
{
key: 'camera_orbit',
label: 'Camera Orbit',
type: 'boolean',
description: 'Orbit camera around the product.',
section: 'Scene',
default: true,
min: null,
max: null,
step: null,
unit: null,
options: [],
allow_blank: true,
max_length: null,
text_format: null,
},
],
execution_kind: 'native',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['order_line_context', 'render_template', 'bbox'] },
output_contract: { context: 'order_line', provides: ['rendered_video'] },
artifact_roles_consumed: ['order_line_context', 'render_template', 'bbox'],
artifact_roles_produced: ['rendered_video'],
legacy_source: 'legacy.blender_turntable',
}
function createRenderTemplate(overrides: Partial<RenderTemplate> = {}): RenderTemplate {
return {
id: '0d87b85f-c454-4d61-a124-d5b59e6a43a2',
@@ -256,14 +581,60 @@ describe('WorkflowNodeInspector', () => {
)
expect(screen.getByText('This node has no editor settings.')).toBeInTheDocument()
expect(screen.getByText(/each required upstream input gets its own socket/i)).toBeInTheDocument()
expect(screen.getByText(/0 local variables by design/i)).toBeInTheDocument()
expect(screen.getByText('Socket 1')).toBeInTheDocument()
expect(
screen.getAllByText(
'This node accepts one upstream artifact from any of: rendered image / rendered frames / rendered video / workflow result / blend asset.',
).length,
).toBeGreaterThan(0)
expect(screen.getAllByText(/0 local variables by design/i).length).toBeGreaterThan(0)
expect(screen.getByText('Alternative Groups')).toBeInTheDocument()
expect(screen.getByText('Group 1')).toBeInTheDocument()
expect(screen.getByText('Validation Watchpoints')).toBeInTheDocument()
expect(screen.getByText('Watch Artifact Flow')).toBeInTheDocument()
expect(screen.getByText('Watch Runtime Gap')).toBeInTheDocument()
expect(
screen.getAllByText('Any of: Rendered Image / Rendered Frames / Rendered Video / Workflow Result / Blend Asset').length,
).toBeGreaterThan(0)
})
test('explains context-entry nodes as context-supplied instead of misconfigured', async () => {
listRenderTemplates.mockResolvedValue([])
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
render(
<QueryClientProvider client={queryClient}>
<WorkflowNodeInspector
params={{}}
onChange={vi.fn()}
nodeDefinition={orderLineSetupDefinition}
step="order_line_setup"
nodeDefinitions={[orderLineSetupDefinition]}
graphFamily="order_line"
onStepChange={vi.fn()}
/>
</QueryClientProvider>,
)
expect(screen.getByText('Context Entry')).toBeInTheDocument()
expect(
screen.getAllByText(
'Workflow context supplies Order Line Record. No additional upstream sockets are required.',
).length,
).toBeGreaterThan(0)
expect(screen.getAllByText('Workflow context already provides Order Line Record.').length).toBeGreaterThan(0)
expect(screen.getAllByText(/0 local variables by design/i).length).toBeGreaterThan(0)
expect(screen.getByText('Validation Watchpoints')).toBeInTheDocument()
expect(screen.getByText('Watch Context')).toBeInTheDocument()
expect(screen.queryByText('Socket 1')).not.toBeInTheDocument()
})
test('summarizes wired inputs and inspector variables separately', async () => {
listRenderTemplates.mockResolvedValue([
createRenderTemplate({
@@ -298,10 +669,289 @@ describe('WorkflowNodeInspector', () => {
})
await user.selectOptions(templateOverride, '0d87b85f-c454-4d61-a124-d5b59e6a43a2')
expect(await screen.findByText(/1 canvas socket is required/i)).toBeInTheDocument()
expect(await screen.findByText(/1 required socket is exposed on the canvas/i)).toBeInTheDocument()
expect(screen.getAllByText('This node waits for Order Line Context from upstream.').length).toBeGreaterThan(0)
expect(screen.getByText('Required Sockets')).toBeInTheDocument()
expect(screen.getByText('Socket 1')).toBeInTheDocument()
expect(await screen.findByText(/2 local variables are edited in the inspector/i)).toBeInTheDocument()
expect(screen.getByText(/Static: Template Override/i)).toBeInTheDocument()
expect(screen.getByText(/Template-driven: Studio Variant/i)).toBeInTheDocument()
expect(screen.getByText('Watch Legacy Drift')).toBeInTheDocument()
})
test('shows automatic template variable coverage before a template override is selected', async () => {
listRenderTemplates.mockResolvedValue([
createRenderTemplate({
id: 'template-a',
name: 'Bearing Studio',
output_type_names: ['Still'],
workflow_input_schema: [
{
key: 'studio_variant',
label: 'Studio Variant',
type: 'select',
section: 'Template Inputs',
description: 'Choose the blend lighting preset.',
default: 'default',
min: null,
max: null,
step: null,
unit: null,
options: [
{ value: 'default', label: 'Default' },
{ value: 'warm', label: 'Warm' },
],
allow_blank: false,
},
],
}),
createRenderTemplate({
id: 'template-b',
name: 'Shadow Studio',
output_type_names: ['Shadow Still'],
workflow_input_schema: [
{
key: 'shadow_density',
label: 'Shadow Density',
type: 'number',
section: 'Template Inputs',
description: 'Shadow catcher strength.',
default: 0.65,
min: 0,
max: 1,
step: 0.05,
unit: null,
options: [],
allow_blank: false,
},
{
key: 'studio_variant',
label: 'Studio Variant',
type: 'select',
section: 'Template Inputs',
description: 'Choose the blend lighting preset.',
default: 'default',
min: null,
max: null,
step: null,
unit: null,
options: [
{ value: 'default', label: 'Default' },
{ value: 'dramatic', label: 'Dramatic' },
],
allow_blank: false,
},
],
}),
])
renderInspector({})
expect(await screen.findByText('Automatic Resolution Coverage')).toBeInTheDocument()
expect(screen.getByText(/2 active templates expose 2 unique workflow variables/i)).toBeInTheDocument()
expect(screen.getByText('Potential Template Variables')).toBeInTheDocument()
expect(screen.getAllByText('Studio Variant').length).toBeGreaterThan(0)
expect(screen.getByText(/Available via Bearing Studio, Shadow Studio \(Still, Shadow Still\)\./i)).toBeInTheDocument()
expect(screen.getByText(/Available via Shadow Studio \(Shadow Still\)\./i)).toBeInTheDocument()
})
test('disables contract-owned still render fields until custom render settings are enabled', async () => {
listRenderTemplates.mockResolvedValue([])
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
render(
<QueryClientProvider client={queryClient}>
<WorkflowNodeInspector
params={{ use_custom_render_settings: false }}
onChange={vi.fn()}
nodeDefinition={blenderStillDefinition}
step="blender_still"
nodeDefinitions={[blenderStillDefinition]}
graphFamily="order_line"
onStepChange={vi.fn()}
/>
</QueryClientProvider>,
)
expect(screen.getByText('Render Override Scope')).toBeInTheDocument()
expect(screen.getByLabelText('Samples')).toBeDisabled()
expect(screen.getByLabelText('Noise Threshold')).toBeDisabled()
expect(screen.getByLabelText('Focal Length (mm)')).toBeDisabled()
expect(screen.getByLabelText('Target Collection')).toBeDisabled()
})
test('disables contract-owned turntable fields until custom render settings are enabled', async () => {
listRenderTemplates.mockResolvedValue([])
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
render(
<QueryClientProvider client={queryClient}>
<WorkflowNodeInspector
params={{ use_custom_render_settings: false }}
onChange={vi.fn()}
nodeDefinition={blenderTurntableDefinition}
step="blender_turntable"
nodeDefinitions={[blenderTurntableDefinition]}
graphFamily="order_line"
onStepChange={vi.fn()}
/>
</QueryClientProvider>,
)
expect(screen.getByLabelText('Frames Per Second')).toBeDisabled()
expect(screen.getByLabelText('Turntable Axis')).toBeDisabled()
expect(screen.getByLabelText('Background Color')).toBeDisabled()
expect(screen.getByLabelText('Camera Orbit')).toBeDisabled()
})
test('explains output handoff semantics for output_save nodes', async () => {
listRenderTemplates.mockResolvedValue([])
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
render(
<QueryClientProvider client={queryClient}>
<WorkflowNodeInspector
params={{ expected_artifact_role: 'render_output', require_upstream_artifact: true }}
onChange={vi.fn()}
nodeDefinition={outputSaveDefinition}
step="output_save"
nodeDefinitions={[outputSaveDefinition]}
graphFamily="order_line"
onStepChange={vi.fn()}
/>
</QueryClientProvider>,
)
expect(screen.getByText('Output Handoff')).toBeInTheDocument()
expect(screen.getByText(/does not render files itself/i)).toBeInTheDocument()
expect(screen.getByText('render_output')).toBeInTheDocument()
expect(screen.getByText(/shadow runs stay observer-only/i)).toBeInTheDocument()
expect(
screen.getAllByText(
'This node requires Order Line Context and one additional upstream artifact from any of: rendered image.',
).length,
).toBeGreaterThan(0)
expect(screen.getByText(/1 required socket and 1 alternative group are exposed on the canvas/i)).toBeInTheDocument()
expect(screen.getByText('Required Sockets')).toBeInTheDocument()
expect(screen.getByText('Alternative Groups')).toBeInTheDocument()
})
test('explains blend delivery semantics for export_blend nodes', async () => {
listRenderTemplates.mockResolvedValue([])
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
render(
<QueryClientProvider client={queryClient}>
<WorkflowNodeInspector
params={{ output_name_suffix: 'studio' }}
onChange={vi.fn()}
nodeDefinition={exportBlendDefinition}
step="export_blend"
nodeDefinitions={[exportBlendDefinition]}
graphFamily="order_line"
onStepChange={vi.fn()}
/>
</QueryClientProvider>,
)
expect(screen.getAllByText('Blend Delivery').length).toBeGreaterThan(0)
expect(screen.getByText(/does not render pixels itself/i)).toBeInTheDocument()
expect(screen.getByText('studio')).toBeInTheDocument()
expect(screen.getByText(/save output/i)).toBeInTheDocument()
})
test('explains notification handoff semantics for notify nodes', async () => {
listRenderTemplates.mockResolvedValue([])
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
render(
<QueryClientProvider client={queryClient}>
<WorkflowNodeInspector
params={{ channel: 'audit_log', require_armed_render: true }}
onChange={vi.fn()}
nodeDefinition={{
...notifyDefinition,
fields: [
{
key: 'channel',
label: 'Channel',
type: 'select',
description: 'Notification channel.',
section: 'Notification',
default: 'audit_log',
min: null,
max: null,
step: null,
unit: null,
options: [{ value: 'audit_log', label: 'Audit Log' }],
allow_blank: false,
max_length: null,
text_format: null,
},
{
key: 'require_armed_render',
label: 'Require Armed Render',
type: 'boolean',
description: 'Fail when no render task hands off notifications.',
section: 'Notification',
default: false,
min: null,
max: null,
step: null,
unit: null,
options: [],
allow_blank: true,
max_length: null,
text_format: null,
},
],
}}
step="notify"
nodeDefinitions={[notifyDefinition]}
graphFamily="order_line"
onStepChange={vi.fn()}
/>
</QueryClientProvider>,
)
expect(screen.getAllByText('Notification Handoff').length).toBeGreaterThan(0)
expect(screen.getByText(/does not emit independently/i)).toBeInTheDocument()
expect(screen.getByText('audit_log')).toBeInTheDocument()
expect(screen.getByText(/shadow runs suppress user notifications entirely/i)).toBeInTheDocument()
})
})