import api from './client' import type { OutputTypeArtifactKind, OutputTypeWorkflowRolloutMode } from './outputTypes' 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 type WorkflowBlueprintType = | 'cad_intake' | 'order_rendering' | 'still_graph_reference' | 'still_graph_alpha_reference' | 'still_graph_blend_reference' export type WorkflowCanonicalBlueprintType = WorkflowBlueprintType | 'starter_cad_intake' | 'starter_order_rendering' export interface WorkflowRolloutLatestRun { workflow_run_id: string execution_mode: WorkflowExecutionMode status: string created_at: string completed_at: string | null } export interface WorkflowRolloutLinkedOutputType { id: string name: string is_active: boolean artifact_kind: OutputTypeArtifactKind workflow_rollout_mode: OutputTypeWorkflowRolloutMode } export interface WorkflowRolloutSummary { linked_output_type_count: number active_output_type_count: number linked_output_type_names: string[] linked_output_types: WorkflowRolloutLinkedOutputType[] rollout_modes: ('legacy_only' | 'shadow' | 'graph' | string)[] has_blocking_contracts: boolean blocking_reasons: string[] latest_run: WorkflowRolloutLatestRun | null latest_shadow_run: WorkflowRolloutLatestRun | null latest_rollout_gate_verdict: 'pass' | 'warn' | 'fail' | null latest_rollout_ready: boolean | null latest_rollout_status: 'ready_for_rollout' | 'hold_legacy_authoritative' | string | null latest_rollout_reasons: string[] } export interface WorkflowDefinition { id: string name: string output_type_id: string | null config: WorkflowConfig family: WorkflowNodeFamily | 'mixed' | null supported_artifact_kinds?: OutputTypeArtifactKind[] rollout_summary: WorkflowRolloutSummary is_active: boolean created_at: string updated_at: string } export interface WorkflowConfig { version: number nodes: WorkflowNode[] edges: WorkflowEdge[] ui?: WorkflowUi } export interface WorkflowParams { [key: string]: unknown use_custom_render_settings?: boolean render_engine?: 'cycles' | 'eevee' samples?: number resolution?: [number, number] fps?: number duration_s?: number angles?: number[] rotation_z?: number width?: number height?: number } export interface WorkflowNode { id: string step: string params: WorkflowParams ui?: WorkflowNodeUi } export interface WorkflowNodeUi { type?: string position?: { x: number; y: number } label?: string } export interface WorkflowEdge { from: string to: string } export interface WorkflowUi { preset?: WorkflowPresetType execution_mode?: WorkflowExecutionMode family?: WorkflowNodeFamily | 'mixed' blueprint?: string } export interface WorkflowCreate { name: string output_type_id?: string | null config: WorkflowConfig is_active?: boolean } export interface WorkflowRun { id: string workflow_def_id: string | null order_line_id: string | null celery_task_id: string | null execution_mode: WorkflowExecutionMode status: 'pending' | 'running' | 'completed' | 'failed' started_at: string | null completed_at: string | null error_message: string | null created_at: string node_results: WorkflowNodeResult[] } export interface WorkflowNodeResult { id: string node_name: string status: string output: Record | null log: string | null duration_s: number | null created_at: string } export interface WorkflowDispatchResponse { workflow_run: WorkflowRun context_id: string execution_mode: WorkflowExecutionMode dispatched: number task_ids: string[] } export interface WorkflowPreflightIssue { severity: 'error' | 'warning' | 'info' code: string message: string node_id: string | null step: string | null } export interface WorkflowPreflightNode { node_id: string step: string label: string | null execution_kind: WorkflowNodeExecutionKind supported: boolean status: 'ready' | 'warning' | 'error' | 'unsupported' issues: WorkflowPreflightIssue[] } export interface WorkflowPreflightResponse { workflow_id: string | null context_id: string context_kind: 'order_line' | 'cad_file' | null expected_context_kind: 'order_line' | 'cad_file' execution_mode: WorkflowExecutionMode graph_dispatch_allowed: boolean summary: string resolved_order_line_id: string | null resolved_cad_file_id: string | null unsupported_node_ids: string[] issues: WorkflowPreflightIssue[] nodes: WorkflowPreflightNode[] } export interface WorkflowOrderLineContextOption { value: string label: string meta: string is_renderable: boolean renderability_reason: string | null } export interface WorkflowOrderLineContextGroup { order_id: string order_label: string options: WorkflowOrderLineContextOption[] } export interface WorkflowDraftPreflightRequest { workflow_id?: string | null context_id: string config: WorkflowConfig } export interface WorkflowDraftDispatchRequest { workflow_id?: string | null context_id: string config: WorkflowConfig } export interface WorkflowComparisonArtifact { path: string | null storage_key: string | null exists: boolean file_size_bytes: number | null sha256: string | null mime_type: string | null image_width: number | null image_height: number | null } export interface WorkflowRunComparison { workflow_run_id: string workflow_def_id: string | null order_line_id: string | null execution_mode: WorkflowExecutionMode status: string summary: string rollout_gate_verdict: 'pass' | 'warn' | 'fail' workflow_rollout_ready: boolean workflow_rollout_status: 'ready_for_rollout' | 'hold_legacy_authoritative' rollout_reasons: string[] rollout_thresholds: Record authoritative_output: WorkflowComparisonArtifact observer_output: WorkflowComparisonArtifact exact_match: boolean | null dimensions_match: boolean | null mean_pixel_delta: number | null } export const getWorkflows = (): Promise => api.get('/workflows').then(r => r.data.map(normalizeWorkflowDefinition)) export const getWorkflow = (id: string): Promise => api.get(`/workflows/${id}`).then(r => normalizeWorkflowDefinition(r.data)) export const createWorkflow = (data: WorkflowCreate): Promise => api.post('/workflows', data).then(r => normalizeWorkflowDefinition(r.data)) export interface WorkflowUpdatePayload extends Partial { updated_at?: string } export const updateWorkflow = (id: string, data: WorkflowUpdatePayload): Promise => api.put(`/workflows/${id}`, data).then(r => normalizeWorkflowDefinition(r.data)) export const deleteWorkflow = (id: string): Promise => api.delete(`/workflows/${id}`).then(() => undefined) export const getWorkflowRuns = (workflowId: string): Promise => api.get(`/workflows/${workflowId}/runs`).then(r => r.data) export const dispatchWorkflow = ( workflowId: string, contextId: string, ): Promise => api.post(`/workflows/${workflowId}/dispatch`, undefined, { params: { context_id: contextId } }).then(r => r.data) export const dispatchWorkflowDraft = ( data: WorkflowDraftDispatchRequest, ): Promise => api.post('/workflows/dispatch', data).then(r => r.data) export const preflightWorkflow = ( workflowId: string, contextId: string, ): Promise => api.get(`/workflows/${workflowId}/preflight`, { params: { context_id: contextId } }).then(r => r.data) export const preflightWorkflowDraft = ( data: WorkflowDraftPreflightRequest, ): Promise => api.post('/workflows/preflight', data).then(r => r.data) export const getWorkflowOrderLineContexts = (limit = 50): Promise => api.get('/workflows/contexts/order-lines', { params: { limit } }).then(r => r.data) export const getWorkflowRunComparison = (runId: string): Promise => api.get(`/workflows/runs/${runId}/comparison`).then(r => r.data) // ─── Node Definitions / Pipeline Steps ─────────────────────────────────────── export type StepCategory = 'input' | 'processing' | 'rendering' | 'output' export type WorkflowNodeFieldType = 'number' | 'select' | 'boolean' | 'text' export type WorkflowNodeExecutionKind = 'native' | 'bridge' export interface WorkflowNodeFieldOption { value: string | number | boolean label: string } export interface WorkflowNodeFieldDefinition { key: string label: string type: WorkflowNodeFieldType description: string section: string default: unknown min: number | null max: number | null step: number | null unit: string | null options: WorkflowNodeFieldOption[] allow_blank?: boolean max_length?: number | null text_format?: string } export type WorkflowNodeFamily = 'cad_file' | 'order_line' | 'shared' export interface WorkflowNodeDefinition { step: string label: string family: WorkflowNodeFamily module_key: string category: StepCategory description: string node_type: string icon: string defaults: WorkflowParams fields: WorkflowNodeFieldDefinition[] execution_kind: WorkflowNodeExecutionKind legacy_compatible: boolean input_contract: Record output_contract: Record artifact_roles_produced: string[] artifact_roles_consumed: string[] legacy_source: string | null } export interface WorkflowNodeDefinitionsResponse { definitions: WorkflowNodeDefinition[] } export interface PipelineStep { name: string label: string category: StepCategory description: string } export interface PipelineStepsResponse { steps: PipelineStep[] } export const getNodeDefinitions = (): Promise => api.get('/workflows/node-definitions').then(r => r.data) export const getPipelineSteps = (): Promise => api.get('/workflows/pipeline-steps').then(r => r.data) function normalizeRenderParams(params: WorkflowParams = {}): WorkflowParams { const normalized = { ...params } const resolution = Array.isArray(normalized.resolution) ? normalized.resolution : undefined if (resolution && resolution.length === 2) { normalized.width = Number(resolution[0]) normalized.height = Number(resolution[1]) delete normalized.resolution } return normalized } function buildWorkflowNode( id: string, step: string, x: number, y: number, options: { label: string type?: string params?: WorkflowParams }, ): WorkflowNode { return { id, step, params: { ...(options.params ?? {}) }, ui: { type: options.type, label: options.label, position: { x, y }, }, } } function extractRenderParamsFromNodes(nodes: WorkflowNode[], step: string): WorkflowParams { const match = nodes.find(node => node.step === step) return normalizeRenderParams(match?.params ?? {}) } function buildOrderLineStillGraphNodes( renderParams: WorkflowParams, options: { transparentBg?: boolean includeBlendExport?: boolean } = {}, ): { nodes: WorkflowNode[]; edges: WorkflowEdge[] } { const resolvedRenderParams = { use_custom_render_settings: false, ...renderParams, ...(options.transparentBg !== undefined ? { transparent_bg: options.transparentBg } : {}), } const nodes: WorkflowNode[] = [ buildWorkflowNode('setup', 'order_line_setup', 0, 160, { label: 'Order Line Setup' }), buildWorkflowNode('template', 'resolve_template', 220, 160, { label: 'Resolve Template' }), buildWorkflowNode('populate_materials', 'auto_populate_materials', 220, 320, { label: 'Auto Populate Materials', type: 'processNode', }), buildWorkflowNode('bbox', 'glb_bbox', 220, 40, { label: 'Compute Bounding Box', type: 'processNode', }), buildWorkflowNode('resolve_materials', 'material_map_resolve', 440, 200, { label: 'Resolve Material Map', type: 'processNode', }), buildWorkflowNode('render', 'blender_still', 680, 160, { label: 'Still Render', type: 'renderNode', params: resolvedRenderParams, }), buildWorkflowNode('output', 'output_save', 920, 120, { label: 'Save Output', type: 'outputNode', }), buildWorkflowNode('notify', 'notify', 920, 220, { label: 'Notify Result', type: 'outputNode', }), ] const edges: WorkflowEdge[] = [ { 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' }, ] if (options.includeBlendExport) { nodes.push( buildWorkflowNode('blend_export', 'export_blend', 920, 360, { label: 'Export Blend', type: 'outputNode', }), buildWorkflowNode('save_blend', 'output_save', 1160, 320, { label: 'Save Blend Output', type: 'outputNode', params: { expected_artifact_role: 'blend_export' }, }), buildWorkflowNode('notify_blend', 'notify', 1160, 420, { label: 'Notify Blend Export', type: 'outputNode', }), ) edges.push( { from: 'setup', to: 'blend_export' }, { from: 'template', to: 'blend_export' }, { from: 'blend_export', to: 'save_blend' }, { from: 'blend_export', to: 'notify_blend' }, ) } return { nodes, edges, } } function buildPresetWorkflowConfigInternal(type: WorkflowPresetType, params: WorkflowParams = {}): WorkflowConfig { const renderParams = normalizeRenderParams(params) if (type === 'still') { return { version: 1, ui: { preset: type, execution_mode: 'legacy', family: 'order_line' }, nodes: [ buildWorkflowNode('setup', 'order_line_setup', 0, 100, { label: 'Order Line Setup' }), buildWorkflowNode('template', 'resolve_template', 220, 100, { label: 'Resolve Template' }), buildWorkflowNode('render', 'blender_still', 440, 100, { label: 'Still Render', type: 'renderNode', params: renderParams, }), buildWorkflowNode('output', 'output_save', 660, 100, { label: 'Save Output', type: 'outputNode', }), ], edges: [ { from: 'setup', to: 'template' }, { from: 'template', to: 'render' }, { from: 'render', to: 'output' }, ], } } if (type === 'still_graph') { const { nodes, edges } = buildOrderLineStillGraphNodes(renderParams) return { version: 1, ui: { preset: type, execution_mode: 'graph', family: 'order_line' }, nodes, edges, } } if (type === 'turntable') { return { version: 1, ui: { preset: type, execution_mode: 'legacy', family: 'order_line' }, nodes: [ buildWorkflowNode('setup', 'order_line_setup', 0, 100, { label: 'Order Line Setup' }), buildWorkflowNode('template', 'resolve_template', 220, 100, { label: 'Resolve Template' }), buildWorkflowNode('turntable', 'blender_turntable', 440, 100, { label: 'Turntable Render', type: 'renderFramesNode', params: renderParams, }), buildWorkflowNode('output', 'output_save', 660, 100, { label: 'Save Output', type: 'outputNode', }), ], edges: [ { from: 'setup', to: 'template' }, { from: 'template', to: 'turntable' }, { from: 'turntable', to: 'output' }, ], } } if (type === 'multi_angle') { const angles = (params.angles ?? [0, 45, 90]).map(Number) const sharedParams = { ...renderParams } delete sharedParams.angles return { version: 1, ui: { preset: type, execution_mode: 'legacy', family: 'order_line' }, nodes: [ buildWorkflowNode('setup', 'order_line_setup', 0, 195, { label: 'Order Line Setup' }), buildWorkflowNode('template', 'resolve_template', 220, 195, { label: 'Resolve Template' }), ...angles.map((angle, index) => buildWorkflowNode(`render_${index}`, 'blender_still', 440, index * 130, { label: `Render ${angle}°`, type: 'renderNode', params: { ...sharedParams, rotation_z: angle }, }), ), buildWorkflowNode('output', 'output_save', 700, 195, { label: 'Save Output', type: 'outputNode', }), ], edges: [ { from: 'setup', to: 'template' }, ...angles.map((_, index) => ({ from: 'template', to: `render_${index}` })), ...angles.map((_, index) => ({ from: `render_${index}`, to: 'output' })), ], } } if (type === 'still_with_exports') { return { version: 1, ui: { preset: type, execution_mode: 'legacy', family: 'order_line' }, nodes: [ buildWorkflowNode('setup', 'order_line_setup', 0, 100, { label: 'Order Line Setup' }), buildWorkflowNode('template', 'resolve_template', 220, 100, { label: 'Resolve Template' }), buildWorkflowNode('render', 'blender_still', 440, 100, { label: 'Still Render', type: 'renderNode', params: renderParams, }), buildWorkflowNode('output', 'output_save', 660, 70, { label: 'Save Output', type: 'outputNode', }), buildWorkflowNode('blend', 'export_blend', 660, 160, { label: 'Export Blend', type: 'outputNode', }), ], edges: [ { from: 'setup', to: 'template' }, { from: 'template', to: 'render' }, { from: 'render', to: 'output' }, { from: 'render', to: 'blend' }, ], } } return { version: 1, ui: { preset: 'custom', execution_mode: 'legacy', family: 'order_line' }, nodes: [ buildWorkflowNode('setup', 'order_line_setup', 120, 140, { label: 'Order Line Setup', type: 'processNode', }), ], edges: [], } } export function buildWorkflowBlueprintConfig(blueprint: WorkflowBlueprintType): WorkflowConfig { if (blueprint === 'cad_intake') { return { version: 1, ui: { preset: 'custom', execution_mode: 'legacy', family: 'cad_file', blueprint }, nodes: [ buildWorkflowNode('resolve_step', 'resolve_step_path', 0, 180, { label: 'Resolve STEP Path' }), buildWorkflowNode('extract_objects', 'occ_object_extract', 220, 180, { label: 'Extract STEP Objects', }), buildWorkflowNode('export_glb', 'occ_glb_export', 440, 180, { label: 'Export GLB' }), buildWorkflowNode('bbox', 'glb_bbox', 660, 120, { label: 'Compute Bounding Box', type: 'processNode', }), buildWorkflowNode('stl_cache', 'stl_cache_generate', 660, 300, { label: 'Generate STL Cache' }), buildWorkflowNode('blender_thumb', 'blender_render', 880, 120, { label: 'Render Thumbnail (Blender)', type: 'renderNode', params: { render_engine: 'cycles', samples: 64, width: 512, height: 512 }, }), buildWorkflowNode('threejs_thumb', 'threejs_render', 880, 320, { label: 'Render Thumbnail (Three.js)', type: 'renderNode', params: { width: 512, height: 512, transparent_bg: true }, }), buildWorkflowNode('save_blender_thumb', 'thumbnail_save', 1100, 120, { label: 'Save Blender Thumbnail', type: 'outputNode', }), buildWorkflowNode('save_threejs_thumb', 'thumbnail_save', 1100, 320, { label: 'Save Three.js Thumbnail', type: 'outputNode', }), ], edges: [ { from: 'resolve_step', to: 'extract_objects' }, { from: 'extract_objects', to: 'export_glb' }, { from: 'export_glb', to: 'bbox' }, { from: 'export_glb', to: 'stl_cache' }, { from: 'export_glb', to: 'blender_thumb' }, { from: 'export_glb', to: 'threejs_thumb' }, { from: 'bbox', to: 'threejs_thumb' }, { from: 'blender_thumb', to: 'save_blender_thumb' }, { from: 'threejs_thumb', to: 'save_threejs_thumb' }, ], } } if (blueprint === 'order_rendering') { return { version: 1, ui: { preset: 'custom', execution_mode: 'legacy', family: 'order_line', blueprint }, nodes: [ buildWorkflowNode('setup', 'order_line_setup', 0, 220, { label: 'Order Line Setup' }), buildWorkflowNode('template', 'resolve_template', 220, 220, { label: 'Resolve Template' }), buildWorkflowNode('populate_materials', 'auto_populate_materials', 220, 360, { label: 'Auto Populate Materials', }), buildWorkflowNode('bbox', 'glb_bbox', 220, 80, { label: 'Compute Bounding Box' }), buildWorkflowNode('resolve_materials', 'material_map_resolve', 440, 220, { label: 'Resolve Material Map', }), buildWorkflowNode('still_render', 'blender_still', 680, 80, { label: 'Render Still', type: 'renderNode', params: { rotation_z: 0 }, }), buildWorkflowNode('turntable_render', 'blender_turntable', 680, 220, { label: 'Render Turntable', type: 'renderFramesNode', params: { fps: 24, duration_s: 5 }, }), buildWorkflowNode('blend_export', 'export_blend', 680, 360, { label: 'Export Blend', type: 'outputNode', }), buildWorkflowNode('save_still', 'output_save', 920, 80, { label: 'Save Still Output', type: 'outputNode', }), buildWorkflowNode('save_turntable', 'output_save', 920, 220, { label: 'Save Turntable Output', type: 'outputNode', }), buildWorkflowNode('notify_still', 'notify', 920, 140, { label: 'Notify Still Result', type: 'outputNode', }), buildWorkflowNode('notify_turntable', 'notify', 920, 280, { label: 'Notify Turntable Result', type: 'outputNode', }), buildWorkflowNode('notify_export', 'notify', 920, 360, { label: 'Notify Blend Export', type: 'outputNode', }), ], 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: 'still_render' }, { from: 'resolve_materials', to: 'turntable_render' }, { from: 'bbox', to: 'still_render' }, { from: 'bbox', to: 'turntable_render' }, { from: 'template', to: 'still_render' }, { from: 'template', to: 'turntable_render' }, { from: 'template', to: 'blend_export' }, { from: 'still_render', to: 'save_still' }, { from: 'still_render', to: 'notify_still' }, { from: 'turntable_render', to: 'save_turntable' }, { from: 'turntable_render', to: 'notify_turntable' }, { from: 'blend_export', to: 'notify_export' }, ], } } const stillReferenceParams: WorkflowParams = { render_engine: 'cycles', samples: 256, width: 1920, height: 1080, } const buildStillReference = () => { if (blueprint === 'still_graph_alpha_reference') { return buildOrderLineStillGraphNodes(stillReferenceParams, { transparentBg: true }) } if (blueprint === 'still_graph_blend_reference') { return buildOrderLineStillGraphNodes(stillReferenceParams, { includeBlendExport: true }) } return buildOrderLineStillGraphNodes(stillReferenceParams) } const { nodes, edges } = buildStillReference() return { version: 1, ui: { preset: 'custom', execution_mode: 'graph', family: 'order_line', blueprint }, nodes, edges, } } function buildStarterWorkflowConfigInternal(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: [ buildWorkflowNode('resolve_step', 'resolve_step_path', 120, 140, { label: 'Resolve STEP Path', type: 'inputNode', }), ], edges: [], } } return { version: 1, ui: { preset: 'custom', execution_mode: 'legacy', family: 'order_line', blueprint: 'starter_order_rendering', }, nodes: [ buildWorkflowNode('setup', 'order_line_setup', 120, 140, { label: 'Order Line Setup', type: 'processNode', }), ], edges: [], } } export function buildStillGraphNodes(renderParams: WorkflowParams): { nodes: WorkflowNode[]; edges: WorkflowEdge[] } { return buildOrderLineStillGraphNodes(normalizeRenderParams(renderParams)) } function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams = {}): WorkflowConfig { return buildPresetWorkflowConfigInternal(type, params) } function normalizeWorkflowDefinition(raw: WorkflowDefinition): WorkflowDefinition { const config = normalizeWorkflowConfig(raw.config as unknown as Record) return { ...raw, family: raw.family ?? inferWorkflowFamily(config), supported_artifact_kinds: Array.isArray(raw.supported_artifact_kinds) ? raw.supported_artifact_kinds : [], rollout_summary: { linked_output_type_count: Number(raw.rollout_summary?.linked_output_type_count ?? 0), active_output_type_count: Number(raw.rollout_summary?.active_output_type_count ?? 0), linked_output_type_names: Array.isArray(raw.rollout_summary?.linked_output_type_names) ? raw.rollout_summary.linked_output_type_names : [], linked_output_types: Array.isArray(raw.rollout_summary?.linked_output_types) ? raw.rollout_summary.linked_output_types .filter((outputType): outputType is WorkflowRolloutLinkedOutputType => ( outputType != null && typeof outputType === 'object' && typeof outputType.id === 'string' && typeof outputType.name === 'string' )) .map(outputType => ({ id: outputType.id, name: outputType.name, is_active: Boolean(outputType.is_active), artifact_kind: outputType.artifact_kind, workflow_rollout_mode: outputType.workflow_rollout_mode ?? 'legacy_only', })) : [], rollout_modes: Array.isArray(raw.rollout_summary?.rollout_modes) ? raw.rollout_summary.rollout_modes : [], has_blocking_contracts: Boolean(raw.rollout_summary?.has_blocking_contracts), blocking_reasons: Array.isArray(raw.rollout_summary?.blocking_reasons) ? raw.rollout_summary.blocking_reasons : [], latest_run: raw.rollout_summary?.latest_run ?? null, latest_shadow_run: raw.rollout_summary?.latest_shadow_run ?? null, latest_rollout_gate_verdict: raw.rollout_summary?.latest_rollout_gate_verdict ?? null, latest_rollout_ready: typeof raw.rollout_summary?.latest_rollout_ready === 'boolean' ? raw.rollout_summary.latest_rollout_ready : null, latest_rollout_status: raw.rollout_summary?.latest_rollout_status ?? null, latest_rollout_reasons: Array.isArray(raw.rollout_summary?.latest_rollout_reasons) ? raw.rollout_summary.latest_rollout_reasons : [], }, config, } } export function normalizeWorkflowConfig(raw: Record): 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[]) : [] const mergedUi = { ...rawUi, execution_mode: rawUi.execution_mode ?? 'legacy', } if (rawUi.preset === 'still_graph') { const canonical = buildPresetWorkflowConfigInternal('still_graph', extractRenderParamsFromNodes(nodes, 'blender_still')) return { ...canonical, ui: { ...canonical.ui, ...mergedUi, }, } } if ( rawUi.blueprint === 'cad_intake' || rawUi.blueprint === 'order_rendering' || rawUi.blueprint === 'still_graph_reference' || rawUi.blueprint === 'still_graph_alpha_reference' || rawUi.blueprint === 'still_graph_blend_reference' ) { const canonical = buildWorkflowBlueprintConfig(rawUi.blueprint) return { ...canonical, ui: { ...canonical.ui, ...mergedUi, }, } } if (rawUi.blueprint === 'starter_cad_intake' || rawUi.blueprint === 'starter_order_rendering') { const canonical = buildStarterWorkflowConfigInternal(rawUi.blueprint === 'starter_cad_intake' ? 'cad_file' : 'order_line') return { ...canonical, ui: { ...canonical.ui, ...mergedUi, }, } } return { version: Number(raw.version ?? 1), nodes, edges, ui: { ...mergedUi, family: rawUi.family ?? inferWorkflowFamily({ version: Number(raw.version ?? 1), nodes, edges }) ?? undefined, }, } } if (typeof raw.type === 'string') { return migratePresetConfig(raw.type as WorkflowPresetType, (raw.params as WorkflowParams | undefined) ?? {}) } return { version: 1, nodes: [], edges: [], ui: { preset: 'custom', execution_mode: 'legacy' }, } } export function createPresetWorkflowConfig(type: WorkflowPresetType, params: WorkflowParams = {}): WorkflowConfig { return buildPresetWorkflowConfigInternal(type, params) } export function createStarterWorkflowConfig(family: WorkflowStarterFamily = 'order_line'): WorkflowConfig { return buildStarterWorkflowConfigInternal(family) } 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 'glb_bbox': return null case 'order_line_setup': case 'resolve_template': case 'material_map_resolve': case 'auto_populate_materials': case 'blender_still': case 'blender_turntable': case 'output_save': case 'export_blend': case 'notify': return 'order_line' default: return null } }) .filter((family): family is Exclude => family !== null), ) if (families.size === 0) return null if (families.size > 1) return 'mixed' return Array.from(families)[0] }