From 042f62fe5537b36f06a4f93dff3c09018d4de682 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 8 Apr 2026 21:44:08 +0200 Subject: [PATCH] feat: refactor workflow editor authoring surfaces --- .../src/__tests__/components/cadUtils.test.ts | 202 ++ .../components/workflowEditorUi.test.tsx | 390 ++++ .../components/workflowGraphDraft.test.ts | 328 +++ .../components/workflows/NewWorkflowModal.tsx | 255 ++ .../components/workflows/NodeCommandMenu.tsx | 68 + .../workflows/NodeDefinitionsPanel.tsx | 39 + .../workflows/WorkflowCanvasToolbar.tsx | 211 ++ .../WorkflowCanvasUtilitySidebar.tsx | 159 ++ .../workflows/WorkflowEditorEmptyState.tsx | 42 + .../workflows/WorkflowEditorHeader.tsx | 52 + .../workflows/WorkflowListSidebar.tsx | 151 ++ .../workflows/WorkflowNodeCatalogBrowser.tsx | 479 ++++ .../workflows/WorkflowNodeContractCard.tsx | 128 + .../workflows/WorkflowNodeInspector.tsx | 245 ++ .../workflows/WorkflowPreflightPanel.tsx | 96 + .../workflows/WorkflowRunsPanel.tsx | 137 ++ .../workflows/WorkflowUtilityRail.tsx | 73 + .../workflows/WorkflowValidationBanner.tsx | 45 + .../workflows/useWorkflowCanvasController.ts | 471 ++++ .../workflows/workflowBlueprints.ts | 78 + .../workflows/workflowGraphDraft.ts | 581 +++++ .../workflows/workflowNodeCatalog.ts | 117 + .../workflows/workflowNodeLibrary.ts | 247 ++ .../workflows/workflowRunPresentation.ts | 33 + frontend/src/pages/WorkflowEditor.tsx | 2073 ++--------------- 25 files changed, 4877 insertions(+), 1823 deletions(-) create mode 100644 frontend/src/__tests__/components/cadUtils.test.ts create mode 100644 frontend/src/__tests__/components/workflowEditorUi.test.tsx create mode 100644 frontend/src/__tests__/components/workflowGraphDraft.test.ts create mode 100644 frontend/src/components/workflows/NewWorkflowModal.tsx create mode 100644 frontend/src/components/workflows/NodeCommandMenu.tsx create mode 100644 frontend/src/components/workflows/NodeDefinitionsPanel.tsx create mode 100644 frontend/src/components/workflows/WorkflowCanvasToolbar.tsx create mode 100644 frontend/src/components/workflows/WorkflowCanvasUtilitySidebar.tsx create mode 100644 frontend/src/components/workflows/WorkflowEditorEmptyState.tsx create mode 100644 frontend/src/components/workflows/WorkflowEditorHeader.tsx create mode 100644 frontend/src/components/workflows/WorkflowListSidebar.tsx create mode 100644 frontend/src/components/workflows/WorkflowNodeCatalogBrowser.tsx create mode 100644 frontend/src/components/workflows/WorkflowNodeContractCard.tsx create mode 100644 frontend/src/components/workflows/WorkflowNodeInspector.tsx create mode 100644 frontend/src/components/workflows/WorkflowPreflightPanel.tsx create mode 100644 frontend/src/components/workflows/WorkflowRunsPanel.tsx create mode 100644 frontend/src/components/workflows/WorkflowUtilityRail.tsx create mode 100644 frontend/src/components/workflows/WorkflowValidationBanner.tsx create mode 100644 frontend/src/components/workflows/useWorkflowCanvasController.ts create mode 100644 frontend/src/components/workflows/workflowBlueprints.ts create mode 100644 frontend/src/components/workflows/workflowGraphDraft.ts create mode 100644 frontend/src/components/workflows/workflowNodeCatalog.ts create mode 100644 frontend/src/components/workflows/workflowNodeLibrary.ts create mode 100644 frontend/src/components/workflows/workflowRunPresentation.ts diff --git a/frontend/src/__tests__/components/cadUtils.test.ts b/frontend/src/__tests__/components/cadUtils.test.ts new file mode 100644 index 0000000..4e2d26e --- /dev/null +++ b/frontend/src/__tests__/components/cadUtils.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, test } from 'vitest' +import * as THREE from 'three' + +import { buildScenePartRegistry, convertSceneManifestMaterials, remapToPartKeys, resolveObjectPartKey } from '../../components/cad/cadUtils' + +describe('cadUtils scene manifest conversion', () => { + test('uses scene manifest part keys as authoritative viewer material map', () => { + const materials = convertSceneManifestMaterials([ + { + part_key: 'rwdr_b_f_802044_tr4_h122bk', + effective_material: 'HARTOMAT_010101_Steel-Bare', + }, + { + part_key: 'o_ring_rg_f_802044_tr4_h122bk_1', + effective_material: 'HARTOMAT_050101_Elastomer-Black', + }, + { + part_key: 'ignored', + effective_material: null, + }, + ]) + + expect(materials).toEqual({ + rwdr_b_f_802044_tr4_h122bk: { + type: 'library', + value: 'HARTOMAT_010101_Steel-Bare', + }, + o_ring_rg_f_802044_tr4_h122bk_1: { + type: 'library', + value: 'HARTOMAT_050101_Elastomer-Black', + }, + }) + }) +}) + +describe('cadUtils legacy fallback remapping', () => { + test('remaps simple legacy source keys onto part keys when no scene manifest exists', () => { + const materials = remapToPartKeys( + { + 'F-802044-0011_AU_TR1_04_1': { type: 'library', value: 'Steel--Stahl' }, + 'RWDR_B_F-802044_TR4_H122BK': { type: 'library', value: 'Steel--Stahl' }, + }, + { + 'F-802044-0011_AU_TR1_04': 'f_802044_0011_au_tr1_04', + 'RWDR_B_F-802044_TR4_H122BK': 'rwdr_b_f_802044_tr4_h122bk', + }, + ) + + expect(materials).toEqual({ + f_802044_0011_au_tr1_04: { type: 'library', value: 'Steel--Stahl' }, + rwdr_b_f_802044_tr4_h122bk: { type: 'library', value: 'Steel--Stahl' }, + }) + }) + + test('remaps serialized instance names using blender-style fuzzy lookup', () => { + const materials = remapToPartKeys( + { + 'RWDR_B_F-802044_TR4_H122B-69186': { type: 'library', value: 'Steel--Stahl' }, + 'RWDR_K_F-802044_TR4_H122B-68272': { type: 'library', value: 'Steel--Stahl' }, + 'RWDR_F_F-802044_TR4_H122B-69391': { type: 'library', value: 'Steel--Stahl' }, + 'O_RING_RG_F-802044_TR4_H-120220': { type: 'library', value: 'Eslastomer_black--Elastomer_schwarz' }, + 'F-802044-3001_IR_TR2-H_A1-25921_AF0': { type: 'library', value: 'Steel--Stahl' }, + 'F-802044-0011_AU_TR1_04_1_AF0_1': { type: 'library', value: 'Steel--Stahl' }, + }, + { + 'RWDR_B_F-802044_TR4_H122BK': 'rwdr_b_f_802044_tr4_h122bk', + 'RWDR_K_F-802044_TR4_H122BK': 'rwdr_k_f_802044_tr4_h122bk', + 'RWDR_F_F-802044_TR4_H122BK': 'rwdr_f_f_802044_tr4_h122bk', + 'O_RING_RG_F-802044_TR4_H122BK': 'o_ring_rg_f_802044_tr4_h122bk', + 'F-802044-3001_IR_TR2-H_A1_04': 'f_802044_3001_ir_tr2_h_a1_04', + 'F-802044-0011_AU_TR1_04_1': 'f_802044_0011_au_tr1_04_1', + }, + ) + + expect(materials.rwdr_b_f_802044_tr4_h122bk).toEqual({ + type: 'library', + value: 'Steel--Stahl', + }) + expect(materials.rwdr_k_f_802044_tr4_h122bk).toEqual({ + type: 'library', + value: 'Steel--Stahl', + }) + expect(materials.rwdr_f_f_802044_tr4_h122bk).toEqual({ + type: 'library', + value: 'Steel--Stahl', + }) + expect(materials.o_ring_rg_f_802044_tr4_h122bk).toEqual({ + type: 'library', + value: 'Eslastomer_black--Elastomer_schwarz', + }) + expect(materials.f_802044_3001_ir_tr2_h_a1_04).toEqual({ + type: 'library', + value: 'Steel--Stahl', + }) + expect(materials.f_802044_0011_au_tr1_04_1).toEqual({ + type: 'library', + value: 'Steel--Stahl', + }) + }) + + test('keeps ambiguous fuzzy matches unresolved', () => { + const materials = remapToPartKeys( + { + 'PART_ALPHA-11111': { type: 'library', value: 'Steel--Stahl' }, + 'PART_ALPHA-22222': { type: 'library', value: 'Bronze--Bronze' }, + }, + { + PART_ALPHA: 'part_alpha', + }, + ) + + expect(materials.part_alpha).toBeUndefined() + }) +}) + +describe('cadUtils scene graph part-key registry', () => { + test('inherits instance part keys from ancestor nodes and keeps logical keys from scene metadata', () => { + const scene = new THREE.Group() + + const instanceGroup = new THREE.Group() + instanceGroup.name = 'KERO_Z-575693-QP-DRH_ISB_1' + instanceGroup.userData.partKey = 'kero_z_575693_qp_drh_isb_1' + + const mesh = new THREE.Mesh(new THREE.BufferGeometry(), new THREE.MeshStandardMaterial()) + mesh.name = 'KERO_Z-575693-QP-DRH_ISB_1_1' + instanceGroup.add(mesh) + + const logicalOnlyNode = new THREE.Group() + logicalOnlyNode.name = 'RWDR_SKEL_F-802044_TR4_H122BK' + logicalOnlyNode.userData.partKey = 'rwdr_skel_f_802044_tr4_h122bk' + + scene.add(instanceGroup) + scene.add(logicalOnlyNode) + + const { meshRegistry, logicalPartKeys } = buildScenePartRegistry(scene, { + 'F-802044_TR4-H122BK_04': 'f_802044_tr4_h122bk_04', + }) + + expect(meshRegistry).toHaveLength(1) + expect(meshRegistry[0].partKey).toBe('kero_z_575693_qp_drh_isb_1') + expect(resolveObjectPartKey(mesh, {})).toBe('kero_z_575693_qp_drh_isb_1') + expect(logicalPartKeys).toEqual(new Set([ + 'kero_z_575693_qp_drh_isb_1', + 'rwdr_skel_f_802044_tr4_h122bk', + 'f_802044_tr4_h122bk_04', + ])) + }) + + test('prefers sibling semantic instance nodes over mesh-local exporter keys when transforms match', () => { + const scene = new THREE.Group() + const assembly = new THREE.Group() + + const semanticSibling = new THREE.Group() + semanticSibling.name = 'KERO_Z-575693-QP-DRH_ISB_1_AF21' + semanticSibling.userData.partKey = 'kero_z_575693_qp_drh_isb_1' + semanticSibling.position.set(0.08113920585353, 0.236350432177, 0.2109037401181) + semanticSibling.quaternion.set(0.10417282880530283, -0.01738337059295405, -0.1636740589602252, 0.9808448616315497) + + const mesh = new THREE.Mesh(new THREE.BufferGeometry(), new THREE.MeshStandardMaterial()) + mesh.name = 'KERO_Z-575693-QP-DRH_ISB_1_1' + mesh.userData.partKey = 'kero_z_575693_qp_drh_isb_1_1' + mesh.position.copy(semanticSibling.position) + mesh.quaternion.copy(semanticSibling.quaternion) + + assembly.add(semanticSibling) + assembly.add(mesh) + scene.add(assembly) + + const { meshRegistry } = buildScenePartRegistry(scene, {}) + + expect(meshRegistry).toHaveLength(1) + expect(meshRegistry[0].partKey).toBe('kero_z_575693_qp_drh_isb_1') + expect(mesh.userData.partKey).toBe('kero_z_575693_qp_drh_isb_1') + expect(resolveObjectPartKey(mesh, {})).toBe('kero_z_575693_qp_drh_isb_1') + }) + + test('prefers sibling semantic instance nodes even when transforms do not match', () => { + const scene = new THREE.Group() + const assembly = new THREE.Group() + + const semanticSibling = new THREE.Group() + semanticSibling.name = 'KERO_Z-575693-QP-DRH_ISB_1_AF21' + semanticSibling.userData.partKey = 'kero_z_575693_qp_drh_isb_1' + semanticSibling.position.set(0.08113920585353, 0.236350432177, 0.2109037401181) + + const mesh = new THREE.Mesh(new THREE.BufferGeometry(), new THREE.MeshStandardMaterial()) + mesh.name = 'KERO_Z-575693-QP-DRH_ISB_1_1' + mesh.userData.partKey = 'kero_z_575693_qp_drh_isb_1_1' + mesh.position.set(0.2422435981345, 0.06134441033723, 0.2109037401181) + + assembly.add(semanticSibling) + assembly.add(mesh) + scene.add(assembly) + + const { meshRegistry } = buildScenePartRegistry(scene, {}) + + expect(meshRegistry).toHaveLength(1) + expect(meshRegistry[0].partKey).toBe('kero_z_575693_qp_drh_isb_1') + expect(mesh.userData.partKey).toBe('kero_z_575693_qp_drh_isb_1') + expect(resolveObjectPartKey(mesh, {})).toBe('kero_z_575693_qp_drh_isb_1') + }) +}) diff --git a/frontend/src/__tests__/components/workflowEditorUi.test.tsx b/frontend/src/__tests__/components/workflowEditorUi.test.tsx new file mode 100644 index 0000000..49ef1ac --- /dev/null +++ b/frontend/src/__tests__/components/workflowEditorUi.test.tsx @@ -0,0 +1,390 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, test, vi } from 'vitest' + +import type { + WorkflowNodeDefinition, + WorkflowPreflightResponse, + WorkflowRun, + WorkflowRunComparison, +} from '../../api/workflows' +import { NodeCommandMenu } from '../../components/workflows/NodeCommandMenu' +import { NodeDefinitionsPanel } from '../../components/workflows/NodeDefinitionsPanel' +import { WorkflowCanvasToolbar } from '../../components/workflows/WorkflowCanvasToolbar' +import { WorkflowNodeContractCard } from '../../components/workflows/WorkflowNodeContractCard' +import { WorkflowPreflightPanel } from '../../components/workflows/WorkflowPreflightPanel' +import { WorkflowRunsPanel } from '../../components/workflows/WorkflowRunsPanel' + +const nodeDefinitions: WorkflowNodeDefinition[] = [ + { + step: 'resolve_step_path', + label: 'Resolve STEP Path', + family: 'cad_file', + module_key: 'cad.intake', + category: 'input', + description: 'Load CAD input file.', + node_type: 'inputNode', + icon: 'file-up', + defaults: {}, + fields: [], + execution_kind: 'bridge', + legacy_compatible: true, + input_contract: { context: 'cad_file' }, + output_contract: { context: 'cad_file', provides: ['step_path'] }, + artifact_roles_produced: ['step_file'], + artifact_roles_consumed: [], + legacy_source: 'legacy.resolve_step_path', + }, + { + step: 'blender_still', + label: 'Blender Still', + family: 'order_line', + module_key: 'rendering.blender', + category: 'rendering', + description: 'Render still image.', + node_type: 'renderNode', + icon: 'camera', + defaults: {}, + fields: [], + execution_kind: 'native', + legacy_compatible: false, + input_contract: { context: 'order_line', requires: ['order_line'] }, + output_contract: { context: 'order_line', provides: ['render_image'] }, + artifact_roles_produced: ['png_output'], + artifact_roles_consumed: ['cad_preview'], + legacy_source: null, + }, +] + +const shadowRun: WorkflowRun = { + id: 'run-shadow-1234', + workflow_def_id: 'wf-1', + order_line_id: 'ol-1', + celery_task_id: 'celery-1', + execution_mode: 'shadow', + status: 'completed', + started_at: '2026-04-08T10:00:00Z', + completed_at: '2026-04-08T10:02:00Z', + error_message: null, + created_at: '2026-04-08T10:00:00Z', + node_results: [ + { + id: 'node-result-1', + node_name: 'Blender Still', + status: 'completed', + output: null, + log: 'Rendered successfully.', + duration_s: 2.3, + created_at: '2026-04-08T10:02:00Z', + }, + ], +} + +const shadowComparison: WorkflowRunComparison = { + workflow_run_id: shadowRun.id, + workflow_def_id: 'wf-1', + order_line_id: 'ol-1', + execution_mode: 'shadow', + status: 'matched', + summary: 'Observer output matches authoritative output.', + authoritative_output: { + path: null, + storage_key: null, + exists: true, + file_size_bytes: 100, + sha256: 'abc', + mime_type: 'image/png', + image_width: 1200, + image_height: 900, + }, + observer_output: { + path: null, + storage_key: null, + exists: true, + file_size_bytes: 100, + sha256: 'abc', + mime_type: 'image/png', + image_width: 1200, + image_height: 900, + }, + exact_match: true, + dimensions_match: true, + mean_pixel_delta: 0, +} + +const preflightResponse: WorkflowPreflightResponse = { + workflow_id: 'wf-1', + context_id: 'ol-1', + context_kind: 'order_line', + expected_context_kind: 'order_line', + execution_mode: 'graph', + graph_dispatch_allowed: false, + summary: 'Graph requires one missing upstream artifact.', + resolved_order_line_id: 'ol-1', + resolved_cad_file_id: null, + unsupported_node_ids: [], + issues: [ + { + severity: 'warning', + code: 'missing-artifact', + message: 'Missing cad_preview artifact.', + node_id: null, + step: null, + }, + ], + nodes: [ + { + node_id: 'node-1', + step: 'blender_still', + label: 'Blender Still', + execution_kind: 'native', + supported: true, + status: 'warning', + issues: [ + { + severity: 'warning', + code: 'missing-artifact', + message: 'cad_preview must be produced upstream.', + node_id: 'node-1', + step: 'blender_still', + }, + ], + }, + ], +} + +describe('WorkflowNodeContractCard', () => { + test('renders module contract metadata for production nodes', () => { + render( + , + ) + + expect(screen.getByText('Production Module')).toBeInTheDocument() + expect(screen.getByText('rendering.blender')).toBeInTheDocument() + expect(screen.getByText('Legacy Safe')).toBeInTheDocument() + expect(screen.getByText('legacy.still_render')).toBeInTheDocument() + expect(screen.getByText('Order Line')).toBeInTheDocument() + expect(screen.getByText('Render Template')).toBeInTheDocument() + expect(screen.getByText('Cad Preview')).toBeInTheDocument() + expect(screen.getByText('Render Image')).toBeInTheDocument() + expect(screen.getByText('Png Output')).toBeInTheDocument() + }) +}) + +describe('WorkflowCanvasToolbar', () => { + test('surfaces workflow controls and wires callbacks', async () => { + const user = userEvent.setup() + const onDispatchContextIdChange = vi.fn() + const onExecutionModeChange = vi.fn() + const onOpenNodeMenu = vi.fn() + const onAutoLayout = vi.fn() + const onDeleteSelectedEdges = vi.fn() + const onPreflight = vi.fn() + const onDispatch = vi.fn() + const onSave = vi.fn() + + render( + , + ) + + expect(screen.getByText('Workflow Canvas')).toBeInTheDocument() + expect(screen.getByText('Still Image - Graph')).toBeInTheDocument() + expect(screen.getByText('Still Graph')).toBeInTheDocument() + expect(screen.getByText('Right-click to add')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Delete (2)' })).toBeEnabled() + + await user.click(screen.getByRole('button', { name: 'Node' })) + await user.click(screen.getByRole('button', { name: 'Align' })) + await user.click(screen.getByRole('button', { name: 'Delete (2)' })) + await user.click(screen.getByRole('button', { name: 'Dry Run' })) + await user.click(screen.getByRole('button', { name: 'Run' })) + await user.click(screen.getByRole('button', { name: 'Save' })) + await user.type(screen.getByPlaceholderText('context id'), 'order-123') + await user.selectOptions(screen.getByRole('combobox'), 'shadow') + + expect(onOpenNodeMenu).toHaveBeenCalledOnce() + expect(onAutoLayout).toHaveBeenCalledOnce() + expect(onDeleteSelectedEdges).toHaveBeenCalledOnce() + expect(onPreflight).toHaveBeenCalledOnce() + expect(onDispatch).toHaveBeenCalledOnce() + expect(onSave).toHaveBeenCalledOnce() + expect(onDispatchContextIdChange).toHaveBeenCalled() + expect(onExecutionModeChange).toHaveBeenCalledWith('shadow') + }) + + test('disables destructive and runtime actions when validation blocks dispatch', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: 'Align' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Dry Run' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Run' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + }) +}) + +describe('NodeCommandMenu', () => { + test('filters definitions and selects the first visible node via enter', async () => { + const user = userEvent.setup() + const onSelectStep = vi.fn() + + render( + {iconName}} + />, + ) + + await user.click(screen.getByRole('button', { name: 'Graph' })) + await user.type(screen.getByPlaceholderText('Search nodes'), 'blender{enter}') + + expect(onSelectStep).toHaveBeenCalledWith('blender_still') + expect(screen.getByText('Graph Nodes')).toBeInTheDocument() + }) +}) + +describe('NodeDefinitionsPanel', () => { + test('groups nodes by runtime bucket and module in the utility rail library view', () => { + render() + + expect(screen.getByText('Node Library')).toBeInTheDocument() + expect(screen.getAllByText('CAD Intake').length).toBeGreaterThan(0) + expect(screen.getAllByText('Order Rendering').length).toBeGreaterThan(0) + expect(screen.getByText('Legacy Nodes')).toBeInTheDocument() + expect(screen.getByText('Graph Nodes')).toBeInTheDocument() + expect(screen.getByText('Blender Still')).toBeInTheDocument() + expect(screen.getAllByText('Graph').length).toBeGreaterThan(0) + expect(screen.getByRole('button', { name: 'All Modules' })).toBeInTheDocument() + }) + + test('supports direct node insertion from the library sidebar', async () => { + const user = userEvent.setup() + const onSelectStep = vi.fn() + + render( + {iconName}} + />, + ) + + await user.click(screen.getAllByRole('button', { name: 'Insert' })[1]) + + expect(onSelectStep).toHaveBeenCalledWith('blender_still') + }) +}) + +describe('WorkflowRunsPanel', () => { + test('renders selected shadow run details and comparison summary', async () => { + const user = userEvent.setup() + const onSelectRun = vi.fn() + + render( + , + ) + + expect(screen.getByText('Workflow Runs')).toBeInTheDocument() + expect(screen.getByText('Shadow Comparison')).toBeInTheDocument() + expect(screen.getByText('Observer output matches authoritative output.')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /run-shad/i })) + + expect(onSelectRun).toHaveBeenCalledWith(shadowRun.id) + }) +}) + +describe('WorkflowPreflightPanel', () => { + test('renders node-level readiness issues for graph dispatch', () => { + render() + + expect(screen.getByText('Graph Preflight')).toBeInTheDocument() + expect(screen.getByText('Graph requires one missing upstream artifact.')).toBeInTheDocument() + expect(screen.getByText('Missing cad_preview artifact.')).toBeInTheDocument() + expect(screen.getByText('cad_preview must be produced upstream.')).toBeInTheDocument() + expect(screen.getByText('blocked')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/__tests__/components/workflowGraphDraft.test.ts b/frontend/src/__tests__/components/workflowGraphDraft.test.ts new file mode 100644 index 0000000..488506d --- /dev/null +++ b/frontend/src/__tests__/components/workflowGraphDraft.test.ts @@ -0,0 +1,328 @@ +import type { Edge, Node } from '@xyflow/react' +import { describe, expect, test } from 'vitest' + +import type { WorkflowNodeDefinition } from '../../api/workflows' +import { resolveParamsForStepChange, validateWorkflowDraft } from '../../components/workflows/workflowGraphDraft' + +function createNode(id: string, step: string, label = step): Node { + return { + id, + type: 'processNode', + position: { x: 0, y: 0 }, + data: { + label, + step, + params: {}, + }, + } as Node +} + +function createEdge(source: string, target: string): Edge { + return { + id: `${source}-${target}`, + source, + target, + } as Edge +} + +const definitions: Record = { + order_line_setup: { + step: 'order_line_setup', + label: 'Order Line Setup', + family: 'order_line', + module_key: 'order_line.prepare_render_context', + category: 'processing', + description: 'Prepare order line context.', + node_type: 'processNode', + icon: 'layers', + defaults: {}, + fields: [], + execution_kind: 'bridge', + legacy_compatible: true, + input_contract: { context: 'order_line', requires: ['order_line_record'] }, + output_contract: { context: 'order_line', provides: ['order_line_context', 'cad_file_ref'] }, + artifact_roles_consumed: [], + artifact_roles_produced: ['order_line_context', 'cad_file_ref'], + legacy_source: 'legacy.order_line_setup', + }, + resolve_template: { + step: 'resolve_template', + label: 'Resolve Template', + family: 'order_line', + module_key: 'rendering.resolve_template', + category: 'processing', + description: 'Resolve the render template.', + node_type: 'processNode', + icon: 'layers', + defaults: {}, + fields: [], + execution_kind: 'bridge', + legacy_compatible: true, + input_contract: { context: 'order_line', requires: ['order_line_context'] }, + output_contract: { context: 'order_line', provides: ['render_template', 'output_profile'] }, + artifact_roles_consumed: ['order_line_context'], + artifact_roles_produced: ['render_template', 'output_profile'], + legacy_source: 'legacy.resolve_template', + }, + material_map_resolve: { + step: 'material_map_resolve', + label: 'Resolve Material Map', + family: 'order_line', + module_key: 'materials.resolve_map', + category: 'processing', + description: 'Resolve materials.', + node_type: 'processNode', + icon: 'layers', + defaults: {}, + fields: [], + execution_kind: 'bridge', + legacy_compatible: true, + input_contract: { context: 'order_line', requires: ['order_line_context', 'cad_materials'] }, + output_contract: { context: 'order_line', provides: ['material_assignments'] }, + artifact_roles_consumed: ['order_line_context', 'cad_materials'], + artifact_roles_produced: ['material_assignments'], + legacy_source: 'legacy.material_map_resolve', + }, + auto_populate_materials: { + step: 'auto_populate_materials', + label: 'Auto Populate Materials', + family: 'order_line', + module_key: 'materials.auto_populate', + category: 'processing', + description: 'Populate missing materials.', + node_type: 'processNode', + icon: 'layers', + defaults: {}, + fields: [], + execution_kind: 'bridge', + legacy_compatible: true, + input_contract: { context: 'order_line', requires: ['cad_materials'] }, + output_contract: { context: 'order_line', provides: ['material_catalog_updates'] }, + artifact_roles_consumed: ['cad_materials'], + artifact_roles_produced: ['material_catalog_updates'], + legacy_source: 'legacy.auto_populate_materials', + }, + glb_bbox: { + step: 'glb_bbox', + label: 'Compute Bounding Box', + family: 'order_line', + module_key: 'geometry.compute_bbox', + category: 'processing', + description: 'Compute bbox.', + node_type: 'processNode', + icon: 'layers', + defaults: {}, + fields: [], + execution_kind: 'bridge', + legacy_compatible: true, + input_contract: { context: 'order_line', requires: ['glb_preview'] }, + output_contract: { context: 'order_line', provides: ['bbox'] }, + artifact_roles_consumed: ['glb_preview'], + artifact_roles_produced: ['bbox'], + legacy_source: 'legacy.glb_bbox', + }, + blender_still: { + step: 'blender_still', + label: 'Render Still', + family: 'order_line', + module_key: 'render.production.still', + category: 'rendering', + description: 'Render still image.', + node_type: 'renderNode', + icon: 'camera', + defaults: { use_custom_render_settings: false, rotation_z: 0, width: 2048 }, + fields: [ + { + key: 'use_custom_render_settings', + label: 'Custom Render Settings', + type: 'boolean', + description: '', + section: 'Render', + default: false, + min: null, + max: null, + step: null, + unit: null, + options: [], + }, + { + key: 'width', + label: 'Width', + type: 'number', + description: '', + section: 'Output', + default: 2048, + min: null, + max: null, + step: null, + unit: 'px', + options: [], + }, + ], + execution_kind: 'native', + legacy_compatible: false, + input_contract: { + context: 'order_line', + requires: ['order_line_context', 'render_template', 'material_assignments', 'bbox'], + }, + output_contract: { context: 'order_line', provides: ['rendered_image'] }, + artifact_roles_consumed: ['order_line_context', 'render_template', 'material_assignments', 'bbox'], + artifact_roles_produced: ['rendered_image'], + legacy_source: null, + }, + output_save: { + step: 'output_save', + label: 'Save Output', + family: 'order_line', + module_key: 'media.save_output', + category: 'output', + description: 'Persist media.', + node_type: 'outputNode', + icon: 'download', + defaults: {}, + fields: [], + execution_kind: 'bridge', + legacy_compatible: true, + input_contract: { context: 'order_line', requires: ['rendered_image', 'rendered_frames', 'rendered_video'] }, + output_contract: { context: 'order_line', provides: ['media_asset'] }, + artifact_roles_consumed: ['rendered_image', 'rendered_frames', 'rendered_video'], + artifact_roles_produced: ['media_asset'], + legacy_source: 'legacy.output_save', + }, + notify: { + step: 'notify', + label: 'Notify', + family: 'order_line', + module_key: 'notifications.emit', + category: 'output', + description: 'Emit notification.', + node_type: 'outputNode', + icon: 'bell', + defaults: { channel: 'audit_log' }, + fields: [], + execution_kind: 'bridge', + legacy_compatible: true, + input_contract: { context: 'order_line', requires: ['workflow_result'] }, + output_contract: { context: 'order_line', provides: ['notification_event'] }, + artifact_roles_consumed: ['workflow_result'], + artifact_roles_produced: ['notification_event'], + legacy_source: 'legacy.notify', + }, + resolve_step_path: { + step: 'resolve_step_path', + label: 'Resolve STEP Path', + family: 'cad_file', + module_key: 'cad.resolve_step_path', + category: 'input', + description: 'Load STEP path.', + node_type: 'inputNode', + icon: 'file-up', + defaults: {}, + fields: [], + execution_kind: 'bridge', + legacy_compatible: true, + input_contract: { context: 'cad_file', requires: ['cad_file_record'] }, + output_contract: { context: 'cad_file', provides: ['step_path'] }, + artifact_roles_consumed: [], + artifact_roles_produced: ['step_path'], + legacy_source: 'legacy.resolve_step_path', + }, +} + +describe('validateWorkflowDraft', () => { + test('requires order line setup before render nodes', () => { + const result = validateWorkflowDraft( + [createNode('render', 'blender_still', 'Render Still')], + [], + definitions, + true, + ) + + expect(result.errors).toContain('Node "Render Still" requires an earlier "Order Line Setup" node.') + expect(result.warnings).toContain( + 'Node "Render Still" has no earlier "Resolve Template" node. Render defaults may drift from legacy behavior.', + ) + }) + + test('accepts the canonical still-render chain with compatibility outputs from setup', () => { + const nodes = [ + createNode('setup', 'order_line_setup', 'Order Line Setup'), + createNode('template', 'resolve_template', 'Resolve Template'), + createNode('materials', 'material_map_resolve', 'Resolve Material Map'), + createNode('bbox', 'glb_bbox', 'Compute Bounding Box'), + createNode('render', 'blender_still', 'Render Still'), + createNode('save', 'output_save', 'Save Output'), + ] + const edges = [ + createEdge('setup', 'template'), + createEdge('setup', 'materials'), + createEdge('setup', 'bbox'), + createEdge('setup', 'render'), + createEdge('template', 'render'), + createEdge('materials', 'render'), + createEdge('bbox', 'render'), + createEdge('render', 'save'), + ] + + const result = validateWorkflowDraft(nodes, edges, definitions, true) + + expect(result.errors).toEqual([]) + expect(result.warnings).toEqual([]) + }) + + test('accepts the current reference graph with explicit setup, template, materials, and bbox branches', () => { + const nodes = [ + createNode('setup', 'order_line_setup', 'Order Line Setup'), + createNode('template', 'resolve_template', 'Resolve Template'), + createNode('populate', 'auto_populate_materials', 'Auto Populate Materials'), + createNode('bbox', 'glb_bbox', 'Compute Bounding Box'), + createNode('materials', 'material_map_resolve', 'Resolve Material Map'), + createNode('render', 'blender_still', 'Render Still'), + createNode('save', 'output_save', 'Save Output'), + createNode('notify', 'notify', 'Notify'), + ] + const edges = [ + createEdge('setup', 'template'), + createEdge('setup', 'populate'), + createEdge('setup', 'bbox'), + createEdge('template', 'materials'), + createEdge('populate', 'materials'), + createEdge('materials', 'render'), + createEdge('bbox', 'render'), + createEdge('template', 'render'), + createEdge('render', 'save'), + createEdge('render', 'notify'), + ] + + const result = validateWorkflowDraft(nodes, edges, definitions, true) + + expect(result.errors).toEqual([]) + }) + + test('blocks mixed CAD-file and order-line graphs', () => { + const result = validateWorkflowDraft( + [createNode('cad', 'resolve_step_path', 'Resolve STEP Path'), createNode('render', 'blender_still', 'Render Still')], + [createEdge('cad', 'render')], + definitions, + true, + ) + + expect(result.errors).toContain('Workflow mixes CAD-file and order-line nodes. Split them into separate workflows.') + }) +}) + +describe('resolveParamsForStepChange', () => { + test('keeps only parameters supported by the target step schema', () => { + const next = resolveParamsForStepChange(definitions.blender_still, { + width: 1024, + use_custom_render_settings: true, + stale_key: 'drop-me', + }) + + expect(next).toEqual({ + use_custom_render_settings: true, + rotation_z: 0, + width: 1024, + }) + }) +}) diff --git a/frontend/src/components/workflows/NewWorkflowModal.tsx b/frontend/src/components/workflows/NewWorkflowModal.tsx new file mode 100644 index 0000000..f81ffaa --- /dev/null +++ b/frontend/src/components/workflows/NewWorkflowModal.tsx @@ -0,0 +1,255 @@ +import { useMemo, useState } from 'react' +import { X } from 'lucide-react' + +import { + createPresetWorkflowConfig, + createStarterWorkflowConfig, + type WorkflowConfig, + type WorkflowDefinition, + type WorkflowParams, + type WorkflowPresetType, + type WorkflowStarterFamily, +} from '../../api/workflows' +import { + FAMILY_FILTER_LABELS, + FAMILY_FILTER_STYLES, + GRAPH_FAMILY_LABELS, + GRAPH_FAMILY_STYLES, + type WorkflowNodeDefinitionMap, +} from './workflowNodeLibrary' +import { + BLUEPRINT_DESCRIPTION, + BLUEPRINT_LABELS, + cloneWorkflowConfig, + compareWorkflows, + getWorkflowBlueprint, + inferWorkflowFamily, + isReferenceBlueprint, +} from './workflowBlueprints' + +interface NewWorkflowModalProps { + workflows: WorkflowDefinition[] + nodeDefinitionsByStep: WorkflowNodeDefinitionMap + onClose: () => void + onCreate: (name: string, config: WorkflowConfig) => void + isLoading: boolean +} + +export function NewWorkflowModal({ + workflows, + nodeDefinitionsByStep, + onClose, + onCreate, + isLoading, +}: NewWorkflowModalProps) { + const [name, setName] = useState('') + const [type, setType] = useState('still_graph') + const [selectedBlueprintId, setSelectedBlueprintId] = useState(null) + const [starterFamily, setStarterFamily] = useState('order_line') + + const referenceBlueprints = useMemo( + () => workflows.filter(workflow => isReferenceBlueprint(workflow.config)).sort(compareWorkflows), + [workflows], + ) + + const selectedBlueprint = referenceBlueprints.find(workflow => workflow.id === selectedBlueprintId) ?? null + const selectedBlueprintFamily = selectedBlueprint + ? inferWorkflowFamily(selectedBlueprint.config, nodeDefinitionsByStep) + : null + const selectedBlueprintLabel = selectedBlueprint + ? BLUEPRINT_LABELS[getWorkflowBlueprint(selectedBlueprint.config) ?? ''] + : null + + const handleCreate = () => { + const trimmedName = name.trim() + if (!trimmedName) return + + if (selectedBlueprint) { + const clonedConfig = cloneWorkflowConfig(selectedBlueprint.config, { stripBlueprint: true }) + onCreate(trimmedName, clonedConfig) + return + } + + if (type === 'custom') { + onCreate(trimmedName, createStarterWorkflowConfig(starterFamily)) + return + } + + const defaultParams: WorkflowParams = + type === 'turntable' + ? { fps: 24, duration_s: 5 } + : type === 'multi_angle' + ? { angles: [0, 45, 90] } + : {} + + onCreate(trimmedName, createPresetWorkflowConfig(type, defaultParams)) + } + + return ( +
+
+
+

New Workflow

+ +
+ +
+
+ + setName(event.target.value)} + autoFocus + /> +
+ +
+ +
+ {([ + { value: 'still_graph', label: 'Still (Graph)', desc: 'Single PNG image via graph runtime' }, + { value: 'still', label: 'Still (Legacy)', desc: 'Single PNG image via legacy runtime' }, + { value: 'turntable', label: 'Turntable', desc: 'Animation MP4' }, + { value: 'multi_angle', label: 'Multi-Angle', desc: 'Multiple angles' }, + { value: 'still_with_exports', label: 'Still + Blend', desc: 'PNG + .blend export' }, + { value: 'custom', label: 'Custom', desc: 'Free canvas' }, + ] as { value: WorkflowPresetType; label: string; desc: string }[]).map(option => ( + + ))} +
+
+ + {!selectedBlueprint && type === 'custom' && ( +
+ +
+ {([ + { + value: 'order_line', + label: 'Order Rendering', + desc: 'Starts with order-line setup and keeps the legacy production path parallel-safe.', + }, + { + value: 'cad_file', + label: 'CAD Intake', + desc: 'Starts with CAD-file intake for preview, extraction, and cache workflows.', + }, + ] as { value: WorkflowStarterFamily; label: string; desc: string }[]).map(option => ( + + ))} +
+

+ Custom workflows now start from a family-safe starter graph instead of a mixed free canvas. +

+
+ )} + + {referenceBlueprints.length > 0 && ( +
+
+ + +
+
+ {referenceBlueprints.map(workflow => { + const blueprint = getWorkflowBlueprint(workflow.config) + const family = inferWorkflowFamily(workflow.config, nodeDefinitionsByStep) + const isSelected = selectedBlueprintId === workflow.id + + return ( + + ) + })} +
+ {selectedBlueprint && ( +

+ New workflow will be cloned from {selectedBlueprint.name} + {selectedBlueprintFamily ? ` (${GRAPH_FAMILY_LABELS[selectedBlueprintFamily]})` : ''} + {selectedBlueprintLabel ? ` as ${selectedBlueprintLabel.toLowerCase()}` : ''}. +

+ )} +
+ )} +
+ +
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/workflows/NodeCommandMenu.tsx b/frontend/src/components/workflows/NodeCommandMenu.tsx new file mode 100644 index 0000000..4275b92 --- /dev/null +++ b/frontend/src/components/workflows/NodeCommandMenu.tsx @@ -0,0 +1,68 @@ +import { useEffect, type ReactNode } from 'react' +import { X } from 'lucide-react' + +import type { WorkflowNodeDefinition } from '../../api/workflows' +import type { WorkflowGraphFamily } from './workflowNodeLibrary' +import { WorkflowNodeCatalogBrowser } from './WorkflowNodeCatalogBrowser' + +interface NodeCommandMenuProps { + definitions: WorkflowNodeDefinition[] + graphFamily: WorkflowGraphFamily + onSelectStep: (step: string) => void + onClose: () => void + renderIcon: (iconName?: string, size?: number) => ReactNode +} + +export function NodeCommandMenu({ + definitions, + graphFamily, + onSelectStep, + onClose, + renderIcon, +}: NodeCommandMenuProps) { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose() + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [onClose]) + + return ( +
+
+
+
+

Add Workflow Node

+

+ Search by label, step, family, or execution mode. +

+
+ +
+
+ +
+ +
+
+ ) +} diff --git a/frontend/src/components/workflows/NodeDefinitionsPanel.tsx b/frontend/src/components/workflows/NodeDefinitionsPanel.tsx new file mode 100644 index 0000000..f81eb53 --- /dev/null +++ b/frontend/src/components/workflows/NodeDefinitionsPanel.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from 'react' + +import type { WorkflowNodeDefinition } from '../../api/workflows' +import type { WorkflowGraphFamily } from './workflowNodeLibrary' +import { WorkflowNodeCatalogBrowser } from './WorkflowNodeCatalogBrowser' + +interface NodeDefinitionsPanelProps { + definitions: WorkflowNodeDefinition[] + graphFamily: WorkflowGraphFamily + onSelectStep?: (step: string) => void + renderIcon?: (iconName?: string, size?: number) => ReactNode +} + +export function NodeDefinitionsPanel({ definitions, graphFamily, onSelectStep, renderIcon }: NodeDefinitionsPanelProps) { + return ( +
+
+
+

+ Node Library +

+ + {onSelectStep ? 'Click insert to add to canvas' : `${definitions.length} definitions`} + +
+

+ Browse by runtime family and module contract, then insert nodes directly from the sidebar. +

+
+ +
+ ) +} diff --git a/frontend/src/components/workflows/WorkflowCanvasToolbar.tsx b/frontend/src/components/workflows/WorkflowCanvasToolbar.tsx new file mode 100644 index 0000000..10d7307 --- /dev/null +++ b/frontend/src/components/workflows/WorkflowCanvasToolbar.tsx @@ -0,0 +1,211 @@ +import { + BadgeInfo, + GitBranch, + LayoutGrid, + Loader2, + MousePointer2, + Play, + Plus, + RefreshCw, + Save, + Trash2, +} from 'lucide-react' + +type WorkflowExecutionModeOption = { + value: string + label: string +} + +interface WorkflowCanvasToolbarProps { + workflowName: string + blueprintLabel?: string | null + blueprintDescription?: string | null + graphFamilyLabel: string + graphFamilyClassName: string + executionMode: string + executionModeLabel: string + executionModeClassName: string + executionModeHint: string + dispatchContextId: string + executionModes: WorkflowExecutionModeOption[] + selectedEdgeCount: number + canAutoLayout: boolean + hasValidationErrors: boolean + isPreflightPending: boolean + isDispatchPending: boolean + isSaving: boolean + onDispatchContextIdChange: (value: string) => void + onExecutionModeChange: (value: string) => void + onOpenNodeMenu: () => void + onAutoLayout: () => void + onDeleteSelectedEdges: () => void + onPreflight: () => void + onDispatch: () => void + onSave: () => void +} + +export function WorkflowCanvasToolbar({ + workflowName, + blueprintLabel, + blueprintDescription, + graphFamilyLabel, + graphFamilyClassName, + executionMode, + executionModeLabel, + executionModeClassName, + executionModeHint, + dispatchContextId, + executionModes, + selectedEdgeCount, + canAutoLayout, + hasValidationErrors, + isPreflightPending, + isDispatchPending, + isSaving, + onDispatchContextIdChange, + onExecutionModeChange, + onOpenNodeMenu, + onAutoLayout, + onDeleteSelectedEdges, + onPreflight, + onDispatch, + onSave, +}: WorkflowCanvasToolbarProps) { + return ( +
+
+
+
+
+ + Workflow Canvas +
+

{workflowName}

+ + {graphFamilyLabel} + + + {executionModeLabel} + + {blueprintLabel && ( + + {blueprintLabel} + + )} +
+
+ {(blueprintDescription || executionModeHint) && ( + + + {blueprintDescription ?? executionModeHint} + + )} + + + Right-click to add + + + + Delete removes connections + +
+
+ +
+ + + +
+
+ +
+
+ + + + + +
+

+ {executionModeHint} +

+
+
+ ) +} diff --git a/frontend/src/components/workflows/WorkflowCanvasUtilitySidebar.tsx b/frontend/src/components/workflows/WorkflowCanvasUtilitySidebar.tsx new file mode 100644 index 0000000..225c3f9 --- /dev/null +++ b/frontend/src/components/workflows/WorkflowCanvasUtilitySidebar.tsx @@ -0,0 +1,159 @@ +import type { ReactNode } from 'react' +import { Activity, Library, ShieldCheck, SlidersHorizontal } from 'lucide-react' +import type { + WorkflowNodeDefinition, + WorkflowParams, + WorkflowPreflightResponse, + WorkflowRun, + WorkflowRunComparison, +} from '../../api/workflows' +import { NodeDefinitionsPanel } from './NodeDefinitionsPanel' +import { WorkflowNodeInspector } from './WorkflowNodeInspector' +import { WorkflowPreflightPanel } from './WorkflowPreflightPanel' +import { WorkflowRunsPanel } from './WorkflowRunsPanel' +import { WorkflowUtilityRail } from './WorkflowUtilityRail' +import type { WorkflowCanvasNodeData } from './workflowGraphDraft' +import type { WorkflowGraphFamily } from './workflowNodeLibrary' + +export type WorkflowUtilityTab = 'inspector' | 'library' | 'runs' | 'preflight' + +type WorkflowCanvasUtilitySidebarProps = { + activeTab: WorkflowUtilityTab + onTabChange: (tab: WorkflowUtilityTab) => void + selectedNode: { + data?: WorkflowCanvasNodeData + } | null + onNodeParamsChange: (params: WorkflowParams) => void + onNodeStepChange: (step: string) => void + nodeDefinitions: WorkflowNodeDefinition[] + nodeDefinitionsByStep: Record + graphFamily: WorkflowGraphFamily + onInsertNode: (step: string) => void + renderNodeIcon: (iconName?: string, size?: number) => ReactNode + workflowRuns: WorkflowRun[] + selectedRunId: string | null + onSelectRun: (runId: string | null) => void + comparison?: WorkflowRunComparison | null + isComparisonLoading: boolean + preflightResult: WorkflowPreflightResponse | null + isPreflightPending: boolean +} + +export function WorkflowCanvasUtilitySidebar({ + activeTab, + onTabChange, + selectedNode, + onNodeParamsChange, + onNodeStepChange, + nodeDefinitions, + nodeDefinitionsByStep, + graphFamily, + onInsertNode, + renderNodeIcon, + workflowRuns, + selectedRunId, + onSelectRun, + comparison, + isComparisonLoading, + preflightResult, + isPreflightPending, +}: WorkflowCanvasUtilitySidebarProps) { + if (nodeDefinitions.length === 0) return null + + const utilityTabs: { + key: WorkflowUtilityTab + label: string + icon: typeof SlidersHorizontal + count?: number | null + disabled?: boolean + }[] = [ + { + key: 'inspector', + label: 'Inspector', + icon: SlidersHorizontal, + disabled: !selectedNode, + }, + { + key: 'library', + label: 'Library', + icon: Library, + count: nodeDefinitions.length, + }, + { + key: 'runs', + label: 'Runs', + icon: Activity, + count: workflowRuns.length, + }, + { + key: 'preflight', + label: 'Preflight', + icon: ShieldCheck, + count: preflightResult ? preflightResult.nodes.length : null, + }, + ] + + return ( + + {activeTab === 'inspector' && selectedNode && ( + + )} + + {activeTab === 'inspector' && !selectedNode && ( +
+

No node selected

+

+ Select a node on the canvas to edit its settings here. +

+
+ )} + + {activeTab === 'library' && ( + + )} + + {activeTab === 'runs' && ( + + )} + + {activeTab === 'preflight' && ( + + )} + + {activeTab === 'preflight' && !preflightResult && !isPreflightPending && ( +
+

No preflight yet

+

+ Run `Dry Run` to inspect graph readiness and node-level issues here. +

+
+ )} +
+ ) +} diff --git a/frontend/src/components/workflows/WorkflowEditorEmptyState.tsx b/frontend/src/components/workflows/WorkflowEditorEmptyState.tsx new file mode 100644 index 0000000..3803f3a --- /dev/null +++ b/frontend/src/components/workflows/WorkflowEditorEmptyState.tsx @@ -0,0 +1,42 @@ +import { GitBranch, Plus } from 'lucide-react' + +type WorkflowEditorEmptyStateProps = { + hasWorkflows: boolean + onCreateWorkflow: () => void +} + +export function WorkflowEditorEmptyState({ + hasWorkflows, + onCreateWorkflow, +}: WorkflowEditorEmptyStateProps) { + return ( +
+
+ + {hasWorkflows ? ( + <> +

No workflow selected

+

+ Select a workflow from the list or create a new one. +

+ + ) : ( + <> +

No workflows configured.

+

+ Workflows define the sequence of pipeline steps for rendering orders. + Click "New Workflow" to create one. +

+ + )} + +
+
+ ) +} diff --git a/frontend/src/components/workflows/WorkflowEditorHeader.tsx b/frontend/src/components/workflows/WorkflowEditorHeader.tsx new file mode 100644 index 0000000..a3d49d3 --- /dev/null +++ b/frontend/src/components/workflows/WorkflowEditorHeader.tsx @@ -0,0 +1,52 @@ +interface WorkflowEditorHeaderProps { + workflowName: string | null + familyLabel?: string | null + familyClassName?: string | null + executionModeLabel?: string | null + executionModeClassName?: string | null + blueprintLabel?: string | null + blueprintDescription?: string | null +} + +export function WorkflowEditorHeader({ + workflowName, + familyLabel, + familyClassName, + executionModeLabel, + executionModeClassName, + blueprintLabel, + blueprintDescription, +}: WorkflowEditorHeaderProps) { + return ( +
+
+
+

Workflow Editor

+ {workflowName && ( + <> + {workflowName} + {familyLabel && familyClassName && ( + + {familyLabel} + + )} + {executionModeLabel && executionModeClassName && ( + + {executionModeLabel} + + )} + {blueprintLabel && ( + + {blueprintLabel} + + )} + + )} +
+
+ {workflowName && blueprintDescription && ( +

{blueprintDescription}

+ )} +
+ ) +} diff --git a/frontend/src/components/workflows/WorkflowListSidebar.tsx b/frontend/src/components/workflows/WorkflowListSidebar.tsx new file mode 100644 index 0000000..514d0a8 --- /dev/null +++ b/frontend/src/components/workflows/WorkflowListSidebar.tsx @@ -0,0 +1,151 @@ +import { GitBranch, Plus, Trash2 } from 'lucide-react' + +interface WorkflowListItem { + id: string + name: string + isActive: boolean + presetLabel: string + presetClassName: string + familyLabel: string + familyClassName: string + executionModeLabel: string + executionModeClassName: string + blueprintLabel?: string | null + isReference?: boolean +} + +interface WorkflowListSection { + key: string + label: string + className: string + items: WorkflowListItem[] +} + +interface WorkflowListSidebarProps { + isLoading: boolean + sections: WorkflowListSection[] + selectedId: string | null + onSelectWorkflow: (workflowId: string) => void + onCreateWorkflow: () => void + onDeleteWorkflow: (workflowId: string, workflowName: string) => void +} + +export function WorkflowListSidebar({ + isLoading, + sections, + selectedId, + onSelectWorkflow, + onCreateWorkflow, + onDeleteWorkflow, +}: WorkflowListSidebarProps) { + const workflowCount = sections.reduce((count, section) => count + section.items.length, 0) + + return ( + + ) +} diff --git a/frontend/src/components/workflows/WorkflowNodeCatalogBrowser.tsx b/frontend/src/components/workflows/WorkflowNodeCatalogBrowser.tsx new file mode 100644 index 0000000..0860d5f --- /dev/null +++ b/frontend/src/components/workflows/WorkflowNodeCatalogBrowser.tsx @@ -0,0 +1,479 @@ +import { useEffect, useMemo, useState, type ReactNode } from 'react' +import { ArrowRight, Plus, Search } from 'lucide-react' + +import type { WorkflowNodeDefinition } from '../../api/workflows' +import { + CATEGORY_COLORS, + CATEGORY_LABELS, + FAMILY_FILTER_LABELS, + FAMILY_FILTER_STYLES, + NODE_KIND_FILTER_LABELS, + NODE_LIBRARY_GROUP_DESCRIPTIONS, + NODE_LIBRARY_GROUP_LABELS, + NODE_LIBRARY_GROUP_STYLES, + getDefinitionBadges, + getDefinitionFamily, + getDefinitionModuleNamespace, + type WorkflowGraphFamily, + type WorkflowNodeFamilyFilter, + type WorkflowNodeKindFilter, + type WorkflowNodeLibraryGroup, +} from './workflowNodeLibrary' +import { + buildWorkflowNodeCatalog, + filterWorkflowNodeDefinitions, + getAvailableFamilyFilters, +} from './workflowNodeCatalog' + +type WorkflowNodeCatalogBrowserProps = { + definitions: WorkflowNodeDefinition[] + graphFamily: WorkflowGraphFamily + variant?: 'menu' | 'panel' + onSelectStep?: (step: string) => void + onEmptyAction?: () => void + emptyActionLabel?: string + renderIcon?: (iconName?: string, size?: number) => ReactNode + searchPlaceholder?: string + autoFocusSearch?: boolean +} + +type WorkflowNodeCatalogModuleFilter = { + namespace: string + label: string + count: number +} + +function readContractList(contract: Record, key: string) { + const value = contract[key] + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0) : [] +} + +function readContractContext(contract: Record) { + return typeof contract.context === 'string' ? contract.context : null +} + +function formatContractLabel(value: string) { + return value + .split(/[_\s]+/) + .filter(Boolean) + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} + +export function WorkflowNodeCatalogBrowser({ + definitions, + graphFamily, + variant = 'panel', + onSelectStep, + onEmptyAction, + emptyActionLabel = 'Clear Filters', + renderIcon, + searchPlaceholder = 'Search node label, step, or capability', + autoFocusSearch = false, +}: WorkflowNodeCatalogBrowserProps) { + const [query, setQuery] = useState('') + const [familyFilter, setFamilyFilter] = useState( + graphFamily === 'mixed' ? 'all' : graphFamily, + ) + const [kindFilter, setKindFilter] = useState('all') + const [moduleFilter, setModuleFilter] = useState('all') + + useEffect(() => { + setFamilyFilter(graphFamily === 'mixed' ? 'all' : graphFamily) + setModuleFilter('all') + }, [graphFamily]) + + const availableFamilyFilters = useMemo( + () => getAvailableFamilyFilters(graphFamily), + [graphFamily], + ) + + const filteredDefinitions = useMemo(() => { + return filterWorkflowNodeDefinitions(definitions, { + graphFamily, + familyFilter, + kindFilter, + query, + }) + }, [definitions, familyFilter, graphFamily, kindFilter, query]) + + const moduleFilters = useMemo(() => { + const modules = new Map() + + for (const definition of filteredDefinitions) { + const namespace = getDefinitionModuleNamespace(definition) + const current = modules.get(namespace) + if (current) { + current.count += 1 + continue + } + modules.set(namespace, { + namespace, + label: definition.module_key, + count: 1, + }) + } + + return Array.from(modules.values()).sort((a, b) => a.label.localeCompare(b.label)) + }, [filteredDefinitions]) + + useEffect(() => { + if (moduleFilter !== 'all' && !moduleFilters.some(module => module.namespace === moduleFilter)) { + setModuleFilter('all') + } + }, [moduleFilter, moduleFilters]) + + const visibleDefinitions = useMemo(() => { + if (moduleFilter === 'all') return filteredDefinitions + return filteredDefinitions.filter(definition => getDefinitionModuleNamespace(definition) === moduleFilter) + }, [filteredDefinitions, moduleFilter]) + + const catalogSections = useMemo(() => buildWorkflowNodeCatalog(visibleDefinitions), [visibleDefinitions]) + const firstVisibleDefinition = visibleDefinitions[0] + const totalModuleCount = moduleFilters.length + + return ( +
+
+
+
+ + {visibleDefinitions.length} nodes + + + {totalModuleCount} modules + + {graphFamily !== 'mixed' && ( + + {FAMILY_FILTER_LABELS[graphFamily]} + + )} +
+ {moduleFilter !== 'all' && ( + + )} +
+ +
+ + setQuery(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter' && firstVisibleDefinition && onSelectStep) { + event.preventDefault() + onSelectStep(firstVisibleDefinition.step) + } + }} + placeholder={searchPlaceholder} + className="w-full rounded-xl border border-border-default bg-surface px-8 py-2 text-sm text-content focus:outline-none focus:ring-2 focus:ring-accent" + /> +
+ +
+
+ {(['all', 'legacy', 'bridge', 'graph'] as WorkflowNodeKindFilter[]).map(filter => ( + + ))} +
+ +
+ {availableFamilyFilters.map(filter => ( + + ))} +
+ + {moduleFilters.length > 0 && ( +
+
+

+ Modules +

+ + family + runtime scoped + +
+
+ + {moduleFilters.map(module => ( + + ))} +
+
+ )} +
+
+ +
+ {(['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[]).map(group => { + const count = catalogSections.find(section => section.group === group)?.definitions.length ?? 0 + if (count === 0) return null + return ( + + {NODE_KIND_FILTER_LABELS[group]} + {count} + + ) + })} +
+ + {visibleDefinitions.length === 0 && ( +
+

No matching nodes

+

+ Adjust search, runtime, family, or module filters to bring nodes back into view. +

+ {onEmptyAction && ( + + )} +
+ )} + +
+ {catalogSections.map(section => { + const group = section.group as WorkflowNodeLibraryGroup + return ( +
+
+ + {NODE_LIBRARY_GROUP_LABELS[group]} + + {section.definitions.length} +
+

{NODE_LIBRARY_GROUP_DESCRIPTIONS[group]}

+
+ {section.modules.map(moduleGroup => ( +
+
+
+ + {moduleGroup.label} + + + {moduleGroup.namespace} + + {moduleGroup.familyCounts.cad_file > 0 && ( + + {FAMILY_FILTER_LABELS.cad_file} + + )} + {moduleGroup.familyCounts.order_line > 0 && ( + + {FAMILY_FILTER_LABELS.order_line} + + )} +
+ {moduleGroup.definitions.length} +
+ +
+ {moduleGroup.categories.map(categorySection => { + const { category, definitions: categoryDefinitions } = categorySection + return ( +
+
+ + {CATEGORY_LABELS[category]} + + {categoryDefinitions.length} +
+ +
+ {categoryDefinitions.map(definition => { + const family = getDefinitionFamily(definition) + const requiredInputs = readContractList(definition.input_contract, 'requires') + const providedOutputs = readContractList(definition.output_contract, 'provides') + const inputContext = readContractContext(definition.input_contract) + const outputContext = readContractContext(definition.output_contract) + const isActionable = Boolean(onSelectStep) + + return ( +
+
+ {renderIcon && ( + + {renderIcon(definition.icon, variant === 'menu' ? 14 : 13)} + + )} +
+
+
+
+

{definition.label}

+ + {FAMILY_FILTER_LABELS[family]} + + {getDefinitionBadges(definition).map(badge => ( + + {badge.label} + + ))} +
+

{definition.step}

+
+ + {isActionable && ( + + )} +
+ +

{definition.description}

+ +
+ {inputContext && ( + + In {formatContractLabel(inputContext)} + + )} + {outputContext && ( + + Out {formatContractLabel(outputContext)} + + )} + {requiredInputs.slice(0, 2).map(input => ( + + Requires {formatContractLabel(input)} + + ))} + {providedOutputs.slice(0, 2).map(output => ( + + Provides {formatContractLabel(output)} + + ))} + {definition.artifact_roles_consumed.slice(0, 1).map(artifact => ( + + Consumes {formatContractLabel(artifact)} + + ))} + {definition.artifact_roles_produced.slice(0, 1).map(artifact => ( + + Produces {formatContractLabel(artifact)} + + ))} +
+
+
+
+ ) + })} +
+
+ ) + })} +
+
+ ))} +
+
+ ) + })} +
+
+ ) +} diff --git a/frontend/src/components/workflows/WorkflowNodeContractCard.tsx b/frontend/src/components/workflows/WorkflowNodeContractCard.tsx new file mode 100644 index 0000000..efdfaca --- /dev/null +++ b/frontend/src/components/workflows/WorkflowNodeContractCard.tsx @@ -0,0 +1,128 @@ +interface WorkflowNodeContractCardProps { + moduleLabel: string + moduleKey: string + familyLabel: string + familyClassName: string + runtimeLabel: string + runtimeClassName: string + legacyCompatible: boolean + legacySource?: string | null + inputContextLabel?: string | null + outputContextLabel?: string | null + requiredInputs: string[] + consumedArtifacts: string[] + providedOutputs: string[] + producedArtifacts: string[] +} + +function formatContractRole(role: string): string { + return role + .split('_') + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} + +function ContractRolePills({ + roles, +}: { + roles: string[] +}) { + if (roles.length === 0) return null + + return ( +
+ {roles.map(role => ( + + {formatContractRole(role)} + + ))} +
+ ) +} + +export function WorkflowNodeContractCard({ + moduleLabel, + moduleKey, + familyLabel, + familyClassName, + runtimeLabel, + runtimeClassName, + legacyCompatible, + legacySource, + inputContextLabel, + outputContextLabel, + requiredInputs, + consumedArtifacts, + providedOutputs, + producedArtifacts, +}: WorkflowNodeContractCardProps) { + return ( +
+
+
+

+ Production Module +

+

{moduleLabel}

+

{moduleKey}

+
+ + {familyLabel} + +
+ +
+ + {runtimeLabel} + + {legacyCompatible && ( + + Legacy Safe + + )} + {legacySource && ( + + {legacySource} + + )} +
+ +
+
+

Inputs

+ {inputContextLabel &&

Context: {inputContextLabel}

} + {requiredInputs.length > 0 ? ( + + ) : ( +

No declared upstream requirements.

+ )} + {consumedArtifacts.length > 0 && ( +
+

Artifacts Consumed

+ +
+ )} +
+ +
+

Outputs

+ {outputContextLabel &&

Context: {outputContextLabel}

} + {providedOutputs.length > 0 ? ( + + ) : ( +

No declared downstream outputs.

+ )} + {producedArtifacts.length > 0 && ( +
+

Artifacts Produced

+ +
+ )} +
+
+
+ ) +} diff --git a/frontend/src/components/workflows/WorkflowNodeInspector.tsx b/frontend/src/components/workflows/WorkflowNodeInspector.tsx new file mode 100644 index 0000000..78596ba --- /dev/null +++ b/frontend/src/components/workflows/WorkflowNodeInspector.tsx @@ -0,0 +1,245 @@ +import { useMemo, type ChangeEvent } from 'react' +import type { WorkflowNodeDefinition, WorkflowNodeFieldDefinition, WorkflowParams } from '../../api/workflows' +import { + FAMILY_FILTER_LABELS, + FAMILY_FILTER_STYLES, + getDefinitionFamily, + getDefinitionModuleLabel, + groupDefinitionsForStepSelect, + isDefinitionAllowedForGraphFamily, + type WorkflowGraphFamily, +} from './workflowNodeLibrary' +import { WorkflowNodeContractCard } from './WorkflowNodeContractCard' + +function groupFieldsBySection(fields: WorkflowNodeFieldDefinition[]) { + return fields.reduce>((sections, field) => { + const section = field.section || 'General' + sections[section] = [...(sections[section] ?? []), field] + return sections + }, {}) +} + +function getContractValues(contract: Record | undefined, key: string): string[] { + const value = contract?.[key] + if (!Array.isArray(value)) return [] + return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) +} + +function getContractContextLabel(contract: Record | undefined): string | null { + const value = contract?.context + if (value !== 'cad_file' && value !== 'order_line') return null + return value === 'cad_file' ? 'CAD File' : 'Order Line' +} + +type WorkflowNodeInspectorProps = { + params: WorkflowParams + onChange: (params: WorkflowParams) => void + nodeDefinition?: WorkflowNodeDefinition + step?: string + onStepChange?: (step: string) => void + nodeDefinitions: WorkflowNodeDefinition[] + graphFamily: WorkflowGraphFamily +} + +export function WorkflowNodeInspector({ + params, + onChange, + nodeDefinition, + step, + onStepChange, + nodeDefinitions, + graphFamily, +}: WorkflowNodeInspectorProps) { + const customRenderSettingsEnabled = Boolean(params.use_custom_render_settings) + const selectableNodeDefinitions = useMemo( + () => + nodeDefinitions.filter(definition => + isDefinitionAllowedForGraphFamily(definition, graphFamily), + ), + [graphFamily, nodeDefinitions], + ) + const nodeSelectionGroups = groupDefinitionsForStepSelect(selectableNodeDefinitions) + + const updateField = (field: WorkflowNodeFieldDefinition, value: unknown) => { + onChange({ + ...params, + [field.key]: value, + }) + } + + const handleNumberChange = (field: WorkflowNodeFieldDefinition, event: ChangeEvent) => { + const rawValue = event.target.value + if (rawValue === '') { + const nextParams = { ...params } + delete nextParams[field.key] + onChange(nextParams) + return + } + updateField(field, Number(rawValue)) + } + + const fieldsBySection = groupFieldsBySection(nodeDefinition?.fields ?? []) + const inputContextLabel = getContractContextLabel(nodeDefinition?.input_contract as Record | undefined) + const outputContextLabel = getContractContextLabel(nodeDefinition?.output_contract as Record | undefined) + const requiredInputs = getContractValues(nodeDefinition?.input_contract as Record | undefined, 'requires') + const providedOutputs = getContractValues(nodeDefinition?.output_contract as Record | undefined, 'provides') + const consumedArtifacts = nodeDefinition?.artifact_roles_consumed ?? [] + const producedArtifacts = nodeDefinition?.artifact_roles_produced ?? [] + + return ( +
+

Node Configuration

+ + {nodeDefinitions.length > 0 && onStepChange && ( +
+ + + {nodeDefinition && ( +
+

{nodeDefinition.description}

+ {graphFamily !== 'mixed' && ( +

+ Step selection is scoped to {FAMILY_FILTER_LABELS[graphFamily]} nodes for this workflow. +

+ )} + + {nodeDefinition.execution_kind === 'bridge' ? 'Legacy Bridge' : 'Native Node'} + +
+ )} +
+ )} + + {nodeDefinition && ( + + )} + + {Object.keys(fieldsBySection).length === 0 && ( +

+ This node currently has no configurable settings in the editor. +

+ )} + + {Object.entries(fieldsBySection).map(([section, fields]) => ( +
+

+ {section} +

+ {fields.map(field => { + const rawValue = params[field.key] + const value = rawValue ?? field.default + const disableRenderOverrideField = + (step === 'blender_still' || step === 'blender_turntable') && + !customRenderSettingsEnabled && + field.key !== 'use_custom_render_settings' && + (field.section === 'Render' || field.section === 'Output') + + return ( +
+ + {field.type === 'select' && ( + + )} + {field.type === 'number' && ( + handleNumberChange(field, event)} + disabled={disableRenderOverrideField} + className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" + /> + )} + {field.type === 'boolean' && ( + + )} + {field.type === 'text' && ( + updateField(field, event.target.value)} + disabled={disableRenderOverrideField} + className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" + /> + )} + {field.description && ( +

{field.description}

+ )} + {disableRenderOverrideField && ( +

+ In Graph/Shadow mode this field inherits from Output Type and Template until + Custom Render Settings is enabled. +

+ )} +
+ ) + })} +
+ ))} +
+ ) +} diff --git a/frontend/src/components/workflows/WorkflowPreflightPanel.tsx b/frontend/src/components/workflows/WorkflowPreflightPanel.tsx new file mode 100644 index 0000000..71a7d59 --- /dev/null +++ b/frontend/src/components/workflows/WorkflowPreflightPanel.tsx @@ -0,0 +1,96 @@ +import { Loader2 } from 'lucide-react' + +import type { WorkflowPreflightResponse } from '../../api/workflows' +import { getPreflightStatusClassName } from './workflowRunPresentation' + +interface WorkflowPreflightPanelProps { + preflight: WorkflowPreflightResponse | null + isLoading: boolean +} + +export function WorkflowPreflightPanel({ + preflight, + isLoading, +}: WorkflowPreflightPanelProps) { + if (!preflight && !isLoading) { + return null + } + + return ( +
+
+

Graph Preflight

+ {isLoading && } +
+ + {preflight && ( +
+
+
+

{preflight.summary}

+

+ Expected `{preflight.expected_context_kind}` · Resolved `{preflight.context_kind ?? 'n/a'}` +

+
+ + {preflight.graph_dispatch_allowed ? 'ready' : 'blocked'} + +
+ + {(preflight.resolved_order_line_id || preflight.resolved_cad_file_id) && ( +
+ {preflight.resolved_order_line_id &&

Order Line: {preflight.resolved_order_line_id}

} + {preflight.resolved_cad_file_id &&

CAD File: {preflight.resolved_cad_file_id}

} +
+ )} + + {preflight.issues.length > 0 && ( +
+

+ Global Issues +

+ {preflight.issues.map(issue => ( +
+
+ {issue.message} + + {issue.severity} + +
+
+ ))} +
+ )} + +
+

+ Node Checks +

+ {preflight.nodes.map(node => ( +
+
+
+

{node.label ?? node.node_id}

+

{node.step}

+
+ + {node.status} + +
+ {node.issues.length > 0 && ( +
+ {node.issues.map(issue => ( +

+ {issue.message} +

+ ))} +
+ )} +
+ ))} +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/workflows/WorkflowRunsPanel.tsx b/frontend/src/components/workflows/WorkflowRunsPanel.tsx new file mode 100644 index 0000000..294919d --- /dev/null +++ b/frontend/src/components/workflows/WorkflowRunsPanel.tsx @@ -0,0 +1,137 @@ +import { Loader2 } from 'lucide-react' + +import type { WorkflowRun, WorkflowRunComparison } from '../../api/workflows' +import { + EXECUTION_MODE_BADGE_STYLES, + EXECUTION_MODE_LABELS, + formatDateTime, + getRunStatusClassName, +} from './workflowRunPresentation' + +interface WorkflowRunsPanelProps { + runs: WorkflowRun[] + selectedRunId: string | null + onSelectRun: (runId: string) => void + comparison?: WorkflowRunComparison + isComparisonLoading: boolean +} + +export function WorkflowRunsPanel({ + runs, + selectedRunId, + onSelectRun, + comparison, + isComparisonLoading, +}: WorkflowRunsPanelProps) { + const selectedRun = runs.find(run => run.id === selectedRunId) ?? null + + return ( +
+
+

Workflow Runs

+ {runs.length} +
+ + {runs.length === 0 && ( +

+ No workflow runs recorded for this workflow yet. +

+ )} + + {runs.length > 0 && ( +
+ {runs.slice(0, 8).map(run => ( + + ))} +
+ )} + + {selectedRun && ( +
+
+
+

Run {selectedRun.id.slice(0, 8)}

+

+ Started {formatDateTime(selectedRun.started_at ?? selectedRun.created_at)} +

+
+ + {selectedRun.status} + +
+ + {selectedRun.error_message && ( +

{selectedRun.error_message}

+ )} + +
+

+ Node Results +

+ {selectedRun.node_results.map(result => ( +
+
+ {result.node_name} + + {result.status} + +
+ {result.log && ( +

{result.log}

+ )} +
+ ))} +
+ + {selectedRun.execution_mode === 'shadow' && ( +
+
+

+ Shadow Comparison +

+ {isComparisonLoading && } +
+ {comparison && ( +
+

{comparison.summary}

+

Status: {comparison.status}

+

+ Authoritative: {comparison.authoritative_output.image_width ?? '?'} x {comparison.authoritative_output.image_height ?? '?'} +

+

+ Observer: {comparison.observer_output.image_width ?? '?'} x {comparison.observer_output.image_height ?? '?'} +

+ {comparison.mean_pixel_delta != null && ( +

Mean Pixel Delta: {comparison.mean_pixel_delta.toFixed(6)}

+ )} +
+ )} +
+ )} +
+ )} +
+ ) +} diff --git a/frontend/src/components/workflows/WorkflowUtilityRail.tsx b/frontend/src/components/workflows/WorkflowUtilityRail.tsx new file mode 100644 index 0000000..234d5c3 --- /dev/null +++ b/frontend/src/components/workflows/WorkflowUtilityRail.tsx @@ -0,0 +1,73 @@ +import type { ReactNode } from 'react' +import { PanelRight, type LucideIcon } from 'lucide-react' + +interface WorkflowUtilityTabItem { + key: T + label: string + icon: LucideIcon + count?: number | null + disabled?: boolean +} + +interface WorkflowUtilityRailProps { + tabs: WorkflowUtilityTabItem[] + activeTab: T + onTabChange: (tab: T) => void + children: ReactNode +} + +export function WorkflowUtilityRail({ + tabs, + activeTab, + onTabChange, + children, +}: WorkflowUtilityRailProps) { + return ( +
+
+
+
+ +
+
+

Utility Rail

+

+ Context-aware tools without sacrificing canvas space. +

+
+
+
+ {tabs.map(tab => { + const Icon = tab.icon + const isActive = activeTab === tab.key + return ( + + ) + })} +
+
+ +
{children}
+
+ ) +} diff --git a/frontend/src/components/workflows/WorkflowValidationBanner.tsx b/frontend/src/components/workflows/WorkflowValidationBanner.tsx new file mode 100644 index 0000000..80756e1 --- /dev/null +++ b/frontend/src/components/workflows/WorkflowValidationBanner.tsx @@ -0,0 +1,45 @@ +import { AlertTriangle } from 'lucide-react' + +interface WorkflowValidationBannerProps { + errors: string[] + warnings: string[] +} + +export function WorkflowValidationBanner({ + errors, + warnings, +}: WorkflowValidationBannerProps) { + if (errors.length === 0 && warnings.length === 0) { + return null + } + + return ( +
+ {errors.length > 0 && ( +
+
+ + {errors.length} validation error{errors.length === 1 ? '' : 's'} +
+
    + {errors.map(error => ( +
  • {error}
  • + ))} +
+
+ )} + {warnings.length > 0 && ( +
+
+ {warnings.length} warning{warnings.length === 1 ? '' : 's'} +
+
    + {warnings.map(warning => ( +
  • {warning}
  • + ))} +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/workflows/useWorkflowCanvasController.ts b/frontend/src/components/workflows/useWorkflowCanvasController.ts new file mode 100644 index 0000000..aaeb82d --- /dev/null +++ b/frontend/src/components/workflows/useWorkflowCanvasController.ts @@ -0,0 +1,471 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react' +import { addEdge, useEdgesState, useNodesState, type Connection, type Edge, type Node, type ReactFlowInstance } from '@xyflow/react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { + dispatchWorkflowDraft, + getNodeDefinitions, + getWorkflowRunComparison, + getWorkflowRuns, + preflightWorkflowDraft, + type WorkflowConfig, + type WorkflowDefinition, + type WorkflowExecutionMode, + type WorkflowNodeDefinition, + type WorkflowParams, + type WorkflowPreflightResponse, +} from '../../api/workflows' +import { + applyAutoLayout, + buildCurrentWorkflowConfig, + inferNodeLabel, + inferNodeType, + inferStepFromNodeType, + normalizeWorkflowParams, + resolveParamsForStepChange, + type WorkflowCanvasNodeData, + validateWorkflowDraft, + workflowToGraph, +} from './workflowGraphDraft' +import { + inferWorkflowFamily, +} from './workflowBlueprints' +import { + GRAPH_FAMILY_LABELS, + isDefinitionAllowedForGraphFamily, +} from './workflowNodeLibrary' +import type { WorkflowUtilityTab } from './WorkflowCanvasUtilitySidebar' + +export type NodeMenuAnchor = { + clientX: number + clientY: number + flowPosition: { x: number; y: number } +} + +function buildNodeData( + step: string, + params: WorkflowParams = {}, + definition?: WorkflowNodeDefinition, + overrides?: Partial, +): WorkflowCanvasNodeData { + return { + label: overrides?.label ?? definition?.label ?? inferNodeLabel(step), + params: normalizeWorkflowParams(params), + step, + description: overrides?.description ?? definition?.description, + icon: overrides?.icon ?? definition?.icon, + category: overrides?.category ?? definition?.category, + } +} + +type UseWorkflowCanvasControllerArgs = { + workflow: WorkflowDefinition + onSave: (config: WorkflowConfig) => void +} + +export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCanvasControllerArgs) { + const queryClient = useQueryClient() + const { data: nodeDefinitionsData } = useQuery({ + queryKey: ['workflow-node-definitions'], + queryFn: getNodeDefinitions, + staleTime: 5 * 60 * 1000, + }) + const nodeDefinitions = nodeDefinitionsData?.definitions ?? [] + const nodeDefinitionsByStep = Object.fromEntries(nodeDefinitions.map(definition => [definition.step, definition])) + + const { nodes: initNodes, edges: initEdges } = workflowToGraph(workflow.config, nodeDefinitionsByStep) + const [nodes, setNodes, onNodesChange] = useNodesState(initNodes) + const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges) + const [selectedNodeId, setSelectedNodeId] = useState(null) + const [selectedRunId, setSelectedRunId] = useState(null) + const [dispatchContextId, setDispatchContextId] = useState('') + const [preflightResult, setPreflightResult] = useState(null) + const [executionMode, setExecutionMode] = useState(workflow.config.ui?.execution_mode ?? 'legacy') + const [nodeMenuAnchor, setNodeMenuAnchor] = useState(null) + const [activeUtilityTab, setActiveUtilityTab] = useState('library') + const reactFlowWrapper = useRef(null) + const [reactFlowInstance, setReactFlowInstance] = useState | null>(null) + + const validation = validateWorkflowDraft(nodes, edges, nodeDefinitionsByStep, nodeDefinitions.length > 0) + const selectedEdgeIds = useMemo( + () => edges.filter(edge => Boolean((edge as Edge & { selected?: boolean }).selected)).map(edge => edge.id), + [edges], + ) + const graphFamily = useMemo( + () => + inferWorkflowFamily( + buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode), + nodeDefinitionsByStep, + ), + [edges, executionMode, nodeDefinitionsByStep, nodes, workflow], + ) + + const { data: workflowRuns = [] } = useQuery({ + queryKey: ['workflow-runs', workflow.id], + queryFn: () => getWorkflowRuns(workflow.id), + refetchInterval: 5000, + }) + + const selectedRun = workflowRuns.find(run => run.id === selectedRunId) ?? workflowRuns[0] ?? null + + const { data: selectedRunComparison, isFetching: isComparisonLoading } = useQuery({ + queryKey: ['workflow-run-comparison', selectedRun?.id], + queryFn: () => getWorkflowRunComparison(selectedRun!.id), + enabled: Boolean(selectedRun?.id && selectedRun.execution_mode === 'shadow'), + refetchInterval: selectedRun?.status === 'pending' || selectedRun?.status === 'running' ? 5000 : false, + }) + + const dispatchMutation = useMutation({ + mutationFn: ({ contextId, config }: { contextId: string; config: WorkflowConfig }) => + dispatchWorkflowDraft({ + workflow_id: workflow.id, + context_id: contextId, + config, + }), + onSuccess: result => { + queryClient.invalidateQueries({ queryKey: ['workflow-runs', workflow.id] }) + setSelectedRunId(result.workflow_run.id) + toast.success(`Graph run dispatched: ${result.dispatched} task${result.dispatched === 1 ? '' : 's'}`) + }, + onError: (error: any) => { + toast.error(error?.response?.data?.detail || 'Failed to dispatch workflow') + }, + }) + + const preflightMutation = useMutation({ + mutationFn: ({ contextId, config }: { contextId: string; config: WorkflowConfig }) => + preflightWorkflowDraft({ + workflow_id: workflow.id, + context_id: contextId, + config, + }), + onSuccess: result => { + setPreflightResult(result) + if (result.graph_dispatch_allowed) { + toast.success(result.summary) + } else { + toast.error(result.summary) + } + }, + onError: (error: any) => { + setPreflightResult(null) + toast.error(error?.response?.data?.detail || 'Failed to preflight workflow') + }, + }) + + useEffect(() => { + const graph = workflowToGraph(workflow.config, nodeDefinitionsByStep) + setNodes(graph.nodes) + setEdges(graph.edges) + setSelectedNodeId(null) + setSelectedRunId(null) + setNodeMenuAnchor(null) + setPreflightResult(null) + setExecutionMode(workflow.config.ui?.execution_mode ?? 'legacy') + setActiveUtilityTab('library') + }, [nodeDefinitionsData, setEdges, setNodes, workflow.config]) + + useEffect(() => { + if (!selectedRunId && workflowRuns.length > 0) { + setSelectedRunId(workflowRuns[0].id) + return + } + if (selectedRunId && !workflowRuns.some(run => run.id === selectedRunId)) { + setSelectedRunId(workflowRuns[0]?.id ?? null) + } + }, [selectedRunId, workflowRuns]) + + const onConnect = useCallback( + (connection: Connection) => setEdges(currentEdges => addEdge(connection, currentEdges)), + [setEdges], + ) + + const onNodeClick = useCallback((_: ReactMouseEvent, node: Node) => { + setNodeMenuAnchor(null) + setSelectedNodeId(node.id) + setActiveUtilityTab('inspector') + }, []) + + const onEdgeClick = useCallback((_: ReactMouseEvent, edge: Edge) => { + setNodeMenuAnchor(null) + setSelectedNodeId(null) + setEdges(currentEdges => + currentEdges.map(currentEdge => ({ + ...currentEdge, + selected: currentEdge.id === edge.id, + })), + ) + }, [setEdges]) + + const onPaneClick = useCallback(() => { + setNodeMenuAnchor(null) + setSelectedNodeId(null) + setEdges(currentEdges => + currentEdges.map(edge => ({ + ...edge, + selected: false, + })), + ) + }, [setEdges]) + + const handleParamsChange = useCallback( + (newParams: WorkflowParams) => { + setNodes(currentNodes => + currentNodes.map(node => { + if (node.id === selectedNodeId) { + return { ...node, data: { ...node.data, params: normalizeWorkflowParams(newParams) } } + } + return node + }), + ) + }, + [selectedNodeId, setNodes], + ) + + const handlePipelineStepChange = useCallback( + (stepName: string) => { + const definition = nodeDefinitionsByStep[stepName] + if (definition && !isDefinitionAllowedForGraphFamily(definition, graphFamily)) { + toast.error(`${definition.label} does not belong to the ${GRAPH_FAMILY_LABELS[graphFamily]} family.`) + return + } + + setNodes(currentNodes => + currentNodes.map(node => { + if (node.id !== selectedNodeId) return node + + const currentData = (node.data as WorkflowCanvasNodeData | undefined) ?? buildNodeData(stepName) + return { + ...node, + type: definition?.node_type ?? inferNodeType(stepName), + data: { + ...buildNodeData( + stepName || inferStepFromNodeType(node.type), + resolveParamsForStepChange(definition, currentData.params), + definition, + ), + step: stepName || inferStepFromNodeType(node.type), + }, + } + }), + ) + }, + [graphFamily, nodeDefinitionsByStep, selectedNodeId, setNodes], + ) + + const openNodeMenu = useCallback( + (clientX: number, clientY: number) => { + if (!reactFlowInstance) return + setNodeMenuAnchor({ + clientX, + clientY, + flowPosition: reactFlowInstance.screenToFlowPosition({ x: clientX, y: clientY }), + }) + }, + [reactFlowInstance], + ) + + const handlePaneContextMenu = useCallback( + (event: MouseEvent | ReactMouseEvent) => { + event.preventDefault() + setSelectedNodeId(null) + openNodeMenu(event.clientX, event.clientY) + }, + [openNodeMenu], + ) + + const handleNodeContextMenu = useCallback( + (event: ReactMouseEvent, node: Node) => { + event.preventDefault() + setSelectedNodeId(node.id) + openNodeMenu(event.clientX, event.clientY) + }, + [openNodeMenu], + ) + + const insertNode = useCallback( + (step: string, preferredPosition?: { x: number; y: number }) => { + const definition = nodeDefinitionsByStep[step] + if (definition && !isDefinitionAllowedForGraphFamily(definition, graphFamily)) { + toast.error(`${definition.label} cannot be added to a ${GRAPH_FAMILY_LABELS[graphFamily]} workflow.`) + return + } + + const type = definition?.node_type ?? inferNodeType(step) + const fallbackX = nodes.length > 0 ? Math.max(...nodes.map(node => node.position.x)) + 220 : 120 + const fallbackY = nodes.length > 0 ? Math.max(...nodes.map(node => node.position.y)) + 40 : 120 + const newNode: Node = { + id: `${step}_${Date.now()}`, + type, + position: preferredPosition ?? { x: fallbackX, y: fallbackY }, + data: buildNodeData(step, definition?.defaults ?? {}, definition), + } + + setNodes(currentNodes => [...currentNodes, newNode]) + setSelectedNodeId(newNode.id) + setNodeMenuAnchor(null) + setActiveUtilityTab('inspector') + }, + [graphFamily, nodeDefinitionsByStep, nodes, setNodes], + ) + + const handleOpenToolbarNodeMenu = useCallback(() => { + if (!reactFlowWrapper.current || !reactFlowInstance) return + const bounds = reactFlowWrapper.current.getBoundingClientRect() + openNodeMenu(bounds.left + 36, bounds.top + 36) + }, [openNodeMenu, reactFlowInstance]) + + const handleAutoLayout = useCallback(() => { + setNodes(currentNodes => applyAutoLayout(currentNodes, edges)) + setNodeMenuAnchor(null) + window.requestAnimationFrame(() => { + reactFlowInstance?.fitView({ padding: 0.2, duration: 250 }) + }) + }, [edges, reactFlowInstance, setNodes]) + + const deleteEdgesById = useCallback((edgeIds: string[]) => { + if (edgeIds.length === 0) return + setEdges(currentEdges => currentEdges.filter(edge => !edgeIds.includes(edge.id))) + setSelectedNodeId(null) + setNodeMenuAnchor(null) + toast.success(edgeIds.length === 1 ? 'Connection deleted' : `${edgeIds.length} connections deleted`) + }, [setEdges]) + + const handleDeleteSelectedEdges = useCallback(() => { + deleteEdgesById(selectedEdgeIds) + }, [deleteEdgesById, selectedEdgeIds]) + + const onEdgeContextMenu = useCallback((event: ReactMouseEvent, edge: Edge) => { + event.preventDefault() + event.stopPropagation() + deleteEdgesById([edge.id]) + }, [deleteEdgesById]) + + const onEdgeDoubleClick = useCallback((event: ReactMouseEvent, edge: Edge) => { + event.preventDefault() + event.stopPropagation() + deleteEdgesById([edge.id]) + }, [deleteEdgesById]) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null + const isEditingField = + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + Boolean(target?.closest('[contenteditable="true"]')) + + if (isEditingField) return + if ((event.key === 'Delete' || event.key === 'Backspace') && selectedEdgeIds.length > 0) { + event.preventDefault() + deleteEdgesById(selectedEdgeIds) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [deleteEdgesById, selectedEdgeIds]) + + useEffect(() => { + if (selectedNodeId) { + setActiveUtilityTab('inspector') + return + } + if (activeUtilityTab === 'inspector') { + setActiveUtilityTab('library') + } + }, [activeUtilityTab, selectedNodeId]) + + const handleSave = useCallback(() => { + if (validation.errors.length > 0) { + toast.error('Resolve workflow validation errors before saving.') + return + } + onSave(buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode)) + }, [edges, executionMode, nodes, onSave, validation.errors.length, workflow]) + + const handleDispatch = useCallback(() => { + if (!dispatchContextId.trim()) { + toast.error('Context ID is required for a graph test run.') + return + } + if (validation.errors.length > 0) { + toast.error('Resolve workflow validation errors before dispatching.') + return + } + dispatchMutation.mutate({ + contextId: dispatchContextId.trim(), + config: buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode), + }) + }, [dispatchContextId, dispatchMutation, edges, executionMode, nodes, validation.errors.length, workflow]) + + const handlePreflight = useCallback(() => { + if (!dispatchContextId.trim()) { + toast.error('Context ID is required for a graph preflight.') + return + } + if (validation.errors.length > 0) { + toast.error('Resolve workflow validation errors before running preflight.') + return + } + preflightMutation.mutate({ + contextId: dispatchContextId.trim(), + config: buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode), + }) + }, [dispatchContextId, edges, executionMode, nodes, preflightMutation, validation.errors.length, workflow]) + + const selectedNode = useMemo( + () => nodes.find(node => node.id === selectedNodeId), + [nodes, selectedNodeId], + ) + + return { + reactFlowWrapper, + nodeDefinitions, + nodeDefinitionsByStep, + nodes, + edges, + onNodesChange, + onEdgesChange, + selectedEdgeIds, + selectedNode, + workflowRuns, + selectedRun, + selectedRunComparison, + isComparisonLoading, + dispatchMutation, + preflightMutation, + dispatchContextId, + setDispatchContextId, + preflightResult, + executionMode, + setExecutionMode, + nodeMenuAnchor, + setNodeMenuAnchor, + activeUtilityTab, + setActiveUtilityTab, + validation, + graphFamily, + onConnect, + onNodeClick, + onEdgeClick, + onPaneClick, + handleParamsChange, + handlePipelineStepChange, + handlePaneContextMenu, + handleNodeContextMenu, + insertNode, + handleOpenToolbarNodeMenu, + handleAutoLayout, + handleDeleteSelectedEdges, + onEdgeContextMenu, + onEdgeDoubleClick, + handleSave, + handleDispatch, + handlePreflight, + setReactFlowInstance, + setSelectedRunId, + } +} diff --git a/frontend/src/components/workflows/workflowBlueprints.ts b/frontend/src/components/workflows/workflowBlueprints.ts new file mode 100644 index 0000000..395354c --- /dev/null +++ b/frontend/src/components/workflows/workflowBlueprints.ts @@ -0,0 +1,78 @@ +import type { WorkflowConfig, WorkflowDefinition } from '../../api/workflows' +import type { WorkflowGraphFamily, WorkflowNodeDefinitionMap } from './workflowNodeLibrary' +import { getNodeFamily } from './workflowNodeLibrary' + +export const BLUEPRINT_LABELS: Record = { + cad_intake: 'Reference Blueprint', + order_rendering: 'Reference Blueprint', + still_graph_reference: 'Graph Reference', + starter_cad_intake: 'Starter', + starter_order_rendering: 'Starter', +} + +export const BLUEPRINT_DESCRIPTION: Record = { + cad_intake: 'Canonical CAD-file workflow for intake, preview generation, and material discovery.', + order_rendering: 'Canonical order-line workflow for production rendering, exports, and notifications.', + still_graph_reference: 'Reference still-render graph that stays parallel to the legacy workflow while native nodes reach parity.', + starter_cad_intake: 'Minimal CAD-file starter graph.', + starter_order_rendering: 'Minimal order-line starter graph.', +} + +export function inferWorkflowFamily( + config: WorkflowConfig, + nodeDefinitionsByStep?: WorkflowNodeDefinitionMap, +): WorkflowGraphFamily { + const nodes = Array.isArray(config.nodes) ? config.nodes : [] + if (nodes.length > 0) { + const families = new Set(nodes.map(node => getNodeFamily(node.step, nodeDefinitionsByStep))) + if (families.size === 1) { + return Array.from(families)[0] + } + return 'mixed' + } + + const presetType = config.ui?.preset ?? 'custom' + if (presetType === 'custom') { + return 'mixed' + } + + return 'order_line' +} + +export function getWorkflowBlueprint(config: WorkflowConfig): string | null { + const blueprint = config.ui?.blueprint + return typeof blueprint === 'string' && blueprint.trim().length > 0 ? blueprint : null +} + +export function isReferenceBlueprint(config: WorkflowConfig): boolean { + const blueprint = getWorkflowBlueprint(config) + return blueprint === 'cad_intake' || blueprint === 'order_rendering' || blueprint === 'still_graph_reference' +} + +export function cloneWorkflowConfig(config: WorkflowConfig, options?: { stripBlueprint?: boolean }): WorkflowConfig { + const nextUi = { ...(config.ui ?? {}) } + if (options?.stripBlueprint) { + delete nextUi.blueprint + } + + return { + version: config.version, + nodes: config.nodes.map(node => ({ + ...node, + params: { ...(node.params ?? {}) }, + ui: node.ui ? { ...node.ui } : undefined, + })), + edges: config.edges.map(edge => ({ ...edge })), + ui: nextUi, + } +} + +export function compareWorkflows(a: WorkflowDefinition, b: WorkflowDefinition): number { + const blueprintRank = Number(isReferenceBlueprint(b.config)) - Number(isReferenceBlueprint(a.config)) + if (blueprintRank !== 0) return blueprintRank + + const activeRank = Number(b.is_active) - Number(a.is_active) + if (activeRank !== 0) return activeRank + + return a.name.localeCompare(b.name) +} diff --git a/frontend/src/components/workflows/workflowGraphDraft.ts b/frontend/src/components/workflows/workflowGraphDraft.ts new file mode 100644 index 0000000..980c275 --- /dev/null +++ b/frontend/src/components/workflows/workflowGraphDraft.ts @@ -0,0 +1,581 @@ +import type { Edge, Node } from '@xyflow/react' +import type { + StepCategory, + WorkflowConfig, + WorkflowDefinition, + WorkflowEdge, + WorkflowExecutionMode, + WorkflowNodeDefinition, + WorkflowParams, +} from '../../api/workflows' +import { getNodeFamily, type WorkflowNodeDefinitionMap } from './workflowNodeLibrary' + +export type WorkflowCanvasNodeData = { + label: string + params: WorkflowParams + step: string + description?: string + icon?: string + category?: StepCategory +} + +export type WorkflowValidationResult = { + errors: string[] + warnings: string[] +} + +type WorkflowNodeContractContext = 'cad_file' | 'order_line' +type WorkflowSemanticState = { + availableValues: Set + executedSteps: Set +} + +const CAD_FILE_ENTRY_STEPS = new Set([ + 'resolve_step_path', + 'occ_object_extract', + 'occ_glb_export', + 'stl_cache_generate', + 'blender_render', + 'threejs_render', + 'thumbnail_save', +]) + +const ORDER_LINE_SETUP_REQUIRED_STEPS = new Set([ + 'resolve_template', + 'material_map_resolve', + 'auto_populate_materials', + 'glb_bbox', + 'blender_still', + 'blender_turntable', + 'output_save', + 'export_blend', + 'notify', +]) + +const RESOLVE_TEMPLATE_RECOMMENDED_STEPS = new Set([ + 'blender_still', + 'blender_turntable', + 'export_blend', + 'notify', +]) + +const ROOT_CONTEXT_VALUES: Record = { + cad_file: ['cad_file', 'cad_file_record'], + order_line: ['order_line', 'order_line_record'], +} + +const ORDER_LINE_SETUP_COMPATIBILITY_VALUES = ['cad_materials', 'glb_preview', 'bbox'] + +const OUTPUT_SAVE_ALTERNATIVE_INPUTS = ['rendered_image', 'rendered_frames', 'rendered_video'] + +function getContractContext( + contract: Record | undefined, +): WorkflowNodeContractContext | null { + const value = contract?.context + return value === 'cad_file' || value === 'order_line' ? value : null +} + +function getContractValues( + contract: Record | undefined, + key: string, +): string[] { + const value = contract?.[key] + if (!Array.isArray(value)) return [] + return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) +} + +function getContractAlternativeValues( + contract: Record | undefined, + key: string, +): string[][] { + const value = contract?.[key] + if (!Array.isArray(value)) return [] + + if (value.every(entry => typeof entry === 'string' && entry.trim().length > 0)) { + return [value as string[]] + } + + return value + .filter((entry): entry is string[] => Array.isArray(entry)) + .map(group => group.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)) + .filter(group => group.length > 0) +} + +function formatContractValue(value: string): string { + return value + .split('_') + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} + +function inferWorkflowContextKind( + nodes: Node[], + nodeDefinitionsByStep: WorkflowNodeDefinitionMap, + definitionsLoaded: boolean, +): WorkflowNodeContractContext | null { + const families = new Set() + + for (const node of nodes) { + const step = ((node.data as WorkflowCanvasNodeData | undefined)?.step as string | undefined) ?? inferStepFromNodeType(node.type) + const definition = nodeDefinitionsByStep[step] + if (definition) { + families.add(definition.family) + continue + } + + if (!definitionsLoaded) { + families.add(getNodeFamily(step, nodeDefinitionsByStep)) + } + } + + if (families.size !== 1) return null + return Array.from(families)[0] +} + +function getFieldKeys(definition: WorkflowNodeDefinition | undefined): Set { + return new Set((definition?.fields ?? []).map(field => field.key)) +} + +function isFieldValueEmpty(value: unknown): boolean { + return value === undefined || value === null || value === '' +} + +export function resolveParamsForStepChange( + definition: WorkflowNodeDefinition | undefined, + currentParams: WorkflowParams, +): WorkflowParams { + if (!definition) return normalizeWorkflowParams(currentParams) + + const allowedKeys = new Set([ + ...Object.keys(definition.defaults ?? {}), + ...Array.from(getFieldKeys(definition)), + ]) + const nextParams: WorkflowParams = { ...(definition.defaults ?? {}) } + + for (const key of allowedKeys) { + if (Object.prototype.hasOwnProperty.call(currentParams, key) && !isFieldValueEmpty(currentParams[key])) { + nextParams[key] = currentParams[key] + } + } + + return normalizeWorkflowParams(nextParams) +} + +function getNodeStep(node: Node): string { + return ((node.data as WorkflowCanvasNodeData | undefined)?.step as string | undefined) ?? inferStepFromNodeType(node.type) +} + +function getNodeLabel(node: Node): string { + return ((node.data as WorkflowCanvasNodeData | undefined)?.label as string | undefined) ?? node.id +} + +export function normalizeWorkflowParams(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 +} + +export function inferNodeType(step: string): string { + if (step === 'resolve_step_path') return 'inputNode' + if (step === 'stl_cache_generate') return 'convertNode' + if (step === 'blender_turntable') return 'renderFramesNode' + if (step === 'output_save' || step === 'export_blend' || step === 'notify' || step === 'thumbnail_save') return 'outputNode' + if (step.startsWith('blender_') || step === 'threejs_render') return 'renderNode' + if (step.startsWith('occ_') || step === 'glb_bbox' || step === 'material_map_resolve' || step === 'auto_populate_materials') { + return 'processNode' + } + return 'renderNode' +} + +export function inferNodeLabel(step: string): string { + return step + .split('_') + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} + +export function inferStepFromNodeType(type?: string): string { + if (type === 'inputNode') return 'resolve_step_path' + if (type === 'convertNode') return 'stl_cache_generate' + if (type === 'processNode') return 'order_line_setup' + if (type === 'renderFramesNode') return 'blender_turntable' + if (type === 'outputNode') return 'output_save' + return 'blender_still' +} + +export function validateWorkflowDraft( + nodes: Node[], + edges: Edge[], + nodeDefinitionsByStep: Record, + definitionsLoaded: boolean, +): WorkflowValidationResult { + const errors: string[] = [] + const warnings: string[] = [] + + if (nodes.length === 0) { + errors.push('Workflow must contain at least one node.') + return { errors, warnings } + } + + const nodeIds = new Set() + const connectedNodeIds = new Set() + const inDegree = new Map() + const adjacency = new Map() + + for (const node of nodes) { + const step = getNodeStep(node) + const label = getNodeLabel(node) + + if (nodeIds.has(node.id)) { + errors.push(`Duplicate node id "${node.id}" is not allowed.`) + } + nodeIds.add(node.id) + inDegree.set(node.id, 0) + adjacency.set(node.id, []) + + if (definitionsLoaded && !nodeDefinitionsByStep[step]) { + errors.push(`Node "${label}" uses unknown step "${step}".`) + } + } + + const edgePairs = new Set() + for (const edge of edges) { + if (!nodeIds.has(edge.source)) { + errors.push(`Edge references unknown source node "${edge.source}".`) + continue + } + if (!nodeIds.has(edge.target)) { + errors.push(`Edge references unknown target node "${edge.target}".`) + continue + } + if (edge.source === edge.target) { + errors.push(`Node "${edge.source}" cannot point to itself.`) + continue + } + + const pair = `${edge.source}->${edge.target}` + if (edgePairs.has(pair)) { + errors.push(`Duplicate edge "${edge.source}" -> "${edge.target}" is not allowed.`) + continue + } + edgePairs.add(pair) + connectedNodeIds.add(edge.source) + connectedNodeIds.add(edge.target) + adjacency.set(edge.source, [...(adjacency.get(edge.source) ?? []), edge.target]) + inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1) + } + + const queue = Array.from(inDegree.entries()) + .filter(([, degree]) => degree === 0) + .map(([nodeId]) => nodeId) + let processed = 0 + const topologicalNodeIds: string[] = [] + + while (queue.length > 0) { + const nodeId = queue.shift()! + processed += 1 + topologicalNodeIds.push(nodeId) + for (const neighbor of adjacency.get(nodeId) ?? []) { + const nextDegree = (inDegree.get(neighbor) ?? 0) - 1 + inDegree.set(neighbor, nextDegree) + if (nextDegree === 0) { + queue.push(neighbor) + } + } + } + + if (processed !== nodes.length) { + errors.push('Workflow graph contains a cycle.') + return { errors, warnings } + } + + if (nodes.length > 1) { + for (const node of nodes) { + const label = getNodeLabel(node) + if (!connectedNodeIds.has(node.id)) { + warnings.push(`Node "${label}" is disconnected.`) + } + } + } + + const workflowContextKind = inferWorkflowContextKind(nodes, nodeDefinitionsByStep, definitionsLoaded) + if (definitionsLoaded && workflowContextKind == null) { + errors.push('Workflow mixes CAD-file and order-line nodes. Split them into separate workflows.') + } + + const semanticStateByNodeId = new Map() + + for (const nodeId of topologicalNodeIds) { + const node = nodes.find(candidate => candidate.id === nodeId) + if (!node) continue + + const step = getNodeStep(node) + const label = getNodeLabel(node) + const definition = nodeDefinitionsByStep[step] + const inputContext = getContractContext(definition?.input_contract as Record | undefined) + const outputContext = getContractContext(definition?.output_contract as Record | undefined) + const upstreamNodeIds = edges.filter(edge => edge.target === nodeId).map(edge => edge.source) + const availableValues = new Set() + const executedSteps = new Set() + + if (workflowContextKind) { + for (const value of ROOT_CONTEXT_VALUES[workflowContextKind]) { + availableValues.add(value) + } + } + + for (const upstreamNodeId of upstreamNodeIds) { + const upstreamState = semanticStateByNodeId.get(upstreamNodeId) + if (!upstreamState) continue + for (const value of upstreamState.availableValues) { + availableValues.add(value) + } + for (const executedStep of upstreamState.executedSteps) { + executedSteps.add(executedStep) + } + } + + if (definitionsLoaded && definition) { + if (workflowContextKind === 'order_line' && CAD_FILE_ENTRY_STEPS.has(step)) { + errors.push(`Node "${label}" requires a direct CAD-file entry context and cannot run inside an order-line workflow.`) + } + + if (ORDER_LINE_SETUP_REQUIRED_STEPS.has(step) && step !== 'order_line_setup') { + if (workflowContextKind !== 'order_line') { + errors.push(`Node "${label}" requires an order-line workflow context.`) + } + if (!executedSteps.has('order_line_setup')) { + errors.push(`Node "${label}" requires an earlier "Order Line Setup" node.`) + } + } + + if ( + workflowContextKind && + inputContext && + inputContext !== workflowContextKind && + !(workflowContextKind === 'order_line' && CAD_FILE_ENTRY_STEPS.has(step)) + ) { + errors.push(`Node "${label}" expects ${formatContractValue(inputContext)} context, but this workflow is ${formatContractValue(workflowContextKind)} based.`) + } + + if (RESOLVE_TEMPLATE_RECOMMENDED_STEPS.has(step) && !executedSteps.has('resolve_template')) { + warnings.push(`Node "${label}" has no earlier "Resolve Template" node. Render defaults may drift from legacy behavior.`) + } + + const requiredValues = new Set([ + ...getContractValues(definition.input_contract as Record | undefined, 'requires'), + ...(definition.artifact_roles_consumed ?? []), + ]) + const alternativeRequiredGroups = getContractAlternativeValues( + definition.input_contract as Record | undefined, + 'requires_any', + ) + + if (step === 'output_save') { + if (alternativeRequiredGroups.length === 0) { + alternativeRequiredGroups.push(OUTPUT_SAVE_ALTERNATIVE_INPUTS) + } + } + + if (step === 'notify') { + requiredValues.delete('workflow_result') + } + + const compatibilityValues = new Set() + if (executedSteps.has('order_line_setup') || step === 'order_line_setup') { + for (const value of ORDER_LINE_SETUP_COMPATIBILITY_VALUES) { + compatibilityValues.add(value) + } + } + + for (const group of alternativeRequiredGroups) { + for (const value of group) { + requiredValues.delete(value) + } + if (!group.some(value => availableValues.has(value) || compatibilityValues.has(value))) { + errors.push(`Node "${label}" requires at least one upstream input (${group.map(formatContractValue).join(', ')}).`) + } + } + + const missingValues = Array.from(requiredValues).filter( + value => !availableValues.has(value) && !compatibilityValues.has(value), + ) + + for (const missingValue of missingValues) { + errors.push(`Node "${label}" is missing upstream input "${formatContractValue(missingValue)}".`) + } + + if (outputContext) { + availableValues.add(outputContext) + } + for (const value of getContractValues(definition.output_contract as Record | undefined, 'provides')) { + availableValues.add(value) + } + for (const value of definition.artifact_roles_produced ?? []) { + availableValues.add(value) + } + if (step === 'order_line_setup') { + for (const value of ORDER_LINE_SETUP_COMPATIBILITY_VALUES) { + availableValues.add(value) + } + } + } + + executedSteps.add(step) + semanticStateByNodeId.set(nodeId, { availableValues, executedSteps }) + } + + return { errors, warnings } +} + +export function workflowToGraph( + config: WorkflowConfig, + nodeDefinitionsByStep: Record, +): { nodes: Node[]; edges: Edge[] } { + return { + nodes: config.nodes.map(node => ({ + id: node.id, + type: node.ui?.type ?? nodeDefinitionsByStep[node.step]?.node_type ?? inferNodeType(node.step), + position: node.ui?.position ?? { x: 0, y: 0 }, + data: { + label: node.ui?.label ?? nodeDefinitionsByStep[node.step]?.label ?? inferNodeLabel(node.step), + params: normalizeWorkflowParams(node.params ?? {}), + step: node.step, + description: nodeDefinitionsByStep[node.step]?.description, + icon: nodeDefinitionsByStep[node.step]?.icon, + category: nodeDefinitionsByStep[node.step]?.category, + } satisfies WorkflowCanvasNodeData, + })), + edges: config.edges.map((edge, index) => ({ + id: `e_${edge.from}_${edge.to}_${index}`, + source: edge.from, + target: edge.to, + })), + } +} + +export function buildCurrentWorkflowConfig( + workflow: WorkflowDefinition, + nodes: Node[], + edges: Edge[], + executionMode: WorkflowExecutionMode, +): WorkflowConfig { + return { + version: workflow.config.version ?? 1, + ui: { + ...(workflow.config.ui ?? {}), + execution_mode: executionMode, + }, + nodes: nodes.map(node => { + const step = + ((node.data as WorkflowCanvasNodeData | undefined)?.step as string | undefined) ?? + inferStepFromNodeType(node.type) + const label = + ((node.data as WorkflowCanvasNodeData | undefined)?.label as string | undefined) ?? + inferNodeLabel(step) + + return { + id: node.id, + step, + params: normalizeWorkflowParams( + (((node.data as WorkflowCanvasNodeData | undefined)?.params as WorkflowParams | undefined) ?? {}), + ), + ui: { + type: node.type, + position: node.position, + label, + }, + } + }), + edges: edges.map(edge => ({ + from: edge.source, + to: edge.target, + })) as WorkflowEdge[], + } +} + +export function applyAutoLayout(nodes: Node[], edges: Edge[]) { + if (nodes.length === 0) return nodes + + const HORIZONTAL_SPACING = 280 + const VERTICAL_SPACING = 140 + const PADDING_X = 48 + const PADDING_Y = 48 + + const nodeById = new Map(nodes.map(node => [node.id, node])) + const inDegree = new Map() + const adjacency = new Map() + const layerByNodeId = new Map() + + for (const node of nodes) { + inDegree.set(node.id, 0) + adjacency.set(node.id, []) + layerByNodeId.set(node.id, 0) + } + + for (const edge of edges) { + if (!nodeById.has(edge.source) || !nodeById.has(edge.target)) continue + adjacency.set(edge.source, [...(adjacency.get(edge.source) ?? []), edge.target]) + inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1) + } + + const queue = nodes + .filter(node => (inDegree.get(node.id) ?? 0) === 0) + .sort((a, b) => a.position.y - b.position.y || a.position.x - b.position.x) + .map(node => node.id) + + const processed = new Set() + + while (queue.length > 0) { + const nodeId = queue.shift()! + processed.add(nodeId) + + for (const neighbor of adjacency.get(nodeId) ?? []) { + layerByNodeId.set( + neighbor, + Math.max(layerByNodeId.get(neighbor) ?? 0, (layerByNodeId.get(nodeId) ?? 0) + 1), + ) + const nextDegree = (inDegree.get(neighbor) ?? 0) - 1 + inDegree.set(neighbor, nextDegree) + if (nextDegree === 0) { + queue.push(neighbor) + } + } + } + + let fallbackLayer = Math.max(...layerByNodeId.values(), 0) + for (const node of nodes) { + if (processed.has(node.id)) continue + fallbackLayer += 1 + layerByNodeId.set(node.id, fallbackLayer) + } + + const layers = new Map() + for (const node of nodes) { + const layer = layerByNodeId.get(node.id) ?? 0 + layers.set(layer, [...(layers.get(layer) ?? []), node]) + } + + return nodes.map(node => { + const layer = layerByNodeId.get(node.id) ?? 0 + const layerNodes = [...(layers.get(layer) ?? [])].sort((a, b) => { + const aLabel = ((a.data as WorkflowCanvasNodeData | undefined)?.label as string | undefined) ?? a.id + const bLabel = ((b.data as WorkflowCanvasNodeData | undefined)?.label as string | undefined) ?? b.id + return a.position.y - b.position.y || a.position.x - b.position.x || aLabel.localeCompare(bLabel) + }) + const index = layerNodes.findIndex(candidate => candidate.id === node.id) + + return { + ...node, + position: { + x: PADDING_X + layer * HORIZONTAL_SPACING, + y: PADDING_Y + Math.max(index, 0) * VERTICAL_SPACING, + }, + } + }) +} diff --git a/frontend/src/components/workflows/workflowNodeCatalog.ts b/frontend/src/components/workflows/workflowNodeCatalog.ts new file mode 100644 index 0000000..e90ec06 --- /dev/null +++ b/frontend/src/components/workflows/workflowNodeCatalog.ts @@ -0,0 +1,117 @@ +import type { StepCategory, WorkflowNodeDefinition, WorkflowNodeFamily } from '../../api/workflows' +import { + compareNodeDefinitions, + getDefinitionFamily, + getDefinitionSearchText, + getPrimaryLibraryGroup, + getDefinitionModuleNamespace, + getDefinitionModuleLabel, + isDefinitionAllowedForGraphFamily, + matchesNodeKindFilter, + NODE_CATEGORY_ORDER, + type WorkflowGraphFamily, + type WorkflowNodeFamilyFilter, + type WorkflowNodeKindFilter, + type WorkflowNodeLibraryGroup, +} from './workflowNodeLibrary' + +export type WorkflowNodeCatalogFilters = { + graphFamily: WorkflowGraphFamily + familyFilter: WorkflowNodeFamilyFilter + kindFilter: WorkflowNodeKindFilter + query: string +} + +export type WorkflowNodeCatalogCategorySection = { + category: StepCategory + definitions: WorkflowNodeDefinition[] +} + +export type WorkflowNodeCatalogModuleSection = { + namespace: string + label: string + definitions: WorkflowNodeDefinition[] + categories: WorkflowNodeCatalogCategorySection[] + familyCounts: Record +} + +export type WorkflowNodeCatalogGroupSection = { + group: WorkflowNodeLibraryGroup + definitions: WorkflowNodeDefinition[] + modules: WorkflowNodeCatalogModuleSection[] +} + +export function getAvailableFamilyFilters( + graphFamily: WorkflowGraphFamily, +): WorkflowNodeFamilyFilter[] { + return graphFamily === 'mixed' + ? ['all', 'cad_file', 'order_line'] + : [graphFamily] +} + +export function filterWorkflowNodeDefinitions( + definitions: WorkflowNodeDefinition[], + { + graphFamily, + familyFilter, + kindFilter, + query, + }: WorkflowNodeCatalogFilters, +) { + const normalizedQuery = query.trim().toLowerCase() + + return definitions + .filter(definition => isDefinitionAllowedForGraphFamily(definition, graphFamily)) + .filter(definition => familyFilter === 'all' || getDefinitionFamily(definition) === familyFilter) + .filter(definition => matchesNodeKindFilter(definition, kindFilter)) + .filter(definition => !normalizedQuery || getDefinitionSearchText(definition).includes(normalizedQuery)) + .sort(compareNodeDefinitions) +} + +export function buildWorkflowNodeCatalog( + definitions: WorkflowNodeDefinition[], +): WorkflowNodeCatalogGroupSection[] { + const groupEntries = new Map< + WorkflowNodeLibraryGroup, + Map + >() + + for (const definition of definitions) { + const group = getPrimaryLibraryGroup(definition) + const namespace = getDefinitionModuleNamespace(definition) + const modules = groupEntries.get(group) ?? new Map() + modules.set(namespace, [...(modules.get(namespace) ?? []), definition]) + groupEntries.set(group, modules) + } + + return (['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[]) + .map(group => { + const moduleEntries = groupEntries.get(group) ?? new Map() + const modules = Array.from(moduleEntries.entries()) + .map(([namespace, moduleDefinitions]) => { + const definitionsForModule = [...moduleDefinitions].sort(compareNodeDefinitions) + return { + namespace, + label: getDefinitionModuleLabel(definitionsForModule[0]), + definitions: definitionsForModule, + categories: NODE_CATEGORY_ORDER.map(category => ({ + category, + definitions: definitionsForModule.filter(definition => definition.category === category), + })).filter(section => section.definitions.length > 0), + familyCounts: { + cad_file: definitionsForModule.filter(definition => getDefinitionFamily(definition) === 'cad_file').length, + order_line: definitionsForModule.filter(definition => getDefinitionFamily(definition) === 'order_line').length, + }, + } + }) + .sort((a, b) => a.label.localeCompare(b.label)) + + const groupDefinitions = modules.flatMap(module => module.definitions) + return { + group, + definitions: groupDefinitions, + modules, + } + }) + .filter(section => section.definitions.length > 0) +} diff --git a/frontend/src/components/workflows/workflowNodeLibrary.ts b/frontend/src/components/workflows/workflowNodeLibrary.ts new file mode 100644 index 0000000..4058f93 --- /dev/null +++ b/frontend/src/components/workflows/workflowNodeLibrary.ts @@ -0,0 +1,247 @@ +import type { StepCategory, WorkflowNodeDefinition, WorkflowNodeFamily } from '../../api/workflows' + +export type WorkflowNodeFamilyFilter = 'all' | WorkflowNodeFamily +export type WorkflowGraphFamily = WorkflowNodeFamily | 'mixed' +export type WorkflowNodeKindFilter = 'all' | 'legacy' | 'bridge' | 'graph' +export type WorkflowNodeLibraryGroup = 'legacy' | 'bridge' | 'graph' + +export const CATEGORY_LABELS: Record = { + input: 'Input', + processing: 'Processing', + rendering: 'Rendering', + output: 'Output', +} + +export const CATEGORY_COLORS: Record = { + input: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', + processing: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', + rendering: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300', + output: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300', +} + +export const NODE_CATEGORY_ORDER: StepCategory[] = ['input', 'processing', 'rendering', 'output'] + +export const FAMILY_FILTER_LABELS: Record = { + all: 'All Nodes', + cad_file: 'CAD Intake', + order_line: 'Order Rendering', +} + +export const NODE_KIND_FILTER_LABELS: Record = { + all: 'All Modes', + legacy: 'Legacy', + bridge: 'Bridge', + graph: 'Graph', +} + +export const NODE_LIBRARY_GROUP_LABELS: Record = { + legacy: 'Legacy Nodes', + bridge: 'Bridge Nodes', + graph: 'Graph Nodes', +} + +export const NODE_LIBRARY_GROUP_STYLES: Record = { + legacy: 'bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300', + bridge: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', + graph: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', +} + +export const NODE_LIBRARY_GROUP_DESCRIPTIONS: Record = { + legacy: 'Legacy-safe nodes that map cleanly to the existing production path.', + bridge: 'Compatibility nodes that still rely on bridge execution behavior.', + graph: 'Native graph runtime nodes for the non-legacy editor flow.', +} + +export const FAMILY_FILTER_DESCRIPTIONS: Record = { + cad_file: 'Start with a CAD file context and produce previews, caches, or derived assets.', + order_line: 'Start with an order line context and run production rendering/output steps.', +} + +export const FAMILY_FILTER_STYLES: Record = { + cad_file: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300', + order_line: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', +} + +export const GRAPH_FAMILY_LABELS: Record = { + cad_file: 'CAD Intake', + order_line: 'Order Rendering', + mixed: 'Mixed Family', +} + +export const GRAPH_FAMILY_STYLES: Record = { + cad_file: FAMILY_FILTER_STYLES.cad_file, + order_line: FAMILY_FILTER_STYLES.order_line, + mixed: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', +} + +export type WorkflowNodeDefinitionMap = Record + +const CAD_FILE_NODE_STEPS = new Set([ + 'resolve_step_path', + 'occ_object_extract', + 'occ_glb_export', + 'stl_cache_generate', + 'blender_render', + 'threejs_render', + 'thumbnail_save', +]) + +export function getNodeFamily(step: string, nodeDefinitionsByStep?: WorkflowNodeDefinitionMap): WorkflowNodeFamily { + return nodeDefinitionsByStep?.[step]?.family ?? (CAD_FILE_NODE_STEPS.has(step) ? 'cad_file' : 'order_line') +} + +export function getDefinitionFamily( + definition: WorkflowNodeDefinition, + nodeDefinitionsByStep?: WorkflowNodeDefinitionMap, +): WorkflowNodeFamily { + return definition.family ?? getNodeFamily(definition.step, nodeDefinitionsByStep) +} + +export function isDefinitionAllowedForGraphFamily( + definition: WorkflowNodeDefinition, + graphFamily: WorkflowGraphFamily, + nodeDefinitionsByStep?: WorkflowNodeDefinitionMap, +): boolean { + if (graphFamily === 'mixed') return true + return getDefinitionFamily(definition, nodeDefinitionsByStep) === graphFamily +} + +export function compareNodeDefinitions(a: WorkflowNodeDefinition, b: WorkflowNodeDefinition) { + const categoryDelta = NODE_CATEGORY_ORDER.indexOf(a.category) - NODE_CATEGORY_ORDER.indexOf(b.category) + if (categoryDelta !== 0) return categoryDelta + return a.label.localeCompare(b.label) +} + +export function getDefinitionModuleNamespace(definition: WorkflowNodeDefinition): string { + const [namespace] = definition.module_key.split('.') + return namespace || 'workflow' +} + +export function getDefinitionModuleLabel(definition: WorkflowNodeDefinition): string { + const namespace = getDefinitionModuleNamespace(definition) + return namespace + .split('_') + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} + +export function groupDefinitionsForStepSelect(definitions: WorkflowNodeDefinition[]) { + const groups = new Map() + + for (const definition of [...definitions].sort(compareNodeDefinitions)) { + const family = getDefinitionFamily(definition) + const groupLabel = `${FAMILY_FILTER_LABELS[family]} · ${getDefinitionModuleLabel(definition)} · ${CATEGORY_LABELS[definition.category]}` + groups.set(groupLabel, [...(groups.get(groupLabel) ?? []), definition]) + } + + return Array.from(groups.entries()).map(([label, options]) => ({ label, options })) +} + +export function groupDefinitionsByFamily( + definitions: WorkflowNodeDefinition[], + nodeDefinitionsByStep?: WorkflowNodeDefinitionMap, +) { + return { + cad_file: definitions + .filter(definition => getDefinitionFamily(definition, nodeDefinitionsByStep) === 'cad_file') + .sort(compareNodeDefinitions), + order_line: definitions + .filter(definition => getDefinitionFamily(definition, nodeDefinitionsByStep) === 'order_line') + .sort(compareNodeDefinitions), + } as Record +} + +export function groupDefinitionsByModule(definitions: WorkflowNodeDefinition[]) { + const groups = new Map< + string, + { + namespace: string + label: string + definitions: WorkflowNodeDefinition[] + } + >() + + for (const definition of [...definitions].sort(compareNodeDefinitions)) { + const namespace = getDefinitionModuleNamespace(definition) + const existing = groups.get(namespace) + if (existing) { + existing.definitions.push(definition) + continue + } + + groups.set(namespace, { + namespace, + label: getDefinitionModuleLabel(definition), + definitions: [definition], + }) + } + + return Array.from(groups.values()).sort((a, b) => a.label.localeCompare(b.label)) +} + +export function getPrimaryLibraryGroup(definition: WorkflowNodeDefinition): WorkflowNodeLibraryGroup { + if (definition.execution_kind === 'native') { + return 'graph' + } + if (definition.legacy_compatible) { + return 'legacy' + } + return 'bridge' +} + +export function matchesNodeKindFilter( + definition: WorkflowNodeDefinition, + filter: WorkflowNodeKindFilter, +): boolean { + if (filter === 'all') return true + if (filter === 'legacy') return definition.legacy_compatible + if (filter === 'bridge') return definition.execution_kind === 'bridge' + return definition.execution_kind === 'native' +} + +export function getDefinitionSearchText(definition: WorkflowNodeDefinition): string { + return [ + definition.label, + definition.step, + definition.module_key, + getDefinitionModuleLabel(definition), + definition.description, + CATEGORY_LABELS[definition.category], + FAMILY_FILTER_LABELS[getDefinitionFamily(definition)], + definition.execution_kind === 'bridge' ? 'bridge' : 'graph', + definition.legacy_compatible ? 'legacy' : '', + definition.artifact_roles_consumed.join(' '), + definition.artifact_roles_produced.join(' '), + ] + .join(' ') + .toLowerCase() +} + +export function getDefinitionBadges(definition: WorkflowNodeDefinition) { + const badges: { label: string; className: string }[] = [] + + if (definition.legacy_compatible) { + badges.push({ + label: 'Legacy', + className: NODE_LIBRARY_GROUP_STYLES.legacy, + }) + } + + badges.push({ + label: definition.execution_kind === 'bridge' ? 'Bridge' : 'Graph', + className: + definition.execution_kind === 'bridge' + ? NODE_LIBRARY_GROUP_STYLES.bridge + : NODE_LIBRARY_GROUP_STYLES.graph, + }) + + return badges +} + +export function groupDefinitionsByPrimaryLibraryGroup(definitions: WorkflowNodeDefinition[]) { + return { + legacy: definitions.filter(definition => getPrimaryLibraryGroup(definition) === 'legacy').sort(compareNodeDefinitions), + bridge: definitions.filter(definition => getPrimaryLibraryGroup(definition) === 'bridge').sort(compareNodeDefinitions), + graph: definitions.filter(definition => getPrimaryLibraryGroup(definition) === 'graph').sort(compareNodeDefinitions), + } as Record +} diff --git a/frontend/src/components/workflows/workflowRunPresentation.ts b/frontend/src/components/workflows/workflowRunPresentation.ts new file mode 100644 index 0000000..ed17bf0 --- /dev/null +++ b/frontend/src/components/workflows/workflowRunPresentation.ts @@ -0,0 +1,33 @@ +import type { WorkflowExecutionMode } from '../../api/workflows' + +export const EXECUTION_MODE_LABELS: Record = { + legacy: 'Legacy', + shadow: 'Shadow', + graph: 'Graph', +} + +export const EXECUTION_MODE_BADGE_STYLES: Record = { + legacy: 'bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300', + shadow: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', + graph: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', +} + +export function formatDateTime(value: string | null | undefined) { + if (!value) return 'Unknown' + return new Date(value).toLocaleString([], { + dateStyle: 'medium', + timeStyle: 'short', + }) +} + +export function getRunStatusClassName(status: string) { + if (status === 'completed' || status === 'success') return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' + if (status === 'failed') return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' + return 'bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300' +} + +export function getPreflightStatusClassName(status: string) { + if (status === 'ready' || status === 'success' || status === 'info') return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' + if (status === 'warning') return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' + return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' +} diff --git a/frontend/src/pages/WorkflowEditor.tsx b/frontend/src/pages/WorkflowEditor.tsx index 419ea0e..1cd3406 100644 --- a/frontend/src/pages/WorkflowEditor.tsx +++ b/frontend/src/pages/WorkflowEditor.tsx @@ -1,46 +1,47 @@ -import { useState, useCallback, useRef, useEffect, useMemo, type ChangeEvent, type DragEvent } from 'react' +import { useState, useEffect, useMemo } from 'react' import { ReactFlow, Background, Controls, MiniMap, - addEdge, - useNodesState, - useEdgesState, Handle, Position, type Node, type Edge, - type Connection, type NodeTypes, } from '@xyflow/react' import '@xyflow/react/dist/style.css' import { useThemeStore, resolveTheme } from '../store/theme' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { NewWorkflowModal } from '../components/workflows/NewWorkflowModal' +import { NodeCommandMenu } from '../components/workflows/NodeCommandMenu' +import { WorkflowCanvasToolbar } from '../components/workflows/WorkflowCanvasToolbar' +import { WorkflowCanvasUtilitySidebar } from '../components/workflows/WorkflowCanvasUtilitySidebar' +import { WorkflowEditorEmptyState } from '../components/workflows/WorkflowEditorEmptyState' +import { WorkflowListSidebar } from '../components/workflows/WorkflowListSidebar' +import { WorkflowValidationBanner } from '../components/workflows/WorkflowValidationBanner' +import { + type WorkflowCanvasNodeData, +} from '../components/workflows/workflowGraphDraft' +import { + BLUEPRINT_DESCRIPTION, + BLUEPRINT_LABELS, + compareWorkflows, + getWorkflowBlueprint, + inferWorkflowFamily, + isReferenceBlueprint, +} from '../components/workflows/workflowBlueprints' import { getWorkflows, createWorkflow, updateWorkflow, deleteWorkflow, getNodeDefinitions, - getWorkflowRuns, - getWorkflowRunComparison, - dispatchWorkflowDraft, - preflightWorkflowDraft, - createPresetWorkflowConfig, getWorkflowPresetType, type WorkflowDefinition, type WorkflowConfig, - type WorkflowEdge, type WorkflowExecutionMode, type WorkflowPresetType, - type WorkflowParams, - type StepCategory, - type WorkflowNodeDefinition, - type WorkflowNodeFieldDefinition, - type WorkflowRun, - type WorkflowRunComparison, - type WorkflowPreflightResponse, } from '../api/workflows' import { FileUp, @@ -50,37 +51,19 @@ import { Layers, Download, Bell, - Plus, - Save, - Trash2, - GitBranch, - X, - Play, - Loader2, - AlertTriangle, - Search, } from 'lucide-react' import { toast } from 'sonner' - -function normalizeWorkflowParams(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 -} - -type WorkflowCanvasNodeData = { - label: string - params: WorkflowParams - step: string - description?: string - icon?: string - category?: StepCategory -} +import { + GRAPH_FAMILY_LABELS, + GRAPH_FAMILY_STYLES, + type WorkflowGraphFamily, + type WorkflowNodeDefinitionMap, +} from '../components/workflows/workflowNodeLibrary' +import { + EXECUTION_MODE_BADGE_STYLES, + EXECUTION_MODE_LABELS, +} from '../components/workflows/workflowRunPresentation' +import { useWorkflowCanvasController } from '../components/workflows/useWorkflowCanvasController' function renderWorkflowIcon(iconName?: string, size = 14) { switch (iconName) { @@ -102,22 +85,6 @@ function renderWorkflowIcon(iconName?: string, size = 14) { } } -function buildNodeData( - step: string, - params: WorkflowParams = {}, - definition?: WorkflowNodeDefinition, - overrides?: Partial, -): WorkflowCanvasNodeData { - return { - label: overrides?.label ?? definition?.label ?? inferNodeLabel(step), - params: normalizeWorkflowParams(params), - step, - description: overrides?.description ?? definition?.description, - icon: overrides?.icon ?? definition?.icon, - category: overrides?.category ?? definition?.category, - } -} - // ─── Custom Node Components ────────────────────────────────────────────────── interface BaseNodeProps { @@ -241,1203 +208,12 @@ const nodeTypes: NodeTypes = { outputNode: OutputNode as any, } -function inferNodeType(step: string): string { - if (step === 'resolve_step_path') return 'inputNode' - if (step === 'stl_cache_generate') return 'convertNode' - if (step === 'blender_turntable') return 'renderFramesNode' - if (step === 'output_save' || step === 'export_blend' || step === 'notify' || step === 'thumbnail_save') return 'outputNode' - if (step.startsWith('blender_') || step === 'threejs_render') return 'renderNode' - if (step.startsWith('occ_') || step === 'glb_bbox' || step === 'material_map_resolve' || step === 'auto_populate_materials') { - return 'processNode' - } - return 'renderNode' -} - -function inferNodeLabel(step: string): string { - return step - .split('_') - .map(part => part.charAt(0).toUpperCase() + part.slice(1)) - .join(' ') -} - -function inferStepFromNodeType(type?: string): string { - if (type === 'inputNode') return 'resolve_step_path' - if (type === 'convertNode') return 'stl_cache_generate' - if (type === 'processNode') return 'order_line_setup' - if (type === 'renderFramesNode') return 'blender_turntable' - if (type === 'outputNode') return 'output_save' - return 'blender_still' -} - -const EXECUTION_MODE_LABELS: Record = { - legacy: 'Legacy', - graph: 'Graph', - shadow: 'Shadow', -} - -const EXECUTION_MODE_BADGE_STYLES: Record = { - legacy: 'bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300', - graph: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', - shadow: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', -} - const EXECUTION_MODE_HINTS: Record = { legacy: 'Preset dispatcher remains authoritative for production runs.', graph: 'Production dispatch uses graph runtime with hard fallback to legacy on failure.', shadow: 'Currently stored and exposed, but production dispatch still falls back to legacy until shadow parity lands.', } -const RUN_STATUS_STYLES: Record = { - pending: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', - running: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', - queued: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', - completed: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', - failed: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', - retrying: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', - skipped: 'bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300', -} - -type WorkflowValidationResult = { - errors: string[] - warnings: string[] -} - -function formatDateTime(value: string | null | undefined) { - if (!value) return 'n/a' - const date = new Date(value) - if (Number.isNaN(date.getTime())) return value - return date.toLocaleString() -} - -function getRunStatusClassName(status: string) { - return RUN_STATUS_STYLES[status] ?? 'bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300' -} - -function getPreflightStatusClassName(status: string) { - if (status === 'ready') return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300' - if (status === 'warning') return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' - if (status === 'error') return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' - return 'bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300' -} - -function validateWorkflowDraft( - nodes: Node[], - edges: Edge[], - nodeDefinitionsByStep: Record, - definitionsLoaded: boolean, -): WorkflowValidationResult { - const errors: string[] = [] - const warnings: string[] = [] - - if (nodes.length === 0) { - errors.push('Workflow must contain at least one node.') - return { errors, warnings } - } - - const nodeIds = new Set() - const connectedNodeIds = new Set() - const inDegree = new Map() - const adjacency = new Map() - - for (const node of nodes) { - const step = ((node.data as WorkflowCanvasNodeData | undefined)?.step as string | undefined) ?? inferStepFromNodeType(node.type) - const label = ((node.data as WorkflowCanvasNodeData | undefined)?.label as string | undefined) ?? node.id - - if (nodeIds.has(node.id)) { - errors.push(`Duplicate node id "${node.id}" is not allowed.`) - } - nodeIds.add(node.id) - inDegree.set(node.id, 0) - adjacency.set(node.id, []) - - if (definitionsLoaded && !nodeDefinitionsByStep[step]) { - errors.push(`Node "${label}" uses unknown step "${step}".`) - } - } - - const edgePairs = new Set() - for (const edge of edges) { - if (!nodeIds.has(edge.source)) { - errors.push(`Edge references unknown source node "${edge.source}".`) - continue - } - if (!nodeIds.has(edge.target)) { - errors.push(`Edge references unknown target node "${edge.target}".`) - continue - } - if (edge.source === edge.target) { - errors.push(`Node "${edge.source}" cannot point to itself.`) - continue - } - - const pair = `${edge.source}->${edge.target}` - if (edgePairs.has(pair)) { - errors.push(`Duplicate edge "${edge.source}" -> "${edge.target}" is not allowed.`) - continue - } - edgePairs.add(pair) - connectedNodeIds.add(edge.source) - connectedNodeIds.add(edge.target) - adjacency.set(edge.source, [...(adjacency.get(edge.source) ?? []), edge.target]) - inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1) - } - - const queue = Array.from(inDegree.entries()) - .filter(([, degree]) => degree === 0) - .map(([nodeId]) => nodeId) - let processed = 0 - - while (queue.length > 0) { - const nodeId = queue.shift()! - processed += 1 - for (const neighbor of adjacency.get(nodeId) ?? []) { - const nextDegree = (inDegree.get(neighbor) ?? 0) - 1 - inDegree.set(neighbor, nextDegree) - if (nextDegree === 0) { - queue.push(neighbor) - } - } - } - - if (processed !== nodes.length) { - errors.push('Workflow graph contains a cycle.') - } - - if (nodes.length > 1) { - for (const node of nodes) { - const label = ((node.data as WorkflowCanvasNodeData | undefined)?.label as string | undefined) ?? node.id - if (!connectedNodeIds.has(node.id)) { - warnings.push(`Node "${label}" is disconnected.`) - } - } - } - - return { errors, warnings } -} - -function workflowToGraph( - config: WorkflowConfig, - nodeDefinitionsByStep: Record, -): { nodes: Node[]; edges: Edge[] } { - return { - nodes: config.nodes.map(node => ({ - id: node.id, - type: node.ui?.type ?? nodeDefinitionsByStep[node.step]?.node_type ?? inferNodeType(node.step), - position: node.ui?.position ?? { x: 0, y: 0 }, - data: buildNodeData(node.step, node.params ?? {}, nodeDefinitionsByStep[node.step], { - label: node.ui?.label ?? undefined, - }), - })), - edges: config.edges.map((edge, index) => ({ - id: `e_${edge.from}_${edge.to}_${index}`, - source: edge.from, - target: edge.to, - })), - } -} - -function buildCurrentWorkflowConfig( - workflow: WorkflowDefinition, - nodes: Node[], - edges: Edge[], - executionMode: WorkflowExecutionMode, -): WorkflowConfig { - return { - version: workflow.config.version ?? 1, - ui: { - ...(workflow.config.ui ?? {}), - execution_mode: executionMode, - }, - nodes: nodes.map(node => { - const step = - ((node.data as WorkflowCanvasNodeData | undefined)?.step as string | undefined) ?? - inferStepFromNodeType(node.type) - const label = - ((node.data as WorkflowCanvasNodeData | undefined)?.label as string | undefined) ?? - inferNodeLabel(step) - - return { - id: node.id, - step, - params: normalizeWorkflowParams( - (((node.data as WorkflowCanvasNodeData | undefined)?.params as WorkflowParams | undefined) ?? {}), - ), - ui: { - type: node.type, - position: node.position, - label, - }, - } - }), - edges: edges.map(edge => ({ - from: edge.source, - to: edge.target, - })) as WorkflowEdge[], - } -} - -const CATEGORY_LABELS: Record = { - input: 'Input', - processing: 'Processing', - rendering: 'Rendering', - output: 'Output', -} - -const CATEGORY_COLORS: Record = { - input: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', - processing: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', - rendering: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300', - output: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300', -} - -const NODE_CATEGORY_ORDER: StepCategory[] = ['input', 'processing', 'rendering', 'output'] - -type WorkflowNodeFamily = 'cad_file' | 'order_line' -type WorkflowNodeFamilyFilter = 'all' | WorkflowNodeFamily -type WorkflowGraphFamily = WorkflowNodeFamily | 'mixed' - -const FAMILY_FILTER_LABELS: Record = { - all: 'All Nodes', - cad_file: 'CAD Intake', - order_line: 'Order Rendering', -} - -const FAMILY_FILTER_DESCRIPTIONS: Record = { - cad_file: 'Start with a CAD file context and produce previews, caches, or derived assets.', - order_line: 'Start with an order line context and run production rendering/output steps.', -} - -const FAMILY_FILTER_STYLES: Record = { - cad_file: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300', - order_line: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', -} - -const GRAPH_FAMILY_LABELS: Record = { - cad_file: 'CAD Intake', - order_line: 'Order Rendering', - mixed: 'Mixed Family', -} - -const GRAPH_FAMILY_STYLES: Record = { - cad_file: FAMILY_FILTER_STYLES.cad_file, - order_line: FAMILY_FILTER_STYLES.order_line, - mixed: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', -} - -const BLUEPRINT_LABELS: Record = { - cad_intake: 'Reference Blueprint', - order_rendering: 'Reference Blueprint', - starter_cad_intake: 'Starter', - starter_order_rendering: 'Starter', -} - -const BLUEPRINT_DESCRIPTION: Record = { - cad_intake: 'Canonical CAD-file workflow for intake, preview generation, and material discovery.', - order_rendering: 'Canonical order-line workflow for production rendering, exports, and notifications.', - starter_cad_intake: 'Minimal CAD-file starter graph.', - starter_order_rendering: 'Minimal order-line starter graph.', -} - -const CAD_FILE_NODE_STEPS = new Set([ - 'resolve_step_path', - 'occ_object_extract', - 'occ_glb_export', - 'stl_cache_generate', - 'blender_render', - 'threejs_render', - 'thumbnail_save', -]) - -function getNodeFamily(step: string): WorkflowNodeFamily { - return CAD_FILE_NODE_STEPS.has(step) ? 'cad_file' : 'order_line' -} - -function inferWorkflowFamily(config: WorkflowConfig): WorkflowGraphFamily { - const nodes = Array.isArray(config.nodes) ? config.nodes : [] - if (nodes.length > 0) { - const families = new Set(nodes.map(node => getNodeFamily(node.step))) - if (families.size === 1) { - return Array.from(families)[0] - } - return 'mixed' - } - - const presetType = getWorkflowPresetType(config) - if (presetType === 'custom') { - return 'mixed' - } - - return 'order_line' -} - -function getWorkflowBlueprint(config: WorkflowConfig): string | null { - const blueprint = config.ui?.blueprint - return typeof blueprint === 'string' && blueprint.trim().length > 0 ? blueprint : null -} - -function isReferenceBlueprint(config: WorkflowConfig): boolean { - const blueprint = getWorkflowBlueprint(config) - return blueprint === 'cad_intake' || blueprint === 'order_rendering' -} - -function cloneWorkflowConfig(config: WorkflowConfig, options?: { stripBlueprint?: boolean }): WorkflowConfig { - const nextUi = { ...(config.ui ?? {}) } - if (options?.stripBlueprint) { - delete nextUi.blueprint - } - - return { - version: config.version, - nodes: config.nodes.map(node => ({ - ...node, - params: { ...(node.params ?? {}) }, - ui: node.ui ? { ...node.ui } : undefined, - })), - edges: config.edges.map(edge => ({ ...edge })), - ui: nextUi, - } -} - -function compareWorkflows(a: WorkflowDefinition, b: WorkflowDefinition): number { - const blueprintRank = Number(isReferenceBlueprint(b.config)) - Number(isReferenceBlueprint(a.config)) - if (blueprintRank !== 0) return blueprintRank - - const activeRank = Number(b.is_active) - Number(a.is_active) - if (activeRank !== 0) return activeRank - - return a.name.localeCompare(b.name) -} - -function compareNodeDefinitions(a: WorkflowNodeDefinition, b: WorkflowNodeDefinition) { - const categoryDelta = NODE_CATEGORY_ORDER.indexOf(a.category) - NODE_CATEGORY_ORDER.indexOf(b.category) - if (categoryDelta !== 0) return categoryDelta - return a.label.localeCompare(b.label) -} - -function groupDefinitionsByCategory(definitions: WorkflowNodeDefinition[]) { - const grouped: Record = { - input: [], - processing: [], - rendering: [], - output: [], - } - - for (const definition of definitions) { - grouped[definition.category] = [...grouped[definition.category], definition] - } - - for (const category of NODE_CATEGORY_ORDER) { - grouped[category].sort(compareNodeDefinitions) - } - - return grouped -} - -function groupDefinitionsForStepSelect(definitions: WorkflowNodeDefinition[]) { - const groups = new Map() - - for (const definition of [...definitions].sort(compareNodeDefinitions)) { - const family = getNodeFamily(definition.step) - const groupLabel = `${FAMILY_FILTER_LABELS[family]} · ${CATEGORY_LABELS[definition.category]}` - groups.set(groupLabel, [...(groups.get(groupLabel) ?? []), definition]) - } - - return Array.from(groups.entries()).map(([label, options]) => ({ label, options })) -} - -function groupDefinitionsByFamily(definitions: WorkflowNodeDefinition[]) { - return { - cad_file: definitions.filter(definition => getNodeFamily(definition.step) === 'cad_file').sort(compareNodeDefinitions), - order_line: definitions.filter(definition => getNodeFamily(definition.step) === 'order_line').sort(compareNodeDefinitions), - } as Record -} - -// ─── Config Sidepanel ───────────────────────────────────────────────────────── - -function groupFieldsBySection(fields: WorkflowNodeFieldDefinition[]) { - return fields.reduce>((sections, field) => { - const section = field.section || 'General' - sections[section] = [...(sections[section] ?? []), field] - return sections - }, {}) -} - -function ConfigSidepanel({ - params, - onChange, - nodeDefinition, - step, - onStepChange, - nodeDefinitions, -}: { - params: WorkflowParams - onChange: (p: WorkflowParams) => void - nodeDefinition?: WorkflowNodeDefinition - step?: string - onStepChange?: (step: string) => void - nodeDefinitions: WorkflowNodeDefinition[] -}) { - const customRenderSettingsEnabled = Boolean(params.use_custom_render_settings) - const nodeSelectionGroups = groupDefinitionsForStepSelect(nodeDefinitions) - - const updateField = (field: WorkflowNodeFieldDefinition, value: unknown) => { - onChange( - normalizeWorkflowParams({ - ...params, - [field.key]: value, - }), - ) - } - - const handleNumberChange = (field: WorkflowNodeFieldDefinition, event: ChangeEvent) => { - const rawValue = event.target.value - if (rawValue === '') { - const nextParams = { ...params } - delete nextParams[field.key] - onChange(nextParams) - return - } - updateField(field, Number(rawValue)) - } - - const fieldsBySection = groupFieldsBySection(nodeDefinition?.fields ?? []) - - return ( -
-

Node Configuration

- - {nodeDefinitions.length > 0 && onStepChange && ( -
- - - {nodeDefinition && ( -
-

{nodeDefinition.description}

- - {nodeDefinition.execution_kind === 'bridge' ? 'Legacy Bridge' : 'Native Node'} - -
- )} -
- )} - - {Object.keys(fieldsBySection).length === 0 && ( -

- This node currently has no configurable settings in the editor. -

- )} - - {Object.entries(fieldsBySection).map(([section, fields]) => ( -
-

- {section} -

- {fields.map(field => { - const rawValue = params[field.key] - const value = rawValue ?? field.default - const disableRenderOverrideField = - (step === 'blender_still' || step === 'blender_turntable') && - !customRenderSettingsEnabled && - field.key !== 'use_custom_render_settings' && - (field.section === 'Render' || field.section === 'Output') - - return ( -
- - {field.type === 'select' && ( - - )} - {field.type === 'number' && ( - handleNumberChange(field, event)} - disabled={disableRenderOverrideField} - className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" - /> - )} - {field.type === 'boolean' && ( - - )} - {field.type === 'text' && ( - updateField(field, event.target.value)} - disabled={disableRenderOverrideField} - className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" - /> - )} - {field.description && ( -

{field.description}

- )} - {disableRenderOverrideField && ( -

- In Graph/Shadow mode this field inherits from Output Type and Template until - Custom Render Settings is enabled. -

- )} -
- ) - })} -
- ))} -
- ) -} - -// ─── Node Definitions Panel ─────────────────────────────────────────────────── - -function NodePalette({ - definitions, - onDragStep, -}: { - definitions: WorkflowNodeDefinition[] - onDragStep: (event: DragEvent, step: string) => void -}) { - const [query, setQuery] = useState('') - const [familyFilter, setFamilyFilter] = useState('all') - - const visibleDefinitions = useMemo(() => { - const normalizedQuery = query.trim().toLowerCase() - - return definitions - .filter(definition => familyFilter === 'all' || getNodeFamily(definition.step) === familyFilter) - .filter(definition => { - if (!normalizedQuery) return true - return [ - definition.label, - definition.step, - definition.description, - CATEGORY_LABELS[definition.category], - FAMILY_FILTER_LABELS[getNodeFamily(definition.step)], - ] - .join(' ') - .toLowerCase() - .includes(normalizedQuery) - }) - .sort(compareNodeDefinitions) - }, [definitions, familyFilter, query]) - - const groupedDefinitions = useMemo( - () => groupDefinitionsByCategory(visibleDefinitions), - [visibleDefinitions], - ) - - return ( -
-
- Node Library -
- - setQuery(event.target.value)} - placeholder="Search nodes, steps, or categories" - className="w-full rounded-lg border border-border-default bg-surface px-9 py-2 text-sm text-content focus:outline-none focus:ring-2 focus:ring-accent" - /> -
- {(['all', 'cad_file', 'order_line'] as WorkflowNodeFamilyFilter[]).map(filter => ( - - ))} -
- -
- {(['cad_file', 'order_line'] as WorkflowNodeFamily[]).map(family => ( - - - {FAMILY_FILTER_LABELS[family]} - - {FAMILY_FILTER_DESCRIPTIONS[family]} - - ))} - Mixed node families are currently blocked by preflight and should be modeled as separate workflows. -
- - {visibleDefinitions.length === 0 ? ( -
- No nodes match the current search/filter. -
- ) : ( -
- {NODE_CATEGORY_ORDER.map(category => { - const categoryDefinitions = groupedDefinitions[category] - if (categoryDefinitions.length === 0) return null - - return ( -
-
- - {CATEGORY_LABELS[category]} - - {categoryDefinitions.length} -
-
- {categoryDefinitions.map(definition => { - const family = getNodeFamily(definition.step) - return ( -
onDragStep(event, definition.step)} - className="cursor-grab rounded-lg border border-border-default bg-surface px-3 py-2 transition-colors hover:bg-surface-hover active:cursor-grabbing" - title={definition.description} - > -
- {renderWorkflowIcon(definition.icon)} -
-
-

{definition.label}

- - {FAMILY_FILTER_LABELS[family]} - - - {definition.execution_kind === 'bridge' ? 'Bridge' : 'Native'} - -
-

{definition.step}

-

{definition.description}

-
-
-
- ) - })} -
-
- ) - })} -
- )} -
- ) -} - -function NodeDefinitionsPanel({ definitions }: { definitions: WorkflowNodeDefinition[] }) { - const [expanded, setExpanded] = useState(null) - const definitionsByFamily = groupDefinitionsByFamily(definitions) - const categories: StepCategory[] = ['input', 'processing', 'rendering', 'output'] - - return ( -
-

- Available Nodes -

-
- {(['cad_file', 'order_line'] as WorkflowNodeFamily[]).map(family => { - const familyDefinitions = definitionsByFamily[family] - if (familyDefinitions.length === 0) return null - - const groupedByCategory = groupDefinitionsByCategory(familyDefinitions) - - return ( -
-
- - {FAMILY_FILTER_LABELS[family]} - - {familyDefinitions.length} -
-
- {categories.map(category => { - const categoryDefinitions = groupedByCategory[category] - if (categoryDefinitions.length === 0) return null - - const sectionKey = `${family}:${category}` - return ( -
- - {expanded === sectionKey && ( -
- {categoryDefinitions.map(definition => ( -
-
-

{definition.label}

- - {definition.execution_kind === 'bridge' ? 'Bridge' : 'Native'} - -
-

{definition.step}

-

{definition.description}

-
- ))} -
- )} -
- ) - })} -
-
- ) - })} -
-
- ) -} - -function WorkflowRunsPanel({ - runs, - selectedRunId, - onSelectRun, - comparison, - isComparisonLoading, -}: { - runs: WorkflowRun[] - selectedRunId: string | null - onSelectRun: (runId: string) => void - comparison?: WorkflowRunComparison - isComparisonLoading: boolean -}) { - const selectedRun = runs.find(run => run.id === selectedRunId) ?? null - - return ( -
-
-

Workflow Runs

- {runs.length} -
- - {runs.length === 0 && ( -

- No workflow runs recorded for this workflow yet. -

- )} - - {runs.length > 0 && ( -
- {runs.slice(0, 8).map(run => ( - - ))} -
- )} - - {selectedRun && ( -
-
-
-

Run {selectedRun.id.slice(0, 8)}

-

- Started {formatDateTime(selectedRun.started_at ?? selectedRun.created_at)} -

-
- - {selectedRun.status} - -
- - {selectedRun.error_message && ( -

{selectedRun.error_message}

- )} - -
-

- Node Results -

- {selectedRun.node_results.map(result => ( -
-
- {result.node_name} - - {result.status} - -
- {result.log && ( -

{result.log}

- )} -
- ))} -
- - {selectedRun.execution_mode === 'shadow' && ( -
-
-

- Shadow Comparison -

- {isComparisonLoading && } -
- {comparison && ( -
-

{comparison.summary}

-

Status: {comparison.status}

-

- Authoritative: {comparison.authoritative_output.image_width ?? '?'} x {comparison.authoritative_output.image_height ?? '?'} -

-

- Observer: {comparison.observer_output.image_width ?? '?'} x {comparison.observer_output.image_height ?? '?'} -

- {comparison.mean_pixel_delta != null && ( -

Mean Pixel Delta: {comparison.mean_pixel_delta.toFixed(6)}

- )} -
- )} -
- )} -
- )} -
- ) -} - -function WorkflowPreflightPanel({ - preflight, - isLoading, -}: { - preflight: WorkflowPreflightResponse | null - isLoading: boolean -}) { - if (!preflight && !isLoading) { - return null - } - - return ( -
-
-

Graph Preflight

- {isLoading && } -
- - {preflight && ( -
-
-
-

{preflight.summary}

-

- Expected `{preflight.expected_context_kind}` · Resolved `{preflight.context_kind ?? 'n/a'}` -

-
- - {preflight.graph_dispatch_allowed ? 'ready' : 'blocked'} - -
- - {(preflight.resolved_order_line_id || preflight.resolved_cad_file_id) && ( -
- {preflight.resolved_order_line_id &&

Order Line: {preflight.resolved_order_line_id}

} - {preflight.resolved_cad_file_id &&

CAD File: {preflight.resolved_cad_file_id}

} -
- )} - - {preflight.issues.length > 0 && ( -
-

- Global Issues -

- {preflight.issues.map(issue => ( -
-
- {issue.message} - - {issue.severity} - -
-
- ))} -
- )} - -
-

- Node Checks -

- {preflight.nodes.map(node => ( -
-
-
-

{node.label ?? node.node_id}

-

{node.step}

-
- - {node.status} - -
- {node.issues.length > 0 && ( -
- {node.issues.map(issue => ( -

- {issue.message} -

- ))} -
- )} -
- ))} -
-
- )} -
- ) -} - -// ─── New Workflow Modal ─────────────────────────────────────────────────────── - -interface NewWorkflowModalProps { - workflows: WorkflowDefinition[] - onClose: () => void - onCreate: (name: string, config: WorkflowConfig) => void - isLoading: boolean -} - -function NewWorkflowModal({ workflows, onClose, onCreate, isLoading }: NewWorkflowModalProps) { - const [name, setName] = useState('') - const [type, setType] = useState('still_graph') - const [selectedBlueprintId, setSelectedBlueprintId] = useState(null) - - const referenceBlueprints = useMemo( - () => workflows.filter(workflow => isReferenceBlueprint(workflow.config)).sort(compareWorkflows), - [workflows], - ) - - const selectedBlueprint = referenceBlueprints.find(workflow => workflow.id === selectedBlueprintId) ?? null - const selectedBlueprintFamily = selectedBlueprint ? inferWorkflowFamily(selectedBlueprint.config) : null - const selectedBlueprintLabel = selectedBlueprint ? BLUEPRINT_LABELS[getWorkflowBlueprint(selectedBlueprint.config) ?? ''] : null - - const handleCreate = () => { - const trimmedName = name.trim() - if (!trimmedName) return - - if (selectedBlueprint) { - const clonedConfig = cloneWorkflowConfig(selectedBlueprint.config, { stripBlueprint: true }) - onCreate(trimmedName, clonedConfig) - return - } - - const defaultParams: WorkflowParams = - type === 'turntable' - ? { fps: 24, duration_s: 5 } - : type === 'multi_angle' - ? { angles: [0, 45, 90] } - : {} - - onCreate(trimmedName, createPresetWorkflowConfig(type, defaultParams)) - } - - return ( -
-
-
-

New Workflow

- -
- -
-
- - setName(e.target.value)} - autoFocus - /> -
- -
- -
- {([ - { value: 'still_graph', label: 'Still (Graph)', desc: 'Single PNG image via graph runtime' }, - { value: 'still', label: 'Still (Legacy)', desc: 'Single PNG image via legacy runtime' }, - { value: 'turntable', label: 'Turntable', desc: 'Animation MP4' }, - { value: 'multi_angle', label: 'Multi-Angle', desc: 'Multiple angles' }, - { value: 'still_with_exports', label: 'Still + Blend', desc: 'PNG + .blend export' }, - { value: 'custom', label: 'Custom', desc: 'Free canvas' }, - ] as { value: WorkflowPresetType; label: string; desc: string }[]).map(opt => ( - - ))} -
-
- - {referenceBlueprints.length > 0 && ( -
-
- - -
-
- {referenceBlueprints.map(workflow => { - const blueprint = getWorkflowBlueprint(workflow.config) - const family = inferWorkflowFamily(workflow.config) - const isSelected = selectedBlueprintId === workflow.id - return ( - - ) - })} -
- {selectedBlueprint && ( -

- New workflow will be cloned from {selectedBlueprint.name} - {selectedBlueprintFamily ? ` (${GRAPH_FAMILY_LABELS[selectedBlueprintFamily]})` : ''} - {selectedBlueprintLabel ? ` as ${selectedBlueprintLabel.toLowerCase()}` : ''}. -

- )} -
- )} -
- -
- - -
-
-
- ) -} - // ─── Flow Canvas ────────────────────────────────────────────────────────────── interface FlowCanvasProps { @@ -1447,374 +223,161 @@ interface FlowCanvasProps { } function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) { - const queryClient = useQueryClient() - const { data: nodeDefinitionsData } = useQuery({ - queryKey: ['workflow-node-definitions'], - queryFn: getNodeDefinitions, - staleTime: 5 * 60 * 1000, - }) - const nodeDefinitions = nodeDefinitionsData?.definitions ?? [] - const nodeDefinitionsByStep = Object.fromEntries(nodeDefinitions.map(definition => [definition.step, definition])) - const { nodes: initNodes, edges: initEdges } = workflowToGraph(workflow.config, nodeDefinitionsByStep) - const [nodes, setNodes, onNodesChange] = useNodesState(initNodes) - const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges) - const [selectedNodeId, setSelectedNodeId] = useState(null) - const [selectedRunId, setSelectedRunId] = useState(null) - const [dispatchContextId, setDispatchContextId] = useState('') - const [preflightResult, setPreflightResult] = useState(null) - const [executionMode, setExecutionMode] = useState(workflow.config.ui?.execution_mode ?? 'legacy') - const reactFlowWrapper = useRef(null) - const [reactFlowInstance, setReactFlowInstance] = useState(null) - const validation = validateWorkflowDraft(nodes, edges, nodeDefinitionsByStep, nodeDefinitions.length > 0) + const { + reactFlowWrapper, + nodeDefinitions, + nodeDefinitionsByStep, + nodes, + edges, + onNodesChange, + onEdgesChange, + selectedEdgeIds, + selectedNode, + workflowRuns, + selectedRun, + setSelectedRunId, + selectedRunComparison, + isComparisonLoading, + dispatchMutation, + preflightMutation, + dispatchContextId, + setDispatchContextId, + preflightResult, + executionMode, + setExecutionMode, + nodeMenuAnchor, + setNodeMenuAnchor, + activeUtilityTab, + setActiveUtilityTab, + validation, + graphFamily, + onConnect, + onNodeClick, + onEdgeClick, + onPaneClick, + handleParamsChange, + handlePipelineStepChange, + handlePaneContextMenu, + handleNodeContextMenu, + insertNode, + handleOpenToolbarNodeMenu, + handleAutoLayout, + handleDeleteSelectedEdges, + onEdgeContextMenu, + onEdgeDoubleClick, + handleSave, + handleDispatch, + handlePreflight, + setReactFlowInstance, + } = useWorkflowCanvasController({ workflow, onSave }) + const nodeMenuStyle = useMemo(() => { + if (!nodeMenuAnchor || !reactFlowWrapper.current) return null - const { data: workflowRuns = [] } = useQuery({ - queryKey: ['workflow-runs', workflow.id], - queryFn: () => getWorkflowRuns(workflow.id), - refetchInterval: 5000, - }) + const bounds = reactFlowWrapper.current.getBoundingClientRect() + const MENU_WIDTH = 380 + const HORIZONTAL_MARGIN = 16 + const VERTICAL_MARGIN = 16 - const selectedRun = workflowRuns.find(run => run.id === selectedRunId) ?? workflowRuns[0] ?? null + const left = Math.min( + Math.max(nodeMenuAnchor.clientX - bounds.left, HORIZONTAL_MARGIN), + Math.max(bounds.width - MENU_WIDTH - HORIZONTAL_MARGIN, HORIZONTAL_MARGIN), + ) + const top = Math.min( + Math.max(nodeMenuAnchor.clientY - bounds.top, VERTICAL_MARGIN), + Math.max(bounds.height - 160, VERTICAL_MARGIN), + ) - const { data: selectedRunComparison, isFetching: isComparisonLoading } = useQuery({ - queryKey: ['workflow-run-comparison', selectedRun?.id], - queryFn: () => getWorkflowRunComparison(selectedRun!.id), - enabled: Boolean(selectedRun?.id && selectedRun.execution_mode === 'shadow'), - refetchInterval: selectedRun?.status === 'pending' || selectedRun?.status === 'running' ? 5000 : false, - }) - - const dispatchMutation = useMutation({ - mutationFn: ({ contextId, config }: { contextId: string; config: WorkflowConfig }) => - dispatchWorkflowDraft({ - workflow_id: workflow.id, - context_id: contextId, - config, - }), - onSuccess: result => { - queryClient.invalidateQueries({ queryKey: ['workflow-runs', workflow.id] }) - setSelectedRunId(result.workflow_run.id) - toast.success(`Graph run dispatched: ${result.dispatched} task${result.dispatched === 1 ? '' : 's'}`) - }, - onError: (error: any) => { - toast.error(error?.response?.data?.detail || 'Failed to dispatch workflow') - }, - }) - - const preflightMutation = useMutation({ - mutationFn: ({ contextId, config }: { contextId: string; config: WorkflowConfig }) => - preflightWorkflowDraft({ - workflow_id: workflow.id, - context_id: contextId, - config, - }), - onSuccess: result => { - setPreflightResult(result) - if (result.graph_dispatch_allowed) { - toast.success(result.summary) - } else { - toast.error(result.summary) - } - }, - onError: (error: any) => { - setPreflightResult(null) - toast.error(error?.response?.data?.detail || 'Failed to preflight workflow') - }, - }) - - useEffect(() => { - const graph = workflowToGraph(workflow.config, nodeDefinitionsByStep) - setNodes(graph.nodes) - setEdges(graph.edges) - setSelectedNodeId(null) - setSelectedRunId(null) - setPreflightResult(null) - setExecutionMode(workflow.config.ui?.execution_mode ?? 'legacy') - }, [nodeDefinitionsData, setEdges, setNodes, workflow.config]) - - useEffect(() => { - if (!selectedRunId && workflowRuns.length > 0) { - setSelectedRunId(workflowRuns[0].id) - return - } - if (selectedRunId && !workflowRuns.some(run => run.id === selectedRunId)) { - setSelectedRunId(workflowRuns[0]?.id ?? null) - } - }, [selectedRunId, workflowRuns]) - - const onConnect = useCallback( - (connection: Connection) => setEdges(eds => addEdge(connection, eds)), - [setEdges], - ) - - const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => { - setSelectedNodeId(node.id) - }, []) - - const onPaneClick = useCallback(() => { - setSelectedNodeId(null) - }, []) - - const handleParamsChange = useCallback( - (newParams: WorkflowParams) => { - setNodes(nds => - nds.map(n => { - if (n.id === selectedNodeId) { - return { ...n, data: { ...n.data, params: normalizeWorkflowParams(newParams) } } - } - return n - }), - ) - }, - [selectedNodeId, setNodes], - ) - - const handlePipelineStepChange = useCallback( - (stepName: string) => { - const definition = nodeDefinitionsByStep[stepName] - setNodes(nds => - nds.map(n => { - if (n.id === selectedNodeId) { - const currentData = (n.data as WorkflowCanvasNodeData | undefined) ?? buildNodeData(stepName) - return { - ...n, - type: definition?.node_type ?? inferNodeType(stepName), - data: { - ...buildNodeData( - stepName || inferStepFromNodeType(n.type), - { - ...(definition?.defaults ?? {}), - ...currentData.params, - }, - definition, - ), - step: stepName || inferStepFromNodeType(n.type), - }, - } - } - return n - }), - ) - }, - [nodeDefinitionsByStep, selectedNodeId, setNodes], - ) - - // Drag-drop new nodes from palette - const onDragOver = useCallback((event: DragEvent) => { - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - }, []) - - const onDrop = useCallback( - (event: DragEvent) => { - event.preventDefault() - const step = event.dataTransfer.getData('application/workflow-step') - if (!step || !reactFlowInstance) return - - const definition = nodeDefinitionsByStep[step] - const type = definition?.node_type ?? inferNodeType(step) - - const position = reactFlowInstance.screenToFlowPosition({ - x: event.clientX, - y: event.clientY, - }) - - const newNode: Node = { - id: `${step}_${Date.now()}`, - type, - position, - data: buildNodeData(step, definition?.defaults ?? {}, definition), - } - setNodes(nds => [...nds, newNode]) - }, - [nodeDefinitionsByStep, reactFlowInstance, setNodes], - ) - - const handleNodePaletteDragStart = useCallback((event: DragEvent, step: string) => { - event.dataTransfer.setData('application/workflow-step', step) - event.dataTransfer.effectAllowed = 'move' - }, []) - - const handleSave = () => { - if (validation.errors.length > 0) { - toast.error('Resolve workflow validation errors before saving.') - return - } - const updatedConfig = buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode) - onSave(updatedConfig) - } - - const handleDispatch = () => { - if (!dispatchContextId.trim()) { - toast.error('Context ID is required for a graph test run.') - return - } - if (validation.errors.length > 0) { - toast.error('Resolve workflow validation errors before dispatching.') - return - } - dispatchMutation.mutate({ - contextId: dispatchContextId.trim(), - config: buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode), - }) - } - - const handlePreflight = () => { - if (!dispatchContextId.trim()) { - toast.error('Context ID is required for a graph preflight.') - return - } - if (validation.errors.length > 0) { - toast.error('Resolve workflow validation errors before running preflight.') - return - } - preflightMutation.mutate({ - contextId: dispatchContextId.trim(), - config: buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode), - }) - } - - const selectedNode = nodes.find(n => n.id === selectedNodeId) - const graphFamily = useMemo( - () => - inferWorkflowFamily( - buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode), - ), - [edges, executionMode, nodes, workflow], - ) + return { left, top } + }, [nodeMenuAnchor]) const { mode } = useThemeStore() const isDark = resolveTheme(mode) === 'dark' + const canvasEdges = useMemo( + () => + edges.map(edge => ({ + ...edge, + selectable: true, + focusable: true, + interactionWidth: (edge as Edge & { interactionWidth?: number }).interactionWidth ?? 44, + style: { + ...(edge.style ?? {}), + strokeWidth: edge.selected ? 3.25 : ((edge.style?.strokeWidth as number | undefined) ?? 2.25), + stroke: edge.selected ? (isDark ? '#f59e0b' : '#d97706') : edge.style?.stroke, + opacity: edge.selected ? 1 : 0.95, + }, + zIndex: edge.selected ? 20 : edge.zIndex, + })), + [edges, isDark], + ) return (
- + ({ + value: mode, + label: EXECUTION_MODE_LABELS[mode], + }))} + selectedEdgeCount={selectedEdgeIds.length} + canAutoLayout={nodes.length > 0} + hasValidationErrors={validation.errors.length > 0} + isPreflightPending={preflightMutation.isPending} + isDispatchPending={dispatchMutation.isPending} + isSaving={isSaving} + onDispatchContextIdChange={setDispatchContextId} + onExecutionModeChange={value => setExecutionMode(value as WorkflowExecutionMode)} + onOpenNodeMenu={handleOpenToolbarNodeMenu} + onAutoLayout={handleAutoLayout} + onDeleteSelectedEdges={handleDeleteSelectedEdges} + onPreflight={handlePreflight} + onDispatch={handleDispatch} + onSave={handleSave} + /> -
-
- - Canvas Controls -
-
- - - - - -
-
- -
-
- - {GRAPH_FAMILY_LABELS[graphFamily]} - -

{EXECUTION_MODE_HINTS[executionMode]}

-
-

- `Graph Run` dispatches the current editor graph directly for debugging and does not replace the legacy production path. -

- {graphFamily === 'mixed' && ( -

- Mixed CAD-file and order-line node families are not graph-dispatchable today. Split them into separate workflows for parity. -

- )} -

- `Dry Run` validates the current editor graph against the supplied context and reports graph-runtime blockers without dispatching tasks. -

-
- - {(validation.errors.length > 0 || validation.warnings.length > 0) && ( -
- {validation.errors.length > 0 && ( -
-
- - {validation.errors.length} validation error{validation.errors.length === 1 ? '' : 's'} -
-
    - {validation.errors.map(error => ( -
  • {error}
  • - ))} -
-
- )} - {validation.warnings.length > 0 && ( -
-
- {validation.warnings.length} warning{validation.warnings.length === 1 ? '' : 's'} -
-
    - {validation.warnings.map(warning => ( -
  • {warning}
  • - ))} -
-
- )} -
- )} + {/* Canvas + Sidepanel */}
event.preventDefault()} > + + {nodeMenuAnchor && nodeMenuStyle && ( +
+ setNodeMenuAnchor(null)} + onSelectStep={step => insertNode(step, nodeMenuAnchor.flowPosition)} + renderIcon={renderWorkflowIcon} + /> +
+ )}
- {nodeDefinitions.length > 0 && ( -
- {selectedNode ? ( - - ) : ( - - )} - - -
- )} + insertNode(step)} + renderNodeIcon={renderWorkflowIcon} + workflowRuns={workflowRuns} + selectedRunId={selectedRun?.id ?? null} + onSelectRun={setSelectedRunId} + comparison={selectedRunComparison} + isComparisonLoading={isComparisonLoading} + preflightResult={preflightResult} + isPreflightPending={preflightMutation.isPending} + />
) @@ -1868,6 +441,16 @@ export default function WorkflowEditor() { queryKey: ['workflows'], queryFn: getWorkflows, }) + const { data: nodeDefinitionsData } = useQuery({ + queryKey: ['workflow-node-definitions'], + queryFn: getNodeDefinitions, + staleTime: 5 * 60 * 1000, + }) + const nodeDefinitions = nodeDefinitionsData?.definitions ?? [] + const nodeDefinitionsByStep = useMemo( + () => Object.fromEntries(nodeDefinitions.map(definition => [definition.step, definition])), + [nodeDefinitions], + ) const createMutation = useMutation({ mutationFn: createWorkflow, @@ -1930,7 +513,7 @@ export default function WorkflowEditor() { } for (const workflow of workflows) { - grouped[inferWorkflowFamily(workflow.config)].push(workflow) + grouped[inferWorkflowFamily(workflow.config, nodeDefinitionsByStep)].push(workflow) } grouped.cad_file.sort(compareWorkflows) @@ -1938,7 +521,7 @@ export default function WorkflowEditor() { grouped.mixed.sort(compareWorkflows) return grouped - }, [workflows]) + }, [nodeDefinitionsByStep, workflows]) const typeLabel: Record = { still: 'Still', @@ -1960,180 +543,47 @@ export default function WorkflowEditor() { return (
- {/* Workflow List Sidebar */} - + return { + id: wf.id, + name: wf.name, + isActive: wf.is_active, + presetLabel: typeLabel[presetType], + presetClassName: typeBadgeColor[presetType], + familyLabel: GRAPH_FAMILY_LABELS[workflowFamily], + familyClassName: GRAPH_FAMILY_STYLES[workflowFamily], + executionModeLabel: EXECUTION_MODE_LABELS[executionMode], + executionModeClassName: EXECUTION_MODE_BADGE_STYLES[executionMode], + blueprintLabel: blueprint ? BLUEPRINT_LABELS[blueprint] ?? 'Blueprint' : null, + isReference: isReferenceBlueprint(wf.config), + } + }), + })) + .filter(section => section.items.length > 0)} + selectedId={selectedId} + onSelectWorkflow={setSelectedId} + onCreateWorkflow={() => setShowNewModal(true)} + onDeleteWorkflow={(workflowId, workflowName) => { + if (confirm(`Delete workflow "${workflowName}"?`)) { + deleteMutation.mutate(workflowId) + } + }} + /> {/* Main Canvas Area */}
- {/* Header */} -
-
-

Workflow Editor

- {selectedWorkflow && ( -
-

{selectedWorkflow.name}

- - {GRAPH_FAMILY_LABELS[inferWorkflowFamily(selectedWorkflow.config)]} - - - {EXECUTION_MODE_LABELS[selectedWorkflow.config.ui?.execution_mode ?? 'legacy']} - - {getWorkflowBlueprint(selectedWorkflow.config) && ( - - {BLUEPRINT_LABELS[getWorkflowBlueprint(selectedWorkflow.config)!] ?? 'Blueprint'} - - )} -
- )} - {selectedWorkflow && getWorkflowBlueprint(selectedWorkflow.config) && ( -

- {BLUEPRINT_DESCRIPTION[getWorkflowBlueprint(selectedWorkflow.config)!] ?? 'Reference workflow graph.'} -

- )} -
- -
- {/* Canvas or Empty State */} {selectedWorkflow ? ( ) : ( -
-
- - {workflows.length === 0 ? ( - <> -

No workflows configured.

-

- Workflows define the sequence of pipeline steps for rendering orders. - Click "New Workflow" to create one. -

- - ) : ( - <> -

No workflow selected

-

- Select a workflow from the list or create a new one. -

- - )} - -
-
+ 0} + onCreateWorkflow={() => setShowNewModal(true)} + /> )}
@@ -2178,6 +604,7 @@ export default function WorkflowEditor() { {showNewModal && ( setShowNewModal(false)} onCreate={handleCreate} isLoading={createMutation.isPending}