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 } 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 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 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 const updateWorkflow = (id: string, data: Partial): 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 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[] } 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 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 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 if (resolution && resolution.length === 2) { renderParams.width = Number(resolution[0]) renderParams.height = Number(resolution[1]) delete renderParams.resolution } if (type === 'still') { return { version: 1, 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 } } }, { id: 'render', step: 'blender_still', params: renderParams, ui: { type: 'renderNode', label: 'Still Render', position: { x: 440, y: 100 } } }, { id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 660, y: 100 } } }, ], edges: [ { from: 'setup', to: 'template' }, { from: 'template', to: 'render' }, { from: 'render', to: 'output' }, ], } } if (type === 'still_graph') { const { nodes, edges } = buildStillGraphNodes(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: [ { 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 } } }, { id: 'turntable', step: 'blender_turntable', params: renderParams, ui: { type: 'renderFramesNode', label: 'Turntable Render', position: { x: 440, y: 100 } } }, { id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 660, y: 100 } } }, ], 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: [ { 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 } } }, ...angles.map((angle, index) => ({ id: `render_${index}`, step: 'blender_still', params: { ...sharedParams, rotation_z: angle }, ui: { type: 'renderNode', label: `Render ${angle}°`, position: { x: 440, y: index * 130 } }, })), { id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 700, y: 195 } } }, ], 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: [ { 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 } } }, { id: 'render', step: 'blender_still', params: renderParams, ui: { type: 'renderNode', label: 'Still Render', position: { x: 440, y: 100 } } }, { id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 660, y: 70 } } }, { id: 'blend', step: 'export_blend', params: {}, ui: { type: 'outputNode', label: 'Export Blend', position: { x: 660, y: 160 } } }, ], 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: [ { id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 120, y: 140 } }, }, ], edges: [], } } function normalizeWorkflowDefinition(raw: WorkflowDefinition): WorkflowDefinition { const config = normalizeWorkflowConfig(raw.config as unknown as Record) return { ...raw, family: raw.family ?? inferWorkflowFamily(config), 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[]) : [] return { version: Number(raw.version ?? 1), nodes, edges, ui: { ...rawUi, execution_mode: rawUi.execution_mode ?? 'legacy', family: rawUi.family ?? inferWorkflowFamily({ version: Number(raw.version ?? 1), nodes, edges }), }, } } 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 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] }