feat: refactor workflow editor authoring surfaces

This commit is contained in:
2026-04-08 21:44:08 +02:00
parent fe46dabfc5
commit 042f62fe55
25 changed files with 4877 additions and 1823 deletions
@@ -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')
})
})
@@ -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(
<WorkflowNodeContractCard
moduleLabel="Rendering"
moduleKey="rendering.blender"
familyLabel="Order Rendering"
familyClassName="bg-emerald-100 text-emerald-700"
runtimeLabel="Graph Runtime"
runtimeClassName="bg-green-100 text-green-700"
legacyCompatible
legacySource="legacy.still_render"
inputContextLabel="Order Rendering"
outputContextLabel="Order Rendering"
requiredInputs={['order_line', 'render_template']}
consumedArtifacts={['cad_preview']}
providedOutputs={['render_image']}
producedArtifacts={['png_output']}
/>,
)
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(
<WorkflowCanvasToolbar
workflowName="Still Image - Graph"
blueprintLabel="Still Graph"
blueprintDescription="Reference graph for the non-legacy still render path."
graphFamilyLabel="Order Rendering"
graphFamilyClassName="bg-emerald-100 text-emerald-700"
executionMode="graph"
executionModeLabel="Graph"
executionModeClassName="bg-green-100 text-green-700"
executionModeHint="Production dispatch uses graph runtime with fallback."
dispatchContextId=""
executionModes={[
{ value: 'legacy', label: 'Legacy' },
{ value: 'graph', label: 'Graph' },
{ value: 'shadow', label: 'Shadow' },
]}
selectedEdgeCount={2}
canAutoLayout
hasValidationErrors={false}
isPreflightPending={false}
isDispatchPending={false}
isSaving={false}
onDispatchContextIdChange={onDispatchContextIdChange}
onExecutionModeChange={onExecutionModeChange}
onOpenNodeMenu={onOpenNodeMenu}
onAutoLayout={onAutoLayout}
onDeleteSelectedEdges={onDeleteSelectedEdges}
onPreflight={onPreflight}
onDispatch={onDispatch}
onSave={onSave}
/>,
)
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(
<WorkflowCanvasToolbar
workflowName="CAD Intake"
blueprintLabel={null}
blueprintDescription={null}
graphFamilyLabel="CAD Intake"
graphFamilyClassName="bg-sky-100 text-sky-700"
executionMode="legacy"
executionModeLabel="Legacy"
executionModeClassName="bg-slate-100 text-slate-700"
executionModeHint="Legacy dispatcher stays authoritative."
dispatchContextId=""
executionModes={[{ value: 'legacy', label: 'Legacy' }]}
selectedEdgeCount={0}
canAutoLayout={false}
hasValidationErrors
isPreflightPending={false}
isDispatchPending={false}
isSaving={false}
onDispatchContextIdChange={vi.fn()}
onExecutionModeChange={vi.fn()}
onOpenNodeMenu={vi.fn()}
onAutoLayout={vi.fn()}
onDeleteSelectedEdges={vi.fn()}
onPreflight={vi.fn()}
onDispatch={vi.fn()}
onSave={vi.fn()}
/>,
)
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(
<NodeCommandMenu
definitions={nodeDefinitions}
graphFamily="mixed"
onSelectStep={onSelectStep}
onClose={vi.fn()}
renderIcon={iconName => <span>{iconName}</span>}
/>,
)
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(<NodeDefinitionsPanel definitions={nodeDefinitions} graphFamily="mixed" />)
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(
<NodeDefinitionsPanel
definitions={nodeDefinitions}
graphFamily="mixed"
onSelectStep={onSelectStep}
renderIcon={iconName => <span>{iconName}</span>}
/>,
)
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(
<WorkflowRunsPanel
runs={[shadowRun]}
selectedRunId={shadowRun.id}
onSelectRun={onSelectRun}
comparison={shadowComparison}
isComparisonLoading={false}
/>,
)
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(<WorkflowPreflightPanel preflight={preflightResponse} isLoading={false} />)
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()
})
})
@@ -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<string, WorkflowNodeDefinition> = {
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,
})
})
})
@@ -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<WorkflowPresetType>('still_graph')
const [selectedBlueprintId, setSelectedBlueprintId] = useState<string | null>(null)
const [starterFamily, setStarterFamily] = useState<WorkflowStarterFamily>('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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-2xl rounded-xl bg-surface p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-content">New Workflow</h2>
<button onClick={onClose} className="text-content-muted hover:text-content">
<X size={20} />
</button>
</div>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm text-content-secondary">Name</label>
<input
className="w-full rounded-lg border border-border-default bg-surface px-3 py-2 text-sm text-content focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="e.g. Still Render Standard"
value={name}
onChange={event => setName(event.target.value)}
autoFocus
/>
</div>
<div>
<label className="mb-1 block text-sm text-content-secondary">Preset Workflow</label>
<div className="grid grid-cols-2 gap-2">
{([
{ 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 => (
<button
key={option.value}
onClick={() => setType(option.value)}
className={`rounded-lg border-2 p-3 text-left transition-colors ${
type === option.value
? 'border-accent bg-accent-light'
: 'border-border-default hover:border-border-light'
}`}
>
<p className="text-sm font-medium text-content">{option.label}</p>
<p className="mt-0.5 text-xs text-content-muted">{option.desc}</p>
</button>
))}
</div>
</div>
{!selectedBlueprint && type === 'custom' && (
<div>
<label className="mb-1 block text-sm text-content-secondary">Starter Family</label>
<div className="grid grid-cols-2 gap-2">
{([
{
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 => (
<button
key={option.value}
type="button"
onClick={() => setStarterFamily(option.value)}
className={`rounded-lg border-2 p-3 text-left transition-colors ${
starterFamily === option.value
? 'border-accent bg-accent-light'
: 'border-border-default hover:border-border-light'
}`}
>
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-content">{option.label}</p>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${FAMILY_FILTER_STYLES[option.value]}`}>
{FAMILY_FILTER_LABELS[option.value]}
</span>
</div>
<p className="mt-1 text-xs text-content-muted">{option.desc}</p>
</button>
))}
</div>
<p className="mt-2 text-xs text-content-muted">
Custom workflows now start from a family-safe starter graph instead of a mixed free canvas.
</p>
</div>
)}
{referenceBlueprints.length > 0 && (
<div>
<div className="mb-1 flex items-center justify-between gap-2">
<label className="block text-sm text-content-secondary">Reference Blueprint</label>
<button
type="button"
onClick={() => setSelectedBlueprintId(null)}
className={`text-xs ${selectedBlueprintId ? 'text-accent hover:underline' : 'text-content-muted'}`}
>
Use preset instead
</button>
</div>
<div className="grid grid-cols-1 gap-2">
{referenceBlueprints.map(workflow => {
const blueprint = getWorkflowBlueprint(workflow.config)
const family = inferWorkflowFamily(workflow.config, nodeDefinitionsByStep)
const isSelected = selectedBlueprintId === workflow.id
return (
<button
key={workflow.id}
type="button"
onClick={() => setSelectedBlueprintId(workflow.id)}
className={`rounded-lg border-2 p-3 text-left transition-colors ${
isSelected
? 'border-accent bg-accent-light'
: 'border-border-default hover:border-border-light'
}`}
>
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-content">{workflow.name}</p>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${GRAPH_FAMILY_STYLES[family]}`}>
{GRAPH_FAMILY_LABELS[family]}
</span>
{blueprint && (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">
{BLUEPRINT_LABELS[blueprint] ?? 'Blueprint'}
</span>
)}
</div>
<p className="mt-1 text-xs text-content-muted">
{blueprint ? BLUEPRINT_DESCRIPTION[blueprint] ?? 'Reference workflow graph.' : 'Reference workflow graph.'}
</p>
</button>
)
})}
</div>
{selectedBlueprint && (
<p className="mt-2 text-xs text-content-muted">
New workflow will be cloned from <span className="font-medium text-content">{selectedBlueprint.name}</span>
{selectedBlueprintFamily ? ` (${GRAPH_FAMILY_LABELS[selectedBlueprintFamily]})` : ''}
{selectedBlueprintLabel ? ` as ${selectedBlueprintLabel.toLowerCase()}` : ''}.
</p>
)}
</div>
)}
</div>
<div className="mt-6 flex justify-end gap-2">
<button
onClick={onClose}
className="rounded-lg border border-border-default px-4 py-2 text-sm text-content-secondary hover:bg-surface-hover"
>
Cancel
</button>
<button
disabled={!name.trim() || isLoading}
onClick={handleCreate}
className="rounded-lg bg-accent px-4 py-2 text-sm text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? 'Creating…' : 'Create'}
</button>
</div>
</div>
</div>
)
}
@@ -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 (
<div className="flex max-h-[70vh] w-[380px] flex-col overflow-hidden rounded-2xl border border-border-default bg-surface shadow-2xl">
<div className="border-b border-border-default px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-content">Add Workflow Node</p>
<p className="text-xs text-content-muted">
Search by label, step, family, or execution mode.
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-lg p-1 text-content-muted hover:bg-surface-hover hover:text-content"
title="Close node picker"
>
<X size={16} />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-3 py-3">
<WorkflowNodeCatalogBrowser
definitions={definitions}
graphFamily={graphFamily}
variant="menu"
onSelectStep={onSelectStep}
renderIcon={renderIcon}
searchPlaceholder="Search nodes"
autoFocusSearch
/>
</div>
</div>
)
}
@@ -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 (
<div className="space-y-3">
<div className="space-y-2 rounded-2xl border border-border-default bg-surface-hover/30 p-3">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Node Library
</p>
<span className="text-[11px] text-content-muted">
{onSelectStep ? 'Click insert to add to canvas' : `${definitions.length} definitions`}
</span>
</div>
<p className="text-xs text-content-muted">
Browse by runtime family and module contract, then insert nodes directly from the sidebar.
</p>
</div>
<WorkflowNodeCatalogBrowser
definitions={definitions}
graphFamily={graphFamily}
variant="panel"
onSelectStep={onSelectStep}
renderIcon={renderIcon}
/>
</div>
)
}
@@ -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 (
<div className="border-b border-border-default bg-surface px-3 py-2">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-1.5">
<div className="flex items-center gap-2 rounded-full border border-border-default bg-surface-hover/60 px-2 py-0.5 text-[11px] font-medium text-content-secondary">
<GitBranch size={13} />
Workflow Canvas
</div>
<h1 className="truncate text-sm font-semibold text-content">{workflowName}</h1>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${graphFamilyClassName}`}>
{graphFamilyLabel}
</span>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${executionModeClassName}`}>
{executionModeLabel}
</span>
{blueprintLabel && (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">
{blueprintLabel}
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2 text-[11px] text-content-muted">
{(blueprintDescription || executionModeHint) && (
<span className="inline-flex max-w-3xl items-center gap-1 rounded-full border border-border-default bg-surface px-2 py-0.5">
<BadgeInfo size={11} />
{blueprintDescription ?? executionModeHint}
</span>
)}
<span
className="inline-flex items-center gap-1 rounded-full border border-border-default bg-surface px-2 py-0.5"
title="Right-click anywhere on the canvas to open the searchable node picker."
>
<MousePointer2 size={11} />
Right-click to add
</span>
<span
className="inline-flex items-center gap-1 rounded-full border border-border-default bg-surface px-2 py-0.5"
title="Select an edge and press Delete, or use right-click / double-click to remove it."
>
<Trash2 size={11} />
Delete removes connections
</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={onOpenNodeMenu}
className="flex items-center gap-1.5 rounded-lg border border-border-default px-2.5 py-1.5 text-sm text-content hover:bg-surface-hover"
title="Open searchable node picker"
>
<Plus size={14} />
Node
</button>
<button
type="button"
onClick={onAutoLayout}
disabled={!canAutoLayout}
className="flex items-center gap-1.5 rounded-lg border border-border-default px-2.5 py-1.5 text-sm text-content hover:bg-surface-hover disabled:opacity-50"
title="Automatically align nodes into a readable graph layout"
>
<LayoutGrid size={14} />
Align
</button>
<button
type="button"
onClick={onDeleteSelectedEdges}
disabled={selectedEdgeCount === 0}
className="flex items-center gap-1.5 rounded-lg border border-border-default px-2.5 py-1.5 text-sm text-content hover:bg-surface-hover disabled:opacity-50"
title="Delete the currently selected connection(s)"
>
<Trash2 size={14} />
{selectedEdgeCount > 1 ? `Delete (${selectedEdgeCount})` : 'Delete'}
</button>
</div>
</div>
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 border-t border-border-default/70 pt-2">
<div className="flex flex-wrap items-center gap-2">
<label className="flex items-center gap-2 rounded-lg border border-border-default bg-surface px-2 py-1.5 text-xs text-content-secondary">
<span className="whitespace-nowrap">Context</span>
<input
value={dispatchContextId}
onChange={event => onDispatchContextIdChange(event.target.value)}
placeholder="context id"
className="w-40 bg-transparent text-sm text-content focus:outline-none lg:w-52"
/>
</label>
<label className="flex items-center gap-2 rounded-lg border border-border-default bg-surface px-2 py-1.5 text-xs text-content-secondary">
<span className="whitespace-nowrap">Mode</span>
<select
value={executionMode}
onChange={event => onExecutionModeChange(event.target.value)}
className="bg-transparent text-sm text-content focus:outline-none"
>
{executionModes.map(mode => (
<option key={mode.value} value={mode.value}>
{mode.label}
</option>
))}
</select>
</label>
<button
onClick={onPreflight}
disabled={isPreflightPending || hasValidationErrors}
className="flex items-center gap-1.5 rounded-lg border border-border-default px-2.5 py-1.5 text-sm text-content hover:bg-surface-hover disabled:opacity-50"
title="Validate graph runtime readiness without dispatching tasks"
>
{isPreflightPending ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
{isPreflightPending ? 'Checking…' : 'Dry Run'}
</button>
<button
onClick={onDispatch}
disabled={isDispatchPending || hasValidationErrors}
className="flex items-center gap-1.5 rounded-lg border border-border-default px-2.5 py-1.5 text-sm text-content hover:bg-surface-hover disabled:opacity-50"
title="Manual graph runtime dispatch for workflow debugging"
>
{isDispatchPending ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
{isDispatchPending ? 'Dispatching…' : 'Run'}
</button>
<button
onClick={onSave}
disabled={isSaving || hasValidationErrors}
className="flex items-center gap-1.5 rounded-lg bg-accent px-2.5 py-1.5 text-sm text-white hover:bg-accent-hover disabled:opacity-50"
>
<Save size={14} />
{isSaving ? 'Saving…' : 'Save'}
</button>
</div>
<p className="text-[11px] text-content-muted">
{executionModeHint}
</p>
</div>
</div>
)
}
@@ -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<string, WorkflowNodeDefinition>
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 (
<WorkflowUtilityRail
tabs={utilityTabs}
activeTab={activeTab}
onTabChange={onTabChange}
>
{activeTab === 'inspector' && selectedNode && (
<WorkflowNodeInspector
params={selectedNode.data?.params ?? {}}
onChange={onNodeParamsChange}
graphFamily={graphFamily}
step={selectedNode.data?.step}
onStepChange={onNodeStepChange}
nodeDefinition={selectedNode.data?.step ? nodeDefinitionsByStep[selectedNode.data.step] : undefined}
nodeDefinitions={nodeDefinitions}
/>
)}
{activeTab === 'inspector' && !selectedNode && (
<div className="rounded-2xl border border-dashed border-border-default bg-surface-hover/40 px-4 py-8 text-center">
<p className="text-sm font-medium text-content">No node selected</p>
<p className="mt-1 text-xs text-content-muted">
Select a node on the canvas to edit its settings here.
</p>
</div>
)}
{activeTab === 'library' && (
<NodeDefinitionsPanel
definitions={nodeDefinitions}
graphFamily={graphFamily}
onSelectStep={onInsertNode}
renderIcon={renderNodeIcon}
/>
)}
{activeTab === 'runs' && (
<WorkflowRunsPanel
runs={workflowRuns}
selectedRunId={selectedRunId}
onSelectRun={onSelectRun}
comparison={comparison ?? undefined}
isComparisonLoading={isComparisonLoading}
/>
)}
{activeTab === 'preflight' && (
<WorkflowPreflightPanel
preflight={preflightResult}
isLoading={isPreflightPending}
/>
)}
{activeTab === 'preflight' && !preflightResult && !isPreflightPending && (
<div className="rounded-2xl border border-dashed border-border-default bg-surface-hover/40 px-4 py-8 text-center">
<p className="text-sm font-medium text-content">No preflight yet</p>
<p className="mt-1 text-xs text-content-muted">
Run `Dry Run` to inspect graph readiness and node-level issues here.
</p>
</div>
)}
</WorkflowUtilityRail>
)
}
@@ -0,0 +1,42 @@
import { GitBranch, Plus } from 'lucide-react'
type WorkflowEditorEmptyStateProps = {
hasWorkflows: boolean
onCreateWorkflow: () => void
}
export function WorkflowEditorEmptyState({
hasWorkflows,
onCreateWorkflow,
}: WorkflowEditorEmptyStateProps) {
return (
<div className="flex-1 flex items-center justify-center text-center">
<div>
<GitBranch size={48} className="mx-auto text-content-muted mb-4" />
{hasWorkflows ? (
<>
<p className="text-content-secondary font-medium">No workflow selected</p>
<p className="text-sm text-content-muted mt-1">
Select a workflow from the list or create a new one.
</p>
</>
) : (
<>
<p className="text-content-secondary font-medium">No workflows configured.</p>
<p className="text-sm text-content-muted mt-1 max-w-xs mx-auto">
Workflows define the sequence of pipeline steps for rendering orders.
Click "New Workflow" to create one.
</p>
</>
)}
<button
onClick={onCreateWorkflow}
className="mt-4 flex items-center gap-2 px-4 py-2 mx-auto rounded-lg bg-accent text-white text-sm font-medium hover:bg-accent-hover"
>
<Plus size={16} />
New Workflow
</button>
</div>
</div>
)
}
@@ -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 (
<div className="border-b border-border-default bg-surface px-4 py-2">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h1 className="text-base font-semibold text-content">Workflow Editor</h1>
{workflowName && (
<>
<span className="text-sm text-content-muted">{workflowName}</span>
{familyLabel && familyClassName && (
<span className={`inline-block rounded-full px-1.5 py-0.5 text-xs font-medium ${familyClassName}`}>
{familyLabel}
</span>
)}
{executionModeLabel && executionModeClassName && (
<span className={`inline-block rounded-full px-1.5 py-0.5 text-xs font-medium ${executionModeClassName}`}>
{executionModeLabel}
</span>
)}
{blueprintLabel && (
<span className="inline-block rounded-full bg-slate-100 px-1.5 py-0.5 text-xs font-medium text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">
{blueprintLabel}
</span>
)}
</>
)}
</div>
</div>
{workflowName && blueprintDescription && (
<p className="mt-1 text-xs text-content-muted">{blueprintDescription}</p>
)}
</div>
)
}
@@ -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 (
<aside className="flex w-56 flex-shrink-0 flex-col border-r border-border-default bg-surface">
<div className="flex items-center justify-between border-b border-border-default p-3">
<div className="flex items-center gap-2 text-sm font-semibold text-content-secondary">
<GitBranch size={16} />
Workflows
</div>
<button
onClick={onCreateWorkflow}
className="rounded p-1 text-content-muted hover:bg-surface-hover hover:text-content"
title="New Workflow"
>
<Plus size={16} />
</button>
</div>
<div className="flex-1 space-y-1 overflow-y-auto p-2">
{isLoading && (
<p className="px-2 py-4 text-center text-xs text-content-muted">Loading</p>
)}
{!isLoading && workflowCount === 0 && (
<div className="px-2 py-4 text-center">
<p className="text-xs font-medium text-content-secondary">No workflows configured.</p>
<p className="mt-1 text-xs text-content-muted">
Workflows define the sequence of pipeline steps for rendering orders.
</p>
<button
onClick={onCreateWorkflow}
className="mt-2 text-xs text-accent hover:underline"
>
+ New Workflow
</button>
</div>
)}
{sections.map(section => (
<div key={section.key} className="space-y-1 pb-2">
<div className="flex items-center justify-between px-2 pt-1">
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${section.className}`}>
{section.label}
</span>
<span className="text-xs text-content-muted">{section.items.length}</span>
</div>
{section.items.map(item => (
<div
key={item.id}
role="button"
tabIndex={0}
onClick={() => onSelectWorkflow(item.id)}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onSelectWorkflow(item.id)
}
}}
className={`group w-full rounded-lg border px-3 py-2.5 text-left transition-colors focus:outline-none focus:ring-2 focus:ring-accent ${
selectedId === item.id
? 'border-accent/30 bg-accent-light'
: 'border-transparent hover:bg-surface-hover'
}`}
>
<div className="flex items-start justify-between gap-1">
<div className="flex min-w-0 items-center gap-1.5">
{item.isActive && (
<span className="h-2 w-2 flex-shrink-0 rounded-full bg-green-500" title="Active" />
)}
<p className="truncate text-sm font-medium text-content">{item.name}</p>
</div>
<button
type="button"
onClick={event => {
event.stopPropagation()
onDeleteWorkflow(item.id, item.name)
}}
className="flex-shrink-0 rounded p-0.5 text-content-muted opacity-0 hover:bg-red-100 hover:text-red-600 group-hover:opacity-100"
title="Delete"
>
<Trash2 size={12} />
</button>
</div>
<span className={`mt-1 inline-block rounded-full px-1.5 py-0.5 text-xs font-medium ${item.presetClassName}`}>
{item.presetLabel}
</span>
<span className={`ml-1 mt-1 inline-block rounded-full px-1.5 py-0.5 text-xs font-medium ${item.familyClassName}`}>
{item.familyLabel}
</span>
{item.blueprintLabel && (
<span className="ml-1 mt-1 inline-block rounded-full bg-slate-100 px-1.5 py-0.5 text-xs font-medium text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">
{item.blueprintLabel}
</span>
)}
<span className={`ml-1 mt-1 inline-block rounded-full px-1.5 py-0.5 text-xs font-medium ${item.executionModeClassName}`}>
{item.executionModeLabel}
</span>
{item.isReference && (
<p className="mt-1 text-xs text-content-muted">
Canonical reference workflow for parity work.
</p>
)}
{!item.isActive && (
<span className="ml-1 text-xs text-content-muted">(inactive)</span>
)}
</div>
))}
</div>
))}
</div>
</aside>
)
}
@@ -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<string, unknown>, 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<string, unknown>) {
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<WorkflowNodeFamilyFilter>(
graphFamily === 'mixed' ? 'all' : graphFamily,
)
const [kindFilter, setKindFilter] = useState<WorkflowNodeKindFilter>('all')
const [moduleFilter, setModuleFilter] = useState<string>('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<WorkflowNodeCatalogModuleFilter[]>(() => {
const modules = new Map<string, WorkflowNodeCatalogModuleFilter>()
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 (
<div className="space-y-3">
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2 text-[11px] text-content-muted">
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
{visibleDefinitions.length} nodes
</span>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
{totalModuleCount} modules
</span>
{graphFamily !== 'mixed' && (
<span className={`rounded-full px-2 py-0.5 font-medium ${FAMILY_FILTER_STYLES[graphFamily]}`}>
{FAMILY_FILTER_LABELS[graphFamily]}
</span>
)}
</div>
{moduleFilter !== 'all' && (
<button
type="button"
onClick={() => setModuleFilter('all')}
className="text-[11px] font-medium text-accent hover:text-accent-hover"
>
Show all modules
</button>
)}
</div>
<div className="relative">
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" />
<input
value={query}
autoFocus={autoFocusSearch}
onChange={event => 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"
/>
</div>
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{(['all', 'legacy', 'bridge', 'graph'] as WorkflowNodeKindFilter[]).map(filter => (
<button
key={filter}
type="button"
onClick={() => setKindFilter(filter)}
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
kindFilter === filter
? 'bg-accent text-white'
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
}`}
>
{NODE_KIND_FILTER_LABELS[filter]}
</button>
))}
</div>
<div className="flex flex-wrap gap-2">
{availableFamilyFilters.map(filter => (
<button
key={filter}
type="button"
onClick={() => setFamilyFilter(filter)}
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
familyFilter === filter
? 'bg-accent text-white'
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
}`}
>
{FAMILY_FILTER_LABELS[filter]}
</button>
))}
</div>
{moduleFilters.length > 0 && (
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-content-secondary">
Modules
</p>
<span className="text-[11px] text-content-muted">
family + runtime scoped
</span>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setModuleFilter('all')}
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
moduleFilter === 'all'
? 'bg-accent text-white'
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
}`}
>
All Modules
</button>
{moduleFilters.map(module => (
<button
key={module.namespace}
type="button"
onClick={() => setModuleFilter(module.namespace)}
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
moduleFilter === module.namespace
? 'bg-accent text-white'
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
}`}
title={module.label}
>
{module.namespace}
<span className="ml-1 opacity-70">{module.count}</span>
</button>
))}
</div>
</div>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
{(['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[]).map(group => {
const count = catalogSections.find(section => section.group === group)?.definitions.length ?? 0
if (count === 0) return null
return (
<span
key={group}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`}
title={NODE_LIBRARY_GROUP_DESCRIPTIONS[group]}
>
<span>{NODE_KIND_FILTER_LABELS[group]}</span>
<span>{count}</span>
</span>
)
})}
</div>
{visibleDefinitions.length === 0 && (
<div className="rounded-2xl border border-dashed border-border-default bg-surface-hover/40 px-4 py-8 text-center">
<p className="text-sm font-medium text-content">No matching nodes</p>
<p className="mt-1 text-xs text-content-muted">
Adjust search, runtime, family, or module filters to bring nodes back into view.
</p>
{onEmptyAction && (
<button
type="button"
onClick={onEmptyAction}
className="mt-3 rounded-lg border border-border-default px-3 py-1.5 text-xs font-medium text-content hover:bg-surface-hover"
>
{emptyActionLabel}
</button>
)}
</div>
)}
<div className="space-y-3">
{catalogSections.map(section => {
const group = section.group as WorkflowNodeLibraryGroup
return (
<div key={group} className="rounded-lg border border-border-default bg-surface-hover/40 p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`}>
{NODE_LIBRARY_GROUP_LABELS[group]}
</span>
<span className="text-xs text-content-muted">{section.definitions.length}</span>
</div>
<p className="mb-2 text-xs text-content-muted">{NODE_LIBRARY_GROUP_DESCRIPTIONS[group]}</p>
<div className="space-y-2">
{section.modules.map(moduleGroup => (
<div
key={`${group}:${moduleGroup.namespace}`}
className="rounded-md border border-border-default bg-surface/70 px-2 py-2"
>
<div className="flex items-center justify-between gap-2 py-1 text-xs text-content-secondary">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="rounded-full border border-border-default bg-surface px-1.5 py-0.5 font-medium">
{moduleGroup.label}
</span>
<span className="truncate rounded-full border border-border-default bg-surface px-1.5 py-0.5 font-mono text-[10px]">
{moduleGroup.namespace}
</span>
{moduleGroup.familyCounts.cad_file > 0 && (
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${FAMILY_FILTER_STYLES.cad_file}`}>
{FAMILY_FILTER_LABELS.cad_file}
</span>
)}
{moduleGroup.familyCounts.order_line > 0 && (
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${FAMILY_FILTER_STYLES.order_line}`}>
{FAMILY_FILTER_LABELS.order_line}
</span>
)}
</div>
<span className="text-content-muted">{moduleGroup.definitions.length}</span>
</div>
<div className="mt-1 space-y-2">
{moduleGroup.categories.map(categorySection => {
const { category, definitions: categoryDefinitions } = categorySection
return (
<div key={`${group}:${moduleGroup.namespace}:${category}`}>
<div className="mb-1 flex items-center justify-between gap-2">
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_COLORS[category]}`}>
{CATEGORY_LABELS[category]}
</span>
<span className="text-[10px] text-content-muted">{categoryDefinitions.length}</span>
</div>
<div className="space-y-1">
{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 (
<div
key={definition.step}
className={`rounded-lg border border-border-default bg-surface px-3 py-2 ${
isActionable ? 'transition-colors hover:bg-surface-hover' : ''
}`}
title={definition.description}
>
<div className="flex items-start gap-2">
{renderIcon && (
<span className="mt-0.5 text-content-secondary">
{renderIcon(definition.icon, variant === 'menu' ? 14 : 13)}
</span>
)}
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<p className="truncate text-sm font-medium text-content">{definition.label}</p>
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${FAMILY_FILTER_STYLES[family]}`}>
{FAMILY_FILTER_LABELS[family]}
</span>
{getDefinitionBadges(definition).map(badge => (
<span
key={`${definition.step}-${badge.label}`}
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${badge.className}`}
>
{badge.label}
</span>
))}
</div>
<p className="mt-0.5 truncate font-mono text-[11px] text-content-muted">{definition.step}</p>
</div>
{isActionable && (
<button
type="button"
onClick={() => onSelectStep?.(definition.step)}
className={`shrink-0 rounded-lg px-2 py-1 text-xs font-medium ${
variant === 'panel'
? 'border border-border-default text-content hover:bg-surface-hover'
: 'bg-accent text-white hover:bg-accent-hover'
}`}
>
{variant === 'panel' ? (
<span className="inline-flex items-center gap-1">
<Plus size={12} />
Insert
</span>
) : (
<span className="inline-flex items-center gap-1">
Use
<ArrowRight size={12} />
</span>
)}
</button>
)}
</div>
<p className="mt-1 line-clamp-2 text-xs text-content-muted">{definition.description}</p>
<div className="mt-2 flex flex-wrap gap-1.5 text-[10px]">
{inputContext && (
<span className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary">
In {formatContractLabel(inputContext)}
</span>
)}
{outputContext && (
<span className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary">
Out {formatContractLabel(outputContext)}
</span>
)}
{requiredInputs.slice(0, 2).map(input => (
<span
key={`${definition.step}-requires-${input}`}
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
>
Requires {formatContractLabel(input)}
</span>
))}
{providedOutputs.slice(0, 2).map(output => (
<span
key={`${definition.step}-provides-${output}`}
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
>
Provides {formatContractLabel(output)}
</span>
))}
{definition.artifact_roles_consumed.slice(0, 1).map(artifact => (
<span
key={`${definition.step}-consumes-${artifact}`}
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
>
Consumes {formatContractLabel(artifact)}
</span>
))}
{definition.artifact_roles_produced.slice(0, 1).map(artifact => (
<span
key={`${definition.step}-produces-${artifact}`}
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
>
Produces {formatContractLabel(artifact)}
</span>
))}
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
</div>
))}
</div>
</div>
)
})}
</div>
</div>
)
}
@@ -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 (
<div className="flex flex-wrap gap-1.5">
{roles.map(role => (
<span
key={role}
className="rounded-full border border-border-default bg-surface-hover px-2 py-0.5 text-[11px] text-content-secondary"
>
{formatContractRole(role)}
</span>
))}
</div>
)
}
export function WorkflowNodeContractCard({
moduleLabel,
moduleKey,
familyLabel,
familyClassName,
runtimeLabel,
runtimeClassName,
legacyCompatible,
legacySource,
inputContextLabel,
outputContextLabel,
requiredInputs,
consumedArtifacts,
providedOutputs,
producedArtifacts,
}: WorkflowNodeContractCardProps) {
return (
<div className="space-y-3 rounded-xl border border-border-default bg-surface-hover/40 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Production Module
</p>
<p className="mt-1 text-sm font-medium text-content">{moduleLabel}</p>
<p className="mt-1 break-all font-mono text-[11px] text-content-muted">{moduleKey}</p>
</div>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${familyClassName}`}>
{familyLabel}
</span>
</div>
<div className="flex flex-wrap gap-2">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${runtimeClassName}`}>
{runtimeLabel}
</span>
{legacyCompatible && (
<span className="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">
Legacy Safe
</span>
)}
{legacySource && (
<span className="inline-flex items-center rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-secondary">
{legacySource}
</span>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2 rounded-lg border border-border-default bg-surface px-3 py-2">
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">Inputs</p>
{inputContextLabel && <p className="text-xs text-content-muted">Context: {inputContextLabel}</p>}
{requiredInputs.length > 0 ? (
<ContractRolePills roles={requiredInputs} />
) : (
<p className="text-xs text-content-muted">No declared upstream requirements.</p>
)}
{consumedArtifacts.length > 0 && (
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">Artifacts Consumed</p>
<ContractRolePills roles={consumedArtifacts} />
</div>
)}
</div>
<div className="space-y-2 rounded-lg border border-border-default bg-surface px-3 py-2">
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">Outputs</p>
{outputContextLabel && <p className="text-xs text-content-muted">Context: {outputContextLabel}</p>}
{providedOutputs.length > 0 ? (
<ContractRolePills roles={providedOutputs} />
) : (
<p className="text-xs text-content-muted">No declared downstream outputs.</p>
)}
{producedArtifacts.length > 0 && (
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">Artifacts Produced</p>
<ContractRolePills roles={producedArtifacts} />
</div>
)}
</div>
</div>
</div>
)
}
@@ -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<Record<string, WorkflowNodeFieldDefinition[]>>((sections, field) => {
const section = field.section || 'General'
sections[section] = [...(sections[section] ?? []), field]
return sections
}, {})
}
function getContractValues(contract: Record<string, unknown> | 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<string, unknown> | 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<HTMLInputElement>) => {
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<string, unknown> | undefined)
const outputContextLabel = getContractContextLabel(nodeDefinition?.output_contract as Record<string, unknown> | undefined)
const requiredInputs = getContractValues(nodeDefinition?.input_contract as Record<string, unknown> | undefined, 'requires')
const providedOutputs = getContractValues(nodeDefinition?.output_contract as Record<string, unknown> | undefined, 'provides')
const consumedArtifacts = nodeDefinition?.artifact_roles_consumed ?? []
const producedArtifacts = nodeDefinition?.artifact_roles_produced ?? []
return (
<div className="space-y-5">
<h3 className="font-semibold text-content">Node Configuration</h3>
{nodeDefinitions.length > 0 && onStepChange && (
<div>
<label className="text-sm text-content-secondary mb-2 block">Workflow Node</label>
<select
value={step ?? ''}
onChange={event => onStepChange(event.target.value)}
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"
>
{nodeSelectionGroups.map(group => (
<optgroup key={group.label} label={group.label}>
{group.options.map(definition => (
<option key={definition.step} value={definition.step}>
{definition.label}
</option>
))}
</optgroup>
))}
</select>
{nodeDefinition && (
<div className="mt-2 space-y-1">
<p className="text-xs text-content-muted">{nodeDefinition.description}</p>
{graphFamily !== 'mixed' && (
<p className="text-xs text-content-muted">
Step selection is scoped to {FAMILY_FILTER_LABELS[graphFamily]} nodes for this workflow.
</p>
)}
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${
nodeDefinition.execution_kind === 'bridge'
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
}`}
>
{nodeDefinition.execution_kind === 'bridge' ? 'Legacy Bridge' : 'Native Node'}
</span>
</div>
)}
</div>
)}
{nodeDefinition && (
<WorkflowNodeContractCard
moduleLabel={getDefinitionModuleLabel(nodeDefinition)}
moduleKey={nodeDefinition.module_key}
familyLabel={FAMILY_FILTER_LABELS[getDefinitionFamily(nodeDefinition)]}
familyClassName={FAMILY_FILTER_STYLES[getDefinitionFamily(nodeDefinition)]}
runtimeLabel={nodeDefinition.execution_kind === 'bridge' ? 'Bridge Runtime' : 'Graph Runtime'}
runtimeClassName={
nodeDefinition.execution_kind === 'bridge'
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
}
legacyCompatible={nodeDefinition.legacy_compatible}
legacySource={nodeDefinition.legacy_source}
inputContextLabel={inputContextLabel}
outputContextLabel={outputContextLabel}
requiredInputs={requiredInputs}
consumedArtifacts={consumedArtifacts}
providedOutputs={providedOutputs}
producedArtifacts={producedArtifacts}
/>
)}
{Object.keys(fieldsBySection).length === 0 && (
<p className="text-sm text-content-muted">
This node currently has no configurable settings in the editor.
</p>
)}
{Object.entries(fieldsBySection).map(([section, fields]) => (
<div key={section} className="space-y-3">
<h4 className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
{section}
</h4>
{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 (
<div key={field.key}>
<label className="text-sm text-content-secondary mb-1 block">
{field.label}
{field.unit ? ` (${field.unit})` : ''}
</label>
{field.type === 'select' && (
<select
value={String(value ?? '')}
onChange={event => 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.options.map(option => (
<option key={String(option.value)} value={String(option.value)}>
{option.label}
</option>
))}
</select>
)}
{field.type === 'number' && (
<input
type="number"
min={field.min ?? undefined}
max={field.max ?? undefined}
step={field.step ?? undefined}
value={typeof value === 'number' ? value : value == null ? '' : Number(value)}
onChange={event => 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' && (
<label className="flex items-center gap-2 rounded-lg border border-border-default px-3 py-2 text-sm text-content">
<input
type="checkbox"
checked={Boolean(value)}
onChange={event => updateField(field, event.target.checked)}
disabled={disableRenderOverrideField}
className="accent-accent"
/>
<span>{Boolean(value) ? 'Enabled' : 'Disabled'}</span>
</label>
)}
{field.type === 'text' && (
<input
type="text"
value={value == null ? '' : String(value)}
onChange={event => 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 && (
<p className="mt-1 text-xs text-content-muted">{field.description}</p>
)}
{disableRenderOverrideField && (
<p className="mt-1 text-xs text-content-muted">
In Graph/Shadow mode this field inherits from Output Type and Template until
Custom Render Settings is enabled.
</p>
)}
</div>
)
})}
</div>
))}
</div>
)
}
@@ -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 (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h3 className="font-semibold text-content">Graph Preflight</h3>
{isLoading && <Loader2 size={14} className="animate-spin text-content-muted" />}
</div>
{preflight && (
<div className="space-y-3 rounded-lg border border-border-default bg-surface-hover/40 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium text-content">{preflight.summary}</p>
<p className="mt-1 text-xs text-content-muted">
Expected `{preflight.expected_context_kind}` · Resolved `{preflight.context_kind ?? 'n/a'}`
</p>
</div>
<span className={`rounded-full px-1.5 py-0.5 text-[11px] font-medium ${getPreflightStatusClassName(preflight.graph_dispatch_allowed ? 'ready' : 'error')}`}>
{preflight.graph_dispatch_allowed ? 'ready' : 'blocked'}
</span>
</div>
{(preflight.resolved_order_line_id || preflight.resolved_cad_file_id) && (
<div className="space-y-1 text-xs text-content-muted">
{preflight.resolved_order_line_id && <p>Order Line: {preflight.resolved_order_line_id}</p>}
{preflight.resolved_cad_file_id && <p>CAD File: {preflight.resolved_cad_file_id}</p>}
</div>
)}
{preflight.issues.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Global Issues
</p>
{preflight.issues.map(issue => (
<div key={`${issue.code}-${issue.message}`} className="rounded-md border border-border-default bg-surface px-2.5 py-2">
<div className="flex items-center justify-between gap-2">
<span className="text-sm text-content">{issue.message}</span>
<span className={`rounded-full px-1.5 py-0.5 text-[11px] font-medium ${getPreflightStatusClassName(issue.severity)}`}>
{issue.severity}
</span>
</div>
</div>
))}
</div>
)}
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Node Checks
</p>
{preflight.nodes.map(node => (
<div key={node.node_id} className="rounded-md border border-border-default bg-surface px-2.5 py-2">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-content">{node.label ?? node.node_id}</p>
<p className="truncate text-xs text-content-muted">{node.step}</p>
</div>
<span className={`rounded-full px-1.5 py-0.5 text-[11px] font-medium ${getPreflightStatusClassName(node.status)}`}>
{node.status}
</span>
</div>
{node.issues.length > 0 && (
<div className="mt-2 space-y-1">
{node.issues.map(issue => (
<p key={`${node.node_id}-${issue.code}-${issue.message}`} className="text-xs text-content-muted">
{issue.message}
</p>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)
}
@@ -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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-content">Workflow Runs</h3>
<span className="text-xs text-content-muted">{runs.length}</span>
</div>
{runs.length === 0 && (
<p className="text-sm text-content-muted">
No workflow runs recorded for this workflow yet.
</p>
)}
{runs.length > 0 && (
<div className="space-y-2">
{runs.slice(0, 8).map(run => (
<button
key={run.id}
type="button"
onClick={() => onSelectRun(run.id)}
className={`w-full rounded-lg border px-3 py-2 text-left transition-colors ${
run.id === selectedRunId
? 'border-accent bg-accent-light'
: 'border-border-default hover:bg-surface-hover'
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium text-content">{run.id.slice(0, 8)}</span>
<span className={`rounded-full px-1.5 py-0.5 text-[11px] font-medium ${getRunStatusClassName(run.status)}`}>
{run.status}
</span>
</div>
<div className="mt-1 flex items-center gap-1.5 text-xs text-content-muted">
<span className={`rounded-full px-1.5 py-0.5 font-medium ${EXECUTION_MODE_BADGE_STYLES[run.execution_mode]}`}>
{EXECUTION_MODE_LABELS[run.execution_mode]}
</span>
<span>{formatDateTime(run.created_at)}</span>
</div>
</button>
))}
</div>
)}
{selectedRun && (
<div className="space-y-3 rounded-lg border border-border-default bg-surface-hover/40 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium text-content">Run {selectedRun.id.slice(0, 8)}</p>
<p className="text-xs text-content-muted">
Started {formatDateTime(selectedRun.started_at ?? selectedRun.created_at)}
</p>
</div>
<span className={`rounded-full px-1.5 py-0.5 text-[11px] font-medium ${getRunStatusClassName(selectedRun.status)}`}>
{selectedRun.status}
</span>
</div>
{selectedRun.error_message && (
<p className="text-xs text-red-600 dark:text-red-300">{selectedRun.error_message}</p>
)}
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Node Results
</p>
{selectedRun.node_results.map(result => (
<div key={result.id} className="rounded-md border border-border-default bg-surface px-2.5 py-2">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-content">{result.node_name}</span>
<span className={`rounded-full px-1.5 py-0.5 text-[11px] font-medium ${getRunStatusClassName(result.status)}`}>
{result.status}
</span>
</div>
{result.log && (
<p className="mt-1 line-clamp-3 text-xs text-content-muted">{result.log}</p>
)}
</div>
))}
</div>
{selectedRun.execution_mode === 'shadow' && (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Shadow Comparison
</p>
{isComparisonLoading && <Loader2 size={14} className="animate-spin text-content-muted" />}
</div>
{comparison && (
<div className="space-y-1.5 rounded-md border border-border-default bg-surface px-2.5 py-2 text-xs text-content-muted">
<p className="text-sm text-content">{comparison.summary}</p>
<p>Status: {comparison.status}</p>
<p>
Authoritative: {comparison.authoritative_output.image_width ?? '?'} x {comparison.authoritative_output.image_height ?? '?'}
</p>
<p>
Observer: {comparison.observer_output.image_width ?? '?'} x {comparison.observer_output.image_height ?? '?'}
</p>
{comparison.mean_pixel_delta != null && (
<p>Mean Pixel Delta: {comparison.mean_pixel_delta.toFixed(6)}</p>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,73 @@
import type { ReactNode } from 'react'
import { PanelRight, type LucideIcon } from 'lucide-react'
interface WorkflowUtilityTabItem<T extends string> {
key: T
label: string
icon: LucideIcon
count?: number | null
disabled?: boolean
}
interface WorkflowUtilityRailProps<T extends string> {
tabs: WorkflowUtilityTabItem<T>[]
activeTab: T
onTabChange: (tab: T) => void
children: ReactNode
}
export function WorkflowUtilityRail<T extends string>({
tabs,
activeTab,
onTabChange,
children,
}: WorkflowUtilityRailProps<T>) {
return (
<div className="flex w-[22rem] flex-col border-l border-border-default bg-surface">
<div className="border-b border-border-default px-3 py-2">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-xl border border-border-default bg-surface-hover text-content-secondary">
<PanelRight size={15} />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-content">Utility Rail</p>
<p className="text-xs text-content-muted">
Context-aware tools without sacrificing canvas space.
</p>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
{tabs.map(tab => {
const Icon = tab.icon
const isActive = activeTab === tab.key
return (
<button
key={tab.key}
type="button"
disabled={tab.disabled}
onClick={() => onTabChange(tab.key)}
className={`flex items-center justify-between gap-2 rounded-xl border px-3 py-2 text-left transition-colors ${
isActive
? 'border-accent/40 bg-accent-light text-content'
: 'border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
} disabled:cursor-not-allowed disabled:opacity-50`}
>
<span className="flex items-center gap-2 text-sm font-medium">
<Icon size={14} />
{tab.label}
</span>
{typeof tab.count === 'number' && (
<span className="rounded-full bg-surface-hover px-1.5 py-0.5 text-[11px] text-content-muted">
{tab.count}
</span>
)}
</button>
)
})}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">{children}</div>
</div>
)
}
@@ -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 (
<div className="space-y-2 border-b border-border-default bg-surface px-4 py-3">
{errors.length > 0 && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900/40 dark:bg-red-950/20 dark:text-red-300">
<div className="flex items-center gap-2 font-medium">
<AlertTriangle size={14} />
{errors.length} validation error{errors.length === 1 ? '' : 's'}
</div>
<ul className="mt-2 space-y-1 text-xs">
{errors.map(error => (
<li key={error}>{error}</li>
))}
</ul>
</div>
)}
{warnings.length > 0 && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300">
<div className="font-medium">
{warnings.length} warning{warnings.length === 1 ? '' : 's'}
</div>
<ul className="mt-2 space-y-1 text-xs">
{warnings.map(warning => (
<li key={warning}>{warning}</li>
))}
</ul>
</div>
)}
</div>
)
}
@@ -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>,
): 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<string | null>(null)
const [selectedRunId, setSelectedRunId] = useState<string | null>(null)
const [dispatchContextId, setDispatchContextId] = useState('')
const [preflightResult, setPreflightResult] = useState<WorkflowPreflightResponse | null>(null)
const [executionMode, setExecutionMode] = useState<WorkflowExecutionMode>(workflow.config.ui?.execution_mode ?? 'legacy')
const [nodeMenuAnchor, setNodeMenuAnchor] = useState<NodeMenuAnchor | null>(null)
const [activeUtilityTab, setActiveUtilityTab] = useState<WorkflowUtilityTab>('library')
const reactFlowWrapper = useRef<HTMLDivElement>(null)
const [reactFlowInstance, setReactFlowInstance] = useState<ReactFlowInstance<Node, Edge> | 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,
}
}
@@ -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<string, string> = {
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<string, string> = {
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)
}
@@ -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<string>
executedSteps: Set<string>
}
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<WorkflowNodeContractContext, string[]> = {
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<string, unknown> | undefined,
): WorkflowNodeContractContext | null {
const value = contract?.context
return value === 'cad_file' || value === 'order_line' ? value : null
}
function getContractValues(
contract: Record<string, unknown> | 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<string, unknown> | 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<WorkflowNodeContractContext>()
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<string> {
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<string, WorkflowNodeDefinition>,
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<string>()
const connectedNodeIds = new Set<string>()
const inDegree = new Map<string, number>()
const adjacency = new Map<string, string[]>()
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<string>()
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<string, WorkflowSemanticState>()
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<string, unknown> | undefined)
const outputContext = getContractContext(definition?.output_contract as Record<string, unknown> | undefined)
const upstreamNodeIds = edges.filter(edge => edge.target === nodeId).map(edge => edge.source)
const availableValues = new Set<string>()
const executedSteps = new Set<string>()
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<string, unknown> | undefined, 'requires'),
...(definition.artifact_roles_consumed ?? []),
])
const alternativeRequiredGroups = getContractAlternativeValues(
definition.input_contract as Record<string, unknown> | 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<string>()
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<string, unknown> | 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<string, WorkflowNodeDefinition>,
): { 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<string, number>()
const adjacency = new Map<string, string[]>()
const layerByNodeId = new Map<string, number>()
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<string>()
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<number, Node[]>()
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,
},
}
})
}
@@ -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<WorkflowNodeFamily, number>
}
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<string, WorkflowNodeDefinition[]>
>()
for (const definition of definitions) {
const group = getPrimaryLibraryGroup(definition)
const namespace = getDefinitionModuleNamespace(definition)
const modules = groupEntries.get(group) ?? new Map<string, WorkflowNodeDefinition[]>()
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<string, WorkflowNodeDefinition[]>()
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)
}
@@ -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<StepCategory, string> = {
input: 'Input',
processing: 'Processing',
rendering: 'Rendering',
output: 'Output',
}
export const CATEGORY_COLORS: Record<StepCategory, string> = {
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<WorkflowNodeFamilyFilter, string> = {
all: 'All Nodes',
cad_file: 'CAD Intake',
order_line: 'Order Rendering',
}
export const NODE_KIND_FILTER_LABELS: Record<WorkflowNodeKindFilter, string> = {
all: 'All Modes',
legacy: 'Legacy',
bridge: 'Bridge',
graph: 'Graph',
}
export const NODE_LIBRARY_GROUP_LABELS: Record<WorkflowNodeLibraryGroup, string> = {
legacy: 'Legacy Nodes',
bridge: 'Bridge Nodes',
graph: 'Graph Nodes',
}
export const NODE_LIBRARY_GROUP_STYLES: Record<WorkflowNodeLibraryGroup, string> = {
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<WorkflowNodeLibraryGroup, string> = {
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<WorkflowNodeFamily, string> = {
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<WorkflowNodeFamily, string> = {
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<WorkflowGraphFamily, string> = {
cad_file: 'CAD Intake',
order_line: 'Order Rendering',
mixed: 'Mixed Family',
}
export const GRAPH_FAMILY_STYLES: Record<WorkflowGraphFamily, string> = {
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<string, WorkflowNodeDefinition>
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<string, WorkflowNodeDefinition[]>()
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<WorkflowNodeFamily, WorkflowNodeDefinition[]>
}
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<WorkflowNodeLibraryGroup, WorkflowNodeDefinition[]>
}
@@ -0,0 +1,33 @@
import type { WorkflowExecutionMode } from '../../api/workflows'
export const EXECUTION_MODE_LABELS: Record<WorkflowExecutionMode, string> = {
legacy: 'Legacy',
shadow: 'Shadow',
graph: 'Graph',
}
export const EXECUTION_MODE_BADGE_STYLES: Record<WorkflowExecutionMode, string> = {
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'
}
File diff suppressed because it is too large Load Diff