feat: harden workflow graph contracts

This commit is contained in:
2026-04-08 21:32:14 +02:00
parent 22981af1d2
commit bd18cccb5e
7 changed files with 1403 additions and 100 deletions
+36 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'vitest'
import { createPresetWorkflowConfig } from '../../api/workflows'
import { createPresetWorkflowConfig, createStarterWorkflowConfig, normalizeWorkflowConfig } from '../../api/workflows'
describe('workflow preset config builders', () => {
test('builds a non-legacy still graph preset', () => {
@@ -12,20 +12,54 @@ describe('workflow preset config builders', () => {
expect(config.ui?.preset).toBe('still_graph')
expect(config.ui?.execution_mode).toBe('graph')
expect(config.ui?.family).toBe('order_line')
expect(config.nodes.map(node => node.step)).toEqual([
'order_line_setup',
'auto_populate_materials',
'resolve_template',
'auto_populate_materials',
'glb_bbox',
'material_map_resolve',
'blender_still',
'output_save',
'notify',
])
expect(config.nodes.find(node => node.step === 'blender_still')?.params).toMatchObject({
use_custom_render_settings: true,
render_engine: 'cycles',
samples: 128,
width: 1600,
height: 900,
})
})
test('builds family-specific starter configs', () => {
const cadStarter = createStarterWorkflowConfig('cad_file')
const orderStarter = createStarterWorkflowConfig('order_line')
expect(cadStarter.ui?.blueprint).toBe('starter_cad_intake')
expect(cadStarter.ui?.family).toBe('cad_file')
expect(cadStarter.nodes.map(node => node.step)).toEqual(['resolve_step_path'])
expect(orderStarter.ui?.blueprint).toBe('starter_order_rendering')
expect(orderStarter.ui?.family).toBe('order_line')
expect(orderStarter.nodes.map(node => node.step)).toEqual(['order_line_setup'])
})
test('preserves ui.family during normalization', () => {
const config = normalizeWorkflowConfig({
version: 1,
ui: {
preset: 'custom',
execution_mode: 'shadow',
family: 'order_line',
},
nodes: [
{ id: 'setup', step: 'order_line_setup', params: {} },
],
edges: [],
})
expect(config.ui?.family).toBe('order_line')
expect(config.ui?.execution_mode).toBe('shadow')
})
})
+151 -39
View File
@@ -2,12 +2,14 @@ import api from './client'
export type WorkflowPresetType = 'still' | 'still_graph' | 'turntable' | 'multi_angle' | 'still_with_exports' | 'custom'
export type WorkflowExecutionMode = 'legacy' | 'graph' | 'shadow'
export type WorkflowStarterFamily = 'cad_file' | 'order_line'
export interface WorkflowDefinition {
id: string
name: string
output_type_id: string | null
config: WorkflowConfig
family: WorkflowNodeFamily | 'mixed' | null
is_active: boolean
created_at: string
}
@@ -54,6 +56,7 @@ export interface WorkflowEdge {
export interface WorkflowUi {
preset?: WorkflowPresetType
execution_mode?: WorkflowExecutionMode
family?: WorkflowNodeFamily | 'mixed'
blueprint?: string
}
@@ -234,9 +237,13 @@ export interface WorkflowNodeFieldDefinition {
options: WorkflowNodeFieldOption[]
}
export type WorkflowNodeFamily = 'cad_file' | 'order_line'
export interface WorkflowNodeDefinition {
step: string
label: string
family: WorkflowNodeFamily
module_key: string
category: StepCategory
description: string
node_type: string
@@ -245,6 +252,11 @@ export interface WorkflowNodeDefinition {
fields: WorkflowNodeFieldDefinition[]
execution_kind: WorkflowNodeExecutionKind
legacy_compatible: boolean
input_contract: Record<string, unknown>
output_contract: Record<string, unknown>
artifact_roles_produced: string[]
artifact_roles_consumed: string[]
legacy_source: string | null
}
export interface WorkflowNodeDefinitionsResponse {
@@ -268,6 +280,48 @@ export const getNodeDefinitions = (): Promise<WorkflowNodeDefinitionsResponse> =
export const getPipelineSteps = (): Promise<PipelineStepsResponse> =>
api.get('/workflows/pipeline-steps').then(r => r.data)
function buildStillGraphNodes(renderParams: WorkflowParams): { nodes: WorkflowNode[]; edges: WorkflowEdge[] } {
return {
nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 160 } } },
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 160 } } },
{
id: 'populate_materials',
step: 'auto_populate_materials',
params: {},
ui: { type: 'processNode', label: 'Auto Populate Materials', position: { x: 220, y: 320 } },
},
{ id: 'bbox', step: 'glb_bbox', params: {}, ui: { type: 'processNode', label: 'Compute Bounding Box', position: { x: 220, y: 40 } } },
{
id: 'resolve_materials',
step: 'material_map_resolve',
params: {},
ui: { type: 'processNode', label: 'Resolve Material Map', position: { x: 440, y: 200 } },
},
{
id: 'render',
step: 'blender_still',
params: { use_custom_render_settings: true, ...renderParams },
ui: { type: 'renderNode', label: 'Still Render', position: { x: 680, y: 160 } },
},
{ id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 920, y: 120 } } },
{ id: 'notify', step: 'notify', params: {}, ui: { type: 'outputNode', label: 'Notify Result', position: { x: 920, y: 220 } } },
],
edges: [
{ from: 'setup', to: 'template' },
{ from: 'setup', to: 'populate_materials' },
{ from: 'setup', to: 'bbox' },
{ from: 'template', to: 'resolve_materials' },
{ from: 'populate_materials', to: 'resolve_materials' },
{ from: 'resolve_materials', to: 'render' },
{ from: 'bbox', to: 'render' },
{ from: 'template', to: 'render' },
{ from: 'render', to: 'output' },
{ from: 'render', to: 'notify' },
],
}
}
function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams = {}): WorkflowConfig {
const renderParams = { ...params }
const resolution = Array.isArray(renderParams.resolution) ? renderParams.resolution : undefined
@@ -280,7 +334,7 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
if (type === 'still') {
return {
version: 1,
ui: { preset: type, execution_mode: 'legacy' },
ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } },
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 100 } } },
@@ -296,43 +350,19 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
}
if (type === 'still_graph') {
const { nodes, edges } = buildStillGraphNodes(renderParams)
return {
version: 1,
ui: { preset: type, execution_mode: 'graph' },
nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } },
{
id: 'populate_materials',
step: 'auto_populate_materials',
params: {},
ui: { type: 'processNode', label: 'Auto Populate Materials', position: { x: 220, y: 100 } },
},
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 440, y: 100 } } },
{
id: 'resolve_materials',
step: 'material_map_resolve',
params: {},
ui: { type: 'processNode', label: 'Resolve Material Map', position: { x: 660, y: 100 } },
},
{ id: 'render', step: 'blender_still', params: renderParams, ui: { type: 'renderNode', label: 'Still Render', position: { x: 880, y: 100 } } },
{ id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 1100, y: 70 } } },
{ id: 'notify', step: 'notify', params: {}, ui: { type: 'outputNode', label: 'Notify Result', position: { x: 1100, y: 160 } } },
],
edges: [
{ from: 'setup', to: 'populate_materials' },
{ from: 'populate_materials', to: 'template' },
{ from: 'template', to: 'resolve_materials' },
{ from: 'resolve_materials', to: 'render' },
{ from: 'render', to: 'output' },
{ from: 'render', to: 'notify' },
],
ui: { preset: type, execution_mode: 'graph', family: 'order_line' },
nodes,
edges,
}
}
if (type === 'turntable') {
return {
version: 1,
ui: { preset: type, execution_mode: 'legacy' },
ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } },
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 100 } } },
@@ -353,7 +383,7 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
delete sharedParams.angles
return {
version: 1,
ui: { preset: type, execution_mode: 'legacy' },
ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 195 } } },
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 195 } } },
@@ -376,7 +406,7 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
if (type === 'still_with_exports') {
return {
version: 1,
ui: { preset: type, execution_mode: 'legacy' },
ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } },
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 100 } } },
@@ -395,7 +425,7 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
return {
version: 1,
ui: { preset: 'custom', execution_mode: 'legacy' },
ui: { preset: 'custom', execution_mode: 'legacy', family: 'order_line' },
nodes: [
{
id: 'setup',
@@ -409,25 +439,30 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
}
function normalizeWorkflowDefinition(raw: WorkflowDefinition): WorkflowDefinition {
const config = normalizeWorkflowConfig(raw.config as unknown as Record<string, unknown>)
return {
...raw,
config: normalizeWorkflowConfig(raw.config as unknown as Record<string, unknown>),
family: raw.family ?? inferWorkflowFamily(config),
config,
}
}
export function normalizeWorkflowConfig(raw: Record<string, unknown>): WorkflowConfig {
if ('version' in raw && Array.isArray(raw.nodes)) {
const rawUi = (raw.ui as WorkflowUi | undefined) ?? {}
const nodes = (raw.nodes as WorkflowNode[]).map(node => ({
...node,
params: { ...(node.params ?? {}) },
}))
const edges = Array.isArray(raw.edges) ? (raw.edges as WorkflowEdge[]) : []
return {
version: Number(raw.version ?? 1),
nodes: (raw.nodes as WorkflowNode[]).map(node => ({
...node,
params: { ...(node.params ?? {}) },
})),
edges: Array.isArray(raw.edges) ? (raw.edges as WorkflowEdge[]) : [],
nodes,
edges,
ui: {
...rawUi,
execution_mode: rawUi.execution_mode ?? 'legacy',
family: rawUi.family ?? inferWorkflowFamily({ version: Number(raw.version ?? 1), nodes, edges }),
},
}
}
@@ -448,6 +483,83 @@ export function createPresetWorkflowConfig(type: WorkflowPresetType, params: Wor
return migratePresetConfig(type, params)
}
export function createStarterWorkflowConfig(family: WorkflowStarterFamily = 'order_line'): WorkflowConfig {
if (family === 'cad_file') {
return {
version: 1,
ui: {
preset: 'custom',
execution_mode: 'legacy',
family: 'cad_file',
blueprint: 'starter_cad_intake',
},
nodes: [
{
id: 'resolve_step',
step: 'resolve_step_path',
params: {},
ui: { type: 'inputNode', label: 'Resolve STEP Path', position: { x: 120, y: 140 } },
},
],
edges: [],
}
}
return {
version: 1,
ui: {
preset: 'custom',
execution_mode: 'legacy',
family: 'order_line',
blueprint: 'starter_order_rendering',
},
nodes: [
{
id: 'setup',
step: 'order_line_setup',
params: {},
ui: { type: 'processNode', label: 'Order Line Setup', position: { x: 120, y: 140 } },
},
],
edges: [],
}
}
export function getWorkflowPresetType(config: WorkflowConfig): WorkflowPresetType {
return config.ui?.preset ?? 'custom'
}
export function inferWorkflowFamily(config: WorkflowConfig): WorkflowNodeFamily | 'mixed' | null {
const families = new Set(
config.nodes
.map(node => {
switch (node.step) {
case 'resolve_step_path':
case 'occ_object_extract':
case 'occ_glb_export':
case 'stl_cache_generate':
case 'blender_render':
case 'threejs_render':
case 'thumbnail_save':
return 'cad_file'
case 'order_line_setup':
case 'resolve_template':
case 'material_map_resolve':
case 'auto_populate_materials':
case 'glb_bbox':
case 'blender_still':
case 'blender_turntable':
case 'output_save':
case 'export_blend':
case 'notify':
return 'order_line'
default:
return null
}
})
.filter((family): family is WorkflowNodeFamily => family !== null),
)
if (families.size === 0) return null
if (families.size > 1) return 'mixed'
return Array.from(families)[0]
}