feat: refactor workflow editor authoring surfaces
This commit is contained in:
@@ -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'
|
||||
}
|
||||
+237
-1810
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user