chore: snapshot workflow migration progress

This commit is contained in:
2026-04-12 11:49:04 +02:00
parent 0cd02513d5
commit 3e810c74a3
163 changed files with 31774 additions and 2753 deletions
+119 -107
View File
@@ -1,30 +1,40 @@
import { Suspense, lazy } from 'react'
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { useAuthStore, isPrivileged as checkIsPrivileged } from './store/auth'
import { WebSocketProvider } from './contexts/WebSocketContext'
import Layout from './components/layout/Layout'
import LoginPage from './pages/Login'
import NotFoundPage from './pages/NotFound'
import DashboardPage from './pages/Dashboard'
import OrdersPage from './pages/Orders'
import OrderDetailPage from './pages/OrderDetail'
import NewOrderPage from './pages/NewOrder'
import UploadPage from './pages/Upload'
import AdminPage from './pages/Admin'
import CadPreviewPage from './pages/CadPreview'
import MaterialsPage from './pages/Materials'
import WorkerActivityPage from './pages/WorkerActivity'
import ProductLibraryPage from './pages/ProductLibrary'
import ProductDetailPage from './pages/ProductDetail'
import NewProductOrderPage from './pages/NewProductOrder'
import NotificationsPage from './pages/Notifications'
import NotificationSettingsPage from './pages/NotificationSettings'
import PreferencesPage from './pages/Preferences'
import TenantsPage from './pages/Tenants'
import WorkflowEditorPage from './pages/WorkflowEditor'
import MediaBrowserPage from './pages/MediaBrowser'
import BillingPage from './pages/Billing'
import WorkerManagementPage from './pages/WorkerManagement'
import AssetLibraryPage from './pages/AssetLibrary'
const LoginPage = lazy(() => import('./pages/Login'))
const NotFoundPage = lazy(() => import('./pages/NotFound'))
const DashboardPage = lazy(() => import('./pages/Dashboard'))
const OrdersPage = lazy(() => import('./pages/Orders'))
const OrderDetailPage = lazy(() => import('./pages/OrderDetail'))
const NewOrderPage = lazy(() => import('./pages/NewOrder'))
const UploadPage = lazy(() => import('./pages/Upload'))
const AdminPage = lazy(() => import('./pages/Admin'))
const CadPreviewPage = lazy(() => import('./pages/CadPreview'))
const MaterialsPage = lazy(() => import('./pages/Materials'))
const WorkerActivityPage = lazy(() => import('./pages/WorkerActivity'))
const ProductLibraryPage = lazy(() => import('./pages/ProductLibrary'))
const ProductDetailPage = lazy(() => import('./pages/ProductDetail'))
const NewProductOrderPage = lazy(() => import('./pages/NewProductOrder'))
const NotificationsPage = lazy(() => import('./pages/Notifications'))
const NotificationSettingsPage = lazy(() => import('./pages/NotificationSettings'))
const PreferencesPage = lazy(() => import('./pages/Preferences'))
const TenantsPage = lazy(() => import('./pages/Tenants'))
const WorkflowEditorPage = lazy(() => import('./pages/WorkflowEditor'))
const MediaBrowserPage = lazy(() => import('./pages/MediaBrowser'))
const BillingPage = lazy(() => import('./pages/Billing'))
const WorkerManagementPage = lazy(() => import('./pages/WorkerManagement'))
const AssetLibraryPage = lazy(() => import('./pages/AssetLibrary'))
function RouteFallback() {
return (
<div className="flex min-h-[40vh] items-center justify-center text-sm text-content-muted">
Loading...
</div>
)
}
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token)
@@ -42,91 +52,93 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
export default function App() {
return (
<BrowserRouter>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<WebSocketProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<DashboardPage />} />
<Route path="orders" element={<OrdersPage />} />
<Route path="orders/new" element={<NewOrderPage />} />
<Route path="orders/new/product" element={<NewProductOrderPage />} />
<Route path="orders/:id" element={<OrderDetailPage />} />
<Route path="upload" element={<UploadPage />} />
<Route
path="admin"
element={
<AdminRoute>
<AdminPage />
</AdminRoute>
}
/>
<Route
path="tenants"
element={
<AdminRoute>
<TenantsPage />
</AdminRoute>
}
/>
<Route
path="workflows"
element={
<AdminRoute>
<WorkflowEditorPage />
</AdminRoute>
}
/>
<Route path="materials" element={<MaterialsPage />} />
<Route path="activity" element={<WorkerActivityPage />} />
<Route path="products" element={<ProductLibraryPage />} />
<Route path="products/:id" element={<ProductDetailPage />} />
<Route path="notifications" element={<NotificationsPage />} />
<Route path="notification-settings" element={<NotificationSettingsPage />} />
<Route path="preferences" element={<PreferencesPage />} />
<Route path="cad/:id" element={<CadPreviewPage />} />
<Route
path="media"
element={
<AdminRoute>
<MediaBrowserPage />
</AdminRoute>
}
/>
<Route
path="billing"
element={
<AdminRoute>
<BillingPage />
</AdminRoute>
}
/>
<Route
path="workers"
element={
<AdminRoute>
<WorkerManagementPage />
</AdminRoute>
}
/>
<Route
path="asset-libraries"
element={
<AdminRoute>
<AssetLibraryPage />
</AdminRoute>
}
/>
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
<Suspense fallback={<RouteFallback />}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<DashboardPage />} />
<Route path="orders" element={<OrdersPage />} />
<Route path="orders/new" element={<NewOrderPage />} />
<Route path="orders/new/product" element={<NewProductOrderPage />} />
<Route path="orders/:id" element={<OrderDetailPage />} />
<Route path="upload" element={<UploadPage />} />
<Route
path="admin"
element={
<AdminRoute>
<AdminPage />
</AdminRoute>
}
/>
<Route
path="tenants"
element={
<AdminRoute>
<TenantsPage />
</AdminRoute>
}
/>
<Route
path="workflows"
element={
<AdminRoute>
<WorkflowEditorPage />
</AdminRoute>
}
/>
<Route path="materials" element={<MaterialsPage />} />
<Route path="activity" element={<WorkerActivityPage />} />
<Route path="products" element={<ProductLibraryPage />} />
<Route path="products/:id" element={<ProductDetailPage />} />
<Route path="notifications" element={<NotificationsPage />} />
<Route path="notification-settings" element={<NotificationSettingsPage />} />
<Route path="preferences" element={<PreferencesPage />} />
<Route path="cad/:id" element={<CadPreviewPage />} />
<Route
path="media"
element={
<AdminRoute>
<MediaBrowserPage />
</AdminRoute>
}
/>
<Route
path="billing"
element={
<AdminRoute>
<BillingPage />
</AdminRoute>
}
/>
<Route
path="workers"
element={
<AdminRoute>
<WorkerManagementPage />
</AdminRoute>
}
/>
<Route
path="asset-libraries"
element={
<AdminRoute>
<AssetLibraryPage />
</AdminRoute>
}
/>
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
</WebSocketProvider>
</BrowserRouter>
)
@@ -0,0 +1,253 @@
import { describe, expect, test } from 'vitest'
import {
getCachedOutputTypeContractCatalog,
getCompatibleWorkflowsForOutputTypeContract,
getOutputTypeInvocationOverrides,
getOutputTypeWorkflowContractIssues,
getDefaultOutputFormatForArtifactKind,
inferArtifactKind,
listAllowedInvocationOverrideKeysForArtifactKind,
listAllowedOutputFormatsForFamily,
type OutputType,
} from '../../api/outputTypes'
describe('output type contract helpers', () => {
test('lists family-safe output formats', () => {
const contractCatalog = getCachedOutputTypeContractCatalog()
expect(listAllowedOutputFormatsForFamily('cad_file', contractCatalog)).toEqual([
'png',
'jpg',
'jpeg',
'webp',
'gltf',
'glb',
'stl',
'obj',
'usd',
'usdz',
])
expect(listAllowedOutputFormatsForFamily('order_line', contractCatalog)).toEqual([
'png',
'jpg',
'jpeg',
'webp',
'mp4',
'webm',
'mov',
'blend',
])
})
test('infers artifact kinds for family-safe formats', () => {
expect(inferArtifactKind('order_line', 'blend', false)).toBe('blend_asset')
expect(inferArtifactKind('order_line', 'mp4', true)).toBe('turntable_video')
expect(inferArtifactKind('cad_file', 'gltf', false)).toBe('model_export')
expect(inferArtifactKind('cad_file', 'png', false)).toBe('thumbnail_image')
})
test('returns defaults that match artifact contracts', () => {
const contractCatalog = getCachedOutputTypeContractCatalog()
expect(getDefaultOutputFormatForArtifactKind('still_image', contractCatalog)).toBe('png')
expect(getDefaultOutputFormatForArtifactKind('thumbnail_image', contractCatalog)).toBe('png')
expect(getDefaultOutputFormatForArtifactKind('turntable_video', contractCatalog)).toBe('mp4')
expect(getDefaultOutputFormatForArtifactKind('model_export', contractCatalog)).toBe('gltf')
expect(getDefaultOutputFormatForArtifactKind('blend_asset', contractCatalog)).toBe('blend')
})
test('exposes invocation override keys by artifact contract', () => {
const contractCatalog = getCachedOutputTypeContractCatalog()
expect(listAllowedInvocationOverrideKeysForArtifactKind('turntable_video', contractCatalog)).toEqual([
'width',
'height',
'engine',
'samples',
'bg_color',
'noise_threshold',
'denoiser',
'denoising_input_passes',
'denoising_prefilter',
'denoising_quality',
'denoising_use_gpu',
'frame_count',
'fps',
'turntable_axis',
])
expect(listAllowedInvocationOverrideKeysForArtifactKind('blend_asset', contractCatalog)).toEqual([])
})
test('respects backend-authored contract catalog when provided', () => {
const contractCatalog = {
...getCachedOutputTypeContractCatalog(),
allowed_output_formats_by_family: {
order_line: ['png', 'heic'],
cad_file: ['jpg'],
},
default_output_format_by_artifact_kind: {
...getCachedOutputTypeContractCatalog().default_output_format_by_artifact_kind,
still_image: 'heic',
},
}
expect(listAllowedOutputFormatsForFamily('order_line', contractCatalog)).toEqual(['png', 'heic'])
expect(getDefaultOutputFormatForArtifactKind('still_image', contractCatalog)).toBe('heic')
})
test('exposes parameter ownership boundaries in the contract catalog', () => {
const contractCatalog = getCachedOutputTypeContractCatalog()
expect(contractCatalog.parameter_ownership.output_type_profile_keys).toEqual([
'transparent_bg',
'cycles_device',
'material_override',
])
expect(contractCatalog.parameter_ownership.template_runtime_keys).toEqual([
'target_collection',
'lighting_only',
'shadow_catcher',
'camera_orbit',
'template_inputs',
])
expect(contractCatalog.parameter_ownership.workflow_node_keys_by_step.resolve_template).toContain('target_collection')
expect(contractCatalog.parameter_ownership.workflow_node_keys_by_step.blender_still).toContain('material_override')
expect(contractCatalog.parameter_ownership.workflow_node_keys_by_step.blender_turntable).toContain('camera_orbit')
})
test('prefers invocation_profile overrides over legacy render settings', () => {
const outputType = {
id: 'ot-1',
name: 'Still',
description: null,
renderer: 'blender',
render_settings: { width: 4096, frame_count: 180 },
invocation_overrides: { width: 2048, frame_count: 120 },
output_format: 'png',
sort_order: 0,
compatible_categories: [],
render_backend: 'celery',
is_animation: false,
transparent_bg: false,
workflow_family: 'order_line',
artifact_kind: 'still_image',
cycles_device: null,
pricing_tier_id: null,
pricing_tier_name: null,
price_per_item: null,
workflow_definition_id: null,
workflow_rollout_mode: 'legacy_only',
workflow_name: null,
material_override: null,
invocation_profile: {
renderer: 'blender',
render_backend: 'celery',
workflow_family: 'order_line',
artifact_kind: 'still_image',
output_format: 'png',
is_animation: false,
workflow_definition_id: null,
workflow_rollout_mode: 'legacy_only',
transparent_bg: false,
cycles_device: null,
material_override: null,
allowed_override_keys: ['width', 'height', 'engine', 'samples', 'bg_color', 'noise_threshold', 'denoiser', 'denoising_input_passes', 'denoising_prefilter', 'denoising_quality', 'denoising_use_gpu'],
invocation_overrides: { width: 2048 },
},
is_active: true,
created_at: '',
updated_at: '',
} satisfies OutputType
expect(getOutputTypeInvocationOverrides(outputType)).toEqual({ width: 2048 })
})
test('flags rollout modes without a linked workflow', () => {
expect(getOutputTypeWorkflowContractIssues({
workflowFamily: 'order_line',
artifactKind: 'still_image',
outputFormat: 'png',
isAnimation: false,
workflowDefinitionId: '',
workflowRolloutMode: 'graph',
workflows: [],
})).toEqual([
expect.objectContaining({
code: 'rollout_requires_workflow',
severity: 'error',
}),
])
})
test('flags workflow family and artifact mismatches', () => {
const issues = getOutputTypeWorkflowContractIssues({
workflowFamily: 'order_line',
artifactKind: 'still_image',
outputFormat: 'png',
isAnimation: false,
workflowDefinitionId: 'wf-cad',
workflowRolloutMode: 'shadow',
workflows: [
{
id: 'wf-cad',
name: 'CAD Intake',
family: 'cad_file',
supported_artifact_kinds: ['thumbnail_image'],
},
],
})
expect(issues).toEqual([
expect.objectContaining({ code: 'workflow_family_mismatch', severity: 'error' }),
expect.objectContaining({ code: 'workflow_artifact_mismatch', severity: 'error' }),
])
})
test('flags output formats that are incompatible with the workflow family', () => {
expect(getOutputTypeWorkflowContractIssues({
workflowFamily: 'order_line',
artifactKind: 'custom',
outputFormat: 'gltf',
isAnimation: false,
workflowDefinitionId: '',
workflowRolloutMode: 'legacy_only',
workflows: [],
})).toEqual([
expect.objectContaining({
code: 'format_family_mismatch',
severity: 'error',
}),
])
})
test('returns only workflows that satisfy family and artifact contract', () => {
expect(getCompatibleWorkflowsForOutputTypeContract(
[
{
id: 'wf-1',
name: 'Still Graph',
family: 'order_line',
supported_artifact_kinds: ['still_image', 'turntable_video'],
},
{
id: 'wf-2',
name: 'CAD Intake',
family: 'cad_file',
supported_artifact_kinds: ['thumbnail_image'],
},
{
id: 'wf-3',
name: 'Mixed Graph',
family: 'mixed',
supported_artifact_kinds: ['still_image'],
},
],
'order_line',
'still_image',
)).toEqual([
expect.objectContaining({ id: 'wf-1' }),
])
})
})
+118 -3
View File
@@ -1,6 +1,18 @@
import { describe, expect, test } from 'vitest'
import { describe, expect, test, vi } from 'vitest'
import { createPresetWorkflowConfig, createStarterWorkflowConfig, normalizeWorkflowConfig } from '../../api/workflows'
import {
createPresetWorkflowConfig,
createStarterWorkflowConfig,
normalizeWorkflowConfig,
getWorkflows,
} from '../../api/workflows'
import api from '../../api/client'
vi.mock('../../api/client', () => ({
default: {
get: vi.fn(),
},
}))
describe('workflow preset config builders', () => {
test('builds a non-legacy still graph preset', () => {
@@ -24,7 +36,7 @@ describe('workflow preset config builders', () => {
'notify',
])
expect(config.nodes.find(node => node.step === 'blender_still')?.params).toMatchObject({
use_custom_render_settings: true,
use_custom_render_settings: false,
render_engine: 'cycles',
samples: 128,
width: 1600,
@@ -62,4 +74,107 @@ describe('workflow preset config builders', () => {
expect(config.ui?.family).toBe('order_line')
expect(config.ui?.execution_mode).toBe('shadow')
})
test('rebuilds canonical reference blueprints during normalization', () => {
const config = normalizeWorkflowConfig({
version: 1,
ui: {
preset: 'custom',
execution_mode: 'legacy',
blueprint: 'cad_intake',
},
nodes: [
{ id: 'resolve_step', step: 'resolve_step_path', params: {} },
],
edges: [],
})
expect(config.ui?.blueprint).toBe('cad_intake')
expect(config.ui?.family).toBe('cad_file')
expect(config.nodes.map(node => node.step)).toEqual([
'resolve_step_path',
'occ_object_extract',
'occ_glb_export',
'glb_bbox',
'stl_cache_generate',
'blender_render',
'threejs_render',
'thumbnail_save',
'thumbnail_save',
])
expect(config.edges).toEqual(
expect.arrayContaining([
{ from: 'export_glb', to: 'bbox' },
{ from: 'bbox', to: 'threejs_thumb' },
]),
)
})
test('rebuilds canonical starter blueprints during normalization', () => {
const config = normalizeWorkflowConfig({
version: 1,
ui: {
preset: 'custom',
execution_mode: 'legacy',
blueprint: 'starter_order_rendering',
},
nodes: [],
edges: [],
})
expect(config.ui?.blueprint).toBe('starter_order_rendering')
expect(config.ui?.family).toBe('order_line')
expect(config.nodes.map(node => node.step)).toEqual(['order_line_setup'])
})
test('normalizes workflow rollout summary from the API payload', async () => {
vi.mocked(api.get).mockResolvedValueOnce({
data: [
{
id: 'wf-1',
name: 'Still Graph',
output_type_id: null,
config: createPresetWorkflowConfig('still_graph'),
family: 'order_line',
supported_artifact_kinds: ['still_image'],
rollout_summary: {
linked_output_type_count: 2,
active_output_type_count: 1,
linked_output_type_names: ['Still Render', 'Still Render Shadow'],
rollout_modes: ['shadow'],
has_blocking_contracts: false,
blocking_reasons: [],
latest_run: {
workflow_run_id: 'run-1',
execution_mode: 'graph',
status: 'completed',
created_at: '2026-04-11T10:00:00Z',
completed_at: '2026-04-11T10:01:00Z',
},
latest_shadow_run: {
workflow_run_id: 'run-shadow-1',
execution_mode: 'shadow',
status: 'completed',
created_at: '2026-04-11T09:00:00Z',
completed_at: '2026-04-11T09:01:00Z',
},
latest_rollout_gate_verdict: 'pass',
latest_rollout_ready: true,
latest_rollout_status: 'ready_for_rollout',
latest_rollout_reasons: ['Observer output matches the authoritative legacy output byte-for-byte.'],
},
is_active: true,
created_at: '2026-04-11T08:00:00Z',
},
],
})
const [workflow] = await getWorkflows()
expect(workflow.rollout_summary.linked_output_type_count).toBe(2)
expect(workflow.rollout_summary.rollout_modes).toEqual(['shadow'])
expect(workflow.rollout_summary.latest_shadow_run?.execution_mode).toBe('shadow')
expect(workflow.rollout_summary.latest_rollout_gate_verdict).toBe('pass')
expect(workflow.rollout_summary.latest_rollout_ready).toBe(true)
})
})
@@ -0,0 +1,307 @@
import { useState } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import type { RenderTemplate } from '../../api/renderTemplates'
import type { WorkflowNodeDefinition, WorkflowParams } from '../../api/workflows'
import { WorkflowNodeInspector } from '../../components/workflows/WorkflowNodeInspector'
const listRenderTemplates = vi.fn<() => Promise<RenderTemplate[]>>()
vi.mock('../../api/renderTemplates', () => ({
listRenderTemplates: () => listRenderTemplates(),
}))
const resolveTemplateDefinition: WorkflowNodeDefinition = {
step: 'resolve_template',
label: 'Resolve Template',
family: 'order_line',
module_key: 'rendering.resolve_template',
category: 'processing',
description: 'Resolve a template for order-line rendering.',
node_type: 'processNode',
icon: 'layers',
defaults: {},
fields: [
{
key: 'template_id_override',
label: 'Template Override',
type: 'text',
description: 'Manual template override.',
section: 'General',
default: '',
min: null,
max: null,
step: null,
unit: null,
options: [],
allow_blank: true,
max_length: null,
text_format: 'uuid',
},
],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['order_line_context'] },
output_contract: { context: 'order_line', provides: ['render_template'] },
artifact_roles_consumed: ['order_line_context'],
artifact_roles_produced: ['render_template'],
legacy_source: 'legacy.resolve_template',
}
const notifyDefinition: WorkflowNodeDefinition = {
step: 'notify',
label: 'Notify',
family: 'order_line',
module_key: 'notifications.emit',
category: 'output',
description: 'Emit the workflow result.',
node_type: 'outputNode',
icon: 'bell',
defaults: {},
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',
}
function createRenderTemplate(overrides: Partial<RenderTemplate> = {}): RenderTemplate {
return {
id: '0d87b85f-c454-4d61-a124-d5b59e6a43a2',
name: 'Bearing Studio',
category_key: 'bearings',
output_type_id: null,
output_type_name: null,
output_type_ids: [],
output_type_names: ['Still'],
blend_file_path: '/templates/bearing.blend',
original_filename: 'bearing.blend',
target_collection: 'Product',
material_replace_enabled: true,
lighting_only: false,
shadow_catcher_enabled: false,
camera_orbit: true,
workflow_input_schema: [],
is_active: true,
created_at: '2026-04-11T00:00:00Z',
updated_at: '2026-04-11T00:00:00Z',
...overrides,
}
}
function renderInspector(
params: WorkflowParams,
onChange = vi.fn(),
options: { stateful?: boolean } = {},
) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
function StatefulInspector() {
const [currentParams, setCurrentParams] = useState(params)
return (
<WorkflowNodeInspector
params={currentParams}
onChange={nextParams => {
setCurrentParams(nextParams)
onChange(nextParams)
}}
nodeDefinition={resolveTemplateDefinition}
step="resolve_template"
nodeDefinitions={[resolveTemplateDefinition]}
graphFamily="order_line"
onStepChange={vi.fn()}
/>
)
}
render(
<QueryClientProvider client={queryClient}>
{options.stateful ? (
<StatefulInspector />
) : (
<WorkflowNodeInspector
params={params}
onChange={onChange}
nodeDefinition={resolveTemplateDefinition}
step="resolve_template"
nodeDefinitions={[resolveTemplateDefinition]}
graphFamily="order_line"
onStepChange={vi.fn()}
/>
)}
</QueryClientProvider>,
)
return { onChange, queryClient }
}
describe('WorkflowNodeInspector', () => {
beforeEach(() => {
listRenderTemplates.mockReset()
})
test('renders template-defined workflow input fields for resolve_template nodes', async () => {
listRenderTemplates.mockResolvedValue([
createRenderTemplate({
workflow_input_schema: [
{
key: 'studio_variant',
label: 'Studio Variant',
type: 'select',
section: 'Template Inputs',
description: 'Choose the blend lighting preset.',
default: 'default',
min: null,
max: null,
step: null,
unit: null,
options: [
{ value: 'default', label: 'Default' },
{ value: 'warm', label: 'Warm' },
],
allow_blank: false,
},
],
}),
])
renderInspector({
template_id_override: '0d87b85f-c454-4d61-a124-d5b59e6a43a2',
template_input__studio_variant: 'warm',
})
expect(await screen.findByLabelText('Template Override')).toHaveValue(
'0d87b85f-c454-4d61-a124-d5b59e6a43a2',
)
expect(await screen.findByLabelText('Studio Variant')).toHaveValue('warm')
expect(screen.getByText('Bearing Studio')).toBeInTheDocument()
})
test('clears dynamic template inputs when template override is removed', async () => {
listRenderTemplates.mockResolvedValue([
createRenderTemplate({
workflow_input_schema: [
{
key: 'studio_variant',
label: 'Studio Variant',
type: 'select',
section: 'Template Inputs',
description: 'Choose the blend lighting preset.',
default: 'default',
min: null,
max: null,
step: null,
unit: null,
options: [
{ value: 'default', label: 'Default' },
{ value: 'warm', label: 'Warm' },
],
allow_blank: false,
},
],
}),
])
const user = userEvent.setup()
const { onChange } = renderInspector({
template_id_override: '0d87b85f-c454-4d61-a124-d5b59e6a43a2',
template_input__studio_variant: 'warm',
template_input__camera_profile: 'macro',
})
const templateOverride = await screen.findByLabelText('Template Override')
await user.selectOptions(templateOverride, '')
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith({})
})
})
test('explains connection-driven nodes when no editor fields are available', async () => {
listRenderTemplates.mockResolvedValue([])
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
render(
<QueryClientProvider client={queryClient}>
<WorkflowNodeInspector
params={{}}
onChange={vi.fn()}
nodeDefinition={notifyDefinition}
step="notify"
nodeDefinitions={[notifyDefinition]}
graphFamily="order_line"
onStepChange={vi.fn()}
/>
</QueryClientProvider>,
)
expect(screen.getByText('This node has no editor settings.')).toBeInTheDocument()
expect(screen.getByText(/each required upstream input gets its own socket/i)).toBeInTheDocument()
expect(screen.getByText(/0 local variables by design/i)).toBeInTheDocument()
expect(screen.getByText('Socket 1')).toBeInTheDocument()
expect(
screen.getAllByText('Any of: Rendered Image / Rendered Frames / Rendered Video / Workflow Result / Blend Asset').length,
).toBeGreaterThan(0)
})
test('summarizes wired inputs and inspector variables separately', async () => {
listRenderTemplates.mockResolvedValue([
createRenderTemplate({
workflow_input_schema: [
{
key: 'studio_variant',
label: 'Studio Variant',
type: 'select',
section: 'Template Inputs',
description: 'Choose the blend lighting preset.',
default: 'default',
min: null,
max: null,
step: null,
unit: null,
options: [
{ value: 'default', label: 'Default' },
{ value: 'warm', label: 'Warm' },
],
allow_blank: false,
},
],
}),
])
const user = userEvent.setup()
renderInspector({}, vi.fn(), { stateful: true })
const templateOverride = await screen.findByLabelText('Template Override')
await waitFor(() => {
expect(within(templateOverride).getByRole('option', { name: /bearing studio/i })).toBeInTheDocument()
})
await user.selectOptions(templateOverride, '0d87b85f-c454-4d61-a124-d5b59e6a43a2')
expect(await screen.findByText(/1 canvas socket is required/i)).toBeInTheDocument()
expect(screen.getByText('Socket 1')).toBeInTheDocument()
expect(await screen.findByText(/2 local variables are edited in the inspector/i)).toBeInTheDocument()
expect(screen.getByText(/Static: Template Override/i)).toBeInTheDocument()
expect(screen.getByText(/Template-driven: Studio Variant/i)).toBeInTheDocument()
})
})
@@ -1,7 +1,7 @@
import { describe, expect, test } from 'vitest'
import * as THREE from 'three'
import { buildScenePartRegistry, convertSceneManifestMaterials, remapToPartKeys, resolveObjectPartKey } from '../../components/cad/cadUtils'
import { alignSceneManifestToLogicalPartKeys, buildEffectiveViewerMaterials, buildScenePartRegistry, convertSceneManifestMaterials, mergeViewerMaterialSources, remapToPartKeys, resolveObjectPartKey } from '../../components/cad/cadUtils'
describe('cadUtils scene manifest conversion', () => {
test('uses scene manifest part keys as authoritative viewer material map', () => {
@@ -31,6 +31,55 @@ describe('cadUtils scene manifest conversion', () => {
},
})
})
test('adds viewer logical keys when manifest stores repeated leaf instances under deduplicated part keys', () => {
const materials = alignSceneManifestToLogicalPartKeys(
convertSceneManifestMaterials([
{
part_key: 'kero_z_575693_qp_drh_isb_1_1',
effective_material: 'HARTOMAT_010101_Steel-Bare',
},
{
part_key: 'kero_z_575693_qp_drh_isb_1_1_2',
effective_material: 'HARTOMAT_010101_Steel-Bare',
},
]),
new Set(['kero_z_575693_qp_drh_isb_1']),
)
expect(materials.kero_z_575693_qp_drh_isb_1).toEqual({
type: 'library',
value: 'HARTOMAT_010101_Steel-Bare',
})
})
test('backfills helper and af-instance logical keys through legacy fuzzy lookup when manifest keys differ', () => {
const materials = alignSceneManifestToLogicalPartKeys(
convertSceneManifestMaterials([
{
part_key: 'kero_z_575693_qp_drh_isb',
effective_material: 'HARTOMAT_010101_Steel-Bare',
},
{
part_key: 'kero_z_575693_qp_drh_isb_1_1',
effective_material: 'HARTOMAT_010101_Steel-Bare',
},
]),
new Set([
'kero_z_575693_qp_drh_isb_1',
'kero_z_575693_qp_drh_isb_1_af0',
]),
)
expect(materials.kero_z_575693_qp_drh_isb_1).toEqual({
type: 'library',
value: 'HARTOMAT_010101_Steel-Bare',
})
expect(materials.kero_z_575693_qp_drh_isb_1_af0).toEqual({
type: 'library',
value: 'HARTOMAT_010101_Steel-Bare',
})
})
})
describe('cadUtils legacy fallback remapping', () => {
@@ -113,8 +162,82 @@ describe('cadUtils legacy fallback remapping', () => {
})
})
describe('cadUtils viewer material source merge', () => {
test('keeps fallback assignments authoritative while filling manifest-only gaps', () => {
const merged = mergeViewerMaterialSources(
{
rwdr_b_f_802044_tr4_h122bk: {
type: 'library',
value: 'HARTOMAT_010101_Steel-Bare',
},
usd_only_part: {
type: 'library',
value: 'HARTOMAT_050101_Elastomer-Black',
},
},
{
rwdr_b_f_802044_tr4_h122bk: {
type: 'library',
value: 'Steel--Stahl',
},
},
)
expect(merged).toEqual({
rwdr_b_f_802044_tr4_h122bk: {
type: 'library',
value: 'Steel--Stahl',
},
usd_only_part: {
type: 'library',
value: 'HARTOMAT_050101_Elastomer-Black',
},
})
})
test('applies manual overrides last on top of fallback-authoritative merged sources', () => {
const merged = buildEffectiveViewerMaterials(
{
rwdr_b_f_802044_tr4_h122bk: {
type: 'library',
value: 'HARTOMAT_010101_Steel-Bare',
},
usd_only_part: {
type: 'library',
value: 'HARTOMAT_050101_Elastomer-Black',
},
},
{
rwdr_b_f_802044_tr4_h122bk: {
type: 'library',
value: 'Steel--Stahl',
},
},
{
rwdr_b_f_802044_tr4_h122bk: '#123456',
manual_only_part: 'HARTOMAT_070707_Test-Material',
},
)
expect(merged).toEqual({
rwdr_b_f_802044_tr4_h122bk: {
type: 'hex',
value: '#123456',
},
usd_only_part: {
type: 'library',
value: 'HARTOMAT_050101_Elastomer-Black',
},
manual_only_part: {
type: 'library',
value: 'HARTOMAT_070707_Test-Material',
},
})
})
})
describe('cadUtils scene graph part-key registry', () => {
test('inherits instance part keys from ancestor nodes and keeps logical keys from scene metadata', () => {
test('inherits instance part keys from ancestor nodes while excluding helper-only logical keys from renderable counts', () => {
const scene = new THREE.Group()
const instanceGroup = new THREE.Group()
@@ -139,14 +262,10 @@ describe('cadUtils scene graph part-key registry', () => {
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',
]))
expect(logicalPartKeys).toEqual(new Set(['kero_z_575693_qp_drh_isb_1']))
})
test('prefers sibling semantic instance nodes over mesh-local exporter keys when transforms match', () => {
test('prefers explicit mesh-local exporter keys over sibling semantic instance nodes when transforms match', () => {
const scene = new THREE.Group()
const assembly = new THREE.Group()
@@ -169,12 +288,12 @@ describe('cadUtils scene graph part-key registry', () => {
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')
expect(meshRegistry[0].partKey).toBe('kero_z_575693_qp_drh_isb_1_1')
expect(mesh.userData.partKey).toBe('kero_z_575693_qp_drh_isb_1_1')
expect(resolveObjectPartKey(mesh, {})).toBe('kero_z_575693_qp_drh_isb_1_1')
})
test('prefers sibling semantic instance nodes even when transforms do not match', () => {
test('falls back to sibling semantic instance nodes when explicit mesh keys are absent, even when transforms do not match', () => {
const scene = new THREE.Group()
const assembly = new THREE.Group()
@@ -185,7 +304,6 @@ describe('cadUtils scene graph part-key registry', () => {
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)
@@ -199,4 +317,19 @@ describe('cadUtils scene graph part-key registry', () => {
expect(mesh.userData.partKey).toBe('kero_z_575693_qp_drh_isb_1')
expect(resolveObjectPartKey(mesh, {})).toBe('kero_z_575693_qp_drh_isb_1')
})
test('does not synthesize pseudo part keys from normalized mesh names when no authoritative mapping exists', () => {
const scene = new THREE.Group()
const mesh = new THREE.Mesh(new THREE.BufferGeometry(), new THREE.MeshStandardMaterial())
mesh.name = 'RWDR_B_F-802044_TR4_H122BK_AF0'
scene.add(mesh)
const { meshRegistry, logicalPartKeys, unresolvedMeshNames } = buildScenePartRegistry(scene, {})
expect(resolveObjectPartKey(mesh, {})).toBe('')
expect(mesh.userData.partKey).toBeUndefined()
expect(meshRegistry).toHaveLength(0)
expect(logicalPartKeys).toEqual(new Set())
expect(unresolvedMeshNames).toEqual(new Set(['RWDR_B_F-802044_TR4_H122BK']))
})
})
@@ -0,0 +1,49 @@
import { describe, expect, test } from 'vitest'
import { getOutputTypeRolloutPresentation } from '../../components/admin/outputTypeRolloutPresentation'
describe('outputTypeRolloutPresentation', () => {
test('describes unlinked output types as fully legacy', () => {
expect(getOutputTypeRolloutPresentation({
hasWorkflowLink: false,
workflowRolloutMode: 'legacy_only',
})).toEqual(expect.objectContaining({
badgeLabel: 'Legacy Only',
statusLabel: 'Production: Legacy',
rowSummary: 'No linked graph workflow.',
}))
})
test('describes shadow rollout as legacy-authoritative observer mode', () => {
expect(getOutputTypeRolloutPresentation({
hasWorkflowLink: true,
workflowRolloutMode: 'shadow',
})).toEqual(expect.objectContaining({
badgeLabel: 'Shadow',
statusLabel: 'Production: Legacy',
rowSummary: 'Graph observes only; legacy remains authoritative.',
}))
})
test('describes graph rollout as production-graph with legacy fallback', () => {
expect(getOutputTypeRolloutPresentation({
hasWorkflowLink: true,
workflowRolloutMode: 'graph',
})).toEqual(expect.objectContaining({
badgeLabel: 'Graph Authoritative',
statusLabel: 'Production: Graph',
rowSummary: 'Graph drives production with legacy fallback armed.',
}))
})
test('elevates blocking issues above rollout mode', () => {
expect(getOutputTypeRolloutPresentation({
hasWorkflowLink: true,
workflowRolloutMode: 'graph',
hasBlockingIssues: true,
})).toEqual(expect.objectContaining({
badgeLabel: 'Contract Blocked',
statusLabel: 'Do Not Promote',
}))
})
})
@@ -0,0 +1,50 @@
import { describe, expect, test, vi } from 'vitest'
import {
bindWorkflowAuthoringInsertActions,
type WorkflowAuthoringActions,
} from '../../components/workflows/workflowAuthoringActions'
describe('bindWorkflowAuthoringInsertActions', () => {
test('binds preferred canvas position into insert actions', () => {
const insertNode = vi.fn()
const insertModule = vi.fn()
const insertReferencePath = vi.fn()
const actions: WorkflowAuthoringActions = {
insertNode,
insertModule,
insertReferencePath,
}
const bindings = bindWorkflowAuthoringInsertActions(actions, {
preferredPosition: { x: 120, y: 240 },
})
bindings.onSelectStep?.('blender_still')
bindings.onInsertModule?.('still_render_core')
bindings.onInsertReferencePath?.('still_render_reference')
expect(insertNode).toHaveBeenCalledWith('blender_still', { x: 120, y: 240 })
expect(insertModule).toHaveBeenCalledWith('still_render_core', { x: 120, y: 240 })
expect(insertReferencePath).toHaveBeenCalledWith('still_render_reference', { x: 120, y: 240 })
})
test('runs the after-insert callback after successful bound actions', () => {
const afterInsert = vi.fn()
const insertNode = vi.fn()
const bindings = bindWorkflowAuthoringInsertActions(
{
insertNode,
},
{
onAfterInsert: afterInsert,
},
)
bindings.onSelectStep?.('resolve_template')
expect(insertNode).toHaveBeenCalledWith('resolve_template', undefined)
expect(afterInsert).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,227 @@
import { describe, expect, test } from 'vitest'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import { getWorkflowAuthoringPlan } from '../../components/workflows/workflowAuthoringGuidance'
import {
getWorkflowAuthoringSurfaceModel,
resolveWorkflowAuthoringSection,
} from '../../components/workflows/workflowAuthoringSurface'
const definitions: WorkflowNodeDefinition[] = [
{
step: 'order_line_setup',
label: 'Order Line Setup',
family: 'order_line',
module_key: 'order_line.prepare_render_context',
category: 'input',
description: 'Prepare order line.',
node_type: 'inputNode',
icon: 'refresh-cw',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line' },
output_contract: { context: 'order_line', provides: ['order_line'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: 'legacy.order_line_setup',
},
{
step: 'resolve_template',
label: 'Resolve Template',
family: 'order_line',
module_key: 'rendering.resolve_template',
category: 'processing',
description: 'Resolve template.',
node_type: 'processNode',
icon: 'layers',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['order_line'] },
output_contract: { context: 'order_line', provides: ['render_template'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: 'legacy.resolve_template',
},
{
step: 'auto_populate_materials',
label: 'Auto Populate Materials',
family: 'order_line',
module_key: 'materials.auto_populate',
category: 'processing',
description: 'Populate materials.',
node_type: 'processNode',
icon: 'layers',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['order_line'] },
output_contract: { context: 'order_line', provides: ['cad_materials'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: 'legacy.auto_populate_materials',
},
{
step: 'glb_bbox',
label: 'Compute Bounding Box',
family: 'order_line',
module_key: 'geometry.compute_bbox',
category: 'processing',
description: 'Compute bbox.',
node_type: 'processNode',
icon: 'box',
defaults: {},
fields: [],
execution_kind: 'native',
legacy_compatible: false,
input_contract: { context: 'order_line', requires: ['order_line'] },
output_contract: { context: 'order_line', provides: ['bbox'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: null,
},
{
step: 'material_map_resolve',
label: 'Resolve Material Map',
family: 'order_line',
module_key: 'materials.resolve_map',
category: 'processing',
description: 'Resolve material map.',
node_type: 'processNode',
icon: 'layers',
defaults: {},
fields: [],
execution_kind: 'native',
legacy_compatible: false,
input_contract: { context: 'order_line', requires: ['order_line'] },
output_contract: { context: 'order_line', provides: ['material_map'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: null,
},
{
step: 'blender_still',
label: 'Blender Still',
family: 'order_line',
module_key: 'render.production.still',
category: 'rendering',
description: 'Render still.',
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: [],
artifact_roles_consumed: [],
legacy_source: null,
},
{
step: 'output_save',
label: 'Save Output',
family: 'order_line',
module_key: 'media.save_output',
category: 'output',
description: 'Persist output.',
node_type: 'outputNode',
icon: 'download',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['render_image'] },
output_contract: { context: 'order_line', provides: ['saved_output'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: 'legacy.output_save',
},
{
step: 'notify',
label: 'Notify Result',
family: 'order_line',
module_key: 'notifications.emit',
category: 'output',
description: 'Notify completion.',
node_type: 'outputNode',
icon: 'bell',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['saved_output'] },
output_contract: { context: 'order_line', provides: ['notification'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: 'legacy.notify',
},
]
describe('workflow authoring guidance', () => {
test('derives a single shared order-line authoring plan', () => {
const plan = getWorkflowAuthoringPlan(definitions, 'order_line', ['blender_still'])
expect(plan.referenceBundles).toHaveLength(1)
expect(plan.moduleBundles).toHaveLength(2)
expect(plan.referenceBundles[0]?.presentCount).toBe(1)
expect(plan.moduleBundles.find(bundle => bundle.id === 'still_render_core')?.presentCount).toBe(1)
expect(plan.stageProgress.map(stage => stage.id)).toEqual([
'still_render_reference',
'still_render_core',
'output_publish_notify',
'order_line_setup',
])
expect(plan.gapFillDefinitions.map(definition => definition.step)).toEqual([
'order_line_setup',
'resolve_template',
'auto_populate_materials',
])
})
test('hides starter-only guidance for mixed graphs', () => {
const plan = getWorkflowAuthoringPlan(definitions, 'mixed', ['blender_still'])
expect(plan.title).toBe('Guided Authoring')
expect(plan.starterItems).toEqual([])
expect(plan.stageProgress).toEqual([])
expect(plan.authoringFlow[2]?.title).toBe('Starter Path')
})
test('derives one shared authoring surface model from the same plan', () => {
const surface = getWorkflowAuthoringSurfaceModel({
definitions,
graphFamily: 'order_line',
activeSteps: ['order_line_setup'],
})
expect(surface.defaultSection).toBe('overview')
expect(surface.sections.map(section => section.key)).toEqual([
'overview',
'paths',
'modules',
'starter',
'nodes',
])
expect(surface.plan.referenceBundles[0]?.id).toBe('still_render_reference')
expect(surface.plan.moduleBundles.map(bundle => bundle.id)).toEqual([
'still_render_core',
'output_publish_notify',
])
})
test('falls back to a valid section when the requested section is unavailable', () => {
const surface = getWorkflowAuthoringSurfaceModel({
definitions,
graphFamily: 'mixed',
activeSteps: ['order_line_setup'],
})
expect(resolveWorkflowAuthoringSection('overview', surface.sections, surface.defaultSection)).toBe('nodes')
expect(resolveWorkflowAuthoringSection('nodes', surface.sections, surface.defaultSection)).toBe('nodes')
})
})
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, test, vi } from 'vitest'
@@ -14,6 +14,11 @@ import { WorkflowCanvasToolbar } from '../../components/workflows/WorkflowCanvas
import { WorkflowNodeContractCard } from '../../components/workflows/WorkflowNodeContractCard'
import { WorkflowPreflightPanel } from '../../components/workflows/WorkflowPreflightPanel'
import { WorkflowRunsPanel } from '../../components/workflows/WorkflowRunsPanel'
import {
bindWorkflowAuthoringInsertActions,
getWorkflowAuthoringEntryAction,
} from '../../components/workflows/workflowAuthoringActions'
import { getWorkflowAuthoringSurfaceModel } from '../../components/workflows/workflowAuthoringSurface'
const nodeDefinitions: WorkflowNodeDefinition[] = [
{
@@ -35,6 +40,177 @@ const nodeDefinitions: WorkflowNodeDefinition[] = [
artifact_roles_consumed: [],
legacy_source: 'legacy.resolve_step_path',
},
{
step: 'occ_object_extract',
label: 'Extract OCC Objects',
family: 'cad_file',
module_key: 'cad.intake',
category: 'processing',
description: 'Extract assembly objects from CAD input.',
node_type: 'processNode',
icon: 'boxes',
defaults: {},
fields: [],
execution_kind: 'native',
legacy_compatible: false,
input_contract: { context: 'cad_file', requires: ['step_path'] },
output_contract: { context: 'cad_file', provides: ['cad_objects'] },
artifact_roles_produced: [],
artifact_roles_consumed: ['step_file'],
legacy_source: null,
},
{
step: 'occ_glb_export',
label: 'Export GLB',
family: 'cad_file',
module_key: 'cad.intake',
category: 'processing',
description: 'Export preview GLB from CAD geometry.',
node_type: 'processNode',
icon: 'box',
defaults: {},
fields: [],
execution_kind: 'native',
legacy_compatible: false,
input_contract: { context: 'cad_file', requires: ['cad_objects'] },
output_contract: { context: 'cad_file', provides: ['cad_preview'] },
artifact_roles_produced: ['cad_preview'],
artifact_roles_consumed: [],
legacy_source: null,
},
{
step: 'stl_cache_generate',
label: 'Generate STL Cache',
family: 'cad_file',
module_key: 'cad.intake',
category: 'processing',
description: 'Build STL cache for downstream consumers.',
node_type: 'processNode',
icon: 'database',
defaults: {},
fields: [],
execution_kind: 'native',
legacy_compatible: false,
input_contract: { context: 'cad_file', requires: ['cad_objects'] },
output_contract: { context: 'cad_file', provides: ['stl_cache'] },
artifact_roles_produced: ['stl_cache'],
artifact_roles_consumed: [],
legacy_source: null,
},
{
step: 'thumbnail_save',
label: 'Publish Thumbnail',
family: 'cad_file',
module_key: 'cad.intake',
category: 'output',
description: 'Persist preview thumbnail output.',
node_type: 'outputNode',
icon: 'image',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'cad_file', requires: ['cad_preview'] },
output_contract: { context: 'cad_file', provides: ['thumbnail'] },
artifact_roles_produced: ['thumbnail'],
artifact_roles_consumed: ['cad_preview'],
legacy_source: 'legacy.thumbnail_save',
},
{
step: 'order_line_setup',
label: 'Order Line Setup',
family: 'order_line',
module_key: 'order_line.prepare_render_context',
category: 'input',
description: 'Prepare order-line render context.',
node_type: 'inputNode',
icon: 'refresh-cw',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line' },
output_contract: { context: 'order_line', provides: ['order_line'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: 'legacy.order_line_setup',
},
{
step: 'resolve_template',
label: 'Resolve Template',
family: 'order_line',
module_key: 'rendering.resolve_template',
category: 'processing',
description: 'Resolve render template.',
node_type: 'processNode',
icon: 'layers',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['order_line'] },
output_contract: { context: 'order_line', provides: ['render_template'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: 'legacy.resolve_template',
},
{
step: 'auto_populate_materials',
label: 'Auto Populate Materials',
family: 'order_line',
module_key: 'materials.auto_populate',
category: 'processing',
description: 'Populate materials automatically.',
node_type: 'processNode',
icon: 'layers',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['order_line'] },
output_contract: { context: 'order_line', provides: ['cad_materials'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: 'legacy.auto_populate_materials',
},
{
step: 'glb_bbox',
label: 'Compute Bounding Box',
family: 'shared',
module_key: 'geometry.compute_bbox',
category: 'processing',
description: 'Compute GLB bounding box.',
node_type: 'processNode',
icon: 'layers',
defaults: {},
fields: [],
execution_kind: 'native',
legacy_compatible: false,
input_contract: { requires: ['cad_preview'] },
output_contract: { provides: ['bbox'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: null,
},
{
step: 'material_map_resolve',
label: 'Resolve Material Map',
family: 'order_line',
module_key: 'materials.resolve_map',
category: 'processing',
description: 'Resolve material mapping.',
node_type: 'processNode',
icon: 'layers',
defaults: {},
fields: [],
execution_kind: 'native',
legacy_compatible: false,
input_contract: { context: 'order_line', requires: ['order_line'] },
output_contract: { context: 'order_line', provides: ['material_map'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: null,
},
{
step: 'blender_still',
label: 'Blender Still',
@@ -54,6 +230,44 @@ const nodeDefinitions: WorkflowNodeDefinition[] = [
artifact_roles_consumed: ['cad_preview'],
legacy_source: null,
},
{
step: 'output_save',
label: 'Save Output',
family: 'order_line',
module_key: 'media.save_output',
category: 'output',
description: 'Save rendered output.',
node_type: 'outputNode',
icon: 'download',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['rendered_image'] },
output_contract: { context: 'order_line', provides: ['saved_output'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: 'legacy.output_save',
},
{
step: 'notify',
label: 'Notify Result',
family: 'order_line',
module_key: 'notifications.emit',
category: 'output',
description: 'Emit completion notification.',
node_type: 'outputNode',
icon: 'bell',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['saved_output'] },
output_contract: { context: 'order_line', provides: ['notification'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: 'legacy.notify',
},
]
const shadowRun: WorkflowRun = {
@@ -72,7 +286,10 @@ const shadowRun: WorkflowRun = {
id: 'node-result-1',
node_name: 'Blender Still',
status: 'completed',
output: null,
output: {
image_path: '/tmp/render.png',
artifact_role: 'png_output',
},
log: 'Rendered successfully.',
duration_s: 2.3,
created_at: '2026-04-08T10:02:00Z',
@@ -87,6 +304,16 @@ const shadowComparison: WorkflowRunComparison = {
execution_mode: 'shadow',
status: 'matched',
summary: 'Observer output matches authoritative output.',
rollout_gate_verdict: 'pass',
workflow_rollout_ready: true,
workflow_rollout_status: 'ready_for_rollout',
rollout_reasons: [
'Observer output matches the authoritative legacy output byte-for-byte.',
],
rollout_thresholds: {
pass_max_mean_pixel_delta: 0.000001,
warn_max_mean_pixel_delta: 0.02,
},
authoritative_output: {
path: null,
storage_key: null,
@@ -122,7 +349,7 @@ const preflightResponse: WorkflowPreflightResponse = {
summary: 'Graph requires one missing upstream artifact.',
resolved_order_line_id: 'ol-1',
resolved_cad_file_id: null,
unsupported_node_ids: [],
unsupported_node_ids: ['node-legacy-1'],
issues: [
{
severity: 'warning',
@@ -168,6 +395,7 @@ describe('WorkflowNodeContractCard', () => {
inputContextLabel="Order Rendering"
outputContextLabel="Order Rendering"
requiredInputs={['order_line', 'render_template']}
requiredAnyInputs={[['rendered_image', 'rendered_frames']]}
consumedArtifacts={['cad_preview']}
providedOutputs={['render_image']}
producedArtifacts={['png_output']}
@@ -180,7 +408,8 @@ describe('WorkflowNodeContractCard', () => {
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('Any of: Rendered Image / Rendered Frames')).toBeInTheDocument()
expect(screen.getByText('CAD Preview')).toBeInTheDocument()
expect(screen.getByText('Render Image')).toBeInTheDocument()
expect(screen.getByText('Png Output')).toBeInTheDocument()
})
@@ -197,19 +426,58 @@ describe('WorkflowCanvasToolbar', () => {
const onPreflight = vi.fn()
const onDispatch = vi.fn()
const onSave = vi.fn()
const onRollbackOutputType = vi.fn()
render(
<WorkflowCanvasToolbar
workflowName="Still Image - Graph"
blueprintLabel="Still Graph"
blueprintDescription="Reference graph for the non-legacy still render path."
authoringFamilyLabel="Order Rendering"
authoringFamilyClassName="bg-emerald-100 text-emerald-700"
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=""
rolloutBadgeLabel="Shadow"
rolloutBadgeClassName="bg-sky-100 text-sky-700"
rolloutStatusLabel="Legacy Authoritative"
rolloutStatusClassName="bg-sky-100 text-sky-700"
rolloutSummary="Latest shadow verdict: pass."
linkedOutputTypeCount={2}
linkedOutputTypes={[
{
id: 'ot-1',
name: 'Shadow Still Output',
is_active: true,
artifact_kind: 'still_image',
workflow_rollout_mode: 'shadow',
},
{
id: 'ot-2',
name: 'Legacy Archive Output',
is_active: false,
artifact_kind: 'blend_asset',
workflow_rollout_mode: 'legacy_only',
},
]}
dispatchContextKind="order_line"
dispatchContextLabel="Order Line"
dispatchContextId="line-1"
dispatchContextSummary="Product A · Still"
dispatchContextMeta="ORD-1001 · pending"
orderLineContextGroups={[
{
orderId: 'order-1',
orderLabel: 'ORD-1001',
options: [
{ value: 'line-1', label: 'Product A · Still', meta: 'ORD-1001 · pending' },
{ value: 'line-2', label: 'Product B · Still', meta: 'ORD-1001 · completed' },
],
},
]}
executionModes={[
{ value: 'legacy', label: 'Legacy' },
{ value: 'graph', label: 'Graph' },
@@ -217,39 +485,66 @@ describe('WorkflowCanvasToolbar', () => {
]}
selectedEdgeCount={2}
canAutoLayout
canPreflight
canDispatch
hasValidationErrors={false}
isPreflightPending={false}
isDispatchPending={false}
isContextOptionsLoading={false}
isSaving={false}
rollbackPendingOutputTypeId={null}
preflightState="ready"
authoringActions={{ openNodeMenu: onOpenNodeMenu }}
authoringEntryAction={{
label: 'Author',
title: 'Open guided workflow authoring browser',
helper: 'Open reference paths, production modules, starter steps, and raw nodes.',
icon: () => null,
}}
onDispatchContextIdChange={onDispatchContextIdChange}
onExecutionModeChange={onExecutionModeChange}
onOpenNodeMenu={onOpenNodeMenu}
onAutoLayout={onAutoLayout}
onDeleteSelectedEdges={onDeleteSelectedEdges}
onPreflight={onPreflight}
onDispatch={onDispatch}
onSave={onSave}
onRollbackOutputType={onRollbackOutputType}
/>,
)
expect(screen.getByText('Workflow Canvas')).toBeInTheDocument()
expect(screen.getByText('Still Image - Graph')).toBeInTheDocument()
expect(screen.getByText('Still Graph')).toBeInTheDocument()
expect(screen.getAllByText('Shadow').length).toBeGreaterThan(0)
expect(screen.getByText('Legacy Authoritative')).toBeInTheDocument()
expect(screen.getByText(/2 linked output types/i)).toBeInTheDocument()
expect(screen.getByText('Rollout Controls')).toBeInTheDocument()
expect(screen.getByText('Shadow Still Output')).toBeInTheDocument()
expect(screen.getByText('Legacy Archive Output')).toBeInTheDocument()
expect(screen.getAllByText('Order Line').length).toBeGreaterThan(0)
expect(screen.getAllByText('Product A · Still').length).toBeGreaterThan(0)
expect(screen.getByText('Right-click to add')).toBeInTheDocument()
expect(screen.getByText('Preflight ready')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Delete (2)' })).toBeEnabled()
const rollbackButtons = screen.getAllByRole('button', { name: 'Set Legacy' })
expect(rollbackButtons.length).toBe(2)
expect(rollbackButtons[0]?.getAttribute('title')).toContain('legacy_only')
expect(rollbackButtons[1]).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'Node' }))
await user.click(screen.getByRole('button', { name: 'Author' }))
await user.click(screen.getByRole('button', { name: 'Align' }))
await user.click(screen.getByRole('button', { name: 'Delete (2)' }))
await user.click(rollbackButtons[0] as HTMLElement)
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')
await user.selectOptions(screen.getByRole('combobox', { name: 'Order line context' }), 'line-2')
await user.selectOptions(screen.getByRole('combobox', { name: 'Mode' }), 'shadow')
expect(onOpenNodeMenu).toHaveBeenCalledOnce()
expect(onAutoLayout).toHaveBeenCalledOnce()
expect(onDeleteSelectedEdges).toHaveBeenCalledOnce()
expect(onRollbackOutputType).toHaveBeenCalledWith('ot-1')
expect(onPreflight).toHaveBeenCalledOnce()
expect(onDispatch).toHaveBeenCalledOnce()
expect(onSave).toHaveBeenCalledOnce()
@@ -263,28 +558,54 @@ describe('WorkflowCanvasToolbar', () => {
workflowName="CAD Intake"
blueprintLabel={null}
blueprintDescription={null}
authoringFamilyLabel="CAD Intake"
authoringFamilyClassName="bg-sky-100 text-sky-700"
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."
rolloutBadgeLabel="Unlinked"
rolloutBadgeClassName="bg-surface-muted text-content-muted"
rolloutStatusLabel="Legacy Only"
rolloutStatusClassName="bg-slate-100 text-slate-700"
rolloutSummary="No output types are linked to this workflow yet."
linkedOutputTypeCount={0}
linkedOutputTypes={[]}
dispatchContextKind="cad_file"
dispatchContextLabel="CAD File"
dispatchContextId=""
dispatchContextSummary={null}
dispatchContextMeta={null}
orderLineContextGroups={[]}
executionModes={[{ value: 'legacy', label: 'Legacy' }]}
selectedEdgeCount={0}
canAutoLayout={false}
canPreflight={false}
canDispatch={false}
hasValidationErrors
isPreflightPending={false}
isDispatchPending={false}
isContextOptionsLoading={false}
isSaving={false}
rollbackPendingOutputTypeId={null}
preflightState="required"
authoringActions={{ openNodeMenu: vi.fn() }}
authoringEntryAction={{
label: 'Node',
title: 'Open raw node browser',
helper: 'Open the searchable node catalog directly on the canvas.',
icon: () => null,
}}
onDispatchContextIdChange={vi.fn()}
onExecutionModeChange={vi.fn()}
onOpenNodeMenu={vi.fn()}
onAutoLayout={vi.fn()}
onDeleteSelectedEdges={vi.fn()}
onPreflight={vi.fn()}
onDispatch={vi.fn()}
onSave={vi.fn()}
onRollbackOutputType={vi.fn()}
/>,
)
@@ -305,7 +626,8 @@ describe('NodeCommandMenu', () => {
<NodeCommandMenu
definitions={nodeDefinitions}
graphFamily="mixed"
onSelectStep={onSelectStep}
activeSteps={[]}
actions={{ insertNode: step => onSelectStep(step) }}
onClose={vi.fn()}
renderIcon={iconName => <span>{iconName}</span>}
/>,
@@ -315,22 +637,187 @@ describe('NodeCommandMenu', () => {
await user.type(screen.getByPlaceholderText('Search nodes'), 'blender{enter}')
expect(onSelectStep).toHaveBeenCalledWith('blender_still')
expect(screen.getByRole('button', { name: 'All Categories' })).toBeInTheDocument()
expect(screen.getByText('Quick Insert')).toBeInTheDocument()
expect(screen.getByText('Graph Nodes')).toBeInTheDocument()
})
test('supports module insertion directly from the canvas authoring menu', async () => {
const user = userEvent.setup()
const onInsertReferencePath = vi.fn()
const onInsertModule = vi.fn()
const onSelectStep = vi.fn()
render(
<NodeCommandMenu
definitions={nodeDefinitions}
graphFamily="order_line"
activeSteps={['order_line_setup']}
actions={{
insertNode: step => onSelectStep(step),
insertReferencePath: bundleId => onInsertReferencePath(bundleId),
insertModule: bundleId => onInsertModule(bundleId),
}}
onClose={vi.fn()}
renderIcon={iconName => <span>{iconName}</span>}
/>,
)
expect(screen.getByRole('button', { name: 'Overview' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Paths' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Modules' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Starter' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Nodes' })).toBeInTheDocument()
expect(screen.getByText('Recommended Path')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Insert Still Reference' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Insert Still Reference' }))
expect(onInsertReferencePath).toHaveBeenCalledWith('still_render_reference')
await user.click(screen.getByRole('button', { name: 'Paths' }))
expect(screen.getByText('Reference Paths')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Insert Still Render Reference' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Insert Still Render Reference' }))
expect(onInsertReferencePath).toHaveBeenNthCalledWith(2, 'still_render_reference')
await user.click(screen.getByRole('button', { name: 'Modules' }))
expect(screen.getByText('Production Modules')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Insert Still Render Core' }))
expect(onInsertModule).toHaveBeenCalledWith('still_render_core')
})
})
describe('NodeDefinitionsPanel', () => {
test('groups nodes by runtime bucket and module in the utility rail library view', () => {
test('organizes library authoring into overview and focused browser sections', async () => {
const user = userEvent.setup()
render(<NodeDefinitionsPanel definitions={nodeDefinitions} graphFamily="mixed" />)
expect(screen.getByText('Node Library')).toBeInTheDocument()
expect(screen.getByText('Authoring Browser')).toBeInTheDocument()
expect(screen.getByText('Authoring Flow')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Paths' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Modules' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Nodes' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Paths' }))
expect(screen.getByText('Reference Paths')).toBeInTheDocument()
expect(screen.getByText('Still Render Reference')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Modules' }))
expect(screen.getByText('Production Modules')).toBeInTheDocument()
expect(screen.getByText('Still Render Core')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Nodes' }))
expect(screen.getAllByText('Raw Node Catalog').length).toBeGreaterThan(0)
expect(screen.getByText('Quick Insert')).toBeInTheDocument()
expect(screen.getByText('Runtime')).toBeInTheDocument()
expect(screen.getByText('Family')).toBeInTheDocument()
expect(screen.getByText('Category')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Search modules')).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('Blender Still').length).toBeGreaterThan(0)
expect(screen.getAllByText('Graph').length).toBeGreaterThan(0)
expect(screen.getByRole('button', { name: 'All Modules' })).toBeInTheDocument()
expect(screen.getAllByText('Cad').length).toBeGreaterThan(0)
expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0)
})
test('shows starter-path progress for still-render authoring flows', async () => {
const user = userEvent.setup()
const onInsertReferencePath = vi.fn()
const onInsertModule = vi.fn()
const onSelectStep = vi.fn()
render(
<NodeDefinitionsPanel
definitions={nodeDefinitions}
graphFamily="order_line"
activeSteps={['blender_still']}
actions={{
insertReferencePath: bundleId => onInsertReferencePath(bundleId),
insertModule: bundleId => onInsertModule(bundleId),
insertNode: step => onSelectStep(step),
}}
/>,
)
const stageStatusHeading = screen.getByText('Stage Status')
const recommendedPathHeading = screen.getByText('Recommended Path')
expect(stageStatusHeading).toBeInTheDocument()
expect(recommendedPathHeading).toBeInTheDocument()
expect(
stageStatusHeading.compareDocumentPosition(recommendedPathHeading) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
expect(screen.getByText('Still Render Reference')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Reapply Still Reference' })).toBeInTheDocument()
expect(screen.getAllByRole('button', { name: 'Insert Publish' }).length).toBeGreaterThan(0)
expect(screen.getAllByRole('button', { name: 'Add Order Line Setup' }).length).toBeGreaterThan(0)
await user.click(screen.getByRole('button', { name: 'Reapply Still Reference' }))
await user.click(screen.getAllByRole('button', { name: 'Insert Publish' })[0] as HTMLElement)
await user.click(screen.getAllByRole('button', { name: 'Add Order Line Setup' })[0] as HTMLElement)
expect(onInsertReferencePath).toHaveBeenCalledWith('still_render_reference')
expect(onInsertModule).toHaveBeenCalledWith('output_publish_notify')
expect(onSelectStep).toHaveBeenCalledWith('order_line_setup')
await user.click(screen.getByRole('button', { name: 'Starter' }))
expect(screen.getByText('Starter Path')).toBeInTheDocument()
expect(screen.getAllByText('Still-render assembly').length).toBeGreaterThan(0)
expect(screen.getAllByText('1/8 present').length).toBeGreaterThan(0)
expect(screen.getAllByText('Present').length).toBeGreaterThan(0)
})
test('gives CAD authoring the same guided reference-path flow without duplicate intake stages', async () => {
const user = userEvent.setup()
const onInsertReferencePath = vi.fn()
const onInsertModule = vi.fn()
const onSelectStep = vi.fn()
render(
<NodeDefinitionsPanel
definitions={nodeDefinitions}
graphFamily="cad_file"
activeSteps={['resolve_step_path']}
actions={{
insertReferencePath: bundleId => onInsertReferencePath(bundleId),
insertModule: bundleId => onInsertModule(bundleId),
insertNode: step => onSelectStep(step),
}}
/>,
)
expect(screen.getByText('Stage Status')).toBeInTheDocument()
expect(screen.getByText('Start with the CAD intake assembly')).toBeInTheDocument()
expect(screen.getByText('CAD Intake Reference')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Reapply CAD Intake Reference' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Complete CAD Intake' })).not.toBeInTheDocument()
expect(screen.getAllByRole('button', { name: 'Add Extract OCC Objects' }).length).toBeGreaterThan(0)
await user.click(screen.getByRole('button', { name: 'Reapply CAD Intake Reference' }))
await user.click(screen.getAllByRole('button', { name: 'Add Extract OCC Objects' })[0] as HTMLElement)
expect(onInsertReferencePath).toHaveBeenCalledWith('cad_intake_reference')
expect(onInsertModule).not.toHaveBeenCalled()
expect(onSelectStep).toHaveBeenCalledWith('occ_object_extract')
await user.click(screen.getByRole('button', { name: 'Paths' }))
expect(screen.getByText('Reference Paths')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Insert CAD Intake Reference' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Starter' }))
expect(screen.getByText('Starter Path')).toBeInTheDocument()
expect(screen.getAllByText('CAD intake assembly').length).toBeGreaterThan(0)
expect(screen.getAllByText('1/5 present').length).toBeGreaterThan(0)
})
test('supports direct node insertion from the library sidebar', async () => {
@@ -341,15 +828,89 @@ describe('NodeDefinitionsPanel', () => {
<NodeDefinitionsPanel
definitions={nodeDefinitions}
graphFamily="mixed"
onSelectStep={onSelectStep}
actions={{ insertNode: step => onSelectStep(step) }}
renderIcon={iconName => <span>{iconName}</span>}
/>,
)
await user.click(screen.getAllByRole('button', { name: 'Insert' })[1])
await user.click(screen.getByRole('button', { name: 'Nodes' }))
const blenderCard = screen.getAllByText('Blender Still')[0]?.closest('div.rounded-lg')
expect(blenderCard).not.toBeNull()
await user.click(within(blenderCard as HTMLElement).getByRole('button', { name: 'Insert Blender Still' }))
expect(onSelectStep).toHaveBeenCalledWith('blender_still')
})
test('supports direct workflow-module insertion from the library sidebar', async () => {
const user = userEvent.setup()
const onInsertModule = vi.fn()
render(
<NodeDefinitionsPanel
definitions={nodeDefinitions}
graphFamily="order_line"
activeSteps={[]}
actions={{ insertModule: bundleId => onInsertModule(bundleId) }}
/>,
)
await user.click(screen.getByRole('button', { name: 'Modules' }))
await user.click(screen.getByRole('button', { name: 'Insert Still Render Core' }))
expect(onInsertModule).toHaveBeenCalledWith('still_render_core')
})
})
describe('workflowAuthoringActions', () => {
test('binds preferred position and after-insert callback once for every insert action', () => {
const insertNode = vi.fn()
const insertModule = vi.fn()
const insertReferencePath = vi.fn()
const onAfterInsert = vi.fn()
const bindings = bindWorkflowAuthoringInsertActions(
{
insertNode,
insertModule,
insertReferencePath,
},
{
preferredPosition: { x: 240, y: 180 },
onAfterInsert,
},
)
bindings.onSelectStep?.('blender_still')
bindings.onInsertModule?.('still_render_core')
bindings.onInsertReferencePath?.('still_render_reference')
expect(insertNode).toHaveBeenCalledWith('blender_still', { x: 240, y: 180 })
expect(insertModule).toHaveBeenCalledWith('still_render_core', { x: 240, y: 180 })
expect(insertReferencePath).toHaveBeenCalledWith('still_render_reference', { x: 240, y: 180 })
expect(onAfterInsert).toHaveBeenCalledTimes(3)
})
test('derives the primary authoring entry from the shared surface model', () => {
const guidedEntry = getWorkflowAuthoringEntryAction(
getWorkflowAuthoringSurfaceModel({
definitions: nodeDefinitions,
graphFamily: 'order_line',
activeSteps: [],
}),
)
const rawEntry = getWorkflowAuthoringEntryAction(
getWorkflowAuthoringSurfaceModel({
definitions: nodeDefinitions,
graphFamily: 'mixed',
activeSteps: [],
}),
)
expect(guidedEntry.label).toBe('Author')
expect(guidedEntry.title).toContain('guided workflow authoring')
expect(rawEntry.label).toBe('Node')
expect(rawEntry.title).toContain('raw node browser')
})
})
describe('WorkflowRunsPanel', () => {
@@ -370,6 +931,15 @@ describe('WorkflowRunsPanel', () => {
expect(screen.getByText('Workflow Runs')).toBeInTheDocument()
expect(screen.getByText('Shadow Comparison')).toBeInTheDocument()
expect(screen.getByText('Observer output matches authoritative output.')).toBeInTheDocument()
expect(screen.getByText('Execution Mode')).toBeInTheDocument()
expect(screen.getByText('Celery Task')).toBeInTheDocument()
expect(screen.getByText('Duration: 2.3 s')).toBeInTheDocument()
expect(screen.getByText('Rollout Gate: pass')).toBeInTheDocument()
expect(screen.getByText('Ready For Rollout')).toBeInTheDocument()
expect(screen.getByText('Operator Decision')).toBeInTheDocument()
expect(screen.getByText('Observer output matches the authoritative legacy output byte-for-byte.')).toBeInTheDocument()
expect(screen.getByText('Exact Match: yes')).toBeInTheDocument()
expect(screen.getByText('Dimensions: match')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /run-shad/i }))
@@ -384,6 +954,12 @@ describe('WorkflowPreflightPanel', () => {
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('Mode: graph')).toBeInTheDocument()
expect(screen.getByText('Unsupported Node IDs')).toBeInTheDocument()
expect(screen.getByText('node-legacy-1')).toBeInTheDocument()
expect(screen.getByText('Code: missing-artifact')).toBeInTheDocument()
expect(screen.getByText('Runtime: native')).toBeInTheDocument()
expect(screen.getByText('Supported: yes')).toBeInTheDocument()
expect(screen.getByText('cad_preview must be produced upstream.')).toBeInTheDocument()
expect(screen.getByText('blocked')).toBeInTheDocument()
})
@@ -2,7 +2,16 @@ 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'
import {
buildWorkflowCanvasNodeData,
graphNeedsAutoLayout,
resolveParamsForStepChange,
resolveNodeCollisions,
validateWorkflowDraft,
WORKFLOW_NODE_MIN_HEIGHT,
WORKFLOW_NODE_VERTICAL_GAP,
workflowToGraph,
} from '../../components/workflows/workflowGraphDraft'
function createNode(id: string, step: string, label = step): Node {
return {
@@ -17,6 +26,13 @@ function createNode(id: string, step: string, label = step): Node {
} as Node
}
function createPositionedNode(id: string, step: string, x: number, y: number, label = step): Node {
return {
...createNode(id, step, label),
position: { x, y },
}
}
function createEdge(source: string, target: string): Edge {
return {
id: `${source}-${target}`,
@@ -105,7 +121,7 @@ const definitions: Record<string, WorkflowNodeDefinition> = {
glb_bbox: {
step: 'glb_bbox',
label: 'Compute Bounding Box',
family: 'order_line',
family: 'shared',
module_key: 'geometry.compute_bbox',
category: 'processing',
description: 'Compute bbox.',
@@ -115,8 +131,8 @@ const definitions: Record<string, WorkflowNodeDefinition> = {
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['glb_preview'] },
output_contract: { context: 'order_line', provides: ['bbox'] },
input_contract: { requires: ['glb_preview'] },
output_contract: { provides: ['bbox'] },
artifact_roles_consumed: ['glb_preview'],
artifact_roles_produced: ['bbox'],
legacy_source: 'legacy.glb_bbox',
@@ -160,7 +176,7 @@ const definitions: Record<string, WorkflowNodeDefinition> = {
},
],
execution_kind: 'native',
legacy_compatible: false,
legacy_compatible: true,
input_contract: {
context: 'order_line',
requires: ['order_line_context', 'render_template', 'material_assignments', 'bbox'],
@@ -208,6 +224,25 @@ const definitions: Record<string, WorkflowNodeDefinition> = {
artifact_roles_produced: ['notification_event'],
legacy_source: 'legacy.notify',
},
export_blend: {
step: 'export_blend',
label: 'Export Blend',
family: 'order_line',
module_key: 'rendering.export_blend',
category: 'output',
description: 'Export blend asset.',
node_type: 'outputNode',
icon: 'download',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['render_template'] },
output_contract: { context: 'order_line', provides: ['blend_asset'] },
artifact_roles_consumed: ['render_template'],
artifact_roles_produced: ['blend_asset'],
legacy_source: 'legacy.export_blend',
},
resolve_step_path: {
step: 'resolve_step_path',
label: 'Resolve STEP Path',
@@ -299,6 +334,46 @@ describe('validateWorkflowDraft', () => {
expect(result.errors).toEqual([])
})
test('accepts legacy-compatible still render chains without an explicit material map resolver', () => {
const nodes = [
createNode('setup', 'order_line_setup', 'Order Line Setup'),
createNode('template', 'resolve_template', 'Resolve Template'),
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', 'bbox'),
createEdge('setup', 'render'),
createEdge('template', 'render'),
createEdge('bbox', 'render'),
createEdge('render', 'save'),
]
const result = validateWorkflowDraft(nodes, edges, definitions, true)
expect(result.errors).toEqual([])
})
test('accepts notify nodes fed by blend-export outputs', () => {
const nodes = [
createNode('setup', 'order_line_setup', 'Order Line Setup'),
createNode('template', 'resolve_template', 'Resolve Template'),
createNode('blend', 'export_blend', 'Export Blend'),
createNode('notify', 'notify', 'Notify'),
]
const edges = [
createEdge('setup', 'template'),
createEdge('template', 'blend'),
createEdge('blend', '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')],
@@ -309,6 +384,92 @@ describe('validateWorkflowDraft', () => {
expect(result.errors).toContain('Workflow mixes CAD-file and order-line nodes. Split them into separate workflows.')
})
test('accepts a CAD intake graph that feeds shared bbox into threejs thumbnail render', () => {
const threejsDefinition: WorkflowNodeDefinition = {
step: 'threejs_render',
label: 'Render Thumbnail',
family: 'cad_file',
module_key: 'render.thumbnail.threejs',
category: 'rendering',
description: 'Render a thumbnail from the GLB preview.',
node_type: 'renderNode',
icon: 'camera',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'cad_file', requires: ['glb_preview', 'bbox'] },
output_contract: { context: 'cad_file', provides: ['rendered_image'] },
artifact_roles_consumed: ['glb_preview', 'bbox'],
artifact_roles_produced: ['rendered_image'],
legacy_source: 'legacy.threejs_render',
}
const thumbnailSaveDefinition: WorkflowNodeDefinition = {
step: 'thumbnail_save',
label: 'Save Thumbnail',
family: 'cad_file',
module_key: 'media.save_thumbnail',
category: 'output',
description: 'Persist the thumbnail.',
node_type: 'outputNode',
icon: 'download',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'cad_file', requires: ['rendered_image'] },
output_contract: { context: 'cad_file', provides: ['cad_thumbnail_media'] },
artifact_roles_consumed: ['rendered_image'],
artifact_roles_produced: ['cad_thumbnail_media'],
legacy_source: 'legacy.thumbnail_save',
}
const occGlbDefinition: WorkflowNodeDefinition = {
step: 'occ_glb_export',
label: 'Export GLB',
family: 'cad_file',
module_key: 'cad.export_glb',
category: 'processing',
description: 'Export a GLB preview.',
node_type: 'processNode',
icon: 'layers',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'cad_file', requires: ['step_path'] },
output_contract: { context: 'cad_file', provides: ['glb_preview'] },
artifact_roles_consumed: ['step_path'],
artifact_roles_produced: ['glb_preview'],
legacy_source: 'legacy.occ_glb_export',
}
const result = validateWorkflowDraft(
[
createNode('resolve', 'resolve_step_path', 'Resolve STEP Path'),
createNode('glb', 'occ_glb_export', 'Export GLB'),
createNode('bbox', 'glb_bbox', 'Compute Bounding Box'),
createNode('thumb', 'threejs_render', 'Render Thumbnail'),
createNode('save', 'thumbnail_save', 'Save Thumbnail'),
],
[
createEdge('resolve', 'glb'),
createEdge('glb', 'bbox'),
createEdge('glb', 'thumb'),
createEdge('bbox', 'thumb'),
createEdge('thumb', 'save'),
],
{
...definitions,
occ_glb_export: occGlbDefinition,
threejs_render: threejsDefinition,
thumbnail_save: thumbnailSaveDefinition,
},
true,
)
expect(result.errors).toEqual([])
})
})
describe('resolveParamsForStepChange', () => {
@@ -325,4 +486,103 @@ describe('resolveParamsForStepChange', () => {
width: 1024,
})
})
test('preserves dynamic template input overrides for resolve_template nodes', () => {
const next = resolveParamsForStepChange(definitions.resolve_template, {
template_id_override: 'd7d7a1bb-2f14-4d83-99d1-7d7e36eb05d9',
template_input__studio_variant: 'warm',
template_input__camera_profile: 'macro',
stale_key: 'drop-me',
})
expect(next).toEqual({
template_input__studio_variant: 'warm',
template_input__camera_profile: 'macro',
})
})
})
describe('resolveNodeCollisions', () => {
test('pushes overlapping nodes away from a settled anchor without moving the anchor', () => {
const nodes = [
createPositionedNode('anchor', 'order_line_setup', 56, 48, 'Anchor'),
createPositionedNode('overlap', 'resolve_template', 88, 76, 'Overlap'),
]
const resolved = resolveNodeCollisions(nodes, ['anchor'])
expect(resolved.find(node => node.id === 'anchor')?.position).toEqual({ x: 56, y: 48 })
expect(graphNeedsAutoLayout(resolved)).toBe(false)
expect(resolved.find(node => node.id === 'overlap')?.position.y).toBeGreaterThanOrEqual(
48 + WORKFLOW_NODE_MIN_HEIGHT + WORKFLOW_NODE_VERTICAL_GAP,
)
})
test('cascades pushed nodes so stacked collisions are fully resolved', () => {
const nodes = [
createPositionedNode('anchor', 'order_line_setup', 56, 48, 'Anchor'),
createPositionedNode('middle', 'resolve_template', 56, 48, 'Middle'),
createPositionedNode('tail', 'blender_still', 64, 56, 'Tail'),
]
const resolved = resolveNodeCollisions(nodes, ['anchor'])
const middle = resolved.find(node => node.id === 'middle')
const tail = resolved.find(node => node.id === 'tail')
expect(middle).toBeTruthy()
expect(tail).toBeTruthy()
expect(graphNeedsAutoLayout(resolved)).toBe(false)
expect(middle?.position).not.toEqual({ x: 56, y: 48 })
expect(tail?.position).not.toEqual({ x: 64, y: 56 })
})
})
describe('workflowToGraph', () => {
test('derives explicit input and output ports from the node contract', () => {
const data = buildWorkflowCanvasNodeData('blender_still', {}, definitions.blender_still)
expect(data.inputPorts?.map(port => port.label)).toEqual([
'Order Line Context',
'Render Template',
'Material Assignments',
'Bounding Box',
])
expect(data.outputPorts?.map(port => port.label)).toEqual(['Rendered Image'])
expect(data.editableFieldCount).toBe(2)
})
test('assigns semantic handle ids to edges based on matching contracts', () => {
const graph = workflowToGraph(
{
version: 1,
nodes: [
{ id: 'setup', step: 'order_line_setup', params: {} },
{ id: 'template', step: 'resolve_template', params: {} },
{ id: 'materials', step: 'material_map_resolve', params: {} },
{ id: 'bbox', step: 'glb_bbox', params: {} },
{ id: 'render', step: 'blender_still', params: {} },
],
edges: [
{ from: 'setup', to: 'render' },
{ from: 'template', to: 'render' },
{ from: 'materials', to: 'render' },
{ from: 'bbox', to: 'render' },
],
},
definitions,
)
expect(graph.edges.find(edge => edge.source === 'setup' && edge.target === 'render')?.targetHandle).toBe(
'input:order_line_context',
)
expect(graph.edges.find(edge => edge.source === 'template' && edge.target === 'render')?.targetHandle).toBe(
'input:render_template',
)
expect(graph.edges.find(edge => edge.source === 'materials' && edge.target === 'render')?.targetHandle).toBe(
'input:material_assignments',
)
expect(graph.edges.find(edge => edge.source === 'bbox' && edge.target === 'render')?.targetHandle).toBe(
'input:bbox',
)
})
})
@@ -0,0 +1,414 @@
import { describe, expect, test } from 'vitest'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import { createWorkflowModuleBundleInsertion, getWorkflowModuleBundles } from '../../components/workflows/workflowModuleBundles'
import {
createWorkflowReferenceBundleInsertion,
getWorkflowReferenceBundles,
} from '../../components/workflows/workflowReferenceBundles'
const definitions: WorkflowNodeDefinition[] = [
{
step: 'order_line_setup',
label: 'Order Line Setup',
family: 'order_line',
module_key: 'order_line.prepare_render_context',
category: 'input',
description: 'Prepare render context.',
node_type: 'inputNode',
icon: 'refresh-cw',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line' },
output_contract: { context: 'order_line', provides: ['order_line'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: 'legacy.order_line_setup',
},
{
step: 'resolve_template',
label: 'Resolve Template',
family: 'order_line',
module_key: 'rendering.resolve_template',
category: 'processing',
description: 'Resolve template.',
node_type: 'processNode',
icon: 'layers',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['order_line'] },
output_contract: { context: 'order_line', provides: ['render_template'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: 'legacy.resolve_template',
},
{
step: 'auto_populate_materials',
label: 'Auto Populate Materials',
family: 'order_line',
module_key: 'materials.auto_populate',
category: 'processing',
description: 'Populate materials.',
node_type: 'processNode',
icon: 'layers',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['order_line'] },
output_contract: { context: 'order_line', provides: ['cad_materials'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: 'legacy.auto_populate_materials',
},
{
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: 'native',
legacy_compatible: false,
input_contract: { context: 'order_line', requires: ['order_line'] },
output_contract: { context: 'order_line', provides: ['bbox'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: null,
},
{
step: 'material_map_resolve',
label: 'Resolve Material Map',
family: 'order_line',
module_key: 'materials.resolve_map',
category: 'processing',
description: 'Resolve material map.',
node_type: 'processNode',
icon: 'layers',
defaults: {},
fields: [],
execution_kind: 'native',
legacy_compatible: false,
input_contract: { context: 'order_line', requires: ['order_line'] },
output_contract: { context: 'order_line', provides: ['material_map'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: null,
},
{
step: 'blender_still',
label: 'Blender Still',
family: 'order_line',
module_key: 'render.production.still',
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: ['rendered_image'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: null,
},
{
step: 'output_save',
label: 'Save Output',
family: 'order_line',
module_key: 'media.save_output',
category: 'output',
description: 'Save output.',
node_type: 'outputNode',
icon: 'download',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['rendered_image'] },
output_contract: { context: 'order_line', provides: ['saved_output'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: 'legacy.output_save',
},
{
step: 'notify',
label: 'Notify Result',
family: 'order_line',
module_key: 'notifications.emit',
category: 'output',
description: 'Notify result.',
node_type: 'outputNode',
icon: 'bell',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['saved_output'] },
output_contract: { context: 'order_line', provides: ['notification'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: 'legacy.notify',
},
]
const cadDefinitions: WorkflowNodeDefinition[] = [
{
step: 'resolve_step_path',
label: 'Resolve STEP Path',
family: 'cad_file',
module_key: 'cad.resolve_step_path',
category: 'input',
description: 'Resolve the STEP path.',
node_type: 'inputNode',
icon: 'file-code-2',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'cad_file' },
output_contract: { context: 'cad_file', provides: ['cad_file_record'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: 'legacy.resolve_step_path',
},
{
step: 'occ_object_extract',
label: 'Extract STEP Objects',
family: 'cad_file',
module_key: 'cad.extract_objects',
category: 'processing',
description: 'Extract objects.',
node_type: 'processNode',
icon: 'boxes',
defaults: {},
fields: [],
execution_kind: 'native',
legacy_compatible: false,
input_contract: { context: 'cad_file', requires: ['cad_file_record'] },
output_contract: { context: 'cad_file', provides: ['occ_scene'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: null,
},
{
step: 'occ_glb_export',
label: 'Export GLB',
family: 'cad_file',
module_key: 'cad.export_glb',
category: 'processing',
description: 'Export GLB.',
node_type: 'processNode',
icon: 'package',
defaults: {},
fields: [],
execution_kind: 'native',
legacy_compatible: false,
input_contract: { context: 'cad_file', requires: ['occ_scene'] },
output_contract: { context: 'cad_file', provides: ['glb_preview'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: null,
},
{
step: 'stl_cache_generate',
label: 'Generate STL Cache',
family: 'cad_file',
module_key: 'cad.generate_stl_cache',
category: 'processing',
description: 'Generate STL cache.',
node_type: 'processNode',
icon: 'database',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'cad_file', requires: ['glb_preview'] },
output_contract: { context: 'cad_file', provides: ['stl_cache'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: 'legacy.stl_cache_generate',
},
{
step: 'blender_render',
label: 'Render Thumbnail (Blender)',
family: 'cad_file',
module_key: 'cad.thumbnail.blender',
category: 'rendering',
description: 'Render Blender thumbnail.',
node_type: 'renderNode',
icon: 'camera',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'cad_file', requires: ['glb_preview'] },
output_contract: { context: 'cad_file', provides: ['rendered_image'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: 'legacy.blender_render',
},
{
step: 'threejs_render',
label: 'Render Thumbnail (Three.js)',
family: 'cad_file',
module_key: 'cad.thumbnail.threejs',
category: 'rendering',
description: 'Render Three.js thumbnail.',
node_type: 'renderNode',
icon: 'box',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'cad_file', requires: ['glb_preview'] },
output_contract: { context: 'cad_file', provides: ['rendered_image'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: 'legacy.threejs_render',
},
{
step: 'thumbnail_save',
label: 'Save Thumbnail',
family: 'cad_file',
module_key: 'cad.thumbnail.save',
category: 'output',
description: 'Persist thumbnail.',
node_type: 'outputNode',
icon: 'download',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'cad_file', requires: ['rendered_image'] },
output_contract: { context: 'cad_file', provides: ['saved_thumbnail'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: 'legacy.thumbnail_save',
},
]
describe('workflowModuleBundles', () => {
test('exposes family-scoped bundles when required steps exist', () => {
const bundles = getWorkflowModuleBundles(definitions, 'order_line')
expect(bundles.map(bundle => bundle.id)).toEqual(['still_render_core', 'output_publish_notify'])
})
test('creates a connected bundle insertion graph for still-render authoring', () => {
const insertion = createWorkflowModuleBundleInsertion({
bundleId: 'still_render_core',
graphFamily: 'order_line',
nodeDefinitionsByStep: Object.fromEntries(definitions.map(definition => [definition.step, definition])),
existingNodes: [],
preferredPosition: { x: 200, y: 320 },
})
expect(insertion.ok).toBe(true)
if (!insertion.ok) return
expect(insertion.nodes).toHaveLength(6)
expect(insertion.edges).toHaveLength(5)
expect(insertion.nodes[0].position).toEqual({ x: 200, y: 320 })
expect(insertion.nodes[1].position).toEqual({ x: 420, y: 320 })
expect(insertion.nodes[0].data).toMatchObject({ step: 'order_line_setup', label: 'Order Line Setup' })
expect(insertion.nodes[5].data).toMatchObject({ step: 'blender_still', label: 'Blender Still' })
expect(insertion.edges[0]).toMatchObject({
source: insertion.nodes[0].id,
target: insertion.nodes[1].id,
})
})
test('exposes full reference paths for complete non-legacy authoring flows', () => {
const bundles = getWorkflowReferenceBundles(definitions, 'order_line')
expect(bundles.map(bundle => bundle.id)).toEqual(['still_render_reference'])
})
test('creates the canonical still-render reference graph with branched edges', () => {
const insertion = createWorkflowReferenceBundleInsertion({
bundleId: 'still_render_reference',
graphFamily: 'order_line',
nodeDefinitionsByStep: Object.fromEntries(definitions.map(definition => [definition.step, definition])),
existingNodes: [],
preferredPosition: { x: 200, y: 320 },
})
expect(insertion.ok).toBe(true)
if (!insertion.ok) return
expect(insertion.nodes).toHaveLength(8)
expect(insertion.edges).toHaveLength(10)
expect(insertion.nodes[0].position).toEqual({ x: 200, y: 440 })
expect(insertion.nodes[1].position).toEqual({ x: 420, y: 440 })
expect(insertion.nodes[5].data).toMatchObject({
step: 'blender_still',
label: 'Still Render',
params: { use_custom_render_settings: true },
})
expect(insertion.edges).toEqual(
expect.arrayContaining([
expect.objectContaining({
source: insertion.nodes[0].id,
target: insertion.nodes[1].id,
}),
expect.objectContaining({
source: insertion.nodes[5].id,
target: insertion.nodes[6].id,
}),
expect.objectContaining({
source: insertion.nodes[5].id,
target: insertion.nodes[7].id,
}),
]),
)
})
test('creates the canonical CAD intake reference graph from the shared blueprint', () => {
const insertion = createWorkflowReferenceBundleInsertion({
bundleId: 'cad_intake_reference',
graphFamily: 'cad_file',
nodeDefinitionsByStep: Object.fromEntries(cadDefinitions.map(definition => [definition.step, definition])),
existingNodes: [],
preferredPosition: { x: 120, y: 240 },
})
expect(insertion.ok).toBe(true)
if (!insertion.ok) return
expect(insertion.nodes).toHaveLength(8)
expect(insertion.edges).toHaveLength(7)
expect(insertion.nodes.map(node => node.data.step)).toEqual([
'resolve_step_path',
'occ_object_extract',
'occ_glb_export',
'stl_cache_generate',
'blender_render',
'threejs_render',
'thumbnail_save',
'thumbnail_save',
])
expect(insertion.edges).toEqual(
expect.arrayContaining([
expect.objectContaining({
source: insertion.nodes[2].id,
target: insertion.nodes[4].id,
}),
expect.objectContaining({
source: insertion.nodes[2].id,
target: insertion.nodes[5].id,
}),
]),
)
})
})
@@ -0,0 +1,158 @@
import { describe, expect, test } from 'vitest'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import { buildWorkflowNodeCatalogModel } from '../../components/workflows/workflowNodeCatalog'
import { getDefinitionAuthoringStage } from '../../components/workflows/workflowNodeLibrary'
const definitions: WorkflowNodeDefinition[] = [
{
step: 'resolve_step_path',
label: 'Resolve STEP Path',
family: 'cad_file',
module_key: 'cad.resolve_step_path',
category: 'input',
description: 'Resolve CAD path.',
node_type: 'inputNode',
icon: 'file',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'cad_file' },
output_contract: { context: 'cad_file', provides: ['cad_file'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: 'legacy.resolve_step_path',
},
{
step: 'order_line_setup',
label: 'Order Line Setup',
family: 'order_line',
module_key: 'order_line.prepare_render_context',
category: 'input',
description: 'Prepare order line.',
node_type: 'inputNode',
icon: 'file',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line' },
output_contract: { context: 'order_line', provides: ['order_line'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: 'legacy.order_line_setup',
},
{
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: 'palette',
defaults: {},
fields: [],
execution_kind: 'native',
legacy_compatible: false,
input_contract: { context: 'order_line', requires: ['order_line'] },
output_contract: { context: 'order_line', provides: ['material_map'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: null,
},
{
step: 'blender_still',
label: 'Blender Still',
family: 'order_line',
module_key: 'render.production.still',
category: 'rendering',
description: 'Render 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: ['rendered_image'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: null,
},
{
step: 'output_save',
label: 'Save Output',
family: 'order_line',
module_key: 'media.save_output',
category: 'output',
description: 'Save output.',
node_type: 'outputNode',
icon: 'download',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['rendered_image'] },
output_contract: { context: 'order_line', provides: ['saved_output'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: 'legacy.output_save',
},
]
describe('workflow node organization', () => {
test('assigns authoring stages from module namespaces and categories', () => {
expect(getDefinitionAuthoringStage(definitions[0])).toBe('cad_intake')
expect(getDefinitionAuthoringStage(definitions[1])).toBe('scene_prep')
expect(getDefinitionAuthoringStage(definitions[2])).toBe('materials')
expect(getDefinitionAuthoringStage(definitions[3])).toBe('render')
expect(getDefinitionAuthoringStage(definitions[4])).toBe('publish')
})
test('builds stage and family catalog models with module and runtime counts', () => {
const model = buildWorkflowNodeCatalogModel(definitions)
expect(model.moduleFilters.map(module => module.label)).toEqual([
'Cad',
'Materials',
'Media',
'Order Line',
'Render',
])
expect(model.runtimeCounts).toEqual({
legacy: 3,
bridge: 0,
graph: 2,
})
const stageIds = model.stageSections.map(section => section.stage)
expect(stageIds).toEqual(['cad_intake', 'scene_prep', 'materials', 'render', 'publish'])
const renderSection = model.stageSections.find(section => section.stage === 'render')
expect(renderSection?.modules[0]?.namespace).toBe('render')
expect(renderSection?.runtimeCounts.graph).toBe(1)
const publishSection = model.stageSections.find(section => section.stage === 'publish')
expect(publishSection?.modules[0]?.runtimeCounts.legacy).toBe(1)
expect(model.familySections.map(section => section.family)).toEqual(['cad_file', 'order_line'])
const cadFamilySection = model.familySections.find(section => section.family === 'cad_file')
expect(cadFamilySection?.modules.map(module => module.namespace)).toEqual(['cad'])
expect(cadFamilySection?.modules[0]?.stageSections.map(section => section.stage)).toEqual(['cad_intake'])
const orderFamilySection = model.familySections.find(section => section.family === 'order_line')
expect(orderFamilySection?.modules.map(module => module.namespace)).toEqual([
'materials',
'media',
'order_line',
'render',
])
expect(orderFamilySection?.runtimeCounts.graph).toBe(2)
expect(orderFamilySection?.modules.find(module => module.namespace === 'materials')?.stageSections[0]?.stage).toBe(
'materials',
)
})
})
@@ -0,0 +1,37 @@
import { describe, expect, test } from 'vitest'
import type { WorkflowCanvasPort } from '../../components/workflows/workflowGraphDraft'
import {
getWorkflowNodePortBadgeLabel,
getWorkflowNodePortTitle,
} from '../../components/workflows/workflowNodePresentation'
describe('workflowNodePresentation', () => {
test('renders explicit labels for direct required inputs', () => {
const port: WorkflowCanvasPort = {
id: 'input:material_assignments',
label: 'Material Assignments',
roles: ['material_assignments'],
kind: 'required',
}
expect(getWorkflowNodePortBadgeLabel(port)).toBe('Material Assignments')
expect(getWorkflowNodePortTitle(port)).toBe('Material Assignments')
})
test('renders alternative sockets as explicit role choices', () => {
const port: WorkflowCanvasPort = {
id: 'input-any:rendered_image|rendered_frames|rendered_video|workflow_result|blend_asset',
label: 'Any of Rendered Image / Rendered Frames / Rendered Video / Workflow Result / Blend Asset',
roles: ['rendered_image', 'rendered_frames', 'rendered_video', 'workflow_result', 'blend_asset'],
kind: 'alternative',
}
expect(getWorkflowNodePortBadgeLabel(port)).toBe(
'Any: Image / Frames / Video / Workflow Result / Blend Asset',
)
expect(getWorkflowNodePortTitle(port)).toBe(
'Accepts any of: Rendered Image / Rendered Frames / Rendered Video / Workflow Result / Blend Asset',
)
})
})
+610 -18
View File
@@ -1,6 +1,7 @@
import api from './client'
export type OutputTypeWorkflowFamily = 'cad_file' | 'order_line'
export type OutputTypeWorkflowRolloutMode = 'legacy_only' | 'shadow' | 'graph'
export type OutputTypeArtifactKind =
| 'still_image'
| 'turntable_video'
@@ -10,6 +11,17 @@ export type OutputTypeArtifactKind =
| 'package'
| 'custom'
export type OutputTypeInvocationOverrideKey = typeof OUTPUT_TYPE_INVOCATION_OVERRIDE_KEYS[number]
export type OutputTypeInvocationOverrides = Partial<Record<OutputTypeInvocationOverrideKey, string | number | boolean>>
export type OutputTypeContractIssueSeverity = 'error' | 'warning'
export type OutputTypeContractCatalogMap<K extends string, V> = Record<K, V>
export interface OutputTypeParameterOwnershipCatalog {
output_type_profile_keys: string[]
template_runtime_keys: string[]
workflow_node_keys_by_step: Record<string, string[]>
}
export const OUTPUT_TYPE_INVOCATION_OVERRIDE_KEYS = [
'width',
'height',
@@ -27,8 +39,171 @@ export const OUTPUT_TYPE_INVOCATION_OVERRIDE_KEYS = [
'denoising_use_gpu',
] as const
export const IMAGE_OUTPUT_FORMATS = ['png', 'jpg', 'jpeg', 'webp'] as const
export const VIDEO_OUTPUT_FORMATS = ['mp4', 'webm', 'mov'] as const
export const MODEL_OUTPUT_FORMATS = ['gltf', 'glb', 'stl', 'obj', 'usd', 'usdz'] as const
export const BLEND_OUTPUT_FORMATS = ['blend'] as const
const CAD_FILE_ARTIFACT_KINDS: OutputTypeArtifactKind[] = ['thumbnail_image', 'model_export', 'package', 'custom']
const ORDER_LINE_ARTIFACT_KINDS: OutputTypeArtifactKind[] = ['still_image', 'turntable_video', 'blend_asset', 'package', 'custom']
const STATIC_RENDER_OVERRIDE_KEYS: OutputTypeInvocationOverrideKey[] = [
'width',
'height',
'engine',
'samples',
'bg_color',
'noise_threshold',
'denoiser',
'denoising_input_passes',
'denoising_prefilter',
'denoising_quality',
'denoising_use_gpu',
]
const ANIMATION_OVERRIDE_KEYS: OutputTypeInvocationOverrideKey[] = ['frame_count', 'fps', 'turntable_axis']
export interface OutputTypeContractCatalog {
workflow_families: OutputTypeWorkflowFamily[]
workflow_rollout_modes: OutputTypeWorkflowRolloutMode[]
artifact_kinds: OutputTypeArtifactKind[]
allowed_artifact_kinds_by_family: OutputTypeContractCatalogMap<OutputTypeWorkflowFamily, OutputTypeArtifactKind[]>
allowed_output_formats_by_family: OutputTypeContractCatalogMap<OutputTypeWorkflowFamily, string[]>
allowed_invocation_override_keys_by_artifact_kind: OutputTypeContractCatalogMap<
OutputTypeArtifactKind,
OutputTypeInvocationOverrideKey[]
>
default_output_format_by_artifact_kind: OutputTypeContractCatalogMap<OutputTypeArtifactKind, string>
parameter_ownership: OutputTypeParameterOwnershipCatalog
}
const FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG: OutputTypeContractCatalog = {
workflow_families: ['order_line', 'cad_file'],
workflow_rollout_modes: ['legacy_only', 'shadow', 'graph'],
artifact_kinds: ['still_image', 'turntable_video', 'model_export', 'thumbnail_image', 'blend_asset', 'package', 'custom'],
allowed_artifact_kinds_by_family: {
cad_file: [...CAD_FILE_ARTIFACT_KINDS],
order_line: [...ORDER_LINE_ARTIFACT_KINDS],
},
allowed_output_formats_by_family: {
cad_file: [...IMAGE_OUTPUT_FORMATS, ...MODEL_OUTPUT_FORMATS],
order_line: [...IMAGE_OUTPUT_FORMATS, ...VIDEO_OUTPUT_FORMATS, ...BLEND_OUTPUT_FORMATS],
},
allowed_invocation_override_keys_by_artifact_kind: {
still_image: [...STATIC_RENDER_OVERRIDE_KEYS],
thumbnail_image: [...STATIC_RENDER_OVERRIDE_KEYS],
turntable_video: [...STATIC_RENDER_OVERRIDE_KEYS, ...ANIMATION_OVERRIDE_KEYS],
model_export: [],
blend_asset: [],
package: [...OUTPUT_TYPE_INVOCATION_OVERRIDE_KEYS],
custom: [...OUTPUT_TYPE_INVOCATION_OVERRIDE_KEYS],
},
default_output_format_by_artifact_kind: {
still_image: 'png',
thumbnail_image: 'png',
turntable_video: 'mp4',
model_export: 'gltf',
blend_asset: 'blend',
package: 'png',
custom: 'png',
},
parameter_ownership: {
output_type_profile_keys: ['transparent_bg', 'cycles_device', 'material_override'],
template_runtime_keys: ['target_collection', 'lighting_only', 'shadow_catcher', 'camera_orbit', 'template_inputs'],
workflow_node_keys_by_step: {
resolve_template: [
'template_id_override',
'require_template',
'material_library_path',
'disable_materials',
'target_collection',
'material_replace_mode',
'lighting_only_mode',
'shadow_catcher_mode',
'camera_orbit_mode',
],
blender_still: [
'use_custom_render_settings',
'render_engine',
'cycles_device',
'samples',
'width',
'height',
'transparent_bg',
'noise_threshold',
'denoiser',
'denoising_input_passes',
'denoising_prefilter',
'denoising_quality',
'denoising_use_gpu',
'target_collection',
'lighting_only',
'shadow_catcher',
'rotation_x',
'rotation_y',
'rotation_z',
'focal_length_mm',
'sensor_width_mm',
'material_override',
],
blender_turntable: [
'use_custom_render_settings',
'render_engine',
'cycles_device',
'samples',
'width',
'height',
'transparent_bg',
'bg_color',
'fps',
'frame_count',
'duration_s',
'turntable_degrees',
'turntable_axis',
'camera_orbit',
'target_collection',
'lighting_only',
'shadow_catcher',
'rotation_x',
'rotation_y',
'rotation_z',
'focal_length_mm',
'sensor_width_mm',
'material_override',
],
export_blend: ['output_name_suffix'],
},
},
}
let cachedOutputTypeContractCatalog: OutputTypeContractCatalog = FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG
export interface OutputTypeWorkflowContractWorkflowLike {
id: string
name: string
family: OutputTypeWorkflowFamily | 'mixed' | null
supported_artifact_kinds?: OutputTypeArtifactKind[]
}
export interface OutputTypeWorkflowContractIssue {
code: string
severity: OutputTypeContractIssueSeverity
message: string
}
export interface OutputTypeInvocationProfile {
renderer: string
render_backend: string
workflow_family: OutputTypeWorkflowFamily
artifact_kind: OutputTypeArtifactKind
output_format: string
is_animation: boolean
workflow_definition_id: string | null
workflow_rollout_mode: OutputTypeWorkflowRolloutMode
transparent_bg: boolean
cycles_device: string | null
material_override: string | null
allowed_override_keys: OutputTypeInvocationOverrideKey[]
invocation_overrides: OutputTypeInvocationOverrides
}
export interface OutputType {
id: string
@@ -36,7 +211,7 @@ export interface OutputType {
description: string | null
renderer: string
render_settings: Record<string, unknown>
invocation_overrides: Record<string, unknown>
invocation_overrides: OutputTypeInvocationOverrides
output_format: string
sort_order: number
compatible_categories: string[]
@@ -50,13 +225,141 @@ export interface OutputType {
pricing_tier_name: string | null
price_per_item: number | null
workflow_definition_id: string | null
workflow_rollout_mode: OutputTypeWorkflowRolloutMode
workflow_name?: string | null
material_override: string | null
invocation_profile: OutputTypeInvocationProfile | null
is_active: boolean
created_at: string
updated_at: string
}
function isWorkflowFamily(value: unknown): value is OutputTypeWorkflowFamily {
return value === 'cad_file' || value === 'order_line'
}
function isWorkflowRolloutMode(value: unknown): value is OutputTypeWorkflowRolloutMode {
return value === 'legacy_only' || value === 'shadow' || value === 'graph'
}
function isArtifactKind(value: unknown): value is OutputTypeArtifactKind {
return FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.artifact_kinds.includes(value as OutputTypeArtifactKind)
}
function isInvocationOverrideKey(value: unknown): value is OutputTypeInvocationOverrideKey {
return OUTPUT_TYPE_INVOCATION_OVERRIDE_KEYS.includes(value as OutputTypeInvocationOverrideKey)
}
function normalizeOrderedStrings<T extends string>(
values: unknown,
fallback: readonly T[],
predicate: (value: unknown) => value is T,
): T[] {
const provided = Array.isArray(values) ? values.filter(predicate) : []
const usable = provided.length > 0 ? provided : [...fallback]
const usableSet = new Set(usable)
return fallback.filter(value => usableSet.has(value))
}
function normalizeStringList(values: unknown): string[] {
return Array.isArray(values) ? values.filter((value): value is string => typeof value === 'string' && value.trim().length > 0) : []
}
function normalizeRecordOfStringLists(values: unknown): Record<string, string[]> {
if (!values || typeof values !== 'object' || Array.isArray(values)) return {}
return Object.fromEntries(
Object.entries(values).map(([key, value]) => [key, normalizeStringList(value)]),
)
}
function normalizeOutputTypeContractCatalog(
catalog: Partial<OutputTypeContractCatalog> | undefined | null,
): OutputTypeContractCatalog {
const workflowFamilies = normalizeOrderedStrings(
catalog?.workflow_families,
FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.workflow_families,
isWorkflowFamily,
)
const workflowRolloutModes = normalizeOrderedStrings(
catalog?.workflow_rollout_modes,
FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.workflow_rollout_modes,
isWorkflowRolloutMode,
)
const artifactKinds = normalizeOrderedStrings(
catalog?.artifact_kinds,
FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.artifact_kinds,
isArtifactKind,
)
const allowedArtifactKindsByFamily = Object.fromEntries(
workflowFamilies.map(family => [
family,
normalizeOrderedStrings(
catalog?.allowed_artifact_kinds_by_family?.[family],
FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.allowed_artifact_kinds_by_family[family],
isArtifactKind,
),
]),
) as OutputTypeContractCatalog['allowed_artifact_kinds_by_family']
const allowedOutputFormatsByFamily = Object.fromEntries(
workflowFamilies.map(family => [
family,
normalizeStringList(catalog?.allowed_output_formats_by_family?.[family]).length > 0
? normalizeStringList(catalog?.allowed_output_formats_by_family?.[family])
: [...FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.allowed_output_formats_by_family[family]],
]),
) as OutputTypeContractCatalog['allowed_output_formats_by_family']
const allowedInvocationOverrideKeysByArtifactKind = Object.fromEntries(
artifactKinds.map(artifactKind => [
artifactKind,
normalizeOrderedStrings(
catalog?.allowed_invocation_override_keys_by_artifact_kind?.[artifactKind],
FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.allowed_invocation_override_keys_by_artifact_kind[artifactKind],
isInvocationOverrideKey,
),
]),
) as OutputTypeContractCatalog['allowed_invocation_override_keys_by_artifact_kind']
const defaultOutputFormatByArtifactKind = Object.fromEntries(
artifactKinds.map(artifactKind => [
artifactKind,
typeof catalog?.default_output_format_by_artifact_kind?.[artifactKind] === 'string' &&
catalog.default_output_format_by_artifact_kind[artifactKind].trim().length > 0
? catalog.default_output_format_by_artifact_kind[artifactKind]
: FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.default_output_format_by_artifact_kind[artifactKind],
]),
) as OutputTypeContractCatalog['default_output_format_by_artifact_kind']
const normalizedNodeOwnership = normalizeRecordOfStringLists(catalog?.parameter_ownership?.workflow_node_keys_by_step)
const parameterOwnership: OutputTypeParameterOwnershipCatalog = {
output_type_profile_keys:
normalizeStringList(catalog?.parameter_ownership?.output_type_profile_keys).length > 0
? normalizeStringList(catalog?.parameter_ownership?.output_type_profile_keys)
: [...FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.parameter_ownership.output_type_profile_keys],
template_runtime_keys:
normalizeStringList(catalog?.parameter_ownership?.template_runtime_keys).length > 0
? normalizeStringList(catalog?.parameter_ownership?.template_runtime_keys)
: [...FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.parameter_ownership.template_runtime_keys],
workflow_node_keys_by_step:
Object.keys(normalizedNodeOwnership).length > 0
? normalizedNodeOwnership
: { ...FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.parameter_ownership.workflow_node_keys_by_step },
}
return {
workflow_families: workflowFamilies,
workflow_rollout_modes: workflowRolloutModes,
artifact_kinds: artifactKinds,
allowed_artifact_kinds_by_family: allowedArtifactKindsByFamily,
allowed_output_formats_by_family: allowedOutputFormatsByFamily,
allowed_invocation_override_keys_by_artifact_kind: allowedInvocationOverrideKeysByArtifactKind,
default_output_format_by_artifact_kind: defaultOutputFormatByArtifactKind,
parameter_ownership: parameterOwnership,
}
}
export async function listOutputTypes(
includeInactive = false,
category?: string,
@@ -64,25 +367,151 @@ export async function listOutputTypes(
const params: Record<string, unknown> = { include_inactive: includeInactive }
if (category) params.category = category
const res = await api.get<OutputType[]>('/output-types', { params })
return res.data
return res.data.map(normalizeOutputType)
}
export async function createOutputType(data: Partial<OutputType>): Promise<OutputType> {
const res = await api.post<OutputType>('/output-types', data)
return res.data
return normalizeOutputType(res.data)
}
export async function updateOutputType(id: string, data: Partial<OutputType>): Promise<OutputType> {
const res = await api.patch<OutputType>(`/output-types/${id}`, data)
return res.data
return normalizeOutputType(res.data)
}
export async function deleteOutputType(id: string): Promise<void> {
await api.delete(`/output-types/${id}`)
}
export function listAllowedArtifactKindsForFamily(family: OutputTypeWorkflowFamily): OutputTypeArtifactKind[] {
return family === 'cad_file' ? [...CAD_FILE_ARTIFACT_KINDS] : [...ORDER_LINE_ARTIFACT_KINDS]
export async function getOutputTypeContractCatalog(): Promise<OutputTypeContractCatalog> {
const res = await api.get<OutputTypeContractCatalog>('/output-types/contract-catalog')
cachedOutputTypeContractCatalog = normalizeOutputTypeContractCatalog(res.data)
return cachedOutputTypeContractCatalog
}
export function getCachedOutputTypeContractCatalog(): OutputTypeContractCatalog {
return cachedOutputTypeContractCatalog
}
export function listAllowedArtifactKindsForFamily(
family: OutputTypeWorkflowFamily,
contractCatalog: OutputTypeContractCatalog = cachedOutputTypeContractCatalog,
): OutputTypeArtifactKind[] {
return [...(contractCatalog.allowed_artifact_kinds_by_family[family] ?? FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.allowed_artifact_kinds_by_family[family])]
}
export function listAllowedOutputFormatsForFamily(
family: OutputTypeWorkflowFamily,
contractCatalog: OutputTypeContractCatalog = cachedOutputTypeContractCatalog,
): string[] {
return [...(contractCatalog.allowed_output_formats_by_family[family] ?? FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.allowed_output_formats_by_family[family])]
}
export function getDefaultOutputFormatForArtifactKind(
artifactKind: OutputTypeArtifactKind,
contractCatalog: OutputTypeContractCatalog = cachedOutputTypeContractCatalog,
): string {
return contractCatalog.default_output_format_by_artifact_kind[artifactKind]
?? FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.default_output_format_by_artifact_kind[artifactKind]
}
export function workflowSupportsArtifactKindForOutputTypeContract(
workflow: OutputTypeWorkflowContractWorkflowLike,
artifactKind: OutputTypeArtifactKind,
): boolean {
return Array.isArray(workflow.supported_artifact_kinds) && workflow.supported_artifact_kinds.includes(artifactKind)
}
export function getCompatibleWorkflowsForOutputTypeContract(
workflows: OutputTypeWorkflowContractWorkflowLike[],
workflowFamily: OutputTypeWorkflowFamily,
artifactKind: OutputTypeArtifactKind,
): OutputTypeWorkflowContractWorkflowLike[] {
return workflows.filter(workflow =>
workflow.family === workflowFamily &&
workflowSupportsArtifactKindForOutputTypeContract(workflow, artifactKind),
)
}
export function listAllowedInvocationOverrideKeysForArtifactKind(
artifactKind: OutputTypeArtifactKind,
contractCatalog: OutputTypeContractCatalog = cachedOutputTypeContractCatalog,
): OutputTypeInvocationOverrideKey[] {
return [
...(contractCatalog.allowed_invocation_override_keys_by_artifact_kind[artifactKind]
?? FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.allowed_invocation_override_keys_by_artifact_kind[artifactKind]),
]
}
function isInvocationOverrideValue(value: unknown): value is string | number | boolean {
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
}
function sanitizeInvocationOverrides(
artifactKind: OutputTypeArtifactKind,
overrides: Record<string, unknown> | undefined | null,
contractCatalog: OutputTypeContractCatalog = cachedOutputTypeContractCatalog,
): OutputTypeInvocationOverrides {
const normalized: OutputTypeInvocationOverrides = {}
const allowed = new Set(listAllowedInvocationOverrideKeysForArtifactKind(artifactKind, contractCatalog))
for (const key of OUTPUT_TYPE_INVOCATION_OVERRIDE_KEYS) {
if (!allowed.has(key)) continue
const value = overrides?.[key]
if (value !== undefined && value !== null && value !== '' && isInvocationOverrideValue(value)) {
normalized[key] = value
}
}
return normalized
}
function buildFallbackInvocationProfile(outputType: OutputType): OutputTypeInvocationProfile {
const artifactKind = outputType.artifact_kind ?? inferArtifactKind(
outputType.workflow_family,
outputType.output_format,
outputType.is_animation,
)
return {
renderer: outputType.renderer,
render_backend: outputType.render_backend,
workflow_family: outputType.workflow_family,
artifact_kind: artifactKind,
output_format: outputType.output_format,
is_animation: outputType.is_animation,
workflow_definition_id: outputType.workflow_definition_id,
workflow_rollout_mode: outputType.workflow_rollout_mode ?? 'legacy_only',
transparent_bg: outputType.transparent_bg,
cycles_device: outputType.cycles_device,
material_override: outputType.material_override,
allowed_override_keys: listAllowedInvocationOverrideKeysForArtifactKind(artifactKind),
invocation_overrides: sanitizeInvocationOverrides(artifactKind, {
...outputType.render_settings,
...outputType.invocation_overrides,
}),
}
}
function normalizeOutputType(outputType: OutputType): OutputType {
const invocationProfile = outputType.invocation_profile
? {
...outputType.invocation_profile,
workflow_rollout_mode: outputType.invocation_profile.workflow_rollout_mode ?? outputType.workflow_rollout_mode ?? 'legacy_only',
allowed_override_keys: outputType.invocation_profile.allowed_override_keys
?.filter((key): key is OutputTypeInvocationOverrideKey => OUTPUT_TYPE_INVOCATION_OVERRIDE_KEYS.includes(key as OutputTypeInvocationOverrideKey))
?? listAllowedInvocationOverrideKeysForArtifactKind(outputType.artifact_kind),
invocation_overrides: sanitizeInvocationOverrides(
outputType.invocation_profile.artifact_kind ?? outputType.artifact_kind,
outputType.invocation_profile.invocation_overrides,
),
}
: buildFallbackInvocationProfile(outputType)
return {
...outputType,
workflow_rollout_mode: outputType.workflow_rollout_mode ?? 'legacy_only',
invocation_overrides: invocationProfile.invocation_overrides,
invocation_profile: invocationProfile,
}
}
export function inferArtifactKind(
@@ -92,10 +521,13 @@ export function inferArtifactKind(
): OutputTypeArtifactKind {
const normalizedFormat = outputFormat.trim().toLowerCase()
if (isAnimation || ['mp4', 'webm', 'mov'].includes(normalizedFormat)) {
if (BLEND_OUTPUT_FORMATS.includes(normalizedFormat as (typeof BLEND_OUTPUT_FORMATS)[number])) {
return 'blend_asset'
}
if (isAnimation || VIDEO_OUTPUT_FORMATS.includes(normalizedFormat as (typeof VIDEO_OUTPUT_FORMATS)[number])) {
return 'turntable_video'
}
if (['gltf', 'glb', 'stl', 'obj', 'usd', 'usdz'].includes(normalizedFormat)) {
if (MODEL_OUTPUT_FORMATS.includes(normalizedFormat as (typeof MODEL_OUTPUT_FORMATS)[number])) {
return 'model_export'
}
if (workflowFamily === 'cad_file') {
@@ -107,19 +539,179 @@ export function inferArtifactKind(
export function isArtifactKindAllowedForFamily(
workflowFamily: OutputTypeWorkflowFamily,
artifactKind: OutputTypeArtifactKind,
contractCatalog: OutputTypeContractCatalog = cachedOutputTypeContractCatalog,
): boolean {
return listAllowedArtifactKindsForFamily(workflowFamily).includes(artifactKind)
return listAllowedArtifactKindsForFamily(workflowFamily, contractCatalog).includes(artifactKind)
}
export function getOutputTypeWorkflowContractIssues(args: {
workflowFamily: OutputTypeWorkflowFamily
artifactKind: OutputTypeArtifactKind
outputFormat: string
isAnimation: boolean
workflowDefinitionId?: string | null
workflowRolloutMode: OutputTypeWorkflowRolloutMode
workflows?: OutputTypeWorkflowContractWorkflowLike[]
contractCatalog?: OutputTypeContractCatalog
}): OutputTypeWorkflowContractIssue[] {
const {
workflowFamily,
artifactKind,
outputFormat,
isAnimation,
workflowDefinitionId,
workflowRolloutMode,
workflows = [],
contractCatalog = cachedOutputTypeContractCatalog,
} = args
const issues: OutputTypeWorkflowContractIssue[] = []
const normalizedFormat = outputFormat.trim().toLowerCase()
const selectedWorkflowId = workflowDefinitionId?.trim() ?? ''
const selectedWorkflow = selectedWorkflowId
? workflows.find(workflow => workflow.id === selectedWorkflowId) ?? null
: null
if (!isArtifactKindAllowedForFamily(workflowFamily, artifactKind, contractCatalog)) {
issues.push({
code: 'artifact_family_mismatch',
severity: 'error',
message: `${artifactKind} is not allowed for the ${workflowFamily} workflow family.`,
})
}
if (normalizedFormat && !listAllowedOutputFormatsForFamily(workflowFamily, contractCatalog).includes(normalizedFormat)) {
issues.push({
code: 'format_family_mismatch',
severity: 'error',
message: `${normalizedFormat} is not allowed for the ${workflowFamily} workflow family.`,
})
}
if (workflowFamily === 'cad_file' && isAnimation) {
issues.push({
code: 'cad_animation_unsupported',
severity: 'error',
message: 'CAD intake workflows do not support animated output types.',
})
}
if (artifactKind === 'turntable_video') {
if (!isAnimation) {
issues.push({
code: 'turntable_requires_animation',
severity: 'error',
message: 'Turntable Video requires animation to be enabled.',
})
}
if (normalizedFormat && !VIDEO_OUTPUT_FORMATS.includes(normalizedFormat as (typeof VIDEO_OUTPUT_FORMATS)[number])) {
issues.push({
code: 'turntable_requires_video_format',
severity: 'error',
message: 'Turntable Video requires a video output format.',
})
}
}
if (
(artifactKind === 'still_image' || artifactKind === 'thumbnail_image') &&
VIDEO_OUTPUT_FORMATS.includes(normalizedFormat as (typeof VIDEO_OUTPUT_FORMATS)[number])
) {
issues.push({
code: 'image_artifact_with_video_format',
severity: 'error',
message: `${artifactKind} cannot use a video output format.`,
})
}
if (
artifactKind === 'model_export' &&
normalizedFormat &&
!MODEL_OUTPUT_FORMATS.includes(normalizedFormat as (typeof MODEL_OUTPUT_FORMATS)[number])
) {
issues.push({
code: 'model_export_requires_model_format',
severity: 'error',
message: 'Model Export requires a 3D export format such as gltf, glb, stl, obj, usd, or usdz.',
})
}
if (artifactKind === 'blend_asset') {
if (isAnimation) {
issues.push({
code: 'blend_asset_animation_unsupported',
severity: 'error',
message: 'Blend Asset does not support animation output.',
})
}
if (normalizedFormat && !BLEND_OUTPUT_FORMATS.includes(normalizedFormat as (typeof BLEND_OUTPUT_FORMATS)[number])) {
issues.push({
code: 'blend_asset_requires_blend_format',
severity: 'error',
message: 'Blend Asset requires the blend output format.',
})
}
}
if (
BLEND_OUTPUT_FORMATS.includes(normalizedFormat as (typeof BLEND_OUTPUT_FORMATS)[number]) &&
artifactKind !== 'blend_asset'
) {
issues.push({
code: 'blend_format_requires_blend_asset',
severity: 'error',
message: 'The blend output format requires the Blend Asset artifact kind.',
})
}
if (!selectedWorkflowId) {
if (workflowRolloutMode !== 'legacy_only') {
issues.push({
code: 'rollout_requires_workflow',
severity: 'error',
message: 'Shadow or graph rollout requires a linked workflow definition.',
})
}
return issues
}
if (selectedWorkflow == null) {
issues.push({
code: 'workflow_missing',
severity: 'error',
message: 'The selected workflow definition could not be resolved.',
})
return issues
}
if (selectedWorkflow.family === 'mixed') {
issues.push({
code: 'workflow_family_mixed',
severity: 'error',
message: `Workflow "${selectedWorkflow.name}" mixes CAD and order-line nodes and cannot be linked to an output type.`,
})
} else if (selectedWorkflow.family !== workflowFamily) {
issues.push({
code: 'workflow_family_mismatch',
severity: 'error',
message: `Workflow "${selectedWorkflow.name}" belongs to ${selectedWorkflow.family ?? 'an unknown'} family and does not match ${workflowFamily}.`,
})
}
if (!workflowSupportsArtifactKindForOutputTypeContract(selectedWorkflow, artifactKind)) {
issues.push({
code: 'workflow_artifact_mismatch',
severity: 'error',
message: `Workflow "${selectedWorkflow.name}" does not produce the ${artifactKind} artifact contract.`,
})
}
return issues
}
export function getOutputTypeInvocationOverrides(outputType: OutputType): Record<string, unknown> {
const normalized: Record<string, unknown> = {}
for (const key of OUTPUT_TYPE_INVOCATION_OVERRIDE_KEYS) {
const explicitValue = outputType.invocation_overrides?.[key]
const legacyValue = outputType.render_settings?.[key]
const value = explicitValue ?? legacyValue
if (value !== undefined && value !== null && value !== '') {
normalized[key] = value
}
if (outputType.invocation_profile?.invocation_overrides) {
return outputType.invocation_profile.invocation_overrides
}
return normalized
return buildFallbackInvocationProfile(outputType).invocation_overrides
}
+5 -2
View File
@@ -1,4 +1,5 @@
import api from './client';
import type { WorkflowNodeFieldDefinition } from './workflows'
export interface RenderTemplate {
id: string;
@@ -15,6 +16,7 @@ export interface RenderTemplate {
lighting_only: boolean;
shadow_catcher_enabled: boolean;
camera_orbit: boolean;
workflow_input_schema: WorkflowNodeFieldDefinition[];
is_active: boolean;
created_at: string;
updated_at: string;
@@ -41,7 +43,7 @@ export async function createRenderTemplate(formData: FormData): Promise<RenderTe
export async function duplicateRenderTemplate(
sourceId: string,
overrides: Partial<Pick<RenderTemplate, 'name' | 'category_key' | 'target_collection' | 'material_replace_enabled' | 'lighting_only' | 'shadow_catcher_enabled' | 'camera_orbit'>> & { output_type_ids?: string[] },
overrides: Partial<Pick<RenderTemplate, 'name' | 'category_key' | 'target_collection' | 'material_replace_enabled' | 'lighting_only' | 'shadow_catcher_enabled' | 'camera_orbit' | 'workflow_input_schema'>> & { output_type_ids?: string[] },
): Promise<RenderTemplate> {
const fd = new FormData();
fd.append('name', overrides.name || 'Untitled (copy)');
@@ -53,6 +55,7 @@ export async function duplicateRenderTemplate(
fd.append('lighting_only', String(overrides.lighting_only ?? false));
fd.append('shadow_catcher_enabled', String(overrides.shadow_catcher_enabled ?? false));
fd.append('camera_orbit', String(overrides.camera_orbit ?? true));
fd.append('workflow_input_schema', JSON.stringify(overrides.workflow_input_schema ?? []));
const { data } = await api.post<RenderTemplate>('/render-templates', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
@@ -61,7 +64,7 @@ export async function duplicateRenderTemplate(
export async function updateRenderTemplate(
id: string,
updates: Partial<Pick<RenderTemplate, 'name' | 'category_key' | 'output_type_ids' | 'target_collection' | 'material_replace_enabled' | 'lighting_only' | 'shadow_catcher_enabled' | 'camera_orbit' | 'is_active'>>,
updates: Partial<Pick<RenderTemplate, 'name' | 'category_key' | 'output_type_ids' | 'target_collection' | 'material_replace_enabled' | 'lighting_only' | 'shadow_catcher_enabled' | 'camera_orbit' | 'workflow_input_schema' | 'is_active'>>,
): Promise<RenderTemplate> {
const { data } = await api.patch<RenderTemplate>(`/render-templates/${id}`, updates);
return data;
+455 -107
View File
@@ -1,8 +1,43 @@
import api from './client'
import type { OutputTypeArtifactKind, OutputTypeWorkflowRolloutMode } from './outputTypes'
export type WorkflowPresetType = 'still' | 'still_graph' | 'turntable' | 'multi_angle' | 'still_with_exports' | 'custom'
export type WorkflowExecutionMode = 'legacy' | 'graph' | 'shadow'
export type WorkflowStarterFamily = 'cad_file' | 'order_line'
export type WorkflowBlueprintType = 'cad_intake' | 'order_rendering' | 'still_graph_reference'
export type WorkflowCanonicalBlueprintType = WorkflowBlueprintType | 'starter_cad_intake' | 'starter_order_rendering'
export interface WorkflowRolloutLatestRun {
workflow_run_id: string
execution_mode: WorkflowExecutionMode
status: string
created_at: string
completed_at: string | null
}
export interface WorkflowRolloutLinkedOutputType {
id: string
name: string
is_active: boolean
artifact_kind: OutputTypeArtifactKind
workflow_rollout_mode: OutputTypeWorkflowRolloutMode
}
export interface WorkflowRolloutSummary {
linked_output_type_count: number
active_output_type_count: number
linked_output_type_names: string[]
linked_output_types: WorkflowRolloutLinkedOutputType[]
rollout_modes: ('legacy_only' | 'shadow' | 'graph' | string)[]
has_blocking_contracts: boolean
blocking_reasons: string[]
latest_run: WorkflowRolloutLatestRun | null
latest_shadow_run: WorkflowRolloutLatestRun | null
latest_rollout_gate_verdict: 'pass' | 'warn' | 'fail' | null
latest_rollout_ready: boolean | null
latest_rollout_status: 'ready_for_rollout' | 'hold_legacy_authoritative' | string | null
latest_rollout_reasons: string[]
}
export interface WorkflowDefinition {
id: string
@@ -10,6 +45,8 @@ export interface WorkflowDefinition {
output_type_id: string | null
config: WorkflowConfig
family: WorkflowNodeFamily | 'mixed' | null
supported_artifact_kinds?: OutputTypeArtifactKind[]
rollout_summary: WorkflowRolloutSummary
is_active: boolean
created_at: string
}
@@ -132,6 +169,18 @@ export interface WorkflowPreflightResponse {
nodes: WorkflowPreflightNode[]
}
export interface WorkflowOrderLineContextOption {
value: string
label: string
meta: string
}
export interface WorkflowOrderLineContextGroup {
order_id: string
order_label: string
options: WorkflowOrderLineContextOption[]
}
export interface WorkflowDraftPreflightRequest {
workflow_id?: string | null
context_id: string
@@ -162,6 +211,11 @@ export interface WorkflowRunComparison {
execution_mode: WorkflowExecutionMode
status: string
summary: string
rollout_gate_verdict: 'pass' | 'warn' | 'fail'
workflow_rollout_ready: boolean
workflow_rollout_status: 'ready_for_rollout' | 'hold_legacy_authoritative'
rollout_reasons: string[]
rollout_thresholds: Record<string, number>
authoritative_output: WorkflowComparisonArtifact
observer_output: WorkflowComparisonArtifact
exact_match: boolean | null
@@ -209,6 +263,9 @@ export const preflightWorkflowDraft = (
): Promise<WorkflowPreflightResponse> =>
api.post('/workflows/preflight', data).then(r => r.data)
export const getWorkflowOrderLineContexts = (limit = 50): Promise<WorkflowOrderLineContextGroup[]> =>
api.get('/workflows/contexts/order-lines', { params: { limit } }).then(r => r.data)
export const getWorkflowRunComparison = (runId: string): Promise<WorkflowRunComparison> =>
api.get(`/workflows/runs/${runId}/comparison`).then(r => r.data)
@@ -235,9 +292,12 @@ export interface WorkflowNodeFieldDefinition {
step: number | null
unit: string | null
options: WorkflowNodeFieldOption[]
allow_blank?: boolean
max_length?: number | null
text_format?: string
}
export type WorkflowNodeFamily = 'cad_file' | 'order_line'
export type WorkflowNodeFamily = 'cad_file' | 'order_line' | 'shared'
export interface WorkflowNodeDefinition {
step: string
@@ -280,32 +340,75 @@ export const getNodeDefinitions = (): Promise<WorkflowNodeDefinitionsResponse> =
export const getPipelineSteps = (): Promise<PipelineStepsResponse> =>
api.get('/workflows/pipeline-steps').then(r => r.data)
function buildStillGraphNodes(renderParams: WorkflowParams): { nodes: WorkflowNode[]; edges: WorkflowEdge[] } {
function normalizeRenderParams(params: WorkflowParams = {}): WorkflowParams {
const normalized = { ...params }
const resolution = Array.isArray(normalized.resolution) ? normalized.resolution : undefined
if (resolution && resolution.length === 2) {
normalized.width = Number(resolution[0])
normalized.height = Number(resolution[1])
delete normalized.resolution
}
return normalized
}
function buildWorkflowNode(
id: string,
step: string,
x: number,
y: number,
options: {
label: string
type?: string
params?: WorkflowParams
},
): WorkflowNode {
return {
id,
step,
params: { ...(options.params ?? {}) },
ui: {
type: options.type,
label: options.label,
position: { x, y },
},
}
}
function extractRenderParamsFromNodes(nodes: WorkflowNode[], step: string): WorkflowParams {
const match = nodes.find(node => node.step === step)
return normalizeRenderParams(match?.params ?? {})
}
function buildOrderLineStillGraphNodes(renderParams: WorkflowParams): { nodes: WorkflowNode[]; edges: WorkflowEdge[] } {
return {
nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 160 } } },
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 160 } } },
{
id: 'populate_materials',
step: 'auto_populate_materials',
params: {},
ui: { type: 'processNode', label: 'Auto Populate Materials', position: { x: 220, y: 320 } },
},
{ id: 'bbox', step: 'glb_bbox', params: {}, ui: { type: 'processNode', label: 'Compute Bounding Box', position: { x: 220, y: 40 } } },
{
id: 'resolve_materials',
step: 'material_map_resolve',
params: {},
ui: { type: 'processNode', label: 'Resolve Material Map', position: { x: 440, y: 200 } },
},
{
id: 'render',
step: 'blender_still',
params: { use_custom_render_settings: true, ...renderParams },
ui: { type: 'renderNode', label: 'Still Render', position: { x: 680, y: 160 } },
},
{ id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 920, y: 120 } } },
{ id: 'notify', step: 'notify', params: {}, ui: { type: 'outputNode', label: 'Notify Result', position: { x: 920, y: 220 } } },
buildWorkflowNode('setup', 'order_line_setup', 0, 160, { label: 'Order Line Setup' }),
buildWorkflowNode('template', 'resolve_template', 220, 160, { label: 'Resolve Template' }),
buildWorkflowNode('populate_materials', 'auto_populate_materials', 220, 320, {
label: 'Auto Populate Materials',
type: 'processNode',
}),
buildWorkflowNode('bbox', 'glb_bbox', 220, 40, {
label: 'Compute Bounding Box',
type: 'processNode',
}),
buildWorkflowNode('resolve_materials', 'material_map_resolve', 440, 200, {
label: 'Resolve Material Map',
type: 'processNode',
}),
buildWorkflowNode('render', 'blender_still', 680, 160, {
label: 'Still Render',
type: 'renderNode',
params: { use_custom_render_settings: false, ...renderParams },
}),
buildWorkflowNode('output', 'output_save', 920, 120, {
label: 'Save Output',
type: 'outputNode',
}),
buildWorkflowNode('notify', 'notify', 920, 220, {
label: 'Notify Result',
type: 'outputNode',
}),
],
edges: [
{ from: 'setup', to: 'template' },
@@ -322,24 +425,25 @@ function buildStillGraphNodes(renderParams: WorkflowParams): { nodes: WorkflowNo
}
}
function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams = {}): WorkflowConfig {
const renderParams = { ...params }
const resolution = Array.isArray(renderParams.resolution) ? renderParams.resolution : undefined
if (resolution && resolution.length === 2) {
renderParams.width = Number(resolution[0])
renderParams.height = Number(resolution[1])
delete renderParams.resolution
}
function buildPresetWorkflowConfigInternal(type: WorkflowPresetType, params: WorkflowParams = {}): WorkflowConfig {
const renderParams = normalizeRenderParams(params)
if (type === 'still') {
return {
version: 1,
ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } },
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 100 } } },
{ id: 'render', step: 'blender_still', params: renderParams, ui: { type: 'renderNode', label: 'Still Render', position: { x: 440, y: 100 } } },
{ id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 660, y: 100 } } },
buildWorkflowNode('setup', 'order_line_setup', 0, 100, { label: 'Order Line Setup' }),
buildWorkflowNode('template', 'resolve_template', 220, 100, { label: 'Resolve Template' }),
buildWorkflowNode('render', 'blender_still', 440, 100, {
label: 'Still Render',
type: 'renderNode',
params: renderParams,
}),
buildWorkflowNode('output', 'output_save', 660, 100, {
label: 'Save Output',
type: 'outputNode',
}),
],
edges: [
{ from: 'setup', to: 'template' },
@@ -350,7 +454,7 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
}
if (type === 'still_graph') {
const { nodes, edges } = buildStillGraphNodes(renderParams)
const { nodes, edges } = buildOrderLineStillGraphNodes(renderParams)
return {
version: 1,
ui: { preset: type, execution_mode: 'graph', family: 'order_line' },
@@ -364,10 +468,17 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
version: 1,
ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } },
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 100 } } },
{ id: 'turntable', step: 'blender_turntable', params: renderParams, ui: { type: 'renderFramesNode', label: 'Turntable Render', position: { x: 440, y: 100 } } },
{ id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 660, y: 100 } } },
buildWorkflowNode('setup', 'order_line_setup', 0, 100, { label: 'Order Line Setup' }),
buildWorkflowNode('template', 'resolve_template', 220, 100, { label: 'Resolve Template' }),
buildWorkflowNode('turntable', 'blender_turntable', 440, 100, {
label: 'Turntable Render',
type: 'renderFramesNode',
params: renderParams,
}),
buildWorkflowNode('output', 'output_save', 660, 100, {
label: 'Save Output',
type: 'outputNode',
}),
],
edges: [
{ from: 'setup', to: 'template' },
@@ -385,15 +496,19 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
version: 1,
ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 195 } } },
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 195 } } },
...angles.map((angle, index) => ({
id: `render_${index}`,
step: 'blender_still',
params: { ...sharedParams, rotation_z: angle },
ui: { type: 'renderNode', label: `Render ${angle}°`, position: { x: 440, y: index * 130 } },
})),
{ id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 700, y: 195 } } },
buildWorkflowNode('setup', 'order_line_setup', 0, 195, { label: 'Order Line Setup' }),
buildWorkflowNode('template', 'resolve_template', 220, 195, { label: 'Resolve Template' }),
...angles.map((angle, index) =>
buildWorkflowNode(`render_${index}`, 'blender_still', 440, index * 130, {
label: `Render ${angle}°`,
type: 'renderNode',
params: { ...sharedParams, rotation_z: angle },
}),
),
buildWorkflowNode('output', 'output_save', 700, 195, {
label: 'Save Output',
type: 'outputNode',
}),
],
edges: [
{ from: 'setup', to: 'template' },
@@ -408,11 +523,21 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
version: 1,
ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } },
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 100 } } },
{ id: 'render', step: 'blender_still', params: renderParams, ui: { type: 'renderNode', label: 'Still Render', position: { x: 440, y: 100 } } },
{ id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 660, y: 70 } } },
{ id: 'blend', step: 'export_blend', params: {}, ui: { type: 'outputNode', label: 'Export Blend', position: { x: 660, y: 160 } } },
buildWorkflowNode('setup', 'order_line_setup', 0, 100, { label: 'Order Line Setup' }),
buildWorkflowNode('template', 'resolve_template', 220, 100, { label: 'Resolve Template' }),
buildWorkflowNode('render', 'blender_still', 440, 100, {
label: 'Still Render',
type: 'renderNode',
params: renderParams,
}),
buildWorkflowNode('output', 'output_save', 660, 70, {
label: 'Save Output',
type: 'outputNode',
}),
buildWorkflowNode('blend', 'export_blend', 660, 160, {
label: 'Export Blend',
type: 'outputNode',
}),
],
edges: [
{ from: 'setup', to: 'template' },
@@ -427,22 +552,245 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
version: 1,
ui: { preset: 'custom', execution_mode: 'legacy', family: 'order_line' },
nodes: [
{
id: 'setup',
step: 'order_line_setup',
params: {},
ui: { label: 'Order Line Setup', position: { x: 120, y: 140 } },
},
buildWorkflowNode('setup', 'order_line_setup', 120, 140, {
label: 'Order Line Setup',
type: 'processNode',
}),
],
edges: [],
}
}
export function buildWorkflowBlueprintConfig(blueprint: WorkflowBlueprintType): WorkflowConfig {
if (blueprint === 'cad_intake') {
return {
version: 1,
ui: { preset: 'custom', execution_mode: 'legacy', family: 'cad_file', blueprint },
nodes: [
buildWorkflowNode('resolve_step', 'resolve_step_path', 0, 180, { label: 'Resolve STEP Path' }),
buildWorkflowNode('extract_objects', 'occ_object_extract', 220, 180, {
label: 'Extract STEP Objects',
}),
buildWorkflowNode('export_glb', 'occ_glb_export', 440, 180, { label: 'Export GLB' }),
buildWorkflowNode('bbox', 'glb_bbox', 660, 120, {
label: 'Compute Bounding Box',
type: 'processNode',
}),
buildWorkflowNode('stl_cache', 'stl_cache_generate', 660, 300, { label: 'Generate STL Cache' }),
buildWorkflowNode('blender_thumb', 'blender_render', 880, 120, {
label: 'Render Thumbnail (Blender)',
type: 'renderNode',
params: { render_engine: 'cycles', samples: 64, width: 512, height: 512 },
}),
buildWorkflowNode('threejs_thumb', 'threejs_render', 880, 320, {
label: 'Render Thumbnail (Three.js)',
type: 'renderNode',
params: { width: 512, height: 512, transparent_bg: true },
}),
buildWorkflowNode('save_blender_thumb', 'thumbnail_save', 1100, 120, {
label: 'Save Blender Thumbnail',
type: 'outputNode',
}),
buildWorkflowNode('save_threejs_thumb', 'thumbnail_save', 1100, 320, {
label: 'Save Three.js Thumbnail',
type: 'outputNode',
}),
],
edges: [
{ from: 'resolve_step', to: 'extract_objects' },
{ from: 'extract_objects', to: 'export_glb' },
{ from: 'export_glb', to: 'bbox' },
{ from: 'export_glb', to: 'stl_cache' },
{ from: 'export_glb', to: 'blender_thumb' },
{ from: 'export_glb', to: 'threejs_thumb' },
{ from: 'bbox', to: 'threejs_thumb' },
{ from: 'blender_thumb', to: 'save_blender_thumb' },
{ from: 'threejs_thumb', to: 'save_threejs_thumb' },
],
}
}
if (blueprint === 'order_rendering') {
return {
version: 1,
ui: { preset: 'custom', execution_mode: 'legacy', family: 'order_line', blueprint },
nodes: [
buildWorkflowNode('setup', 'order_line_setup', 0, 220, { label: 'Order Line Setup' }),
buildWorkflowNode('template', 'resolve_template', 220, 220, { label: 'Resolve Template' }),
buildWorkflowNode('populate_materials', 'auto_populate_materials', 220, 360, {
label: 'Auto Populate Materials',
}),
buildWorkflowNode('bbox', 'glb_bbox', 220, 80, { label: 'Compute Bounding Box' }),
buildWorkflowNode('resolve_materials', 'material_map_resolve', 440, 220, {
label: 'Resolve Material Map',
}),
buildWorkflowNode('still_render', 'blender_still', 680, 80, {
label: 'Render Still',
type: 'renderNode',
params: { rotation_z: 0 },
}),
buildWorkflowNode('turntable_render', 'blender_turntable', 680, 220, {
label: 'Render Turntable',
type: 'renderFramesNode',
params: { fps: 24, duration_s: 5 },
}),
buildWorkflowNode('blend_export', 'export_blend', 680, 360, {
label: 'Export Blend',
type: 'outputNode',
}),
buildWorkflowNode('save_still', 'output_save', 920, 80, {
label: 'Save Still Output',
type: 'outputNode',
}),
buildWorkflowNode('save_turntable', 'output_save', 920, 220, {
label: 'Save Turntable Output',
type: 'outputNode',
}),
buildWorkflowNode('notify_still', 'notify', 920, 140, {
label: 'Notify Still Result',
type: 'outputNode',
}),
buildWorkflowNode('notify_turntable', 'notify', 920, 280, {
label: 'Notify Turntable Result',
type: 'outputNode',
}),
buildWorkflowNode('notify_export', 'notify', 920, 360, {
label: 'Notify Blend Export',
type: 'outputNode',
}),
],
edges: [
{ from: 'setup', to: 'template' },
{ from: 'setup', to: 'populate_materials' },
{ from: 'setup', to: 'bbox' },
{ from: 'template', to: 'resolve_materials' },
{ from: 'populate_materials', to: 'resolve_materials' },
{ from: 'resolve_materials', to: 'still_render' },
{ from: 'resolve_materials', to: 'turntable_render' },
{ from: 'bbox', to: 'still_render' },
{ from: 'bbox', to: 'turntable_render' },
{ from: 'template', to: 'still_render' },
{ from: 'template', to: 'turntable_render' },
{ from: 'template', to: 'blend_export' },
{ from: 'still_render', to: 'save_still' },
{ from: 'still_render', to: 'notify_still' },
{ from: 'turntable_render', to: 'save_turntable' },
{ from: 'turntable_render', to: 'notify_turntable' },
{ from: 'blend_export', to: 'notify_export' },
],
}
}
const { nodes, edges } = buildOrderLineStillGraphNodes({
render_engine: 'cycles',
samples: 256,
width: 1920,
height: 1080,
})
return {
version: 1,
ui: { preset: 'custom', execution_mode: 'graph', family: 'order_line', blueprint },
nodes,
edges,
}
}
function buildStarterWorkflowConfigInternal(family: WorkflowStarterFamily = 'order_line'): WorkflowConfig {
if (family === 'cad_file') {
return {
version: 1,
ui: {
preset: 'custom',
execution_mode: 'legacy',
family: 'cad_file',
blueprint: 'starter_cad_intake',
},
nodes: [
buildWorkflowNode('resolve_step', 'resolve_step_path', 120, 140, {
label: 'Resolve STEP Path',
type: 'inputNode',
}),
],
edges: [],
}
}
return {
version: 1,
ui: {
preset: 'custom',
execution_mode: 'legacy',
family: 'order_line',
blueprint: 'starter_order_rendering',
},
nodes: [
buildWorkflowNode('setup', 'order_line_setup', 120, 140, {
label: 'Order Line Setup',
type: 'processNode',
}),
],
edges: [],
}
}
export function buildStillGraphNodes(renderParams: WorkflowParams): { nodes: WorkflowNode[]; edges: WorkflowEdge[] } {
return buildOrderLineStillGraphNodes(normalizeRenderParams(renderParams))
}
function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams = {}): WorkflowConfig {
return buildPresetWorkflowConfigInternal(type, params)
}
function normalizeWorkflowDefinition(raw: WorkflowDefinition): WorkflowDefinition {
const config = normalizeWorkflowConfig(raw.config as unknown as Record<string, unknown>)
return {
...raw,
family: raw.family ?? inferWorkflowFamily(config),
supported_artifact_kinds: Array.isArray(raw.supported_artifact_kinds)
? raw.supported_artifact_kinds
: [],
rollout_summary: {
linked_output_type_count: Number(raw.rollout_summary?.linked_output_type_count ?? 0),
active_output_type_count: Number(raw.rollout_summary?.active_output_type_count ?? 0),
linked_output_type_names: Array.isArray(raw.rollout_summary?.linked_output_type_names)
? raw.rollout_summary.linked_output_type_names
: [],
linked_output_types: Array.isArray(raw.rollout_summary?.linked_output_types)
? raw.rollout_summary.linked_output_types
.filter((outputType): outputType is WorkflowRolloutLinkedOutputType => (
outputType != null
&& typeof outputType === 'object'
&& typeof outputType.id === 'string'
&& typeof outputType.name === 'string'
))
.map(outputType => ({
id: outputType.id,
name: outputType.name,
is_active: Boolean(outputType.is_active),
artifact_kind: outputType.artifact_kind,
workflow_rollout_mode: outputType.workflow_rollout_mode ?? 'legacy_only',
}))
: [],
rollout_modes: Array.isArray(raw.rollout_summary?.rollout_modes)
? raw.rollout_summary.rollout_modes
: [],
has_blocking_contracts: Boolean(raw.rollout_summary?.has_blocking_contracts),
blocking_reasons: Array.isArray(raw.rollout_summary?.blocking_reasons)
? raw.rollout_summary.blocking_reasons
: [],
latest_run: raw.rollout_summary?.latest_run ?? null,
latest_shadow_run: raw.rollout_summary?.latest_shadow_run ?? null,
latest_rollout_gate_verdict: raw.rollout_summary?.latest_rollout_gate_verdict ?? null,
latest_rollout_ready:
typeof raw.rollout_summary?.latest_rollout_ready === 'boolean'
? raw.rollout_summary.latest_rollout_ready
: null,
latest_rollout_status: raw.rollout_summary?.latest_rollout_status ?? null,
latest_rollout_reasons: Array.isArray(raw.rollout_summary?.latest_rollout_reasons)
? raw.rollout_summary.latest_rollout_reasons
: [],
},
config,
}
}
@@ -455,14 +803,51 @@ export function normalizeWorkflowConfig(raw: Record<string, unknown>): WorkflowC
params: { ...(node.params ?? {}) },
}))
const edges = Array.isArray(raw.edges) ? (raw.edges as WorkflowEdge[]) : []
const mergedUi = {
...rawUi,
execution_mode: rawUi.execution_mode ?? 'legacy',
}
if (rawUi.preset === 'still_graph') {
const canonical = buildPresetWorkflowConfigInternal('still_graph', extractRenderParamsFromNodes(nodes, 'blender_still'))
return {
...canonical,
ui: {
...canonical.ui,
...mergedUi,
},
}
}
if (rawUi.blueprint === 'cad_intake' || rawUi.blueprint === 'order_rendering' || rawUi.blueprint === 'still_graph_reference') {
const canonical = buildWorkflowBlueprintConfig(rawUi.blueprint)
return {
...canonical,
ui: {
...canonical.ui,
...mergedUi,
},
}
}
if (rawUi.blueprint === 'starter_cad_intake' || rawUi.blueprint === 'starter_order_rendering') {
const canonical = buildStarterWorkflowConfigInternal(rawUi.blueprint === 'starter_cad_intake' ? 'cad_file' : 'order_line')
return {
...canonical,
ui: {
...canonical.ui,
...mergedUi,
},
}
}
return {
version: Number(raw.version ?? 1),
nodes,
edges,
ui: {
...rawUi,
execution_mode: rawUi.execution_mode ?? 'legacy',
family: rawUi.family ?? inferWorkflowFamily({ version: Number(raw.version ?? 1), nodes, edges }),
...mergedUi,
family: rawUi.family ?? inferWorkflowFamily({ version: Number(raw.version ?? 1), nodes, edges }) ?? undefined,
},
}
}
@@ -480,49 +865,11 @@ export function normalizeWorkflowConfig(raw: Record<string, unknown>): WorkflowC
}
export function createPresetWorkflowConfig(type: WorkflowPresetType, params: WorkflowParams = {}): WorkflowConfig {
return migratePresetConfig(type, params)
return buildPresetWorkflowConfigInternal(type, params)
}
export function createStarterWorkflowConfig(family: WorkflowStarterFamily = 'order_line'): WorkflowConfig {
if (family === 'cad_file') {
return {
version: 1,
ui: {
preset: 'custom',
execution_mode: 'legacy',
family: 'cad_file',
blueprint: 'starter_cad_intake',
},
nodes: [
{
id: 'resolve_step',
step: 'resolve_step_path',
params: {},
ui: { type: 'inputNode', label: 'Resolve STEP Path', position: { x: 120, y: 140 } },
},
],
edges: [],
}
}
return {
version: 1,
ui: {
preset: 'custom',
execution_mode: 'legacy',
family: 'order_line',
blueprint: 'starter_order_rendering',
},
nodes: [
{
id: 'setup',
step: 'order_line_setup',
params: {},
ui: { type: 'processNode', label: 'Order Line Setup', position: { x: 120, y: 140 } },
},
],
edges: [],
}
return buildStarterWorkflowConfigInternal(family)
}
export function getWorkflowPresetType(config: WorkflowConfig): WorkflowPresetType {
@@ -542,11 +889,12 @@ export function inferWorkflowFamily(config: WorkflowConfig): WorkflowNodeFamily
case 'threejs_render':
case 'thumbnail_save':
return 'cad_file'
case 'glb_bbox':
return null
case 'order_line_setup':
case 'resolve_template':
case 'material_map_resolve':
case 'auto_populate_materials':
case 'glb_bbox':
case 'blender_still':
case 'blender_turntable':
case 'output_save':
@@ -557,7 +905,7 @@ export function inferWorkflowFamily(config: WorkflowConfig): WorkflowNodeFamily
return null
}
})
.filter((family): family is WorkflowNodeFamily => family !== null),
.filter((family): family is Exclude<WorkflowNodeFamily, 'shared'> => family !== null),
)
if (families.size === 0) return null
if (families.size > 1) return 'mixed'
File diff suppressed because it is too large Load Diff
@@ -34,6 +34,32 @@ const EMPTY_FORM = {
lighting_only: false,
shadow_catcher_enabled: false,
camera_orbit: true,
workflow_input_schema_text: '[]',
}
function stringifyWorkflowInputSchema(value: unknown): string {
try {
return JSON.stringify(Array.isArray(value) ? value : [], null, 2)
} catch {
return '[]'
}
}
function parseWorkflowInputSchemaText(rawValue: unknown): unknown[] {
const text = typeof rawValue === 'string' ? rawValue.trim() : ''
if (!text) return []
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
throw new Error('Workflow input schema must be valid JSON')
}
if (!Array.isArray(parsed)) {
throw new Error('Workflow input schema must be a JSON array')
}
return parsed
}
export default function RenderTemplateTable() {
@@ -43,7 +69,7 @@ export default function RenderTemplateTable() {
const [addFile, setAddFile] = useState<File | null>(null)
const [cloneBlendFrom, setCloneBlendFrom] = useState<string>('')
const [editingId, setEditingId] = useState<string | null>(null)
const [editDraft, setEditDraft] = useState<Partial<RenderTemplate>>({})
const [editDraft, setEditDraft] = useState<(Partial<RenderTemplate> & { workflow_input_schema_text?: string })>({})
const fileInputRef = useRef<HTMLInputElement>(null)
const reuploadRef = useRef<HTMLInputElement>(null)
const [reuploadId, setReuploadId] = useState<string | null>(null)
@@ -75,6 +101,7 @@ export default function RenderTemplateTable() {
fd.append('lighting_only', String(form.lighting_only))
fd.append('shadow_catcher_enabled', String(form.shadow_catcher_enabled))
fd.append('camera_orbit', String(form.camera_orbit))
fd.append('workflow_input_schema', JSON.stringify(parseWorkflowInputSchemaText(form.workflow_input_schema_text)))
return createRenderTemplate(fd)
},
onSuccess: () => {
@@ -85,7 +112,7 @@ export default function RenderTemplateTable() {
setCloneBlendFrom('')
setShowAdd(false)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create template'),
onError: (e: any) => toast.error(e.response?.data?.detail || e.message || 'Failed to create template'),
})
const updateMut = useMutation({
@@ -96,7 +123,7 @@ export default function RenderTemplateTable() {
qc.invalidateQueries({ queryKey: ['render-templates'] })
setEditingId(null)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to update'),
onError: (e: any) => toast.error(e.response?.data?.detail || e.message || 'Failed to update'),
})
const deleteMut = useMutation({
@@ -128,6 +155,7 @@ export default function RenderTemplateTable() {
shadow_catcher_enabled: t.shadow_catcher_enabled,
camera_orbit: t.camera_orbit,
output_type_ids: t.output_type_ids ?? [],
workflow_input_schema: t.workflow_input_schema ?? [],
}),
onSuccess: () => {
toast.success('Template duplicated')
@@ -147,13 +175,19 @@ export default function RenderTemplateTable() {
lighting_only: t.lighting_only,
shadow_catcher_enabled: t.shadow_catcher_enabled,
camera_orbit: t.camera_orbit,
workflow_input_schema_text: stringifyWorkflowInputSchema(t.workflow_input_schema),
is_active: t.is_active,
})
}
function saveEdit() {
if (!editingId) return
updateMut.mutate({ id: editingId, data: editDraft as Record<string, unknown> })
const data: Record<string, unknown> = { ...editDraft }
if (Object.prototype.hasOwnProperty.call(editDraft, 'workflow_input_schema_text')) {
data.workflow_input_schema = parseWorkflowInputSchemaText(editDraft.workflow_input_schema_text)
delete data.workflow_input_schema_text
}
updateMut.mutate({ id: editingId, data })
}
// Render the edit form grid (shared between edit-row and add-row)
@@ -174,6 +208,9 @@ export default function RenderTemplateTable() {
if (field === 'lighting_only') return editDraft.lighting_only ?? t!.lighting_only
if (field === 'shadow_catcher_enabled') return editDraft.shadow_catcher_enabled ?? t!.shadow_catcher_enabled
if (field === 'camera_orbit') return editDraft.camera_orbit ?? t!.camera_orbit
if (field === 'workflow_input_schema_text') {
return editDraft.workflow_input_schema_text ?? stringifyWorkflowInputSchema(t!.workflow_input_schema)
}
if (field === 'is_active') return editDraft.is_active ?? t!.is_active
return (editDraft as any)[field] ?? (t as any)[field]
}
@@ -381,7 +418,27 @@ export default function RenderTemplateTable() {
)}
</div>
{/* Row 4: Active + Save/Cancel */}
<div className="mt-4">
<label className="block text-xs font-medium text-content-muted mb-1">
Workflow Input Schema (JSON)
</label>
<textarea
className="input-sm w-full min-h-36 font-mono text-xs"
value={String(val('workflow_input_schema_text') ?? '[]')}
onChange={(e) => set('workflow_input_schema_text', e.target.value)}
placeholder='[{"key":"studio_variant","label":"Studio Variant","type":"select","options":[{"value":"default","label":"Default"}]}]'
/>
<p className="mt-1 text-xs text-content-muted">
Defines additional `resolve_template` node inputs for this .blend template.
</p>
<p className="mt-1 text-xs text-content-muted">
Matching variants can be bound inside the template via markers like
`template-input:studio_variant=warm` or a `template_input=studio_variant=warm`
custom property on collections, objects, or worlds.
</p>
</div>
{/* Row 5: Active + Save/Cancel */}
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border-light">
{isEdit ? (
<label className="flex items-center gap-2">
@@ -0,0 +1,80 @@
import type { OutputTypeWorkflowRolloutMode } from '../../api/outputTypes'
export interface OutputTypeRolloutPresentation {
badgeLabel: string
badgeClassName: string
statusLabel: string
statusClassName: string
operatorHint: string
rowSummary: string
}
interface OutputTypeRolloutPresentationOptions {
hasWorkflowLink: boolean
workflowRolloutMode: OutputTypeWorkflowRolloutMode
hasBlockingIssues?: boolean
}
export function getOutputTypeRolloutPresentation({
hasWorkflowLink,
workflowRolloutMode,
hasBlockingIssues = false,
}: OutputTypeRolloutPresentationOptions): OutputTypeRolloutPresentation {
if (!hasWorkflowLink) {
return {
badgeLabel: 'Legacy Only',
badgeClassName: 'bg-surface-muted text-content-muted',
statusLabel: 'Production: Legacy',
statusClassName: 'bg-slate-100 text-slate-700',
operatorHint:
'No workflow is linked. Production stays entirely on the legacy dispatcher until a compatible graph workflow is attached.',
rowSummary: 'No linked graph workflow.',
}
}
if (hasBlockingIssues) {
return {
badgeLabel: 'Contract Blocked',
badgeClassName: 'bg-red-100 text-red-700',
statusLabel: 'Do Not Promote',
statusClassName: 'bg-red-100 text-red-700',
operatorHint:
'The current workflow binding is contract-invalid. Keep legacy authoritative until family, artifact, and rollout settings are fixed.',
rowSummary: 'Linked workflow needs contract fixes before rollout.',
}
}
switch (workflowRolloutMode) {
case 'graph':
return {
badgeLabel: 'Graph Authoritative',
badgeClassName: 'bg-status-success-bg text-status-success-text',
statusLabel: 'Production: Graph',
statusClassName: 'bg-emerald-100 text-emerald-700',
operatorHint:
'Graph dispatch is authoritative for production. Legacy remains the operational fallback if graph dispatch fails.',
rowSummary: 'Graph drives production with legacy fallback armed.',
}
case 'shadow':
return {
badgeLabel: 'Shadow',
badgeClassName: 'bg-status-info-bg text-status-info-text',
statusLabel: 'Production: Legacy',
statusClassName: 'bg-sky-100 text-sky-700',
operatorHint:
'Legacy stays authoritative while the graph runs as an observer for parity and rollout-gate checks.',
rowSummary: 'Graph observes only; legacy remains authoritative.',
}
case 'legacy_only':
default:
return {
badgeLabel: 'Legacy Only',
badgeClassName: 'bg-surface-muted text-content-muted',
statusLabel: 'Production: Legacy',
statusClassName: 'bg-slate-100 text-slate-700',
operatorHint:
'A workflow is linked for authoring and future rollout, but production dispatch remains on the legacy path.',
rowSummary: 'Linked graph is not active in production.',
}
}
}
+91 -72
View File
@@ -2,15 +2,25 @@ import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'rea
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Canvas, useThree } from '@react-three/fiber'
import { OrbitControls, useGLTF, Environment } from '@react-three/drei'
import * as THREE from 'three'
import {
Box3,
Group,
Mesh,
MeshStandardMaterial,
Object3D,
PerspectiveCamera,
Vector3,
type Material,
} from 'three'
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { Loader2, Box, RefreshCw, Grid3X3, Layers, Sun, AlertCircle, EyeOff, Zap } from 'lucide-react'
import { toast } from 'sonner'
import { listMediaAssets as getMediaAssets } from '../../api/media'
import { generateGltfGeometry, getPartMaterials, type PartMaterialMap } from '../../api/cad'
import { generateGltfGeometry, getManualOverrides, getPartMaterials, type PartMaterialMap } from '../../api/cad'
import { fetchSceneManifest } from '../../api/sceneManifest'
import { useAuthStore } from '../../store/auth'
import MaterialPanel, { type IsolateMode } from './MaterialPanel'
import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, type MeshRegistryEntry } from './cadUtils'
import { resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, convertSceneManifestMaterials, alignSceneManifestToLogicalPartKeys, buildScenePartRegistry, buildEffectiveViewerMaterials, resolveObjectPartKey, type MeshRegistryEntry } from './cadUtils'
import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries'
import { useGeometryMerge } from './useGeometryMerge'
import WebGLErrorBoundary from './WebGLErrorBoundary'
@@ -35,7 +45,7 @@ function CameraAutoFit({
controlsRef,
fitTrigger,
}: {
sceneRef: React.MutableRefObject<THREE.Object3D | null>
sceneRef: React.MutableRefObject<Object3D | null>
controlsRef: React.RefObject<any>
fitTrigger: number
}) {
@@ -43,17 +53,17 @@ function CameraAutoFit({
useEffect(() => {
if (fitTrigger === 0 || !sceneRef.current) return
const box = new THREE.Box3()
const box = new Box3()
sceneRef.current.traverse((obj) => {
if ((obj as THREE.Mesh).isMesh) box.expandByObject(obj)
if ((obj as Mesh).isMesh) box.expandByObject(obj)
})
if (box.isEmpty()) return
const center = box.getCenter(new THREE.Vector3())
const sizeVec = box.getSize(new THREE.Vector3())
const center = box.getCenter(new Vector3())
const sizeVec = box.getSize(new Vector3())
const maxDim = Math.max(sizeVec.x, sizeVec.y, sizeVec.z)
const pc = camera as THREE.PerspectiveCamera
const pc = camera as PerspectiveCamera
const fovRad = (pc.fov * Math.PI) / 180
const aspect = size.width / size.height
const fovH = 2 * Math.atan(Math.tan(fovRad / 2) * aspect)
@@ -91,19 +101,19 @@ function GlbModelWithFit({
}: {
url: string
wireframe: boolean
sceneRef: React.MutableRefObject<THREE.Object3D | null>
sceneRef: React.MutableRefObject<Object3D | null>
onReady: () => void
onPointerOver?: (e: any) => void
onPointerOut?: () => void
onClick?: (e: any) => void
}) {
const { scene } = useGLTF(url)
const cloned = useRef<THREE.Group | null>(null)
const cloned = useRef<Group | null>(null)
if (!cloned.current) {
cloned.current = scene.clone(true)
cloned.current.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
if (obj instanceof Mesh) {
if (obj.geometry) {
let geo = obj.geometry.clone()
if (!geo.index) geo = mergeVertices(geo)
@@ -116,7 +126,7 @@ function GlbModelWithFit({
// Clone materials so emissive / color changes don't affect the shared GLTF cache
if (obj.material) {
obj.material = Array.isArray(obj.material)
? obj.material.map((m: THREE.Material) => m.clone())
? obj.material.map((m: Material) => m.clone())
: obj.material.clone()
}
}
@@ -125,10 +135,10 @@ function GlbModelWithFit({
useEffect(() => {
cloned.current?.traverse((obj) => {
if (obj instanceof THREE.Mesh && obj.material) {
if (obj instanceof Mesh && obj.material) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material]
mats.forEach((m) => {
;(m as THREE.MeshStandardMaterial).wireframe = wireframe
;(m as MeshStandardMaterial).wireframe = wireframe
m.needsUpdate = true
})
}
@@ -195,13 +205,13 @@ export default function InlineCadViewer({
const [hideAssigned, setHideAssigned] = useState(false)
const [isolateMode, setIsolateMode] = useState<IsolateMode>('none')
const [perfMode, setPerfMode] = useState(false)
const [totalMeshCount, setTotalMeshCount] = useState(0)
const [glbMeshNames, setGlbMeshNames] = useState<Set<string>>(new Set())
const [logicalPartKeys, setLogicalPartKeys] = useState<Set<string>>(new Set())
const [unresolvedMeshNames, setUnresolvedMeshNames] = useState<Set<string>>(new Set())
const [partKeyMap, setPartKeyMap] = useState<Record<string, string>>({})
const sceneRef = useRef<THREE.Object3D | null>(null)
const sceneRef = useRef<Object3D | null>(null)
const controlsRef = useRef<any>(null)
const hoveredMeshRef = useRef<THREE.Mesh | null>(null)
const hoveredMeshRef = useRef<Mesh | null>(null)
const meshRegistryRef = useRef<MeshRegistryEntry[]>([])
// Media asset queries
@@ -220,6 +230,20 @@ export default function InlineCadViewer({
retry: false,
})
const { data: sceneManifest } = useQuery({
queryKey: ['scene-manifest', cadFileId],
queryFn: () => fetchSceneManifest(cadFileId),
staleTime: Infinity,
retry: false,
})
const { data: manualOverrides = {} } = useQuery({
queryKey: ['manual-overrides', cadFileId],
queryFn: () => getManualOverrides(cadFileId),
staleTime: 30_000,
retry: false,
})
// PBR material properties from Blender asset library
const { data: pbrMap = {} as MaterialPBRMap } = useQuery({
queryKey: ['material-pbr'],
@@ -227,23 +251,33 @@ export default function InlineCadViewer({
staleTime: 300_000,
})
// Merge: initialPartMaterials (from Product Excel data) as base; savedPartMaterials overrides
// Remap keys through partKeyMap so Excel-imported names match partKey slugs
const partMaterials = useMemo(
const manifestMaterials = useMemo(
() => alignSceneManifestToLogicalPartKeys(
convertSceneManifestMaterials(sceneManifest?.parts ?? []),
logicalPartKeys,
),
[sceneManifest, logicalPartKeys],
)
const fallbackMaterials = useMemo(
() => remapToPartKeys({ ...initialPartMaterials, ...savedPartMaterials } as PartMaterialMap, partKeyMap),
[initialPartMaterials, savedPartMaterials, partKeyMap],
)
// Resolve partKey from normalized mesh name (identity fallback when no map loaded)
const resolvePartKey = useCallback(
(normalizedName: string): string => partKeyMap[normalizedName] ?? normalizedName,
[partKeyMap],
// Merge authoritative manifest assignments with legacy/viewer fallback so the
// inline viewer consumes the same effective source contract as the main viewer.
const partMaterials = useMemo(
() => buildEffectiveViewerMaterials(manifestMaterials, fallbackMaterials, manualOverrides),
[manifestMaterials, fallbackMaterials, manualOverrides],
)
const resolvedMeshCount = logicalPartKeys.size
const unresolvedMeshCount = unresolvedMeshNames.size
// Count how many unique GLB mesh types have a resolved material assignment
const assignedCount = useMemo(
() => [...glbMeshNames].filter(n => !!resolvePartMaterial(n, partMaterials)).length,
[glbMeshNames, partMaterials],
() => [...logicalPartKeys].filter(n => !!resolvePartMaterial(n, partMaterials)).length,
[logicalPartKeys, partMaterials],
)
useEffect(() => {
@@ -282,7 +316,7 @@ export default function InlineCadViewer({
// Clone materials on first PBR application (GLB loader shares instances)
if (!mesh.userData._pbrApplied) {
mesh.material = Array.isArray(mesh.material)
? mesh.material.map((m: THREE.Material) => m.clone())
? mesh.material.map((m: Material) => m.clone())
: mesh.material.clone()
mesh.userData._pbrApplied = true
}
@@ -294,7 +328,7 @@ export default function InlineCadViewer({
}
})
}
}, [modelReady, partMaterials, resolvePartKey, pbrMap])
}, [modelReady, partMaterials, pbrMap])
// Unassigned glow — uses MeshRegistry instead of traverse
useEffect(() => {
@@ -313,7 +347,7 @@ export default function InlineCadViewer({
}
})
}
}, [modelReady, showUnassigned, partMaterials, resolvePartKey])
}, [modelReady, showUnassigned, partMaterials])
// Reset isolateMode when no part is pinned
useEffect(() => {
@@ -334,7 +368,7 @@ export default function InlineCadViewer({
// Default: fully visible + raycasting enabled
mesh.visible = true
mesh.raycast = THREE.Mesh.prototype.raycast
mesh.raycast = Mesh.prototype.raycast
forEachMeshMaterial(mesh, (mat) => {
if ('opacity' in mat) { mat.opacity = 1; mat.transparent = false; mat.depthWrite = true; mat.needsUpdate = true }
})
@@ -358,7 +392,7 @@ export default function InlineCadViewer({
}
}
}
}, [modelReady, pinnedPart, isolateMode, hideAssigned, partMaterials, resolvePartKey])
}, [modelReady, pinnedPart, isolateMode, hideAssigned, partMaterials])
// Dev-only: log normalized GLB mesh names vs stored keys to diagnose mismatches
useEffect(() => {
@@ -399,16 +433,16 @@ export default function InlineCadViewer({
// Hover highlight
const handlePointerOver = useCallback((e: any) => {
e.stopPropagation()
const mesh = e.object as THREE.Mesh
const mesh = e.object as Mesh
// Restore previous hovered mesh (correctly preserve unassigned glow)
if (hoveredMeshRef.current && hoveredMeshRef.current !== mesh) {
const prev = hoveredMeshRef.current
const prevMats = Array.isArray(prev.material) ? prev.material : [prev.material]
const hasAny = Object.keys(partMaterials).length > 0
prevMats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
const mat = m as MeshStandardMaterial
if (!mat || !('emissive' in mat)) return
const prevPk = (prev.userData?.partKey as string) || resolvePartKey(normalizeMeshName((prev.userData?.name as string) || prev.name))
const prevPk = resolveObjectPartKey(prev, partKeyMap)
if (showUnassigned && hasAny && !resolvePartMaterial(prevPk, partMaterials as PartMaterialMap)) {
mat.emissive.set(0xff4400); mat.emissiveIntensity = 0.8
} else {
@@ -419,10 +453,10 @@ export default function InlineCadViewer({
hoveredMeshRef.current = mesh
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
const mat = m as MeshStandardMaterial
if (mat && 'emissive' in mat) { mat.emissive.set(0x333333); mat.emissiveIntensity = 0.5 }
})
}, [showUnassigned, partMaterials, resolvePartKey])
}, [showUnassigned, partMaterials, partKeyMap])
const handlePointerOut = useCallback(() => {
if (hoveredMeshRef.current) {
@@ -430,9 +464,9 @@ export default function InlineCadViewer({
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
const hasAnyAssignment = Object.keys(partMaterials).length > 0
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
const mat = m as MeshStandardMaterial
if (!mat || !('emissive' in mat)) return
const pk = (mesh.userData?.partKey as string) || resolvePartKey(normalizeMeshName((mesh.userData?.name as string) || mesh.name))
const pk = resolveObjectPartKey(mesh, partKeyMap)
if (showUnassigned && hasAnyAssignment && !resolvePartMaterial(pk, partMaterials as PartMaterialMap)) {
mat.emissive.set(0xff4400); mat.emissiveIntensity = 0.8
} else {
@@ -441,14 +475,14 @@ export default function InlineCadViewer({
})
hoveredMeshRef.current = null
}
}, [showUnassigned, partMaterials, resolvePartKey])
}, [showUnassigned, partMaterials, partKeyMap])
const handleClick = useCallback((e: any) => {
e.stopPropagation()
const meshObj = e.object as THREE.Mesh
const pk = (meshObj?.userData?.partKey as string) || resolvePartKey(normalizeMeshName((meshObj?.userData?.name as string) || meshObj?.name || ''))
const meshObj = e.object as Mesh
const pk = resolveObjectPartKey(meshObj, partKeyMap)
if (pk) setPinnedPart(pk)
}, [resolvePartKey])
}, [partKeyMap])
// ── Render: model loaded ──────────────────────────────────────────────────
@@ -498,10 +532,15 @@ export default function InlineCadViewer({
<ToolbarBtn
active={showUnassigned}
onClick={() => setShowUnassigned(v => !v)}
title={`Highlight unassigned parts (${assignedCount}/${totalMeshCount} assigned)`}
title={`Highlight unassigned parts (${assignedCount}/${resolvedMeshCount} resolved${unresolvedMeshCount > 0 ? `, ${unresolvedMeshCount} unresolved` : ''})`}
>
<AlertCircle size={11} />
<span className="tabular-nums text-[10px]">{assignedCount}/{totalMeshCount}</span>
<span className="tabular-nums text-[10px]">{assignedCount}/{resolvedMeshCount}</span>
{unresolvedMeshCount > 0 && (
<span className="text-[10px] text-amber-300 tabular-nums" title={`${unresolvedMeshCount} unresolved meshes without authoritative part mapping`}>
?{unresolvedMeshCount}
</span>
)}
</ToolbarBtn>
{assignedCount > 0 && (
<ToolbarBtn
@@ -536,32 +575,12 @@ export default function InlineCadViewer({
// Extract partKeyMap from GLB extras
const glbExtras = (sceneRef.current as any)?.userData ?? {}
const map = glbExtras.partKeyMap as Record<string, string> | undefined
if (map && Object.keys(map).length > 0) {
setPartKeyMap(map)
}
// Single traverse: stamp partKey, build registry, count unique parts
const registry: MeshRegistryEntry[] = []
const names = new Set<string>()
sceneRef.current?.traverse((obj) => {
if (!(obj instanceof THREE.Mesh)) return
// Stamp partKey from parent Group or partKeyMap
if (!obj.userData.partKey) {
const parentPk = obj.parent?.userData?.partKey as string | undefined
if (parentPk) {
obj.userData.partKey = parentPk
} else if (map) {
const normalized = normalizeMeshName((obj.userData?.name as string) || obj.name)
obj.userData.partKey = map[normalized] ?? normalized
}
}
const pk = (obj.userData?.partKey as string) ||
normalizeMeshName((obj.userData?.name as string) || obj.name)
registry.push({ mesh: obj, partKey: pk })
if (pk) names.add(pk)
})
meshRegistryRef.current = registry
setTotalMeshCount(names.size)
setGlbMeshNames(new Set(names))
const resolvedMap = map ?? {}
if (Object.keys(resolvedMap).length > 0) setPartKeyMap(resolvedMap)
const { meshRegistry, logicalPartKeys, unresolvedMeshNames } = buildScenePartRegistry(sceneRef.current, resolvedMap)
meshRegistryRef.current = meshRegistry
setLogicalPartKeys(new Set(logicalPartKeys))
setUnresolvedMeshNames(new Set(unresolvedMeshNames))
setModelReady(true)
setFitTrigger(t => t + 1)
}}
+103 -103
View File
@@ -21,7 +21,17 @@ import {
GizmoHelper,
GizmoViewport,
} from '@react-three/drei'
import * as THREE from 'three'
import {
Box3,
Color,
Mesh,
MeshStandardMaterial,
Object3D,
OrthographicCamera as ThreeOrthographicCamera,
PerspectiveCamera as ThreePerspectiveCamera,
Vector3,
type Material,
} from 'three'
import { toast } from 'sonner'
import {
X, Camera, Loader2, AlertTriangle, Box, Download, ChevronDown,
@@ -32,7 +42,7 @@ import { getParsedObjects, getPartMaterials, getManualOverrides, type PartMateri
import { fetchSceneManifest } from '../../api/sceneManifest'
import { useAuthStore } from '../../store/auth'
import MaterialPanel, { type IsolateMode } from './MaterialPanel'
import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, type MeshRegistryEntry } from './cadUtils'
import { resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, convertSceneManifestMaterials, alignSceneManifestToLogicalPartKeys, buildScenePartRegistry, resolveObjectPartKey, buildEffectiveViewerMaterials, type MeshRegistryEntry } from './cadUtils'
import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries'
import { useGeometryMerge } from './useGeometryMerge'
import WebGLErrorBoundary from './WebGLErrorBoundary'
@@ -68,7 +78,7 @@ const BG_COLORS = [
]
interface SceneInfo {
center: THREE.Vector3
center: Vector3
maxDim: number
groundY: number
}
@@ -84,7 +94,7 @@ function CameraFit({
isOrtho,
onFitted,
}: {
sceneRef: React.MutableRefObject<THREE.Object3D | null>
sceneRef: React.MutableRefObject<Object3D | null>
controlsRef: React.RefObject<any>
fitTrigger: number
isOrtho: boolean
@@ -96,19 +106,19 @@ function CameraFit({
if (fitTrigger === 0 || !sceneRef.current) return
// Compute bbox from meshes only (ignore lights / empty groups)
const box = new THREE.Box3()
const box = new Box3()
sceneRef.current.traverse((obj) => {
if ((obj as THREE.Mesh).isMesh) box.expandByObject(obj)
if ((obj as Mesh).isMesh) box.expandByObject(obj)
})
if (box.isEmpty()) return
const center = box.getCenter(new THREE.Vector3())
const sizeVec = box.getSize(new THREE.Vector3())
const center = box.getCenter(new Vector3())
const sizeVec = box.getSize(new Vector3())
const maxDim = Math.max(sizeVec.x, sizeVec.y, sizeVec.z)
const groundY = box.min.y
if (isOrtho) {
const oc = camera as THREE.OrthographicCamera
const oc = camera as ThreeOrthographicCamera
const aspect = size.width / size.height
const halfH = maxDim * 0.8
oc.left = -halfH * aspect
@@ -121,7 +131,7 @@ function CameraFit({
oc.updateProjectionMatrix()
camera.position.set(center.x, center.y, center.z + maxDim * 5)
} else {
const pc = camera as THREE.PerspectiveCamera
const pc = camera as ThreePerspectiveCamera
const fovRad = (pc.fov * Math.PI) / 180
const aspect = size.width / size.height
const fovH = 2 * Math.atan(Math.tan(fovRad / 2) * aspect)
@@ -156,7 +166,7 @@ function CameraFit({
function SceneBackground({ color }: { color: string }) {
const { scene } = useThree()
useEffect(() => {
scene.background = new THREE.Color(color)
scene.background = new Color(color)
return () => { scene.background = null }
}, [color, scene])
return null
@@ -182,7 +192,7 @@ interface ModelWithReadyProps {
url: string
wireframe: boolean
onReady: () => void
sceneRef: React.MutableRefObject<THREE.Object3D | null>
sceneRef: React.MutableRefObject<Object3D | null>
onPointerOver?: (e: any) => void
onPointerOut?: () => void
onClick?: (e: any) => void
@@ -385,7 +395,7 @@ export default function ThreeDViewer({
const [hoverInfo, setHoverInfo] = useState<{ name: string; x: number; y: number } | null>(null)
// Task 5 — hovered mesh ref for emissive highlight
const hoveredMeshRef = useRef<THREE.Mesh | null>(null)
const hoveredMeshRef = useRef<Mesh | null>(null)
// partKey map from GLB extras: normalizedMeshName → partKey slug
const [partKeyMap, setPartKeyMap] = useState<Record<string, string>>({})
@@ -409,7 +419,7 @@ export default function ThreeDViewer({
const [perfMode, setPerfMode] = useState(false)
// Refs
const sceneRef = useRef<THREE.Object3D | null>(null)
const sceneRef = useRef<Object3D | null>(null)
const controlsRef = useRef<any>(null)
const camPosRef = useRef<[number, number, number]>([0, 0.1, 0.3])
@@ -443,8 +453,8 @@ export default function ThreeDViewer({
})
// Total unique normalized mesh count (set once when model is ready)
const [totalMeshCount, setTotalMeshCount] = useState(0)
const [glbMeshNames, setGlbMeshNames] = useState<Set<string>>(new Set())
const [logicalPartKeys, setLogicalPartKeys] = useState<Set<string>>(new Set())
const [unresolvedMeshNames, setUnresolvedMeshNames] = useState<Set<string>>(new Set())
// Task 6 — load saved part-material assignments (manual overrides from the viewer)
const { data: savedPartMaterials = {} } = useQuery({
@@ -461,37 +471,33 @@ export default function ThreeDViewer({
staleTime: 300_000,
})
// Merge: initialPartMaterials (Product Excel data) as base; savedPartMaterials overrides
const partMaterials = useMemo(
() => ({ ...initialPartMaterials, ...savedPartMaterials } as PartMaterialMap),
[initialPartMaterials, savedPartMaterials],
const manifestMaterials = useMemo(
() => alignSceneManifestToLogicalPartKeys(
convertSceneManifestMaterials(sceneManifest?.parts ?? []),
logicalPartKeys,
),
[sceneManifest, logicalPartKeys],
)
// Effective materials: remap Excel-imported keys to partKey slugs (when
// partKeyMap is available), then layer manual overrides on top.
const effectiveMaterials = useMemo(() => {
// Remap normalized OCC name keys → partKey slugs so they match mesh resolution
const remapped = remapToPartKeys(partMaterials, partKeyMap)
// Manual overrides are already keyed by partKey slug
const fromManual: PartMaterialMap = Object.fromEntries(
Object.entries(manualOverrides).map(([k, v]) => [
k,
{ type: (v.startsWith('#') ? 'hex' : 'library') as 'hex' | 'library', value: v },
])
)
return { ...remapped, ...fromManual }
}, [partMaterials, manualOverrides, partKeyMap])
// Resolve partKey from normalized mesh name (identity fallback when no map loaded)
const resolvePartKey = useCallback(
(normalizedName: string): string => partKeyMap[normalizedName] ?? normalizedName,
[partKeyMap],
const fallbackMaterials = useMemo(
() => remapToPartKeys({ ...initialPartMaterials, ...savedPartMaterials } as PartMaterialMap, partKeyMap),
[initialPartMaterials, savedPartMaterials, partKeyMap],
)
// Merge scene-manifest and legacy/product sources so richer CAD mappings stay
// authoritative while graph/USD manifest keys still fill gaps.
const effectiveMaterials = useMemo(
() => buildEffectiveViewerMaterials(manifestMaterials, fallbackMaterials, manualOverrides),
[manifestMaterials, fallbackMaterials, manualOverrides],
)
const resolvedMeshCount = logicalPartKeys.size
const unresolvedMeshCount = unresolvedMeshNames.size
// Count how many unique GLB mesh types have a resolved material assignment
const assignedCount = useMemo(
() => [...glbMeshNames].filter(n => !!resolvePartMaterial(n, effectiveMaterials)).length,
[glbMeshNames, effectiveMaterials],
() => [...logicalPartKeys].filter(n => !!resolvePartMaterial(n, effectiveMaterials)).length,
[logicalPartKeys, effectiveMaterials],
)
// Raw URL (used as stable key before blob fetch)
@@ -547,33 +553,10 @@ export default function ThreeDViewer({
setPartKeyMap(map)
}
// Single traverse: stamp partKey, build registry, count unique parts
const registry: MeshRegistryEntry[] = []
const names = new Set<string>()
sceneRef.current.traverse((obj) => {
if (!(obj instanceof THREE.Mesh)) return
// Stamp userData.partKey (propagate from parent Group for multi-primitive GLB nodes)
if (!obj.userData.partKey) {
const parentPk = obj.parent?.userData?.partKey as string | undefined
if (parentPk) {
obj.userData.partKey = parentPk
} else if (map) {
const normalized = normalizeMeshName((obj.userData?.name as string) || obj.name)
obj.userData.partKey = map[normalized] ?? normalized
}
}
// Resolve partKey for this mesh
const pk = (obj.userData?.partKey as string) ||
normalizeMeshName((obj.userData?.name as string) || obj.name)
registry.push({ mesh: obj, partKey: pk })
if (pk) names.add(pk)
})
meshRegistryRef.current = registry
setTotalMeshCount(names.size)
setGlbMeshNames(new Set(names))
const { meshRegistry, logicalPartKeys, unresolvedMeshNames } = buildScenePartRegistry(sceneRef.current, map ?? {})
meshRegistryRef.current = meshRegistry
setLogicalPartKeys(new Set(logicalPartKeys))
setUnresolvedMeshNames(new Set(unresolvedMeshNames))
}, [modelReady])
// Re-fit when switching projection mode
@@ -592,7 +575,7 @@ export default function ThreeDViewer({
// Clone materials on first PBR application (GLB loader shares instances)
if (!mesh.userData._pbrApplied) {
mesh.material = Array.isArray(mesh.material)
? mesh.material.map((m: THREE.Material) => m.clone())
? mesh.material.map((m: Material) => m.clone())
: mesh.material.clone()
mesh.userData._pbrApplied = true
}
@@ -604,7 +587,7 @@ export default function ThreeDViewer({
}
})
}
}, [modelReady, effectiveMaterials, resolvePartKey, pbrMap])
}, [modelReady, effectiveMaterials, pbrMap])
// Apply/remove unassigned highlight — uses MeshRegistry instead of traverse
useEffect(() => {
@@ -623,7 +606,7 @@ export default function ThreeDViewer({
}
})
}
}, [modelReady, showUnassigned, effectiveMaterials, resolvePartKey])
}, [modelReady, showUnassigned, effectiveMaterials])
// Reset isolateMode when no part is pinned
useEffect(() => {
@@ -644,7 +627,7 @@ export default function ThreeDViewer({
// Default: fully visible + raycasting enabled
mesh.visible = true
mesh.raycast = THREE.Mesh.prototype.raycast
mesh.raycast = Mesh.prototype.raycast
forEachMeshMaterial(mesh, (mat) => {
if ('opacity' in mat) { mat.opacity = 1; mat.transparent = false; mat.depthWrite = true; mat.needsUpdate = true }
})
@@ -668,7 +651,7 @@ export default function ThreeDViewer({
}
}
}
}, [modelReady, pinnedPart, isolateMode, hideAssigned, effectiveMaterials, resolvePartKey])
}, [modelReady, pinnedPart, isolateMode, hideAssigned, effectiveMaterials])
// Keyboard shortcuts
useEffect(() => {
@@ -706,12 +689,8 @@ export default function ThreeDViewer({
// Task 5 — hover: highlight mesh with emissive, restore on out
const handlePointerOver = useCallback((e: any) => {
e.stopPropagation()
const mesh = e.object as THREE.Mesh
const raw = (mesh?.userData?.name as string) || mesh?.name || (mesh?.parent?.userData?.name as string) || mesh?.parent?.name || ''
const normalized = normalizeMeshName(raw) || 'Part'
// Task 3: prefer userData.partKey (set by GLB node extras or Task 2 stamp) over
// raw normalized name so tooltip shows canonical slug (e.g. "ring_outer") not OCC name
const displayName = (mesh?.userData?.partKey as string | undefined) ?? resolvePartKey(normalized)
const mesh = e.object as Mesh
const displayName = resolveObjectPartKey(mesh, partKeyMap) || 'Part'
setHoverInfo({ name: displayName, x: e.nativeEvent.clientX, y: e.nativeEvent.clientY })
// Restore previous hovered mesh (array-safe)
@@ -720,7 +699,7 @@ export default function ThreeDViewer({
if (!showUnassigned) {
const prevMats = Array.isArray(prev.material) ? prev.material : [prev.material]
prevMats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
const mat = m as MeshStandardMaterial
if (mat && 'emissive' in mat) { mat.emissive.set(0x000000); mat.emissiveIntensity = 0 }
})
}
@@ -732,10 +711,10 @@ export default function ThreeDViewer({
? (Array.isArray(mesh.material) ? mesh.material : [mesh.material])
: []
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
const mat = m as MeshStandardMaterial
if (mat && 'emissive' in mat) { mat.emissive.set(0x333333); mat.emissiveIntensity = 0.5 }
})
}, [showUnassigned, resolvePartKey])
}, [showUnassigned, partKeyMap])
const handlePointerOut = useCallback(() => {
setHoverInfo(null)
@@ -744,10 +723,10 @@ export default function ThreeDViewer({
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
const hasAnyAssignment = Object.keys(effectiveMaterials).length > 0
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
const mat = m as MeshStandardMaterial
if (!mat || !('emissive' in mat)) return
const normalized = normalizeMeshName((mesh.userData?.name as string) || mesh.name)
if (showUnassigned && hasAnyAssignment && !resolvePartMaterial(resolvePartKey(normalized), effectiveMaterials)) {
const partKey = resolveObjectPartKey(mesh, partKeyMap)
if (showUnassigned && hasAnyAssignment && !resolvePartMaterial(partKey, effectiveMaterials)) {
mat.emissive.set(0xff4400); mat.emissiveIntensity = 0.8
} else {
mat.emissive.set(0x000000); mat.emissiveIntensity = 0
@@ -755,7 +734,7 @@ export default function ThreeDViewer({
})
hoveredMeshRef.current = null
}
}, [showUnassigned, effectiveMaterials, resolvePartKey])
}, [showUnassigned, effectiveMaterials, partKeyMap])
const handlePointerMove = useCallback((e: React.PointerEvent) => {
setHoverInfo(prev => prev ? { ...prev, x: e.clientX, y: e.clientY } : null)
@@ -764,10 +743,10 @@ export default function ThreeDViewer({
// Task 7 — click to pin material panel (resolves to partKey slug when available)
const handleClick = useCallback((e: any) => {
e.stopPropagation()
const mesh = e.object as THREE.Mesh
const normalized = normalizeMeshName((mesh?.userData?.name as string) || mesh?.name || (mesh?.parent?.userData?.name as string) || mesh?.parent?.name || '')
if (normalized) setPinnedPart(resolvePartKey(normalized))
}, [resolvePartKey])
const mesh = e.object as Mesh
const partKey = resolveObjectPartKey(mesh, partKeyMap)
if (partKey) setPinnedPart(partKey)
}, [partKeyMap])
return (
<div className="fixed inset-0 z-50 flex flex-col bg-gray-950" onClick={() => setPinnedPart(null)}>
@@ -831,10 +810,15 @@ export default function ThreeDViewer({
<TBtn
active={showUnassigned}
onClick={() => setShowUnassigned(v => !v)}
title={`Highlight unassigned parts (${assignedCount}/${totalMeshCount} assigned)`}
title={`Highlight unassigned parts (${assignedCount}/${resolvedMeshCount} resolved${unresolvedMeshCount > 0 ? `, ${unresolvedMeshCount} unresolved` : ''})`}
>
<AlertCircle size={11} />
<span className="text-[10px] tabular-nums">{assignedCount}/{totalMeshCount}</span>
<span className="text-[10px] tabular-nums">{assignedCount}/{resolvedMeshCount}</span>
{unresolvedMeshCount > 0 && (
<span className="text-[10px] text-amber-300 tabular-nums" title={`${unresolvedMeshCount} unresolved meshes without authoritative part mapping`}>
?{unresolvedMeshCount}
</span>
)}
</TBtn>
)}
@@ -851,18 +835,18 @@ export default function ThreeDViewer({
)}
{/* Reconciliation button — shown when manifest has unmatched/unassigned items */}
{sceneManifest && (sceneManifest.unmatched_source_rows.length > 0 || sceneManifest.unassigned_parts.length > 0) && (
{(sceneManifest && (sceneManifest.unmatched_source_rows.length > 0 || sceneManifest.unassigned_parts.length > 0)) || unresolvedMeshCount > 0 ? (
<TBtn
active={showReconcile}
onClick={() => setShowReconcile(v => !v)}
title={`${sceneManifest.unmatched_source_rows.length} unmatched source rows · ${sceneManifest.unassigned_parts.length} unassigned parts`}
title={`${sceneManifest?.unmatched_source_rows.length ?? 0} unmatched source rows · ${sceneManifest?.unassigned_parts.length ?? 0} unassigned parts · ${unresolvedMeshCount} unresolved meshes`}
>
<AlertTriangle size={11} />
<span className="text-[10px] tabular-nums">
{sceneManifest.unmatched_source_rows.length + sceneManifest.unassigned_parts.length}
{(sceneManifest?.unmatched_source_rows.length ?? 0) + (sceneManifest?.unassigned_parts.length ?? 0) + unresolvedMeshCount}
</span>
</TBtn>
)}
) : null}
{/* Environment */}
<EnvDropdown value={envPreset} onChange={setEnvPreset} />
@@ -978,7 +962,7 @@ export default function ThreeDViewer({
)}
{/* Reconciliation panel */}
{showReconcile && sceneManifest && (
{showReconcile && (sceneManifest || unresolvedMeshCount > 0) && (
<div
className="absolute top-2 right-2 z-30 w-64 bg-gray-900 border border-gray-700 rounded-lg shadow-2xl max-h-[70vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
@@ -992,7 +976,23 @@ export default function ThreeDViewer({
</button>
</div>
<div className="p-3 space-y-3">
{sceneManifest.unassigned_parts.length > 0 && (
{unresolvedMeshCount > 0 && (
<div>
<p className="text-gray-400 text-[10px] font-medium mb-1.5 uppercase tracking-wider">
Unresolved meshes ({unresolvedMeshCount})
</p>
{[...unresolvedMeshNames].sort().map((meshName) => (
<div
key={meshName}
className="px-2 py-1 text-xs text-amber-300 truncate"
title={meshName}
>
{meshName}
</div>
))}
</div>
)}
{sceneManifest?.unassigned_parts.length ? (
<div>
<p className="text-gray-400 text-[10px] font-medium mb-1.5 uppercase tracking-wider">
Unassigned parts ({sceneManifest.unassigned_parts.length})
@@ -1008,8 +1008,8 @@ export default function ThreeDViewer({
</button>
))}
</div>
)}
{sceneManifest.unmatched_source_rows.length > 0 && (
) : null}
{sceneManifest?.unmatched_source_rows.length ? (
<div>
<p className="text-gray-400 text-[10px] font-medium mb-1.5 uppercase tracking-wider">
Unmatched source rows ({sceneManifest.unmatched_source_rows.length})
@@ -1024,7 +1024,7 @@ export default function ThreeDViewer({
</div>
))}
</div>
)}
) : null}
</div>
</div>
)}
@@ -1082,8 +1082,8 @@ export default function ThreeDViewer({
args={[
sceneInfo.maxDim * 6,
Math.round(sceneInfo.maxDim * 6 / (sceneInfo.maxDim / 10)),
new THREE.Color('#3a3a3a'),
new THREE.Color('#222222'),
new Color('#3a3a3a'),
new Color('#222222'),
]}
position={[sceneInfo.center.x, sceneInfo.groundY - 0.0001, sceneInfo.center.z]}
/>
+391 -16
View File
@@ -27,6 +27,144 @@ export function normalizeMeshName(name: string): string {
return n
}
function slugifyMaterialKey(value: string): string {
return value.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
}
function buildLegacyMaterialLookup(
materialMap: PartMaterialMap,
): PartMaterialMap {
const lookup: PartMaterialMap = {}
for (const [rawKey, entry] of Object.entries(materialMap)) {
const normalized = rawKey.toLowerCase().trim()
if (!normalized) continue
lookup[normalized] = entry
const slugKey = slugifyMaterialKey(normalized)
if (slugKey && !lookup[slugKey]) lookup[slugKey] = entry
const stripped = normalized.replace(/(_af\d+(_\d+)?)+$/gi, '')
if (stripped !== normalized) {
if (!lookup[stripped]) lookup[stripped] = entry
const slugStripped = slugifyMaterialKey(stripped)
if (slugStripped && !lookup[slugStripped]) lookup[slugStripped] = entry
}
}
return lookup
}
function commonPrefixLength(left: string, right: string): number {
const limit = Math.min(left.length, right.length)
let idx = 0
while (idx < limit && left[idx] === right[idx]) idx += 1
return idx
}
function lookupByPrefix(
query: string,
materialLookup: PartMaterialMap,
): PartMaterialEntry | undefined {
if (!query) return undefined
const contenders: Array<{ keyLength: number; entry: PartMaterialEntry }> = []
for (const [key, entry] of Object.entries(materialLookup)) {
if (key.length >= 5 && query.length >= 5 && (query.startsWith(key) || key.startsWith(query))) {
contenders.push({ keyLength: key.length, entry })
}
}
if (contenders.length === 0) return undefined
contenders.sort((a, b) => b.keyLength - a.keyLength)
const topLength = contenders[0].keyLength
const closeValues = new Set(
contenders
.filter((item) => item.keyLength >= topLength - 2)
.map((item) => `${item.entry.type}:${item.entry.value}`),
)
return closeValues.size === 1 ? contenders[0].entry : undefined
}
function lookupByCommonPrefix(
query: string,
materialLookup: PartMaterialMap,
): PartMaterialEntry | undefined {
if (!query) return undefined
const scored: Array<{ ratio: number; prefixLength: number; keyLength: number; entry: PartMaterialEntry }> = []
for (const [key, entry] of Object.entries(materialLookup)) {
const prefixLength = commonPrefixLength(query, key)
if (prefixLength < 12) continue
const ratio = prefixLength / Math.max(query.length, key.length)
if (ratio < 0.68) continue
scored.push({ ratio, prefixLength, keyLength: key.length, entry })
}
if (scored.length === 0) return undefined
scored.sort((a, b) =>
b.ratio - a.ratio ||
b.prefixLength - a.prefixLength ||
b.keyLength - a.keyLength,
)
const top = scored[0]
const closeValues = new Set(
scored
.filter((item) => item.ratio >= top.ratio - 0.02 && item.prefixLength >= top.prefixLength - 2)
.map((item) => `${item.entry.type}:${item.entry.value}`),
)
return closeValues.size === 1 ? top.entry : undefined
}
function lookupLegacyPartMaterial(
rawName: string,
materialLookup: PartMaterialMap,
...fallbackNames: string[]
): PartMaterialEntry | undefined {
const candidates = [rawName, ...fallbackNames]
const seen = new Set<string>()
for (const candidate of candidates) {
if (!candidate) continue
const normalized = candidate.toLowerCase().trim()
const variants = [normalized]
const stripped = normalized.replace(/(_af\d+(_\d+)?)+$/gi, '')
if (stripped !== normalized) variants.push(stripped)
const noInstance = stripped.replace(/_\d+$/, '')
if (noInstance && !variants.includes(noInstance)) variants.push(noInstance)
for (const variant of [...variants]) {
const slug = slugifyMaterialKey(variant)
if (slug && !variants.includes(slug)) variants.push(slug)
}
const deduped = variants.filter((variant) => {
if (!variant || seen.has(variant)) return false
seen.add(variant)
return true
})
for (const variant of deduped) {
if (materialLookup[variant]) return materialLookup[variant]
}
for (const variant of deduped) {
const matched = lookupByPrefix(variant, materialLookup)
if (matched) return matched
}
for (const variant of deduped) {
const matched = lookupByCommonPrefix(variant, materialLookup)
if (matched) return matched
}
}
return undefined
}
// ---------------------------------------------------------------------------
// resolvePartMaterial
// ---------------------------------------------------------------------------
@@ -80,24 +218,13 @@ export function remapToPartKeys(
partKeyMap: Record<string, string>,
): PartMaterialMap {
if (!partKeyMap || Object.keys(partKeyMap).length === 0) return materials
const mapKeys = Object.keys(partKeyMap)
const lookup = buildLegacyMaterialLookup(materials)
const result: PartMaterialMap = {}
for (const [key, entry] of Object.entries(materials)) {
// 1. Exact match
if (partKeyMap[key]) { result[partKeyMap[key]] = entry; continue }
// 2. Prefix match: cad_part_materials may have extra _1 instance suffixes
// that partKeyMap doesn't (e.g. "PART_04_1" vs partKeyMap "PART_04")
let matched = false
for (const mk of mapKeys) {
if (key.startsWith(mk + '_') || key === mk) {
result[partKeyMap[mk]] = entry
matched = true
break
}
}
if (!matched) result[key] = entry // preserve unmapped
for (const [sourceName, partKey] of Object.entries(partKeyMap)) {
const entry = lookupLegacyPartMaterial(sourceName, lookup, partKey)
if (entry) result[partKey] = entry
}
return result
return Object.keys(result).length > 0 ? result : materials
}
// ---------------------------------------------------------------------------
@@ -125,6 +252,94 @@ export function convertCadPartMaterials(
return result
}
/**
* Convert scene manifest parts into the PartMaterialMap format used by the viewers.
*
* Scene manifest materials are already authoritative and keyed by part_key.
*/
export function convertSceneManifestMaterials(
parts: Array<{ part_key: string; effective_material: string | null }>,
): PartMaterialMap {
const result: PartMaterialMap = {}
for (const part of parts) {
const key = part.part_key?.trim()
const value = part.effective_material?.trim()
if (!key || !value) continue
result[key] = { type: value.startsWith('#') ? 'hex' : 'library', value }
}
return result
}
/**
* Add viewer-logical keys for scene-manifest entries when GLB semantics collapse
* repeated leaf part keys onto a shared instance key.
*/
export function alignSceneManifestToLogicalPartKeys(
partMaterials: PartMaterialMap,
logicalPartKeys: Iterable<string>,
): PartMaterialMap {
if (!partMaterials || Object.keys(partMaterials).length === 0) return partMaterials
const legacyLookup = buildLegacyMaterialLookup(partMaterials)
let changed = false
const result: PartMaterialMap = { ...partMaterials }
for (const rawKey of logicalPartKeys) {
const logicalKey = rawKey.trim()
if (!logicalKey || result[logicalKey]) continue
const entry =
resolvePartMaterial(logicalKey, partMaterials) ??
lookupLegacyPartMaterial(logicalKey, legacyLookup)
if (!entry) continue
result[logicalKey] = entry
changed = true
}
return changed ? result : partMaterials
}
/**
* Merge scene-manifest and legacy/fallback viewer material maps.
*
* Legacy CAD/product assignments stay authoritative for overlapping keys so the
* viewer keeps parity with the pre-USD material mapping behavior. The scene
* manifest still fills gaps for graph/USD-only logical keys that do not exist
* in the fallback map yet.
*/
export function mergeViewerMaterialSources(
manifestMaterials: PartMaterialMap,
fallbackMaterials: PartMaterialMap,
): PartMaterialMap {
if (!Object.keys(manifestMaterials).length) return fallbackMaterials
if (!Object.keys(fallbackMaterials).length) return manifestMaterials
return { ...manifestMaterials, ...fallbackMaterials }
}
/**
* Build the effective viewer material map shared by inline and fullscreen CAD viewers.
*
* - fallback materials stay authoritative for overlapping keys
* - manifest materials fill graph/USD-only gaps
* - manual overrides win last because they are explicit user actions
*/
export function buildEffectiveViewerMaterials(
manifestMaterials: PartMaterialMap,
fallbackMaterials: PartMaterialMap,
manualOverrides: Record<string, string>,
): PartMaterialMap {
const merged = mergeViewerMaterialSources(manifestMaterials, fallbackMaterials)
if (!manualOverrides || Object.keys(manualOverrides).length === 0) return merged
const overrideEntries: PartMaterialMap = Object.fromEntries(
Object.entries(manualOverrides).map(([key, value]) => [
key,
{ type: (value.startsWith('#') ? 'hex' : 'library') as 'hex' | 'library', value },
]),
)
return { ...merged, ...overrideEntries }
}
// ---------------------------------------------------------------------------
// PBR material helpers
// ---------------------------------------------------------------------------
@@ -184,6 +399,166 @@ export interface MeshRegistryEntry {
partKey: string
}
export interface ScenePartRegistry {
meshRegistry: MeshRegistryEntry[]
logicalPartKeys: Set<string>
unresolvedMeshNames: Set<string>
}
function readPartKey(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
function readFiniteNumber(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function readObjectName(object: any): string {
const raw = object?.userData?.name || object?.name || ''
return typeof raw === 'string' ? raw.trim() : ''
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function findAncestorPartKey(object: any): string | undefined {
let current = object
while (current) {
const partKey = readPartKey(current?.userData?.partKey)
if (partKey) return partKey
current = current.parent
}
return undefined
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function resolveOwnPartKey(object: any, partKeyMap: Record<string, string>): string | undefined {
const explicitPartKey = readPartKey(object?.userData?.partKey)
if (explicitPartKey) return explicitPartKey
const normalized = normalizeMeshName(readObjectName(object))
return normalized ? partKeyMap[normalized] : undefined
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function haveMatchingLocalTransforms(left: any, right: any): boolean {
const epsilon = 1e-5
const samePosition =
Math.abs(readFiniteNumber(left?.position?.x) - readFiniteNumber(right?.position?.x)) <= epsilon &&
Math.abs(readFiniteNumber(left?.position?.y) - readFiniteNumber(right?.position?.y)) <= epsilon &&
Math.abs(readFiniteNumber(left?.position?.z) - readFiniteNumber(right?.position?.z)) <= epsilon
if (!samePosition) return false
const sameScale =
Math.abs(readFiniteNumber(left?.scale?.x, 1) - readFiniteNumber(right?.scale?.x, 1)) <= epsilon &&
Math.abs(readFiniteNumber(left?.scale?.y, 1) - readFiniteNumber(right?.scale?.y, 1)) <= epsilon &&
Math.abs(readFiniteNumber(left?.scale?.z, 1) - readFiniteNumber(right?.scale?.z, 1)) <= epsilon
if (!sameScale) return false
const dot =
readFiniteNumber(left?.quaternion?.x) * readFiniteNumber(right?.quaternion?.x) +
readFiniteNumber(left?.quaternion?.y) * readFiniteNumber(right?.quaternion?.y) +
readFiniteNumber(left?.quaternion?.z) * readFiniteNumber(right?.quaternion?.z) +
readFiniteNumber(left?.quaternion?.w, 1) * readFiniteNumber(right?.quaternion?.w, 1)
return Math.abs(1 - Math.abs(dot)) <= 1e-4
}
function isSemanticSiblingMatch(meshName: string, siblingName: string): boolean {
if (!meshName || !siblingName) return false
if (meshName === siblingName) return true
if (meshName.startsWith(`${siblingName}_`)) return true
return meshName.replace(/_\d+$/, '') === siblingName
}
function scoreSiblingCandidate(meshName: string, siblingName: string, siblingPartKey: string): number {
const canonicalBonus = /_af\d+$/i.test(siblingPartKey) ? 0 : 1000
const baseBonus = meshName.replace(/_\d+$/, '') === siblingName ? 100 : 0
return canonicalBonus + baseBonus + siblingName.length
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function findSiblingSemanticPartKey(object: any, partKeyMap: Record<string, string>): string | undefined {
if (!object?.isMesh || !object?.parent?.children) return undefined
const meshName = normalizeMeshName(readObjectName(object))
if (!meshName) return undefined
let bestMatch: { partKey: string; score: number } | undefined
for (const sibling of object.parent.children) {
if (!sibling || sibling === object || sibling.isMesh) continue
const siblingPartKey = resolveOwnPartKey(sibling, partKeyMap)
const siblingName = normalizeMeshName(readObjectName(sibling))
if (!siblingPartKey || !siblingName) continue
if (!isSemanticSiblingMatch(meshName, siblingName)) continue
// Real OCC exports can place the semantic helper node and the mesh instance
// under the same parent with different local transforms. Matching transforms
// is still a strong hint, but it must not be mandatory.
const score =
scoreSiblingCandidate(meshName, siblingName, siblingPartKey) +
(haveMatchingLocalTransforms(object, sibling) ? 10_000 : 0)
if (!bestMatch || score > bestMatch.score) {
bestMatch = { partKey: siblingPartKey, score }
}
}
return bestMatch?.partKey
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function resolveSceneNodePartKey(object: any, partKeyMap: Record<string, string>): string {
const ownPartKey = resolveOwnPartKey(object, partKeyMap)
if (ownPartKey) return ownPartKey
const siblingPartKey = findSiblingSemanticPartKey(object, partKeyMap)
if (siblingPartKey) return siblingPartKey
const inheritedPartKey = findAncestorPartKey(object?.parent)
if (inheritedPartKey) return inheritedPartKey
return ''
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function resolveObjectPartKey(object: any, partKeyMap: Record<string, string>): string {
return resolveSceneNodePartKey(object, partKeyMap)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function buildScenePartRegistry(object: any, partKeyMap: Record<string, string>): ScenePartRegistry {
const meshRegistry: MeshRegistryEntry[] = []
const logicalPartKeys = new Set<string>()
const unresolvedMeshNames = new Set<string>()
object?.traverse((node: any) => {
const resolvedPartKey = resolveSceneNodePartKey(node, partKeyMap)
if (resolvedPartKey && !readPartKey(node?.userData?.partKey)) {
node.userData = node.userData ?? {}
node.userData.partKey = resolvedPartKey
}
if (!node?.isMesh) return
const nodePartKey = readPartKey(node?.userData?.partKey)
const meshPartKey = resolvedPartKey || nodePartKey
if (!meshPartKey) {
const unresolvedName = normalizeMeshName(readObjectName(node)) || readObjectName(node)
if (unresolvedName) unresolvedMeshNames.add(unresolvedName)
return
}
node.userData = node.userData ?? {}
node.userData.partKey = meshPartKey
logicalPartKeys.add(meshPartKey)
meshRegistry.push({ mesh: node, partKey: meshPartKey })
})
return { meshRegistry, logicalPartKeys, unresolvedMeshNames }
}
/**
* Iterate all materials on a mesh, calling `fn` for each MeshStandardMaterial.
* Handles both single and array materials safely.
+19 -13
View File
@@ -1,5 +1,11 @@
import { useEffect, useRef } from 'react'
import * as THREE from 'three'
import {
BufferGeometry,
Mesh,
MeshStandardMaterial,
Object3D,
Scene,
} from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import type { MeshRegistryEntry } from './cadUtils'
import type { PartMaterialMap } from '../../api/cad'
@@ -17,8 +23,8 @@ interface UseGeometryMergeOpts {
}
interface MergedState {
mergedMeshes: THREE.Mesh[]
hiddenOriginals: THREE.Object3D[]
mergedMeshes: Mesh[]
hiddenOriginals: Object3D[]
}
/**
@@ -57,15 +63,15 @@ export function useGeometryMerge({
const groups = groupRegistryByMaterial(registry, partMaterials)
if (groups.size === 0) return
const mergedMeshes: THREE.Mesh[] = []
const hiddenOriginals: THREE.Object3D[] = []
const mergedMeshes: Mesh[] = []
const hiddenOriginals: Object3D[] = []
let meshesReplaced = 0
for (const [materialKey, entries] of groups) {
// Collect geometries with world transforms baked in
const geometries: THREE.BufferGeometry[] = []
const geometries: BufferGeometry[] = []
for (const entry of entries) {
const mesh = entry.mesh as THREE.Mesh
const mesh = entry.mesh as Mesh
if (!mesh.geometry) continue
// Ensure world matrix is up to date
mesh.updateWorldMatrix(true, false)
@@ -96,10 +102,10 @@ export function useGeometryMerge({
}
// Create merged mesh with material from the first entry
const sourceMesh = entries[0].mesh as THREE.Mesh
const sourceMesh = entries[0].mesh as Mesh
const mat = (Array.isArray(sourceMesh.material)
? sourceMesh.material[0]
: sourceMesh.material) as THREE.MeshStandardMaterial
: sourceMesh.material) as MeshStandardMaterial
const mergedMat = mat.clone()
// Apply PBR properties to the merged material
@@ -112,7 +118,7 @@ export function useGeometryMerge({
}
}
const mergedMesh = new THREE.Mesh(merged, mergedMat)
const mergedMesh = new Mesh(merged, mergedMat)
mergedMesh.name = `__merged_${materialKey}`
mergedMesh.userData._isMerged = true
scene.add(mergedMesh)
@@ -120,7 +126,7 @@ export function useGeometryMerge({
// Hide originals
for (const entry of entries) {
const mesh = entry.mesh as THREE.Object3D
const mesh = entry.mesh as Object3D
mesh.visible = false
mesh.raycast = () => {} // disable raycasting
hiddenOriginals.push(mesh)
@@ -150,7 +156,7 @@ export function useGeometryMerge({
return { drawCallReduction: reductionRef.current }
}
function _restore(state: MergedState, scene: THREE.Scene): void {
function _restore(state: MergedState, scene: Scene): void {
// Remove merged meshes
for (const mesh of state.mergedMeshes) {
scene.remove(mesh)
@@ -164,6 +170,6 @@ function _restore(state: MergedState, scene: THREE.Scene): void {
// Restore originals
for (const obj of state.hiddenOriginals) {
obj.visible = true
obj.raycast = THREE.Mesh.prototype.raycast
obj.raycast = Mesh.prototype.raycast
}
}
@@ -1,14 +1,23 @@
import { useEffect, type ReactNode } from 'react'
import { X } from 'lucide-react'
import { Boxes, Milestone, X } from 'lucide-react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import { WorkflowNodeCatalogBrowser } from './WorkflowNodeCatalogBrowser'
import { WorkflowAuthoringSectionContent } from './WorkflowAuthoringSectionContent'
import {
type WorkflowAuthoringActions,
type WorkflowAuthoringPosition,
} from './workflowAuthoringActions'
import { useWorkflowAuthoringSurface } from './workflowAuthoringSurface'
export const NODE_COMMAND_MENU_WIDTH = 360
interface NodeCommandMenuProps {
definitions: WorkflowNodeDefinition[]
graphFamily: WorkflowGraphFamily
onSelectStep: (step: string) => void
activeSteps?: string[]
actions: WorkflowAuthoringActions
preferredPosition?: WorkflowAuthoringPosition
onClose: () => void
renderIcon: (iconName?: string, size?: number) => ReactNode
}
@@ -16,10 +25,22 @@ interface NodeCommandMenuProps {
export function NodeCommandMenu({
definitions,
graphFamily,
onSelectStep,
activeSteps = [],
actions,
preferredPosition,
onClose,
renderIcon,
}: NodeCommandMenuProps) {
const { activeSection, insertBindings, plan, sections, setActiveSection } =
useWorkflowAuthoringSurface({
definitions,
graphFamily,
activeSteps,
actions,
preferredPosition,
onAfterInsert: onClose,
})
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
@@ -32,14 +53,34 @@ export function NodeCommandMenu({
}, [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="flex max-h-[calc(100vh-2rem)] flex-col overflow-hidden rounded-2xl border border-border-default bg-surface shadow-2xl"
style={{ width: NODE_COMMAND_MENU_WIDTH }}
>
<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-sm font-semibold text-content">Workflow Authoring</p>
<p className="text-xs text-content-muted">
Search by label, step, family, or execution mode.
Use complete reference paths, modules, starter steps, or the raw node library directly on the canvas.
</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-[11px] text-content-muted">
<span className="rounded-full border border-border-default bg-surface-hover px-2 py-0.5">
{activeSteps.length} on canvas
</span>
{plan.referenceBundles.length > 0 && (
<span className="inline-flex items-center gap-1 rounded-full border border-border-default bg-surface-hover px-2 py-0.5">
<Milestone size={11} />
{plan.referenceBundles.length} paths
</span>
)}
{plan.moduleBundles.length > 0 && (
<span className="inline-flex items-center gap-1 rounded-full border border-border-default bg-surface-hover px-2 py-0.5">
<Boxes size={11} />
{plan.moduleBundles.length} modules
</span>
)}
</div>
</div>
<button
type="button"
@@ -53,15 +94,42 @@ export function NodeCommandMenu({
</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 className="space-y-3">
<div className="flex flex-wrap gap-2">
{sections.map(section => {
const Icon = section.icon
const isActive = activeSection === section.key
return (
<button
key={section.key}
type="button"
onClick={() => setActiveSection(section.key)}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-colors ${
isActive
? 'bg-accent text-white'
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
}`}
title={section.helper}
>
<Icon size={12} />
{section.label}
</button>
)
})}
</div>
<WorkflowAuthoringSectionContent
activeSection={activeSection}
definitions={definitions}
graphFamily={graphFamily}
activeSteps={activeSteps}
actions={insertBindings}
renderIcon={renderIcon}
nodesVariant="menu"
searchPlaceholder="Search nodes"
autoFocusSearch
/>
</div>
</div>
</div>
)
@@ -1,39 +1,195 @@
import type { ReactNode } from 'react'
import { ArrowRight } from 'lucide-react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import { WorkflowAuthoringSectionContent } from './WorkflowAuthoringSectionContent'
import { type WorkflowAuthoringActions } from './workflowAuthoringActions'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import { WorkflowNodeCatalogBrowser } from './WorkflowNodeCatalogBrowser'
import { useWorkflowAuthoringSurface } from './workflowAuthoringSurface'
interface NodeDefinitionsPanelProps {
definitions: WorkflowNodeDefinition[]
graphFamily: WorkflowGraphFamily
onSelectStep?: (step: string) => void
activeSteps?: string[]
actions?: WorkflowAuthoringActions
renderIcon?: (iconName?: string, size?: number) => ReactNode
}
export function NodeDefinitionsPanel({ definitions, graphFamily, onSelectStep, renderIcon }: NodeDefinitionsPanelProps) {
type AuthoringFlowStep = {
index: number
title: string
description: string
}
export function NodeDefinitionsPanel({
definitions,
graphFamily,
activeSteps = [],
actions = {},
renderIcon,
}: NodeDefinitionsPanelProps) {
const {
activeSection,
activeSectionMeta,
defaultSection,
insertBindings,
plan: authoringPlan,
sections,
setActiveSection,
} = useWorkflowAuthoringSurface({
definitions,
graphFamily,
activeSteps,
actions,
})
const authoringFlow: AuthoringFlowStep[] = authoringPlan.authoringFlow
const presentStepCount = activeSteps.length
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`}
<div className="space-y-3 rounded-2xl border border-border-default bg-surface-hover/25 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Node Library
</p>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-2">
<p className="text-sm font-semibold text-content">Authoring Browser</p>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{definitions.length} definitions
</span>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{presentStepCount} on canvas
</span>
</div>
<p className="mt-1 text-xs text-content-muted">
Start from reference paths or production modules, then drop to starter steps and raw nodes only when needed.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{insertBindings.onSelectStep ? 'Insert enabled' : 'Browse only'}
</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 className="grid gap-2 sm:grid-cols-2">
{sections.map(section => {
const Icon = section.icon
const isActive = activeSection === section.key
return (
<button
key={section.key}
type="button"
onClick={() => setActiveSection(section.key)}
aria-label={section.label}
className={`rounded-2xl border px-3 py-3 text-left transition-colors ${
isActive
? 'border-accent/40 bg-accent-light'
: 'border-border-default bg-surface hover:bg-surface-hover'
}`}
title={section.helper}
>
<div className="flex items-center justify-between gap-2">
<span className="inline-flex items-center gap-2 text-sm font-medium text-content">
<Icon size={14} />
{section.label}
</span>
{isActive && <ArrowRight size={14} className="text-content-secondary" />}
</div>
<p className="mt-2 text-xs text-content-muted">{section.helper}</p>
</button>
)
})}
</div>
</div>
<WorkflowNodeCatalogBrowser
definitions={definitions}
graphFamily={graphFamily}
variant="panel"
onSelectStep={onSelectStep}
renderIcon={renderIcon}
/>
{activeSectionMeta && (
<div className="rounded-2xl border border-border-default bg-surface-hover/20 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Current Section
</p>
<p className="mt-1 text-xs text-content-muted">
{activeSectionMeta.label}: {activeSectionMeta.helper}
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
Active
</span>
</div>
</div>
)}
{activeSection === defaultSection && (
<div className="rounded-2xl border border-border-default bg-surface-hover/20 p-3">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Authoring Flow
</p>
<p className="mt-1 text-xs text-content-muted">
Keep the legacy workflow safe by starting from graph-safe assemblies, then drilling down only when the module-level path is in place.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
Guided
</span>
</div>
<div className="mt-3 grid gap-2 md:grid-cols-2">
{authoringFlow.map(step => (
<div
key={step.title}
className="rounded-xl border border-border-default bg-surface px-3 py-2"
>
<div className="flex items-center gap-2">
<span className="rounded-full border border-border-default bg-surface-hover/70 px-1.5 py-0.5 text-[10px] font-semibold text-content-secondary">
{step.index}
</span>
<p className="text-sm font-medium text-content">{step.title}</p>
</div>
<p className="mt-1 text-xs text-content-muted">{step.description}</p>
</div>
))}
</div>
</div>
)}
{activeSection === 'nodes' ? (
<div className="space-y-2 rounded-2xl border border-border-default bg-surface-hover/20 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Raw Node Catalog
</p>
<p className="mt-1 text-xs text-content-muted">
Advanced mode for inserting individual legacy, bridge, or graph nodes after the higher-level path is established.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
Escape Hatch
</span>
</div>
<WorkflowAuthoringSectionContent
activeSection={activeSection}
definitions={definitions}
graphFamily={graphFamily}
activeSteps={activeSteps}
actions={insertBindings}
renderIcon={renderIcon}
/>
</div>
) : (
<WorkflowAuthoringSectionContent
activeSection={activeSection}
definitions={definitions}
graphFamily={graphFamily}
activeSteps={activeSteps}
actions={insertBindings}
renderIcon={renderIcon}
/>
)}
</div>
)
}
@@ -0,0 +1,242 @@
import { ArrowRight, Boxes, CheckCircle2, CircleDashed, Library, Milestone, Sparkles } from 'lucide-react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import { getWorkflowAuthoringPlan } from './workflowAuthoringGuidance'
import type { WorkflowModuleBundleId } from './workflowModuleBundles'
import type { WorkflowReferenceBundleId } from './workflowReferenceBundles'
type WorkflowAuthoringOverviewProps = {
definitions: WorkflowNodeDefinition[]
graphFamily: WorkflowGraphFamily
activeSteps: string[]
onInsertModule?: (bundleId: WorkflowModuleBundleId) => void
onInsertReferencePath?: (bundleId: WorkflowReferenceBundleId) => void
onSelectStep?: (step: string) => void
}
export function WorkflowAuthoringOverview({
definitions,
graphFamily,
activeSteps,
onInsertModule,
onInsertReferencePath,
onSelectStep,
}: WorkflowAuthoringOverviewProps) {
const {
description,
gapFillDefinitions,
moduleBundles,
priorities,
referenceBundles,
stageProgress,
title,
} = getWorkflowAuthoringPlan(definitions, graphFamily, activeSteps)
const priorityIcons = [Milestone, Boxes, Library] as const
return (
<div className="space-y-3">
{graphFamily !== 'mixed' && stageProgress.length > 0 && (
<div className="rounded-2xl border border-border-default bg-surface-hover/20 p-3">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Stage Status
</p>
<p className="mt-1 text-xs text-content-muted">
Track the canonical authoring path stage by stage and fill only the missing parts.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
Operational
</span>
</div>
<div className="mt-3 grid gap-2">
{stageProgress.map(stage => {
const isComplete = stage.present >= stage.total
const Icon = isComplete ? CheckCircle2 : CircleDashed
const progressLabel = `${stage.present}/${stage.total} present`
return (
<div
key={stage.id}
className="rounded-xl border border-border-default bg-surface px-3 py-3"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className={`inline-flex items-center gap-1 text-sm font-medium ${isComplete ? 'text-emerald-700 dark:text-emerald-300' : 'text-content'}`}>
<Icon size={14} />
{stage.title}
</span>
<span className="rounded-full border border-border-default bg-surface-hover/70 px-2 py-0.5 text-[11px] text-content-muted">
{progressLabel}
</span>
</div>
<p className="mt-1 text-xs text-content-muted">{stage.description}</p>
</div>
{stage.actionKind === 'reference' && stage.bundleId && onInsertReferencePath && (
<button
type="button"
onClick={() => onInsertReferencePath(stage.bundleId as WorkflowReferenceBundleId)}
className="inline-flex items-center gap-1 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-accent-hover"
>
<Milestone size={12} />
{stage.actionLabel}
</button>
)}
{stage.actionKind === 'module' && stage.bundleId && onInsertModule && (
<button
type="button"
onClick={() => onInsertModule(stage.bundleId as WorkflowModuleBundleId)}
className="inline-flex items-center gap-1 rounded-xl border border-border-default bg-surface px-3 py-1.5 text-xs font-semibold text-content transition-colors hover:bg-surface-hover"
>
<Boxes size={12} />
{stage.actionLabel}
</button>
)}
{stage.actionKind === 'step' && stage.step && onSelectStep && (
<button
type="button"
onClick={() => onSelectStep(stage.step as string)}
className="inline-flex items-center gap-1 rounded-xl border border-border-default bg-surface px-3 py-1.5 text-xs font-medium text-content transition-colors hover:bg-surface-hover"
>
<ArrowRight size={12} />
{stage.actionLabel}
</button>
)}
</div>
</div>
)
})}
</div>
</div>
)}
<div className="rounded-2xl border border-accent/20 bg-accent-light p-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<Sparkles size={14} className="text-accent" />
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Recommended Path
</p>
</div>
<p className="mt-1 text-sm font-semibold text-content">{title}</p>
<p className="mt-1 text-xs text-content-muted">{description}</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{activeSteps.length} on canvas
</span>
</div>
<div className="mt-3 space-y-1.5">
{priorities.map((priority, index) => {
const Icon = priorityIcons[index] ?? Library
return (
<div
key={priority.title}
className="rounded-xl border border-border-default bg-surface px-3 py-2"
>
<div className="flex items-start gap-2">
<span className="mt-0.5 rounded-full border border-border-default bg-surface-hover/70 px-1.5 py-0.5 text-[10px] font-semibold text-content-secondary">
{index + 1}
</span>
<div className="min-w-0">
<span className="inline-flex items-center gap-1 text-sm font-medium text-content">
<Icon size={13} />
{priority.title}
</span>
<p className="mt-0.5 text-xs text-content-muted">{priority.description}</p>
</div>
</div>
</div>
)
})}
</div>
</div>
{(referenceBundles.length > 0 || moduleBundles.length > 0) && (
<div className="rounded-2xl border border-border-default bg-surface-hover/20 p-3">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Quick Start
</p>
<p className="mt-1 text-xs text-content-muted">
Insert the recommended baseline first, then add stage bundles only where the path should diverge.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
Guided
</span>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{referenceBundles.map(bundle => (
<button
key={bundle.id}
type="button"
onClick={() => onInsertReferencePath?.(bundle.id)}
disabled={!onInsertReferencePath}
className="inline-flex items-center gap-1 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-60"
>
<Milestone size={12} />
Insert {bundle.shortLabel}
</button>
))}
{moduleBundles.slice(0, 2).map(bundle => (
<button
key={bundle.id}
type="button"
onClick={() => onInsertModule?.(bundle.id)}
disabled={!onInsertModule}
className="inline-flex items-center gap-1 rounded-xl border border-border-default bg-surface px-3 py-1.5 text-xs font-semibold text-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60"
>
<Boxes size={12} />
Insert {bundle.shortLabel}
</button>
))}
</div>
</div>
)}
{graphFamily !== 'mixed' && onSelectStep && (
<div className="rounded-2xl border border-border-default bg-surface-hover/20 p-3">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Gap Fill
</p>
<p className="mt-1 text-xs text-content-muted">
Use starter-safe inserts only for missing links in the recommended chain.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
Minimal
</span>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{gapFillDefinitions.map(definition => (
<button
key={definition.step}
type="button"
onClick={() => onSelectStep(definition.step)}
className="inline-flex items-center gap-1 rounded-xl border border-border-default bg-surface px-3 py-1.5 text-xs font-medium text-content transition-colors hover:bg-surface-hover"
>
<ArrowRight size={12} />
Add {definition.label}
</button>
))}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,97 @@
import type { ReactNode } from 'react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import { WorkflowAuthoringOverview } from './WorkflowAuthoringOverview'
import { WorkflowModuleBundlePanel } from './WorkflowModuleBundlePanel'
import { WorkflowNodeCatalogBrowser } from './WorkflowNodeCatalogBrowser'
import { WorkflowReferenceBundlePanel } from './WorkflowReferenceBundlePanel'
import { WorkflowStarterPathPanel } from './WorkflowStarterPathPanel'
import type { WorkflowAuthoringInsertHandlers } from './workflowAuthoringActions'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import type { WorkflowAuthoringSection } from './workflowAuthoringSections'
type WorkflowAuthoringSectionContentProps = {
activeSection: WorkflowAuthoringSection
definitions: WorkflowNodeDefinition[]
graphFamily: WorkflowGraphFamily
activeSteps: string[]
actions?: WorkflowAuthoringInsertHandlers
renderIcon?: (iconName?: string, size?: number) => ReactNode
nodesVariant?: 'panel' | 'menu'
searchPlaceholder?: string
autoFocusSearch?: boolean
}
export function WorkflowAuthoringSectionContent({
activeSection,
definitions,
graphFamily,
activeSteps,
actions,
renderIcon,
nodesVariant = 'panel',
searchPlaceholder,
autoFocusSearch = false,
}: WorkflowAuthoringSectionContentProps) {
if (activeSection === 'overview') {
return (
<WorkflowAuthoringOverview
definitions={definitions}
graphFamily={graphFamily}
activeSteps={activeSteps}
onInsertModule={actions?.onInsertModule}
onInsertReferencePath={actions?.onInsertReferencePath}
onSelectStep={actions?.onSelectStep}
/>
)
}
if (activeSection === 'paths') {
return (
<WorkflowReferenceBundlePanel
definitions={definitions}
graphFamily={graphFamily}
activeSteps={activeSteps}
onInsertReferencePath={actions?.onInsertReferencePath}
/>
)
}
if (activeSection === 'modules') {
return (
<WorkflowModuleBundlePanel
definitions={definitions}
graphFamily={graphFamily}
activeSteps={activeSteps}
onInsertModule={actions?.onInsertModule}
/>
)
}
if (activeSection === 'starter') {
return (
<WorkflowStarterPathPanel
definitions={definitions}
graphFamily={graphFamily}
activeSteps={activeSteps}
onSelectStep={actions?.onSelectStep}
/>
)
}
if (activeSection === 'nodes') {
return (
<WorkflowNodeCatalogBrowser
definitions={definitions}
graphFamily={graphFamily}
variant={nodesVariant}
onSelectStep={actions?.onSelectStep}
renderIcon={renderIcon}
searchPlaceholder={searchPlaceholder}
autoFocusSearch={autoFocusSearch}
/>
)
}
return null
}
@@ -1,210 +1,448 @@
import {
BadgeInfo,
GitBranch,
LayoutGrid,
Loader2,
MousePointer2,
Play,
Plus,
RefreshCw,
Save,
Trash2,
} from 'lucide-react'
import { type ReactNode } from 'react'
import { GitBranch, LayoutGrid, Loader2, MousePointer2, Play, RefreshCw, Save, Trash2 } from 'lucide-react'
import type { WorkflowRolloutLinkedOutputType } from '../../api/workflows'
import { getOutputTypeRolloutPresentation } from '../admin/outputTypeRolloutPresentation'
import type { WorkflowOrderLineContextGroup } from './useWorkflowCanvasController'
import type { WorkflowAuthoringActions, WorkflowAuthoringEntryAction } from './workflowAuthoringActions'
type WorkflowExecutionModeOption = {
value: string
label: string
}
function ToolbarBadge({
children,
className = '',
title,
}: {
children: ReactNode
className?: string
title?: string
}) {
return (
<span
className={`inline-flex items-center gap-1 rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] font-medium ${className}`}
title={title}
>
{children}
</span>
)
}
function ToolbarField({
label,
children,
}: {
label: string
children: ReactNode
}) {
return (
<label className="flex min-w-0 items-center gap-2 rounded-lg border border-border-default bg-surface px-2 py-1 text-[11px] text-content-secondary">
<span className="whitespace-nowrap font-medium">{label}</span>
{children}
</label>
)
}
function ToolbarActionButton({
children,
title,
disabled = false,
onClick,
tone = 'default',
}: {
children: ReactNode
title?: string
disabled?: boolean
onClick?: () => void
tone?: 'default' | 'primary'
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={`flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium disabled:opacity-50 ${
tone === 'primary'
? 'bg-accent text-white hover:bg-accent-hover'
: 'border border-border-default text-content hover:bg-surface-hover'
}`}
>
{children}
</button>
)
}
interface WorkflowCanvasToolbarProps {
workflowName: string
blueprintLabel?: string | null
blueprintDescription?: string | null
authoringFamilyLabel: string
authoringFamilyClassName: string
graphFamilyLabel: string
graphFamilyClassName: string
executionMode: string
executionModeLabel: string
executionModeClassName: string
executionModeHint: string
rolloutBadgeLabel: string
rolloutBadgeClassName: string
rolloutStatusLabel: string
rolloutStatusClassName: string
rolloutSummary: string
linkedOutputTypeCount: number
linkedOutputTypes: WorkflowRolloutLinkedOutputType[]
dispatchContextKind: 'order_line' | 'cad_file' | null
dispatchContextLabel: string
dispatchContextId: string
dispatchContextSummary?: string | null
dispatchContextMeta?: string | null
orderLineContextGroups: WorkflowOrderLineContextGroup[]
executionModes: WorkflowExecutionModeOption[]
selectedEdgeCount: number
canAutoLayout: boolean
canPreflight: boolean
canDispatch: boolean
hasValidationErrors: boolean
isPreflightPending: boolean
isDispatchPending: boolean
isContextOptionsLoading: boolean
isSaving: boolean
rollbackPendingOutputTypeId?: string | null
preflightState: 'ready' | 'required' | 'stale' | 'blocked'
authoringActions: WorkflowAuthoringActions
authoringEntryAction: WorkflowAuthoringEntryAction
onDispatchContextIdChange: (value: string) => void
onExecutionModeChange: (value: string) => void
onOpenNodeMenu: () => void
onAutoLayout: () => void
onDeleteSelectedEdges: () => void
onPreflight: () => void
onDispatch: () => void
onSave: () => void
onRollbackOutputType: (outputTypeId: string) => void
}
export function WorkflowCanvasToolbar({
workflowName,
blueprintLabel,
blueprintDescription,
authoringFamilyLabel,
authoringFamilyClassName,
graphFamilyLabel,
graphFamilyClassName,
executionMode,
executionModeLabel,
executionModeClassName,
executionModeHint,
rolloutBadgeLabel,
rolloutBadgeClassName,
rolloutStatusLabel,
rolloutStatusClassName,
rolloutSummary,
linkedOutputTypeCount,
linkedOutputTypes,
dispatchContextKind,
dispatchContextLabel,
dispatchContextId,
dispatchContextSummary,
dispatchContextMeta,
orderLineContextGroups,
executionModes,
selectedEdgeCount,
canAutoLayout,
canPreflight,
canDispatch,
hasValidationErrors,
isPreflightPending,
isDispatchPending,
isContextOptionsLoading,
isSaving,
rollbackPendingOutputTypeId,
preflightState,
authoringActions,
authoringEntryAction,
onDispatchContextIdChange,
onExecutionModeChange,
onOpenNodeMenu,
onAutoLayout,
onDeleteSelectedEdges,
onPreflight,
onDispatch,
onSave,
onRollbackOutputType,
}: WorkflowCanvasToolbarProps) {
const AuthoringEntryIcon = authoringEntryAction.icon
const preflightBadgeClassName = {
ready: 'border-emerald-200 bg-emerald-50 text-emerald-700',
required: 'border-slate-200 bg-slate-100 text-slate-600',
stale: 'border-amber-200 bg-amber-50 text-amber-700',
blocked: 'border-red-200 bg-red-50 text-red-700',
}[preflightState]
const preflightBadgeLabel = {
ready: 'Preflight ready',
required: 'Preflight required',
stale: 'Preflight stale',
blocked: 'Preflight blocked',
}[preflightState]
const showSplitFamilyBadges = authoringFamilyLabel !== graphFamilyLabel || authoringFamilyClassName !== graphFamilyClassName
const selectedEdgeLabel = selectedEdgeCount > 1 ? `Delete (${selectedEdgeCount})` : 'Delete'
const hasRolloutControls = linkedOutputTypes.length > 0
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}
<div className="flex flex-col gap-1.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0 flex flex-1 flex-col gap-1">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<ToolbarBadge className="bg-surface-hover/60 text-content-secondary">
<GitBranch size={13} />
Workflow Canvas
</ToolbarBadge>
<h1 className="max-w-[20rem] truncate text-sm font-semibold text-content">{workflowName}</h1>
{blueprintLabel && (
<ToolbarBadge className="border-transparent bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">
{blueprintLabel}
</ToolbarBadge>
)}
{showSplitFamilyBadges ? (
<>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${authoringFamilyClassName}`}>
Authoring: {authoringFamilyLabel}
</span>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${graphFamilyClassName}`}>
Graph: {graphFamilyLabel}
</span>
</>
) : (
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${authoringFamilyClassName}`}>
Family: {authoringFamilyLabel}
</span>
)}
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${executionModeClassName}`}>
{executionModeLabel}
</span>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${rolloutBadgeClassName}`}>
{rolloutBadgeLabel}
</span>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${rolloutStatusClassName}`}>
{rolloutStatusLabel}
</span>
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${preflightBadgeClassName}`}>
{preflightBadgeLabel}
</span>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-content-muted">
<p className="text-xs text-content-muted">
{linkedOutputTypeCount} linked output type{linkedOutputTypeCount === 1 ? '' : 's'} · {rolloutSummary}
</p>
<span className="hidden text-content-muted lg:inline">|</span>
<span className="text-xs text-content-muted">{executionModeHint}</span>
</div>
{blueprintDescription && <p className="text-[11px] text-content-muted">{blueprintDescription}</p>}
</div>
<div className="flex flex-wrap items-center gap-1.5 self-start">
<ToolbarActionButton
onClick={authoringActions.openNodeMenu}
disabled={!authoringActions.openNodeMenu}
title={authoringEntryAction.title}
>
<AuthoringEntryIcon size={14} />
{authoringEntryAction.label}
</ToolbarActionButton>
<ToolbarActionButton
onClick={onAutoLayout}
disabled={!canAutoLayout}
title="Automatically align nodes into a readable graph layout"
>
<LayoutGrid size={14} />
Align
</ToolbarActionButton>
<ToolbarActionButton
onClick={onDeleteSelectedEdges}
disabled={selectedEdgeCount === 0}
title="Delete the currently selected connection(s)"
>
<Trash2 size={14} />
{selectedEdgeLabel}
</ToolbarActionButton>
<ToolbarActionButton
onClick={onPreflight}
disabled={!canPreflight || isPreflightPending || hasValidationErrors}
title="Validate graph runtime readiness without dispatching tasks"
>
{isPreflightPending ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
{isPreflightPending ? 'Checking…' : 'Dry Run'}
</ToolbarActionButton>
<ToolbarActionButton
onClick={onDispatch}
disabled={!canDispatch || isDispatchPending || hasValidationErrors}
title="Manual graph runtime dispatch for workflow debugging"
>
{isDispatchPending ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
{isDispatchPending ? 'Dispatching…' : 'Run'}
</ToolbarActionButton>
<ToolbarActionButton
onClick={onSave}
disabled={isSaving || hasValidationErrors}
tone="primary"
>
<Save size={14} />
{isSaving ? 'Saving…' : 'Save'}
</ToolbarActionButton>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border-default/70 pt-1.5">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
{dispatchContextKind === 'order_line' ? (
<ToolbarField label={dispatchContextLabel}>
<select
value={dispatchContextId}
onChange={event => onDispatchContextIdChange(event.target.value)}
className="max-w-[18rem] bg-transparent text-sm text-content focus:outline-none"
aria-label="Order line context"
disabled={isContextOptionsLoading || orderLineContextGroups.length === 0}
>
{orderLineContextGroups.length === 0 ? (
<option value="">
{isContextOptionsLoading ? 'Loading order lines…' : 'No order lines available'}
</option>
) : (
orderLineContextGroups.map(group => (
<optgroup key={group.orderId} label={group.orderLabel}>
{group.options.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</optgroup>
))
)}
</select>
</ToolbarField>
) : (
<ToolbarField label={dispatchContextLabel}>
<input
value={dispatchContextId}
onChange={event => onDispatchContextIdChange(event.target.value)}
placeholder={dispatchContextKind === 'cad_file' ? 'cad file uuid' : 'context id'}
className="w-32 bg-transparent text-sm text-content focus:outline-none lg:w-40"
/>
</ToolbarField>
)}
<ToolbarField label="Mode">
<select
value={executionMode}
onChange={event => onExecutionModeChange(event.target.value)}
className="bg-transparent text-sm text-content focus:outline-none"
aria-label="Mode"
title={executionModeHint}
>
{executionModes.map(mode => (
<option key={mode.value} value={mode.value}>
{mode.label}
</option>
))}
</select>
</ToolbarField>
{dispatchContextSummary && (
<ToolbarBadge
className="max-w-[28rem] bg-surface-hover/60 text-content-secondary"
title={dispatchContextMeta ? `${dispatchContextSummary} · ${dispatchContextMeta}` : dispatchContextSummary}
>
<span className="font-medium text-content">{dispatchContextLabel}</span>
<span className="truncate">{dispatchContextSummary}</span>
{dispatchContextMeta && <span className="truncate text-content-muted">· {dispatchContextMeta}</span>}
</ToolbarBadge>
)}
</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."
<div className="flex flex-wrap items-center gap-1.5 text-[11px] text-content-muted">
<ToolbarBadge
className="text-content-muted"
title={blueprintDescription ?? '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"
</ToolbarBadge>
<ToolbarBadge
className="text-content-muted"
title="Select an edge and press Delete, or use right-click / double-click to remove it."
>
<Trash2 size={11} />
Delete removes connections
</span>
</ToolbarBadge>
</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>
{hasRolloutControls && (
<details className="rounded-xl border border-border-default bg-surface-hover/40">
<summary className="flex cursor-pointer list-none flex-wrap items-center justify-between gap-2 px-3 py-2">
<div className="flex min-w-0 flex-col">
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-content-muted">
Rollout Controls
</span>
<span className="text-xs text-content-muted">
Linked output types can be forced back to legacy directly from the workflow editor.
</span>
</div>
<ToolbarBadge className="text-content-muted">
{linkedOutputTypes.length} output type{linkedOutputTypes.length === 1 ? '' : 's'}
</ToolbarBadge>
</summary>
<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 className="flex flex-col gap-2 border-t border-border-default px-3 py-2">
{linkedOutputTypes.map(outputType => {
const rolloutPresentation = getOutputTypeRolloutPresentation({
hasWorkflowLink: true,
workflowRolloutMode: outputType.workflow_rollout_mode,
})
const isRollbackPending = rollbackPendingOutputTypeId === outputType.id
const isAlreadyLegacy = outputType.workflow_rollout_mode === 'legacy_only'
return (
<div
key={outputType.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border-default bg-surface px-2.5 py-2"
>
<div className="min-w-0 flex flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate text-sm font-medium text-content">{outputType.name}</span>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${rolloutPresentation.badgeClassName}`}>
{rolloutPresentation.badgeLabel}
</span>
<span className="rounded-full border border-border-default px-2 py-0.5 text-[11px] font-medium text-content-secondary">
{outputType.artifact_kind}
</span>
{!outputType.is_active && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-700">
Inactive
</span>
)}
</div>
<p className="text-xs text-content-muted">{rolloutPresentation.rowSummary}</p>
</div>
<ToolbarActionButton
onClick={() => onRollbackOutputType(outputType.id)}
disabled={isRollbackPending || isAlreadyLegacy}
title={
isAlreadyLegacy
? `${outputType.name} is already legacy-authoritative.`
: `Set ${outputType.name} rollout mode back to legacy_only.`
}
>
{isRollbackPending ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
{isRollbackPending ? 'Reverting…' : 'Set Legacy'}
</ToolbarActionButton>
</div>
)
})}
</div>
</details>
)}
</div>
</div>
)
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
import { Activity, Library, ShieldCheck, SlidersHorizontal } from 'lucide-react'
import { useMemo, type ReactNode } from 'react'
import { Activity, Library, ShieldCheck, SlidersHorizontal, type LucideIcon } from 'lucide-react'
import type {
WorkflowNodeDefinition,
WorkflowParams,
@@ -12,8 +12,10 @@ import { WorkflowNodeInspector } from './WorkflowNodeInspector'
import { WorkflowPreflightPanel } from './WorkflowPreflightPanel'
import { WorkflowRunsPanel } from './WorkflowRunsPanel'
import { WorkflowUtilityRail } from './WorkflowUtilityRail'
import { getWorkflowAuthoringEntryAction, type WorkflowAuthoringActions } from './workflowAuthoringActions'
import type { WorkflowCanvasNodeData } from './workflowGraphDraft'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import { getWorkflowAuthoringSurfaceModel } from './workflowAuthoringSurface'
export type WorkflowUtilityTab = 'inspector' | 'library' | 'runs' | 'preflight'
@@ -28,7 +30,8 @@ type WorkflowCanvasUtilitySidebarProps = {
nodeDefinitions: WorkflowNodeDefinition[]
nodeDefinitionsByStep: Record<string, WorkflowNodeDefinition>
graphFamily: WorkflowGraphFamily
onInsertNode: (step: string) => void
activeSteps: string[]
authoringActions: WorkflowAuthoringActions
renderNodeIcon: (iconName?: string, size?: number) => ReactNode
workflowRuns: WorkflowRun[]
selectedRunId: string | null
@@ -48,7 +51,8 @@ export function WorkflowCanvasUtilitySidebar({
nodeDefinitions,
nodeDefinitionsByStep,
graphFamily,
onInsertNode,
activeSteps,
authoringActions,
renderNodeIcon,
workflowRuns,
selectedRunId,
@@ -58,12 +62,20 @@ export function WorkflowCanvasUtilitySidebar({
preflightResult,
isPreflightPending,
}: WorkflowCanvasUtilitySidebarProps) {
const surfaceModel = useMemo(
() => getWorkflowAuthoringSurfaceModel({ definitions: nodeDefinitions, graphFamily, activeSteps }),
[activeSteps, graphFamily, nodeDefinitions],
)
const authoringEntryAction = getWorkflowAuthoringEntryAction(surfaceModel)
const authoringTabLabel = authoringEntryAction.label === 'Author' ? 'Authoring' : 'Library'
const AuthoringTabIcon = authoringEntryAction.icon
if (nodeDefinitions.length === 0) return null
const utilityTabs: {
key: WorkflowUtilityTab
label: string
icon: typeof SlidersHorizontal
icon: LucideIcon
count?: number | null
disabled?: boolean
}[] = [
@@ -75,8 +87,8 @@ export function WorkflowCanvasUtilitySidebar({
},
{
key: 'library',
label: 'Library',
icon: Library,
label: authoringTabLabel,
icon: AuthoringTabIcon,
count: nodeDefinitions.length,
},
{
@@ -124,7 +136,8 @@ export function WorkflowCanvasUtilitySidebar({
<NodeDefinitionsPanel
definitions={nodeDefinitions}
graphFamily={graphFamily}
onSelectStep={onInsertNode}
activeSteps={activeSteps}
actions={authoringActions}
renderIcon={renderNodeIcon}
/>
)}
@@ -10,6 +10,12 @@ interface WorkflowListItem {
familyClassName: string
executionModeLabel: string
executionModeClassName: string
rolloutBadgeLabel: string
rolloutBadgeClassName: string
rolloutStatusLabel: string
rolloutStatusClassName: string
rolloutSummary: string
linkedOutputTypeCount: number
blueprintLabel?: string | null
isReference?: boolean
}
@@ -133,6 +139,18 @@ export function WorkflowListSidebar({
<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>
<span className={`ml-1 mt-1 inline-block rounded-full px-1.5 py-0.5 text-xs font-medium ${item.rolloutBadgeClassName}`}>
{item.rolloutBadgeLabel}
</span>
<span className={`ml-1 mt-1 inline-block rounded-full px-1.5 py-0.5 text-xs font-medium ${item.rolloutStatusClassName}`}>
{item.rolloutStatusLabel}
</span>
<p className="mt-1 text-xs text-content-muted">
{item.linkedOutputTypeCount} linked output type{item.linkedOutputTypeCount === 1 ? '' : 's'}.
</p>
<p className="mt-1 text-xs text-content-muted">
{item.rolloutSummary}
</p>
{item.isReference && (
<p className="mt-1 text-xs text-content-muted">
Canonical reference workflow for parity work.
@@ -0,0 +1,83 @@
import { Boxes, Wand2 } from 'lucide-react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import { getWorkflowAuthoringPlan } from './workflowAuthoringGuidance'
import type { WorkflowModuleBundleId } from './workflowModuleBundles'
type WorkflowModuleBundlePanelProps = {
definitions: WorkflowNodeDefinition[]
graphFamily: WorkflowGraphFamily
activeSteps: string[]
onInsertModule?: (bundleId: WorkflowModuleBundleId) => void
}
export function WorkflowModuleBundlePanel({
definitions,
graphFamily,
activeSteps,
onInsertModule,
}: WorkflowModuleBundlePanelProps) {
const { moduleBundles } = getWorkflowAuthoringPlan(definitions, graphFamily, activeSteps)
if (moduleBundles.length === 0) return null
return (
<div className="space-y-2 rounded-2xl border border-border-default bg-surface-hover/30 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<Boxes size={14} className="text-accent" />
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Production Modules
</p>
</div>
<p className="mt-1 text-xs text-content-muted">
Insert reusable subgraphs for core production stages instead of assembling every node from scratch.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{moduleBundles.length} bundles
</span>
</div>
<div className="space-y-2">
{moduleBundles.map(bundle => (
<div
key={bundle.id}
className="rounded-2xl border border-border-default bg-surface px-3 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold text-content">{bundle.label}</p>
<span className="rounded-full bg-surface-hover px-2 py-0.5 text-[11px] font-medium text-content-secondary">
{bundle.stage}
</span>
<span className="rounded-full bg-surface-hover px-2 py-0.5 text-[11px] text-content-muted">
{bundle.presentCount}/{bundle.totalCount} present
</span>
</div>
<p className="text-xs text-content-muted">{bundle.description}</p>
<p className="text-[11px] text-content-muted">
{bundle.stepIds.join(' -> ')}
</p>
</div>
{onInsertModule ? (
<button
type="button"
onClick={() => onInsertModule(bundle.id)}
aria-label={`Insert ${bundle.label}`}
className="inline-flex items-center gap-1 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-accent-hover"
>
<Wand2 size={12} />
Insert
</button>
) : null}
</div>
</div>
))}
</div>
</div>
)
}
@@ -1,18 +1,22 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { ArrowRight, Plus, Search } from 'lucide-react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import type { StepCategory, WorkflowNodeDefinition } from '../../api/workflows'
import {
AUTHORING_STAGE_DESCRIPTIONS,
AUTHORING_STAGE_LABELS,
AUTHORING_STAGE_STYLES,
CATEGORY_COLORS,
CATEGORY_LABELS,
FAMILY_FILTER_DESCRIPTIONS,
FAMILY_FILTER_LABELS,
FAMILY_FILTER_STYLES,
NODE_KIND_FILTER_LABELS,
NODE_LIBRARY_GROUP_DESCRIPTIONS,
NODE_LIBRARY_GROUP_LABELS,
NODE_LIBRARY_GROUP_STYLES,
getDefinitionBadges,
getDefinitionFamily,
getDefinitionModuleLabel,
getDefinitionModuleNamespace,
type WorkflowGraphFamily,
type WorkflowNodeFamilyFilter,
@@ -20,10 +24,11 @@ import {
type WorkflowNodeLibraryGroup,
} from './workflowNodeLibrary'
import {
buildWorkflowNodeCatalog,
buildWorkflowNodeCatalogModel,
filterWorkflowNodeDefinitions,
getAvailableFamilyFilters,
} from './workflowNodeCatalog'
import { STARTER_NODE_STEP_ORDER, STARTER_PATH_TITLES } from './workflowAuthoringGuidance'
type WorkflowNodeCatalogBrowserProps = {
definitions: WorkflowNodeDefinition[]
@@ -37,10 +42,21 @@ type WorkflowNodeCatalogBrowserProps = {
autoFocusSearch?: boolean
}
type WorkflowNodeCatalogModuleFilter = {
namespace: string
type WorkflowNodeCategoryFilter = 'all' | StepCategory
const CATEGORY_FILTERS: WorkflowNodeCategoryFilter[] = ['all', 'input', 'processing', 'rendering', 'output']
const CATEGORY_FILTER_LABELS: Record<WorkflowNodeCategoryFilter, string> = {
all: 'All Categories',
input: 'Input',
processing: 'Processing',
rendering: 'Rendering',
output: 'Output',
}
type FilterPillOption<T extends string> = {
value: T
label: string
count: number
}
function readContractList(contract: Record<string, unknown>, key: string) {
@@ -60,6 +76,42 @@ function formatContractLabel(value: string) {
.join(' ')
}
function FilterPillGroup<T extends string>({
title,
options,
activeValue,
onChange,
}: {
title: string
options: FilterPillOption<T>[]
activeValue: T
onChange: (value: T) => void
}) {
return (
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-wide text-content-secondary">
{title}
</p>
<div className="flex flex-wrap gap-2">
{options.map(option => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
activeValue === option.value
? 'bg-accent text-white'
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
}`}
>
{option.label}
</button>
))}
</div>
</div>
)
}
export function WorkflowNodeCatalogBrowser({
definitions,
graphFamily,
@@ -76,11 +128,15 @@ export function WorkflowNodeCatalogBrowser({
graphFamily === 'mixed' ? 'all' : graphFamily,
)
const [kindFilter, setKindFilter] = useState<WorkflowNodeKindFilter>('all')
const [categoryFilter, setCategoryFilter] = useState<WorkflowNodeCategoryFilter>('all')
const [moduleFilter, setModuleFilter] = useState<string>('all')
const [moduleQuery, setModuleQuery] = useState('')
useEffect(() => {
setFamilyFilter(graphFamily === 'mixed' ? 'all' : graphFamily)
setCategoryFilter('all')
setModuleFilter('all')
setModuleQuery('')
}, [graphFamily])
const availableFamilyFilters = useMemo(
@@ -97,25 +153,32 @@ export function WorkflowNodeCatalogBrowser({
})
}, [definitions, familyFilter, graphFamily, kindFilter, query])
const moduleFilters = useMemo<WorkflowNodeCatalogModuleFilter[]>(() => {
const modules = new Map<string, WorkflowNodeCatalogModuleFilter>()
const normalizedModuleQuery = moduleQuery.trim().toLowerCase()
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,
})
}
const visibleDefinitions = useMemo(() => {
const scopedByModule =
moduleFilter === 'all'
? filteredDefinitions
: filteredDefinitions.filter(definition => getDefinitionModuleNamespace(definition) === moduleFilter)
return Array.from(modules.values()).sort((a, b) => a.label.localeCompare(b.label))
}, [filteredDefinitions])
const scopedByModuleQuery = !normalizedModuleQuery
? scopedByModule
: scopedByModule.filter(definition => {
const namespace = getDefinitionModuleNamespace(definition).toLowerCase()
return (
namespace.includes(normalizedModuleQuery) ||
getDefinitionModuleLabel(definition).toLowerCase().includes(normalizedModuleQuery) ||
definition.module_key.toLowerCase().includes(normalizedModuleQuery) ||
definition.label.toLowerCase().includes(normalizedModuleQuery)
)
})
if (categoryFilter === 'all') return scopedByModuleQuery
return scopedByModuleQuery.filter(definition => definition.category === categoryFilter)
}, [categoryFilter, filteredDefinitions, moduleFilter, normalizedModuleQuery])
const catalogModel = useMemo(() => buildWorkflowNodeCatalogModel(visibleDefinitions), [visibleDefinitions])
const moduleFilters = catalogModel.moduleFilters
useEffect(() => {
if (moduleFilter !== 'all' && !moduleFilters.some(module => module.namespace === moduleFilter)) {
@@ -123,14 +186,36 @@ export function WorkflowNodeCatalogBrowser({
}
}, [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 familySections = catalogModel.familySections
const firstVisibleDefinition = visibleDefinitions[0]
const totalModuleCount = moduleFilters.length
const quickInsertDefinitions = useMemo(() => {
const prioritizedSteps = STARTER_NODE_STEP_ORDER[graphFamily]
if (prioritizedSteps.length === 0) return visibleDefinitions.slice(0, 4)
const byStep = new Map(visibleDefinitions.map(definition => [definition.step, definition]))
return prioritizedSteps
.map(step => byStep.get(step))
.filter((definition): definition is WorkflowNodeDefinition => Boolean(definition))
.slice(0, variant === 'menu' ? 5 : 6)
}, [graphFamily, variant, visibleDefinitions])
const quickInsertTitle = graphFamily === 'mixed' ? 'Suggested nodes' : STARTER_PATH_TITLES[graphFamily]
const runtimeFilterOptions: FilterPillOption<WorkflowNodeKindFilter>[] = [
{ value: 'all', label: NODE_KIND_FILTER_LABELS.all },
{ value: 'legacy', label: NODE_KIND_FILTER_LABELS.legacy },
{ value: 'bridge', label: NODE_KIND_FILTER_LABELS.bridge },
{ value: 'graph', label: NODE_KIND_FILTER_LABELS.graph },
]
const familyFilterOptions = availableFamilyFilters.map(filter => ({
value: filter,
label: FAMILY_FILTER_LABELS[filter],
}))
const categoryFilterOptions: FilterPillOption<WorkflowNodeCategoryFilter>[] = CATEGORY_FILTERS.map(
filter => ({
value: filter,
label: CATEGORY_FILTER_LABELS[filter],
}),
)
return (
<div className="space-y-3">
@@ -149,10 +234,13 @@ export function WorkflowNodeCatalogBrowser({
</span>
)}
</div>
{moduleFilter !== 'all' && (
{(moduleFilter !== 'all' || moduleQuery) && (
<button
type="button"
onClick={() => setModuleFilter('all')}
onClick={() => {
setModuleFilter('all')
setModuleQuery('')
}}
className="text-[11px] font-medium text-accent hover:text-accent-hover"
>
Show all modules
@@ -178,39 +266,26 @@ export function WorkflowNodeCatalogBrowser({
</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>
<FilterPillGroup
title="Runtime"
options={runtimeFilterOptions}
activeValue={kindFilter}
onChange={setKindFilter}
/>
<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>
<FilterPillGroup
title="Family"
options={familyFilterOptions}
activeValue={familyFilter}
onChange={setFamilyFilter}
/>
<FilterPillGroup
title="Category"
options={categoryFilterOptions}
activeValue={categoryFilter}
onChange={setCategoryFilter}
/>
{moduleFilters.length > 0 && (
<div className="space-y-1">
@@ -222,6 +297,15 @@ export function WorkflowNodeCatalogBrowser({
family + runtime scoped
</span>
</div>
<div className="relative">
<Search size={12} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" />
<input
value={moduleQuery}
onChange={event => setModuleQuery(event.target.value)}
placeholder="Search modules"
className="w-full rounded-xl border border-border-default bg-surface px-8 py-2 text-xs text-content focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
@@ -244,9 +328,9 @@ export function WorkflowNodeCatalogBrowser({
? 'bg-accent text-white'
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
}`}
title={module.label}
title={`${module.label} · ${module.stages.map(stage => AUTHORING_STAGE_LABELS[stage]).join(' / ')}`}
>
{module.namespace}
{module.label}
<span className="ml-1 opacity-70">{module.count}</span>
</button>
))}
@@ -258,21 +342,51 @@ export function WorkflowNodeCatalogBrowser({
<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
const count = catalogModel.runtimeCounts[group] ?? 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]}
title={NODE_LIBRARY_GROUP_LABELS[group]}
>
<span>{NODE_KIND_FILTER_LABELS[group]}</span>
<span>{NODE_LIBRARY_GROUP_LABELS[group]}</span>
<span>{count}</span>
</span>
)
})}
</div>
{quickInsertDefinitions.length > 0 && (
<div className="rounded-xl border border-border-default bg-surface-hover/35 p-3">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Quick Insert
</p>
<p className="mt-1 text-xs text-content-muted">{quickInsertTitle}</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{quickInsertDefinitions.length} picks
</span>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{quickInsertDefinitions.map(definition => (
<button
key={`quick-${definition.step}`}
type="button"
onClick={() => onSelectStep?.(definition.step)}
disabled={!onSelectStep}
className="rounded-full border border-border-default bg-surface px-3 py-1.5 text-xs font-medium text-content transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-60"
title={definition.description}
>
{definition.label}
</button>
))}
</div>
</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>
@@ -292,172 +406,258 @@ export function WorkflowNodeCatalogBrowser({
)}
<div className="space-y-3">
{catalogSections.map(section => {
const group = section.group as WorkflowNodeLibraryGroup
{familySections.map(familySection => {
const familyLabel = FAMILY_FILTER_LABELS[familySection.family]
const familyDescription = FAMILY_FILTER_DESCRIPTIONS[familySection.family]
const familyStyle = FAMILY_FILTER_STYLES[familySection.family]
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
key={familySection.family}
className="rounded-xl border border-border-default bg-surface-hover/35 p-3"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${familyStyle}`}>
{familyLabel}
</span>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{familySection.modules.length} modules
</span>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{familySection.definitions.length} nodes
</span>
</div>
<p className="text-xs text-content-muted">{familyDescription}</p>
</div>
<div className="flex flex-wrap gap-2">
{(['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[]).map(group => {
const count = familySection.runtimeCounts[group]
if (count === 0) return null
return (
<span
key={`${familySection.family}-${group}`}
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`}
>
{NODE_KIND_FILTER_LABELS[group]} {count}
</span>
)
})}
</div>
</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 className="mt-3 space-y-3">
{familySection.modules.map(moduleGroup => (
<div
key={`${group}:${moduleGroup.namespace}`}
className="rounded-md border border-border-default bg-surface/70 px-2 py-2"
key={`${familySection.family}:${moduleGroup.namespace}`}
className="rounded-lg border border-border-default bg-surface/80 p-3"
>
<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}
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-xs font-medium text-content">
{moduleGroup.label}
</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 className="truncate rounded-full border border-border-default bg-surface px-2 py-0.5 font-mono text-[10px] text-content-muted">
{moduleGroup.namespace}
</span>
)}
{moduleGroup.stages.map(stage => (
<span
key={`${moduleGroup.namespace}-${stage}`}
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${AUTHORING_STAGE_STYLES[stage]}`}
title={AUTHORING_STAGE_DESCRIPTIONS[stage]}
>
{AUTHORING_STAGE_LABELS[stage]}
</span>
))}
</div>
<div className="flex flex-wrap gap-1.5">
{(['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[]).map(group => {
const count = moduleGroup.runtimeCounts[group]
if (count === 0) return null
return (
<span
key={`${moduleGroup.namespace}-${group}`}
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`}
>
{NODE_KIND_FILTER_LABELS[group]} {count}
</span>
)
})}
</div>
</div>
<span className="text-content-muted">{moduleGroup.definitions.length}</span>
<span className="text-xs text-content-muted">{moduleGroup.definitions.length}</span>
</div>
<div className="mt-1 space-y-2">
{moduleGroup.categories.map(categorySection => {
const { category, definitions: categoryDefinitions } = categorySection
<div className="mt-3 space-y-3">
{moduleGroup.stageSections.map(stageSection => {
const stageLabel = AUTHORING_STAGE_LABELS[stageSection.stage]
const stageDescription = AUTHORING_STAGE_DESCRIPTIONS[stageSection.stage]
const stageStyle = AUTHORING_STAGE_STYLES[stageSection.stage]
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]}
<div
key={`${familySection.family}:${moduleGroup.namespace}:${stageSection.stage}`}
className="rounded-md border border-border-default bg-surface-hover/35 p-2"
>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${stageStyle}`}>
{stageLabel}
</span>
<p className="text-xs text-content-muted">{stageDescription}</p>
</div>
<span className="text-[10px] text-content-muted">
{stageSection.definitions.length}
</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)
<div className="space-y-2">
{stageSection.categories.map(categorySection => {
const { category, definitions: categoryDefinitions } = categorySection
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>
<div key={`${moduleGroup.namespace}:${stageSection.stage}:${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>
{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} />
<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>
)}
</button>
)}
</div>
<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>
<p className="mt-1 line-clamp-2 text-xs text-content-muted">{definition.description}</p>
{isActionable && (
<button
type="button"
onClick={() => onSelectStep?.(definition.step)}
aria-label={
variant === 'panel'
? `Insert ${definition.label}`
: `Use ${definition.label}`
}
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>
<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>
<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>
)
@@ -1,3 +1,5 @@
import { formatContractValue } from './workflowGraphDraft'
interface WorkflowNodeContractCardProps {
moduleLabel: string
moduleKey: string
@@ -10,16 +12,14 @@ interface WorkflowNodeContractCardProps {
inputContextLabel?: string | null
outputContextLabel?: string | null
requiredInputs: string[]
requiredAnyInputs: 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(' ')
return formatContractValue(role)
}
function ContractRolePills({
@@ -43,6 +43,27 @@ function ContractRolePills({
)
}
function ContractAlternativeGroups({
groups,
}: {
groups: string[][]
}) {
if (groups.length === 0) return null
return (
<div className="space-y-1.5">
{groups.map(group => (
<div
key={group.join('|')}
className="rounded-lg border border-dashed border-border-default bg-surface px-2 py-1 text-[11px] text-content-secondary"
>
Any of: {group.map(formatContractRole).join(' / ')}
</div>
))}
</div>
)
}
export function WorkflowNodeContractCard({
moduleLabel,
moduleKey,
@@ -55,6 +76,7 @@ export function WorkflowNodeContractCard({
inputContextLabel,
outputContextLabel,
requiredInputs,
requiredAnyInputs,
consumedArtifacts,
providedOutputs,
producedArtifacts,
@@ -99,6 +121,12 @@ export function WorkflowNodeContractCard({
) : (
<p className="text-xs text-content-muted">No declared upstream requirements.</p>
)}
{requiredAnyInputs.length > 0 && (
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">Alternative Inputs</p>
<ContractAlternativeGroups groups={requiredAnyInputs} />
</div>
)}
{consumedArtifacts.length > 0 && (
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">Artifacts Consumed</p>
@@ -1,4 +1,7 @@
import { useMemo, type ChangeEvent } from 'react'
import { useQuery } from '@tanstack/react-query'
import { listRenderTemplates } from '../../api/renderTemplates'
import type { WorkflowNodeDefinition, WorkflowNodeFieldDefinition, WorkflowParams } from '../../api/workflows'
import {
FAMILY_FILTER_LABELS,
@@ -10,6 +13,11 @@ import {
type WorkflowGraphFamily,
} from './workflowNodeLibrary'
import { WorkflowNodeContractCard } from './WorkflowNodeContractCard'
import { formatContractValue } from './workflowGraphDraft'
const TEMPLATE_INPUT_PARAM_PREFIX = 'template_input__'
const OUTPUT_SAVE_ALTERNATIVE_INPUTS = ['rendered_image', 'rendered_frames', 'rendered_video']
const NOTIFY_ALTERNATIVE_INPUTS = ['rendered_image', 'rendered_frames', 'rendered_video', 'workflow_result', 'blend_asset']
function groupFieldsBySection(fields: WorkflowNodeFieldDefinition[]) {
return fields.reduce<Record<string, WorkflowNodeFieldDefinition[]>>((sections, field) => {
@@ -25,12 +33,104 @@ function getContractValues(contract: Record<string, unknown> | undefined, key: s
return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
}
function getContractAlternativeGroups(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 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'
}
function getAlternativeInputGroupsForNode(step: string | undefined, contract: Record<string, unknown> | undefined): string[][] {
const groups = getContractAlternativeGroups(contract, 'requires_any')
if (step === 'output_save' && groups.length === 0) {
return [OUTPUT_SAVE_ALTERNATIVE_INPUTS]
}
if (step === 'notify') {
if (groups.length === 0) {
return [NOTIFY_ALTERNATIVE_INPUTS]
}
return [Array.from(new Set([...groups[0], 'blend_asset'])), ...groups.slice(1)]
}
return groups
}
function parseSelectValue(field: WorkflowNodeFieldDefinition, rawValue: string): unknown {
if (rawValue === '') return ''
const matchedOption = field.options.find(option => String(option.value) === rawValue)
return matchedOption ? matchedOption.value : rawValue
}
function clearDynamicTemplateInputParams(params: WorkflowParams): WorkflowParams {
return Object.fromEntries(
Object.entries(params).filter(([key]) => !key.startsWith(TEMPLATE_INPUT_PARAM_PREFIX)),
)
}
function getVariableLabels(fields: WorkflowNodeFieldDefinition[]): string[] {
return Array.from(
new Set(
fields
.map(field => field.label?.trim())
.filter((label): label is string => Boolean(label)),
),
)
}
function formatContractRole(value: string): string {
return formatContractValue(value)
}
function describeRequiredInputs(requiredInputs: string[], requiredAnyInputs: string[][]): string[] {
return [
...requiredInputs.map(role => formatContractRole(role)),
...requiredAnyInputs.map(group => `Any of: ${group.map(formatContractRole).join(' / ')}`),
]
}
type InputSocketDescriptor = {
id: string
label: string
tone: 'required' | 'alternative'
}
function buildInputSocketDescriptors(
requiredInputs: string[],
requiredAnyInputs: string[][],
): InputSocketDescriptor[] {
return [
...requiredInputs.map(role => ({
id: `required:${role}`,
label: formatContractRole(role),
tone: 'required' as const,
})),
...requiredAnyInputs.map(group => ({
id: `alternative:${group.join('|')}`,
label: `Any of: ${group.map(formatContractRole).join(' / ')}`,
tone: 'alternative' as const,
})),
]
}
function formatCountNoun(count: number, singular: string, plural: string): string {
return `${count} ${count === 1 ? singular : plural}`
}
type WorkflowNodeInspectorProps = {
params: WorkflowParams
onChange: (params: WorkflowParams) => void
@@ -59,8 +159,80 @@ export function WorkflowNodeInspector({
[graphFamily, nodeDefinitions],
)
const nodeSelectionGroups = groupDefinitionsForStepSelect(selectableNodeDefinitions)
const isResolveTemplateNode = step === 'resolve_template'
const { data: renderTemplates = [] } = useQuery({
queryKey: ['render-templates'],
queryFn: listRenderTemplates,
enabled: isResolveTemplateNode,
staleTime: 30_000,
})
const selectedTemplateId =
typeof params.template_id_override === 'string' ? params.template_id_override : ''
const selectedTemplate = useMemo(
() => renderTemplates.find(template => template.id === selectedTemplateId) ?? null,
[renderTemplates, selectedTemplateId],
)
const effectiveFields = useMemo(() => {
const baseFields = [...(nodeDefinition?.fields ?? [])]
if (!isResolveTemplateNode) return baseFields
const templateOptions = renderTemplates
.filter(template => template.is_active)
.sort((left, right) => left.name.localeCompare(right.name))
.map(template => ({
value: template.id,
label: template.output_type_names?.length
? `${template.name} (${template.output_type_names.join(', ')})`
: template.name,
}))
const selectedTemplateMissing =
selectedTemplateId.length > 0 && !templateOptions.some(option => option.value === selectedTemplateId)
const resolvedBaseFields = baseFields.map(field => {
if (field.key !== 'template_id_override') return field
return {
...field,
label: 'Template Override',
description:
'Select a specific render template to expose its workflow inputs and bypass category/output-type auto resolution.',
type: 'select' as const,
allow_blank: true,
options: selectedTemplateMissing
? [{ value: selectedTemplateId, label: `${selectedTemplateId} (manual UUID)` }, ...templateOptions]
: templateOptions,
}
})
const dynamicTemplateFields = (selectedTemplate?.workflow_input_schema ?? []).map(field => ({
...field,
key: `${TEMPLATE_INPUT_PARAM_PREFIX}${field.key}`,
section: field.section || 'Template Inputs',
}))
return [...resolvedBaseFields, ...dynamicTemplateFields]
}, [isResolveTemplateNode, nodeDefinition?.fields, renderTemplates, selectedTemplate, selectedTemplateId])
const updateField = (field: WorkflowNodeFieldDefinition, value: unknown) => {
if (field.key === 'template_id_override') {
const nextParams = clearDynamicTemplateInputParams(params)
if (value !== '') {
nextParams[field.key] = value
} else {
delete nextParams[field.key]
}
onChange(nextParams)
return
}
if (value === '' && field.allow_blank !== false) {
const nextParams = { ...params }
delete nextParams[field.key]
onChange(nextParams)
return
}
onChange({
...params,
[field.key]: value,
@@ -78,13 +250,24 @@ export function WorkflowNodeInspector({
updateField(field, Number(rawValue))
}
const fieldsBySection = groupFieldsBySection(nodeDefinition?.fields ?? [])
const fieldsBySection = groupFieldsBySection(effectiveFields)
const inputContextLabel = getContractContextLabel(nodeDefinition?.input_contract as Record<string, unknown> | undefined)
const outputContextLabel = getContractContextLabel(nodeDefinition?.output_contract as Record<string, unknown> | undefined)
const requiredAnyInputs = getAlternativeInputGroupsForNode(
step,
nodeDefinition?.input_contract as Record<string, unknown> | undefined,
)
const alternativeRoleSet = new Set(requiredAnyInputs.flat())
const requiredInputs = getContractValues(nodeDefinition?.input_contract as Record<string, unknown> | undefined, 'requires')
.filter(role => !alternativeRoleSet.has(role))
const providedOutputs = getContractValues(nodeDefinition?.output_contract as Record<string, unknown> | undefined, 'provides')
const consumedArtifacts = nodeDefinition?.artifact_roles_consumed ?? []
const producedArtifacts = nodeDefinition?.artifact_roles_produced ?? []
const staticVariableLabels = getVariableLabels(nodeDefinition?.fields ?? [])
const dynamicTemplateVariableLabels = getVariableLabels(selectedTemplate?.workflow_input_schema ?? [])
const inputDescriptions = describeRequiredInputs(requiredInputs, requiredAnyInputs)
const inputSocketDescriptors = buildInputSocketDescriptors(requiredInputs, requiredAnyInputs)
const totalVariableCount = staticVariableLabels.length + dynamicTemplateVariableLabels.length
return (
<div className="space-y-5">
@@ -147,16 +330,139 @@ export function WorkflowNodeInspector({
inputContextLabel={inputContextLabel}
outputContextLabel={outputContextLabel}
requiredInputs={requiredInputs}
requiredAnyInputs={requiredAnyInputs}
consumedArtifacts={consumedArtifacts}
providedOutputs={providedOutputs}
producedArtifacts={producedArtifacts}
/>
)}
{nodeDefinition && (
<div className="space-y-3 rounded-xl border border-border-default bg-surface-hover/40 p-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Authoring Model
</p>
<p className="mt-1 text-sm text-content">
Canvas connections define upstream artifacts, inspector fields define local node variables.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg border border-border-default bg-surface px-3 py-2">
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">
Wired Inputs
</p>
{inputDescriptions.length > 0 ? (
<>
<p className="mt-1 text-xs text-content-muted">
{formatCountNoun(inputDescriptions.length, 'canvas socket', 'canvas sockets')} {inputDescriptions.length === 1 ? 'is' : 'are'} required. Each entry below maps to one input handle on the node.
</p>
<div className="mt-2 space-y-1.5">
{inputSocketDescriptors.map((descriptor, index) => (
<div
key={descriptor.id}
className="flex items-start gap-2 rounded-md border border-border-default bg-surface-hover/50 px-2 py-1"
>
<span
className={`inline-flex rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
descriptor.tone === 'alternative'
? 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300'
: 'bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300'
}`}
>
Socket {index + 1}
</span>
<span className="min-w-0 text-xs text-content">{descriptor.label}</span>
</div>
))}
</div>
</>
) : (
<p className="mt-1 text-xs text-content-muted">
This entry node does not declare additional upstream sockets.
</p>
)}
</div>
<div className="rounded-lg border border-border-default bg-surface px-3 py-2">
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">
Node Variables
</p>
{totalVariableCount > 0 ? (
<>
<p className="mt-1 text-xs text-content-muted">
{formatCountNoun(totalVariableCount, 'local variable', 'local variables')} {totalVariableCount === 1 ? 'is' : 'are'} edited in the inspector.
</p>
{staticVariableLabels.length > 0 && (
<p className="mt-2 text-xs text-content">
Static: {staticVariableLabels.join(', ')}
</p>
)}
{dynamicTemplateVariableLabels.length > 0 && (
<p className="mt-2 text-xs text-content">
Template-driven: {dynamicTemplateVariableLabels.join(', ')}
</p>
)}
</>
) : (
<p className="mt-1 text-xs text-content-muted">
This node has 0 local variables by design. Its behavior is driven entirely by connections and runtime context.
</p>
)}
</div>
</div>
{isResolveTemplateNode && (
<div className="rounded-lg border border-dashed border-border-default bg-surface px-3 py-2">
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">
Dynamic Template Variables
</p>
{dynamicTemplateVariableLabels.length > 0 ? (
<>
<p className="mt-1 text-xs text-content-muted">
The selected template adds {dynamicTemplateVariableLabels.length} extra variable
{dynamicTemplateVariableLabels.length === 1 ? '' : 's'} to this node.
</p>
<p className="mt-2 text-xs text-content">
{dynamicTemplateVariableLabels.join(', ')}
</p>
</>
) : (
<p className="mt-1 text-xs text-content-muted">
Select a template override to expose template-defined variables for this node.
</p>
)}
</div>
)}
</div>
)}
{isResolveTemplateNode && !selectedTemplate && renderTemplates.length > 0 && (
<div className="rounded-xl border border-dashed border-border-default bg-surface-hover/50 px-3 py-3">
<p className="text-sm text-content">Template-specific inputs appear after selecting a template override.</p>
<p className="mt-1 text-xs text-content-muted">
Leave the field empty to keep legacy category/output-type resolution.
</p>
</div>
)}
{selectedTemplate && (selectedTemplate.workflow_input_schema?.length ?? 0) > 0 && (
<div className="rounded-xl border border-border-default bg-surface-hover/50 px-3 py-3">
<p className="text-sm font-medium text-content">{selectedTemplate.name}</p>
<p className="mt-1 text-xs text-content-muted">
{selectedTemplate.workflow_input_schema.length} template input
{selectedTemplate.workflow_input_schema.length === 1 ? '' : 's'} exposed for this node.
</p>
</div>
)}
{Object.keys(fieldsBySection).length === 0 && (
<p className="text-sm text-content-muted">
This node currently has no configurable settings in the editor.
</p>
<div className="rounded-xl border border-dashed border-border-default bg-surface-hover/50 px-3 py-3">
<p className="text-sm text-content">This node has no editor settings.</p>
<p className="mt-1 text-xs text-content-muted">
Configure it by wiring its declared inputs on the canvas. Each required upstream input gets its own socket on the node itself.
</p>
</div>
)}
{Object.entries(fieldsBySection).map(([section, fields]) => (
@@ -167,6 +473,8 @@ export function WorkflowNodeInspector({
{fields.map(field => {
const rawValue = params[field.key]
const value = rawValue ?? field.default
const fieldId = `workflow-node-field-${field.key}`
const fieldOptions = field.options ?? []
const disableRenderOverrideField =
(step === 'blender_still' || step === 'blender_turntable') &&
!customRenderSettingsEnabled &&
@@ -175,18 +483,24 @@ export function WorkflowNodeInspector({
return (
<div key={field.key}>
<label className="text-sm text-content-secondary mb-1 block">
<label htmlFor={fieldId} 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)}
id={fieldId}
value={value == null ? '' : String(value)}
onChange={event => updateField(field, parseSelectValue(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 => (
{field.allow_blank !== false && (
<option value="">
{field.key === 'template_id_override' ? 'Automatic resolution' : 'None'}
</option>
)}
{fieldOptions.map(option => (
<option key={String(option.value)} value={String(option.value)}>
{option.label}
</option>
@@ -195,6 +509,7 @@ export function WorkflowNodeInspector({
)}
{field.type === 'number' && (
<input
id={fieldId}
type="number"
min={field.min ?? undefined}
max={field.max ?? undefined}
@@ -206,8 +521,12 @@ export function WorkflowNodeInspector({
/>
)}
{field.type === 'boolean' && (
<label className="flex items-center gap-2 rounded-lg border border-border-default px-3 py-2 text-sm text-content">
<label
htmlFor={fieldId}
className="flex items-center gap-2 rounded-lg border border-border-default px-3 py-2 text-sm text-content"
>
<input
id={fieldId}
type="checkbox"
checked={Boolean(value)}
onChange={event => updateField(field, event.target.checked)}
@@ -219,8 +538,10 @@ export function WorkflowNodeInspector({
)}
{field.type === 'text' && (
<input
id={fieldId}
type="text"
value={value == null ? '' : String(value)}
maxLength={field.max_length ?? undefined}
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"
@@ -37,6 +37,23 @@ export function WorkflowPreflightPanel({
</span>
</div>
<div className="flex flex-wrap gap-2 text-[11px] text-content-muted">
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
Mode: {preflight.execution_mode}
</span>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
Global issues: {preflight.issues.length}
</span>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
Node checks: {preflight.nodes.length}
</span>
{preflight.unsupported_node_ids.length > 0 && (
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
Unsupported nodes: {preflight.unsupported_node_ids.length}
</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>}
@@ -44,6 +61,13 @@ export function WorkflowPreflightPanel({
</div>
)}
{preflight.unsupported_node_ids.length > 0 && (
<div className="space-y-1 rounded-md border border-border-default bg-surface px-2.5 py-2 text-xs text-content-muted">
<p className="font-medium text-content">Unsupported Node IDs</p>
<p className="break-words">{preflight.unsupported_node_ids.join(', ')}</p>
</div>
)}
{preflight.issues.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
@@ -57,6 +81,11 @@ export function WorkflowPreflightPanel({
{issue.severity}
</span>
</div>
<div className="mt-1 flex flex-wrap gap-2 text-[11px] text-content-muted">
<span>Code: {issue.code}</span>
{issue.step && <span>Step: {issue.step}</span>}
{issue.node_id && <span>Node: {issue.node_id}</span>}
</div>
</div>
))}
</div>
@@ -77,6 +106,14 @@ export function WorkflowPreflightPanel({
{node.status}
</span>
</div>
<div className="mt-1 flex flex-wrap gap-2 text-[11px] text-content-muted">
<span className="rounded-full border border-border-default bg-surface-hover/70 px-2 py-0.5">
Runtime: {node.execution_kind}
</span>
<span className="rounded-full border border-border-default bg-surface-hover/70 px-2 py-0.5">
Supported: {node.supported ? 'yes' : 'no'}
</span>
</div>
{node.issues.length > 0 && (
<div className="mt-2 space-y-1">
{node.issues.map(issue => (
@@ -0,0 +1,83 @@
import { Milestone, Wand2 } from 'lucide-react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import { getWorkflowAuthoringPlan } from './workflowAuthoringGuidance'
import type { WorkflowReferenceBundleId } from './workflowReferenceBundles'
type WorkflowReferenceBundlePanelProps = {
definitions: WorkflowNodeDefinition[]
graphFamily: WorkflowGraphFamily
activeSteps: string[]
onInsertReferencePath?: (bundleId: WorkflowReferenceBundleId) => void
}
export function WorkflowReferenceBundlePanel({
definitions,
graphFamily,
activeSteps,
onInsertReferencePath,
}: WorkflowReferenceBundlePanelProps) {
const { referenceBundles } = getWorkflowAuthoringPlan(definitions, graphFamily, activeSteps)
if (referenceBundles.length === 0) return null
return (
<div className="space-y-2 rounded-2xl border border-border-default bg-surface-hover/30 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<Milestone size={14} className="text-accent" />
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Reference Paths
</p>
</div>
<p className="mt-1 text-xs text-content-muted">
Insert complete canonical production routes when you want a full non-legacy baseline instead of assembling modules piecemeal.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{referenceBundles.length} paths
</span>
</div>
<div className="space-y-2">
{referenceBundles.map(bundle => (
<div
key={bundle.id}
className="rounded-2xl border border-border-default bg-surface px-3 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold text-content">{bundle.label}</p>
<span className="rounded-full bg-surface-hover px-2 py-0.5 text-[11px] font-medium text-content-secondary">
{bundle.stage}
</span>
<span className="rounded-full bg-surface-hover px-2 py-0.5 text-[11px] text-content-muted">
{bundle.presentCount}/{bundle.totalCount} present
</span>
</div>
<p className="text-xs text-content-muted">{bundle.description}</p>
<p className="text-[11px] text-content-muted">
{bundle.stepIds.join(' -> ')}
</p>
</div>
{onInsertReferencePath ? (
<button
type="button"
onClick={() => onInsertReferencePath(bundle.id)}
aria-label={`Insert ${bundle.label}`}
className="inline-flex items-center gap-1 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-accent-hover"
>
<Wand2 size={12} />
Insert
</button>
) : null}
</div>
</div>
))}
</div>
</div>
)
}
@@ -8,6 +8,27 @@ import {
getRunStatusClassName,
} from './workflowRunPresentation'
function formatDuration(durationS: number | null) {
if (durationS == null) return null
if (durationS < 1) return `${Math.round(durationS * 1000)} ms`
return `${durationS.toFixed(durationS >= 10 ? 0 : 1)} s`
}
function formatOutputPreview(output: Record<string, unknown> | null) {
if (!output) return null
return JSON.stringify(output, null, 2)
}
function getRolloutGateClassName(verdict: WorkflowRunComparison['rollout_gate_verdict']) {
if (verdict === 'pass') return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
if (verdict === 'warn') 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'
}
function formatRolloutStatus(status: WorkflowRunComparison['workflow_rollout_status']) {
return status === 'ready_for_rollout' ? 'Ready For Rollout' : 'Hold Legacy Authoritative'
}
interface WorkflowRunsPanelProps {
runs: WorkflowRun[]
selectedRunId: string | null
@@ -82,6 +103,29 @@ export function WorkflowRunsPanel({
</span>
</div>
<div className="grid gap-2 text-xs text-content-muted sm:grid-cols-2">
<div className="rounded-md border border-border-default bg-surface px-2.5 py-2">
<p className="font-medium text-content">Execution Mode</p>
<p>{EXECUTION_MODE_LABELS[selectedRun.execution_mode]}</p>
</div>
<div className="rounded-md border border-border-default bg-surface px-2.5 py-2">
<p className="font-medium text-content">Completed</p>
<p>{formatDateTime(selectedRun.completed_at)}</p>
</div>
{selectedRun.order_line_id && (
<div className="rounded-md border border-border-default bg-surface px-2.5 py-2">
<p className="font-medium text-content">Order Line</p>
<p className="break-all">{selectedRun.order_line_id}</p>
</div>
)}
{selectedRun.celery_task_id && (
<div className="rounded-md border border-border-default bg-surface px-2.5 py-2">
<p className="font-medium text-content">Celery Task</p>
<p className="break-all">{selectedRun.celery_task_id}</p>
</div>
)}
</div>
{selectedRun.error_message && (
<p className="text-xs text-red-600 dark:text-red-300">{selectedRun.error_message}</p>
)}
@@ -90,6 +134,11 @@ export function WorkflowRunsPanel({
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Node Results
</p>
{selectedRun.node_results.length === 0 && (
<p className="rounded-md border border-dashed border-border-default bg-surface px-2.5 py-2 text-xs text-content-muted">
No node results recorded yet for this run.
</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">
@@ -98,9 +147,29 @@ export function WorkflowRunsPanel({
{result.status}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-content-muted">
{formatDuration(result.duration_s) && (
<span className="rounded-full border border-border-default bg-surface-hover/70 px-2 py-0.5">
Duration: {formatDuration(result.duration_s)}
</span>
)}
<span className="rounded-full border border-border-default bg-surface-hover/70 px-2 py-0.5">
Recorded {formatDateTime(result.created_at)}
</span>
</div>
{result.log && (
<p className="mt-1 line-clamp-3 text-xs text-content-muted">{result.log}</p>
)}
{result.output && (
<details className="mt-2 rounded-md border border-border-default bg-surface-hover/40 px-2 py-2">
<summary className="cursor-pointer text-xs font-medium text-content">
Node output
</summary>
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-words text-[11px] text-content-muted">
{formatOutputPreview(result.output)}
</pre>
</details>
)}
</div>
))}
</div>
@@ -117,6 +186,26 @@ export function WorkflowRunsPanel({
<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>
<div className="flex flex-wrap gap-2">
<span className={`rounded-full px-2 py-0.5 font-medium ${getRolloutGateClassName(comparison.rollout_gate_verdict)}`}>
Rollout Gate: {comparison.rollout_gate_verdict}
</span>
<span className={`rounded-full px-2 py-0.5 font-medium ${comparison.workflow_rollout_ready ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : 'bg-slate-200 text-slate-700 dark:bg-slate-800 dark:text-slate-200'}`}>
{formatRolloutStatus(comparison.workflow_rollout_status)}
</span>
</div>
<div className="flex flex-wrap gap-2">
{comparison.exact_match != null && (
<span className={`rounded-full px-2 py-0.5 font-medium ${comparison.exact_match ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'}`}>
Exact Match: {comparison.exact_match ? 'yes' : 'no'}
</span>
)}
{comparison.dimensions_match != null && (
<span className={`rounded-full px-2 py-0.5 font-medium ${comparison.dimensions_match ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'}`}>
Dimensions: {comparison.dimensions_match ? 'match' : 'mismatch'}
</span>
)}
</div>
<p>
Authoritative: {comparison.authoritative_output.image_width ?? '?'} x {comparison.authoritative_output.image_height ?? '?'}
</p>
@@ -126,6 +215,16 @@ export function WorkflowRunsPanel({
{comparison.mean_pixel_delta != null && (
<p>Mean Pixel Delta: {comparison.mean_pixel_delta.toFixed(6)}</p>
)}
{comparison.rollout_reasons.length > 0 && (
<div className="rounded-md border border-border-default bg-surface-hover/40 px-2 py-2">
<p className="font-medium text-content">Operator Decision</p>
<ul className="mt-1 space-y-1">
{comparison.rollout_reasons.map(reason => (
<li key={reason}>{reason}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
@@ -0,0 +1,93 @@
import { CheckCircle2, CircleDashed, Plus } from 'lucide-react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import { getWorkflowAuthoringPlan } from './workflowAuthoringGuidance'
type WorkflowStarterPathPanelProps = {
definitions: WorkflowNodeDefinition[]
graphFamily: WorkflowGraphFamily
activeSteps: string[]
onSelectStep?: (step: string) => void
}
export function WorkflowStarterPathPanel({
definitions,
graphFamily,
activeSteps,
onSelectStep,
}: WorkflowStarterPathPanelProps) {
const plan = getWorkflowAuthoringPlan(definitions, graphFamily, activeSteps)
if (graphFamily === 'mixed' || plan.starterItems.length === 0) return null
return (
<div className="space-y-3 rounded-2xl border border-border-default bg-surface-hover/30 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Starter Path
</p>
<p className="mt-1 text-sm font-medium text-content">{plan.starterTitle}</p>
<p className="mt-1 text-xs text-content-muted">{plan.starterDescription}</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{plan.starterCompletedCount}/{plan.starterItems.length} present
</span>
</div>
<div className="space-y-2">
{plan.starterItems.map(item => {
const { definition, index, isPresent } = item
return (
<div
key={definition.step}
className="flex items-center justify-between gap-2 rounded-xl border border-border-default bg-surface px-3 py-2"
>
<div className="min-w-0 flex items-start gap-2">
<span className="mt-0.5 text-content-secondary">
{isPresent ? (
<CheckCircle2 size={15} className="text-emerald-600" />
) : (
<CircleDashed size={15} className="text-amber-600" />
)}
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<span className="rounded-full border border-border-default bg-surface-hover/70 px-1.5 py-0.5 text-[10px] font-medium text-content-secondary">
{index}
</span>
<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 text-content-muted ring-1 ring-border-default">
{definition.category}
</span>
</div>
<p className="mt-0.5 line-clamp-2 text-xs text-content-muted">{definition.description}</p>
</div>
</div>
{!isPresent && onSelectStep && (
<button
type="button"
onClick={() => onSelectStep(definition.step)}
className="shrink-0 rounded-lg border border-border-default px-2 py-1 text-xs font-medium text-content hover:bg-surface-hover"
>
<span className="inline-flex items-center gap-1">
<Plus size={12} />
Add
</span>
</button>
)}
{isPresent && (
<span className="shrink-0 rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-700">
Present
</span>
)}
</div>
)
})}
</div>
</div>
)
}
@@ -23,7 +23,7 @@ export function WorkflowUtilityRail<T extends string>({
children,
}: WorkflowUtilityRailProps<T>) {
return (
<div className="flex w-[22rem] flex-col border-l border-border-default bg-surface">
<div className="flex w-full flex-col border-t border-border-default bg-surface xl:w-[22rem] xl:flex-shrink-0 xl:border-l xl:border-t-0">
<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">
@@ -67,7 +67,7 @@ export function WorkflowUtilityRail<T extends string>({
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">{children}</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4 xl:max-h-none">{children}</div>
</div>
)
}
@@ -1,10 +1,21 @@
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 {
addEdge,
applyNodeChanges,
useEdgesState,
useNodesState,
type Connection,
type Edge,
type Node,
type NodeChange,
type ReactFlowInstance,
} from '@xyflow/react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import {
dispatchWorkflowDraft,
getWorkflowOrderLineContexts,
getNodeDefinitions,
getWorkflowRunComparison,
getWorkflowRuns,
@@ -13,17 +24,25 @@ import {
type WorkflowDefinition,
type WorkflowExecutionMode,
type WorkflowNodeDefinition,
type WorkflowOrderLineContextGroup as WorkflowOrderLineContextGroupApi,
type WorkflowOrderLineContextOption as WorkflowOrderLineContextOptionApi,
type WorkflowParams,
type WorkflowPreflightResponse,
} from '../../api/workflows'
import {
applyAutoLayout,
buildWorkflowCanvasNodeData,
buildCurrentWorkflowConfig,
deriveWorkflowAuthoringFamily,
findOpenNodePosition,
graphNeedsAutoLayout,
inferNodeLabel,
inferNodeType,
inferStepFromNodeType,
normalizeWorkflowParams,
resolveParamsForStepChange,
resolveNodeCollisions,
shouldAutoLayoutAfterInsert,
type WorkflowCanvasNodeData,
validateWorkflowDraft,
workflowToGraph,
@@ -35,6 +54,11 @@ import {
GRAPH_FAMILY_LABELS,
isDefinitionAllowedForGraphFamily,
} from './workflowNodeLibrary'
import { createWorkflowModuleBundleInsertion, type WorkflowModuleBundleId } from './workflowModuleBundles'
import {
createWorkflowReferenceBundleInsertion,
type WorkflowReferenceBundleId,
} from './workflowReferenceBundles'
import type { WorkflowUtilityTab } from './WorkflowCanvasUtilitySidebar'
export type NodeMenuAnchor = {
@@ -43,20 +67,30 @@ export type NodeMenuAnchor = {
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,
}
export type WorkflowOrderLineContextOption = {
value: string
label: string
meta: string
}
export type WorkflowOrderLineContextGroup = {
orderId: string
orderLabel: string
options: WorkflowOrderLineContextOption[]
}
function normalizeOrderLineContextGroups(
groups: WorkflowOrderLineContextGroupApi[],
): WorkflowOrderLineContextGroup[] {
return groups.map(group => ({
orderId: group.order_id,
orderLabel: group.order_label,
options: group.options.map((option: WorkflowOrderLineContextOptionApi) => ({
value: option.value,
label: option.label,
meta: option.meta,
})),
}))
}
type UseWorkflowCanvasControllerArgs = {
@@ -73,14 +107,17 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
})
const nodeDefinitions = nodeDefinitionsData?.definitions ?? []
const nodeDefinitionsByStep = Object.fromEntries(nodeDefinitions.map(definition => [definition.step, definition]))
const definitionsLoaded = nodeDefinitions.length > 0
const { nodes: initNodes, edges: initEdges } = workflowToGraph(workflow.config, nodeDefinitionsByStep)
const [nodes, setNodes, onNodesChange] = useNodesState(initNodes)
const [nodes, setNodes] = useNodesState(initNodes)
const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges)
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const [selectedEdgeIds, setSelectedEdgeIds] = useState<string[]>([])
const [selectedRunId, setSelectedRunId] = useState<string | null>(null)
const [dispatchContextId, setDispatchContextId] = useState('')
const [preflightResult, setPreflightResult] = useState<WorkflowPreflightResponse | null>(null)
const [lastSuccessfulPreflightFingerprint, setLastSuccessfulPreflightFingerprint] = useState<string | 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')
@@ -88,18 +125,66 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
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 authoringFamily = useMemo(
() => deriveWorkflowAuthoringFamily(workflow, nodes, nodeDefinitionsByStep, definitionsLoaded),
[definitionsLoaded, nodeDefinitionsByStep, nodes, workflow],
)
const currentWorkflowConfig = useMemo(
() => buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode, authoringFamily),
[authoringFamily, edges, executionMode, nodes, workflow],
)
const graphFamily = useMemo(
() =>
inferWorkflowFamily(
buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode),
nodeDefinitionsByStep,
),
[edges, executionMode, nodeDefinitionsByStep, nodes, workflow],
() => inferWorkflowFamily(currentWorkflowConfig, nodeDefinitionsByStep),
[currentWorkflowConfig, nodeDefinitionsByStep],
)
const isOrderLineGraph = graphFamily === 'order_line'
const { data: workflowOrderLineContexts = [], isFetching: isOrderLineContextsLoading } = useQuery({
queryKey: ['workflow-order-line-contexts'],
queryFn: () => getWorkflowOrderLineContexts(50),
enabled: isOrderLineGraph,
staleTime: 30_000,
})
const orderLineContextGroups = useMemo<WorkflowOrderLineContextGroup[]>(
() => normalizeOrderLineContextGroups(workflowOrderLineContexts).filter(group => group.options.length > 0),
[workflowOrderLineContexts],
)
const selectedOrderLineContext = useMemo(
() =>
orderLineContextGroups
.flatMap(group => group.options)
.find(option => option.value === dispatchContextId) ?? null,
[dispatchContextId, orderLineContextGroups],
)
const dispatchContextLabel = useMemo(() => {
if (isOrderLineGraph) return 'Order Line'
if (graphFamily === 'cad_file') return 'CAD File'
return 'Context'
}, [graphFamily, isOrderLineGraph])
const dispatchContextSummary = useMemo(() => {
if (isOrderLineGraph) return selectedOrderLineContext?.label ?? null
const trimmed = dispatchContextId.trim()
if (graphFamily === 'cad_file' && trimmed.length > 0) return 'CAD File'
return trimmed.length > 0 ? trimmed : null
}, [dispatchContextId, graphFamily, isOrderLineGraph, selectedOrderLineContext])
const dispatchContextMeta = useMemo(() => {
if (isOrderLineGraph) return selectedOrderLineContext?.meta ?? null
if (graphFamily !== 'cad_file') return null
const trimmed = dispatchContextId.trim()
if (!trimmed) return null
if (preflightResult?.context_id === trimmed && preflightResult.resolved_cad_file_id) {
return `${preflightResult.resolved_cad_file_id} · validated`
}
return trimmed
}, [dispatchContextId, graphFamily, isOrderLineGraph, preflightResult, selectedOrderLineContext])
const currentDispatchFingerprint = useMemo(
() => JSON.stringify({ contextId: dispatchContextId.trim(), config: currentWorkflowConfig }),
[currentWorkflowConfig, dispatchContextId],
)
const hasFreshSuccessfulPreflight =
preflightResult?.graph_dispatch_allowed === true &&
lastSuccessfulPreflightFingerprint === currentDispatchFingerprint
const { data: workflowRuns = [] } = useQuery({
queryKey: ['workflow-runs', workflow.id],
@@ -140,32 +225,47 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
context_id: contextId,
config,
}),
onSuccess: result => {
onSuccess: (result, variables) => {
setPreflightResult(result)
if (result.graph_dispatch_allowed) {
setLastSuccessfulPreflightFingerprint(JSON.stringify({ contextId: variables.contextId, config: variables.config }))
toast.success(result.summary)
} else {
setLastSuccessfulPreflightFingerprint(null)
toast.error(result.summary)
}
},
onError: (error: any) => {
setPreflightResult(null)
setLastSuccessfulPreflightFingerprint(null)
toast.error(error?.response?.data?.detail || 'Failed to preflight workflow')
},
})
useEffect(() => {
const graph = workflowToGraph(workflow.config, nodeDefinitionsByStep)
setNodes(graph.nodes)
const nextNodes = graphNeedsAutoLayout(graph.nodes) ? applyAutoLayout(graph.nodes, graph.edges) : graph.nodes
setNodes(nextNodes)
setEdges(graph.edges)
setSelectedNodeId(null)
setSelectedEdgeIds([])
setSelectedRunId(null)
setNodeMenuAnchor(null)
setPreflightResult(null)
setLastSuccessfulPreflightFingerprint(null)
setExecutionMode(workflow.config.ui?.execution_mode ?? 'legacy')
setActiveUtilityTab('library')
}, [nodeDefinitionsData, setEdges, setNodes, workflow.config])
useEffect(() => {
if (!isOrderLineGraph) return
if (dispatchContextId.trim()) return
const firstOption = orderLineContextGroups[0]?.options[0]
if (firstOption) {
setDispatchContextId(firstOption.value)
}
}, [dispatchContextId, isOrderLineGraph, orderLineContextGroups])
useEffect(() => {
if (!selectedRunId && workflowRuns.length > 0) {
setSelectedRunId(workflowRuns[0].id)
@@ -177,18 +277,110 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
}, [selectedRunId, workflowRuns])
const onConnect = useCallback(
(connection: Connection) => setEdges(currentEdges => addEdge(connection, currentEdges)),
[setEdges],
(connection: Connection) => {
if (!connection.source || !connection.target) return
const sourceNode = nodes.find(node => node.id === connection.source)
const targetNode = nodes.find(node => node.id === connection.target)
const sourceData = sourceNode?.data as WorkflowCanvasNodeData | undefined
const targetData = targetNode?.data as WorkflowCanvasNodeData | undefined
const sourcePorts = sourceData?.outputPorts ?? []
const targetPorts = targetData?.inputPorts ?? []
if (sourcePorts.length === 0) {
toast.error('Selected source node does not expose any downstream outputs.')
return
}
if (targetPorts.length === 0) {
toast.error('Selected target node does not declare any upstream inputs.')
return
}
const requestedTargetPort = connection.targetHandle
? targetPorts.find(port => port.id === connection.targetHandle)
: undefined
const requestedSourcePort = connection.sourceHandle
? sourcePorts.find(port => port.id === connection.sourceHandle)
: undefined
const matchingTargetPort =
requestedTargetPort &&
sourcePorts.some(sourcePort =>
requestedTargetPort.roles.some(role => sourcePort.roles.includes(role)),
)
? requestedTargetPort
: targetPorts.find(port =>
sourcePorts.some(sourcePort => port.roles.some(role => sourcePort.roles.includes(role))),
)
if (!matchingTargetPort) {
toast.error('These nodes do not share a compatible input/output contract.')
return
}
const matchingSourcePort =
requestedSourcePort &&
matchingTargetPort.roles.some(role => requestedSourcePort.roles.includes(role))
? requestedSourcePort
: sourcePorts.find(port => matchingTargetPort.roles.some(role => port.roles.includes(role)))
if (!matchingSourcePort) {
toast.error('The selected source handle does not satisfy the target input contract.')
return
}
setEdges(currentEdges => {
const duplicateEdge = currentEdges.some(
edge => edge.source === connection.source && edge.target === connection.target,
)
if (duplicateEdge) {
toast.error('A connection between these nodes already exists.')
return currentEdges
}
return addEdge(
{
...connection,
sourceHandle: matchingSourcePort.id,
targetHandle: matchingTargetPort.id,
},
currentEdges,
)
})
},
[nodes, setEdges],
)
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
setNodes(currentNodes => {
const nextNodes = applyNodeChanges(changes, currentNodes)
const settledNodeIds = changes
.filter((change): change is Extract<NodeChange, { type: 'position' }> => change.type === 'position')
.filter(change => change.dragging !== true)
.map(change => change.id)
if (settledNodeIds.length === 0) {
return nextNodes
}
return resolveNodeCollisions(nextNodes, settledNodeIds)
})
},
[setNodes],
)
const onNodeClick = useCallback((_: ReactMouseEvent, node: Node) => {
setNodeMenuAnchor(null)
setSelectedEdgeIds([])
setSelectedNodeId(node.id)
setActiveUtilityTab('inspector')
}, [])
const onEdgeClick = useCallback((_: ReactMouseEvent, edge: Edge) => {
setNodeMenuAnchor(null)
setSelectedEdgeIds([edge.id])
setSelectedNodeId(null)
setEdges(currentEdges =>
currentEdges.map(currentEdge => ({
@@ -200,6 +392,7 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
const onPaneClick = useCallback(() => {
setNodeMenuAnchor(null)
setSelectedEdgeIds([])
setSelectedNodeId(null)
setEdges(currentEdges =>
currentEdges.map(edge => ({
@@ -226,8 +419,8 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
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.`)
if (definition && !isDefinitionAllowedForGraphFamily(definition, authoringFamily)) {
toast.error(`${definition.label} does not belong to the ${GRAPH_FAMILY_LABELS[authoringFamily]} authoring family.`)
return
}
@@ -235,12 +428,12 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
currentNodes.map(node => {
if (node.id !== selectedNodeId) return node
const currentData = (node.data as WorkflowCanvasNodeData | undefined) ?? buildNodeData(stepName)
const currentData = (node.data as WorkflowCanvasNodeData | undefined) ?? buildWorkflowCanvasNodeData(stepName)
return {
...node,
type: definition?.node_type ?? inferNodeType(stepName),
data: {
...buildNodeData(
...buildWorkflowCanvasNodeData(
stepName || inferStepFromNodeType(node.type),
resolveParamsForStepChange(definition, currentData.params),
definition,
@@ -251,7 +444,7 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
}),
)
},
[graphFamily, nodeDefinitionsByStep, selectedNodeId, setNodes],
[authoringFamily, nodeDefinitionsByStep, selectedNodeId, setNodes],
)
const openNodeMenu = useCallback(
@@ -287,27 +480,91 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
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.`)
if (definition && !isDefinitionAllowedForGraphFamily(definition, authoringFamily)) {
toast.error(`${definition.label} cannot be added to a ${GRAPH_FAMILY_LABELS[authoringFamily]} 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 fallbackX = nodes.length > 0 ? Math.max(...nodes.map(node => node.position.x)) + 312 : 120
const fallbackY = nodes.length > 0 ? Math.max(...nodes.map(node => node.position.y)) + 140 : 120
const position = findOpenNodePosition(nodes, preferredPosition ?? { x: fallbackX, y: fallbackY })
const newNode: Node = {
id: `${step}_${Date.now()}`,
type,
position: preferredPosition ?? { x: fallbackX, y: fallbackY },
data: buildNodeData(step, definition?.defaults ?? {}, definition),
position,
data: buildWorkflowCanvasNodeData(step, definition?.defaults ?? {}, definition),
}
const nextNodes = [...nodes, newNode]
const shouldAutoLayout = shouldAutoLayoutAfterInsert(nextNodes, newNode, preferredPosition ?? null)
const laidOutNodes = shouldAutoLayout ? applyAutoLayout(nextNodes, edges) : nextNodes
setNodes(currentNodes => [...currentNodes, newNode])
setNodes(laidOutNodes)
setSelectedNodeId(newNode.id)
setNodeMenuAnchor(null)
setActiveUtilityTab('inspector')
if (shouldAutoLayout) {
window.requestAnimationFrame(() => {
reactFlowInstance?.fitView({ padding: 0.2, duration: 220 })
})
}
},
[graphFamily, nodeDefinitionsByStep, nodes, setNodes],
[authoringFamily, edges, nodeDefinitionsByStep, nodes, reactFlowInstance, setNodes],
)
const insertModuleBundle = useCallback(
(bundleId: WorkflowModuleBundleId, preferredPosition?: { x: number; y: number }) => {
const insertion = createWorkflowModuleBundleInsertion({
bundleId,
graphFamily: authoringFamily,
nodeDefinitionsByStep,
existingNodes: nodes,
preferredPosition,
})
if (!insertion.ok) {
toast.error(insertion.reason)
return
}
const combinedNodes = [...nodes, ...insertion.nodes]
const combinedEdges = [...edges, ...insertion.edges]
setNodes(graphNeedsAutoLayout(combinedNodes) ? applyAutoLayout(combinedNodes, combinedEdges) : combinedNodes)
setEdges(combinedEdges)
setSelectedNodeId(insertion.nodes[0]?.id ?? null)
setNodeMenuAnchor(null)
setActiveUtilityTab('inspector')
toast.success(`${insertion.bundle.label} inserted`)
},
[authoringFamily, edges, nodeDefinitionsByStep, nodes, setEdges, setNodes],
)
const insertReferenceBundle = useCallback(
(bundleId: WorkflowReferenceBundleId, preferredPosition?: { x: number; y: number }) => {
const insertion = createWorkflowReferenceBundleInsertion({
bundleId,
graphFamily: authoringFamily,
nodeDefinitionsByStep,
existingNodes: nodes,
preferredPosition,
})
if (!insertion.ok) {
toast.error(insertion.reason)
return
}
const combinedNodes = [...nodes, ...insertion.nodes]
const combinedEdges = [...edges, ...insertion.edges]
setNodes(graphNeedsAutoLayout(combinedNodes) ? applyAutoLayout(combinedNodes, combinedEdges) : combinedNodes)
setEdges(combinedEdges)
setSelectedNodeId(insertion.nodes[0]?.id ?? null)
setNodeMenuAnchor(null)
setActiveUtilityTab('inspector')
toast.success(`${insertion.bundle.label} inserted`)
},
[authoringFamily, edges, nodeDefinitionsByStep, nodes, setEdges, setNodes],
)
const handleOpenToolbarNodeMenu = useCallback(() => {
@@ -327,6 +584,7 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
const deleteEdgesById = useCallback((edgeIds: string[]) => {
if (edgeIds.length === 0) return
setEdges(currentEdges => currentEdges.filter(edge => !edgeIds.includes(edge.id)))
setSelectedEdgeIds(currentIds => currentIds.filter(edgeId => !edgeIds.includes(edgeId)))
setSelectedNodeId(null)
setNodeMenuAnchor(null)
toast.success(edgeIds.length === 1 ? 'Connection deleted' : `${edgeIds.length} connections deleted`)
@@ -348,6 +606,27 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
deleteEdgesById([edge.id])
}, [deleteEdgesById])
const handleSelectionChange = useCallback(
({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => {
if (selectedNodes.length > 0) {
setSelectedNodeId(selectedNodes[0].id)
setSelectedEdgeIds(selectedEdges.map(edge => edge.id))
setActiveUtilityTab('inspector')
return
}
if (selectedEdges.length > 0) {
setSelectedNodeId(null)
setSelectedEdgeIds(selectedEdges.map(edge => edge.id))
return
}
setSelectedNodeId(null)
setSelectedEdgeIds([])
},
[],
)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null
@@ -383,8 +662,8 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
toast.error('Resolve workflow validation errors before saving.')
return
}
onSave(buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode))
}, [edges, executionMode, nodes, onSave, validation.errors.length, workflow])
onSave(currentWorkflowConfig)
}, [currentWorkflowConfig, onSave, validation.errors.length])
const handleDispatch = useCallback(() => {
if (!dispatchContextId.trim()) {
@@ -395,11 +674,15 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
toast.error('Resolve workflow validation errors before dispatching.')
return
}
if (!hasFreshSuccessfulPreflight) {
toast.error('Run a fresh Dry Run for the current graph and context before dispatching.')
return
}
dispatchMutation.mutate({
contextId: dispatchContextId.trim(),
config: buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode),
config: currentWorkflowConfig,
})
}, [dispatchContextId, dispatchMutation, edges, executionMode, nodes, validation.errors.length, workflow])
}, [currentWorkflowConfig, dispatchContextId, dispatchMutation, hasFreshSuccessfulPreflight, validation.errors.length])
const handlePreflight = useCallback(() => {
if (!dispatchContextId.trim()) {
@@ -412,14 +695,21 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
}
preflightMutation.mutate({
contextId: dispatchContextId.trim(),
config: buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode),
config: currentWorkflowConfig,
})
}, [dispatchContextId, edges, executionMode, nodes, preflightMutation, validation.errors.length, workflow])
}, [currentWorkflowConfig, dispatchContextId, preflightMutation, validation.errors.length])
const selectedNode = useMemo(
() => nodes.find(node => node.id === selectedNodeId),
[nodes, selectedNodeId],
)
const preflightState = useMemo<'ready' | 'required' | 'stale' | 'blocked'>(() => {
if (!dispatchContextId.trim()) return 'required'
if (preflightResult && !preflightResult.graph_dispatch_allowed) return 'blocked'
if (hasFreshSuccessfulPreflight) return 'ready'
if (preflightResult) return 'stale'
return 'required'
}, [dispatchContextId, hasFreshSuccessfulPreflight, preflightResult])
return {
reactFlowWrapper,
@@ -439,7 +729,15 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
preflightMutation,
dispatchContextId,
setDispatchContextId,
isOrderLineGraph,
isOrderLineContextsLoading,
orderLineContextGroups,
dispatchContextLabel,
dispatchContextSummary,
dispatchContextMeta,
preflightResult,
preflightState,
hasFreshSuccessfulPreflight,
executionMode,
setExecutionMode,
nodeMenuAnchor,
@@ -447,6 +745,7 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
activeUtilityTab,
setActiveUtilityTab,
validation,
authoringFamily,
graphFamily,
onConnect,
onNodeClick,
@@ -457,11 +756,14 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
handlePaneContextMenu,
handleNodeContextMenu,
insertNode,
insertModuleBundle,
insertReferenceBundle,
handleOpenToolbarNodeMenu,
handleAutoLayout,
handleDeleteSelectedEdges,
onEdgeContextMenu,
onEdgeDoubleClick,
handleSelectionChange,
handleSave,
handleDispatch,
handlePreflight,
@@ -0,0 +1,79 @@
import { Library, Sparkles, type LucideIcon } from 'lucide-react'
import type { WorkflowModuleBundleId } from './workflowModuleBundles'
import type { WorkflowReferenceBundleId } from './workflowReferenceBundles'
import type { WorkflowAuthoringSurfaceModel } from './workflowAuthoringSurface'
export type WorkflowAuthoringPosition = {
x: number
y: number
}
export type WorkflowAuthoringActions = {
openNodeMenu?: () => void
insertNode?: (step: string, preferredPosition?: WorkflowAuthoringPosition) => void
insertModule?: (bundleId: WorkflowModuleBundleId, preferredPosition?: WorkflowAuthoringPosition) => void
insertReferencePath?: (bundleId: WorkflowReferenceBundleId, preferredPosition?: WorkflowAuthoringPosition) => void
}
export type WorkflowAuthoringInsertHandlers = {
onSelectStep?: (step: string) => void
onInsertModule?: (bundleId: WorkflowModuleBundleId) => void
onInsertReferencePath?: (bundleId: WorkflowReferenceBundleId) => void
}
type BindWorkflowAuthoringInsertActionsOptions = {
preferredPosition?: WorkflowAuthoringPosition
onAfterInsert?: () => void
}
export type WorkflowAuthoringEntryAction = {
label: string
title: string
helper: string
icon: LucideIcon
}
function wrapInsertAction<TArg>(
action: ((arg: TArg, preferredPosition?: WorkflowAuthoringPosition) => void) | undefined,
preferredPosition?: WorkflowAuthoringPosition,
onAfterInsert?: () => void,
) {
if (!action) return undefined
return (arg: TArg) => {
action(arg, preferredPosition)
onAfterInsert?.()
}
}
export function bindWorkflowAuthoringInsertActions(
actions: WorkflowAuthoringActions | undefined,
{ preferredPosition, onAfterInsert }: BindWorkflowAuthoringInsertActionsOptions = {},
): WorkflowAuthoringInsertHandlers {
return {
onSelectStep: wrapInsertAction(actions?.insertNode, preferredPosition, onAfterInsert),
onInsertModule: wrapInsertAction(actions?.insertModule, preferredPosition, onAfterInsert),
onInsertReferencePath: wrapInsertAction(actions?.insertReferencePath, preferredPosition, onAfterInsert),
}
}
export function getWorkflowAuthoringEntryAction(
surfaceModel: WorkflowAuthoringSurfaceModel,
): WorkflowAuthoringEntryAction {
if (surfaceModel.defaultSection === 'overview') {
return {
label: 'Author',
title: 'Open guided workflow authoring browser',
helper: 'Open reference paths, production modules, starter steps, and raw nodes.',
icon: Sparkles,
}
}
return {
label: 'Node',
title: 'Open raw node browser',
helper: 'Open the searchable node catalog directly on the canvas.',
icon: Library,
}
}
@@ -0,0 +1,296 @@
import type { WorkflowNodeDefinition } from '../../api/workflows'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import { getWorkflowModuleBundles, type WorkflowModuleBundleDefinition, type WorkflowModuleBundleId } from './workflowModuleBundles'
import {
getWorkflowReferenceBundles,
type WorkflowReferenceBundleDefinition,
type WorkflowReferenceBundleId,
} from './workflowReferenceBundles'
export const STARTER_NODE_STEP_ORDER: Record<WorkflowGraphFamily, string[]> = {
cad_file: [
'resolve_step_path',
'occ_object_extract',
'occ_glb_export',
'stl_cache_generate',
'thumbnail_save',
],
order_line: [
'order_line_setup',
'resolve_template',
'auto_populate_materials',
'glb_bbox',
'material_map_resolve',
'blender_still',
'output_save',
'notify',
],
mixed: [],
}
export const STARTER_PATH_TITLES: Record<WorkflowGraphFamily, string> = {
cad_file: 'CAD intake assembly',
order_line: 'Still-render assembly',
mixed: 'Starter path',
}
export const STARTER_PATH_DESCRIPTIONS: Record<WorkflowGraphFamily, string> = {
cad_file: 'Use this module sequence to take a CAD file through extraction, preview generation, and published output.',
order_line: 'Use this module sequence to keep the non-legacy still graph parallel to the legacy render path.',
mixed: 'Reference sequence for assembling a workflow graph.',
}
export type WorkflowAuthoringPriority = {
title: string
description: string
}
export type WorkflowAuthoringFlowStep = {
index: number
title: string
description: string
}
export type WorkflowAuthoringBundleStatus<TBundle> = TBundle & {
presentCount: number
totalCount: number
}
export type WorkflowStarterPathItem = {
index: number
definition: WorkflowNodeDefinition
isPresent: boolean
}
export type WorkflowAuthoringStageProgress = {
id: string
title: string
description: string
present: number
total: number
actionLabel: string
actionKind: 'reference' | 'module' | 'step'
bundleId?: WorkflowReferenceBundleId | WorkflowModuleBundleId
step?: string
}
export type WorkflowAuthoringPlan = {
title: string
description: string
priorities: WorkflowAuthoringPriority[]
authoringFlow: WorkflowAuthoringFlowStep[]
referenceBundles: WorkflowAuthoringBundleStatus<WorkflowReferenceBundleDefinition>[]
moduleBundles: WorkflowAuthoringBundleStatus<WorkflowModuleBundleDefinition>[]
starterTitle: string
starterDescription: string
starterItems: WorkflowStarterPathItem[]
starterCompletedCount: number
stageProgress: WorkflowAuthoringStageProgress[]
gapFillDefinitions: WorkflowNodeDefinition[]
}
function haveSameStepSet(left: string[], right: string[]) {
if (left.length !== right.length) return false
const rightSet = new Set(right)
return left.every(step => rightSet.has(step))
}
function getAuthoringPriorities(graphFamily: WorkflowGraphFamily): WorkflowAuthoringPriority[] {
if (graphFamily === 'order_line') {
return [
{
title: 'Start with the Still Render Reference path',
description: 'Insert the full non-legacy still production baseline first, then tune modules or individual nodes.',
},
{
title: 'Swap stages with production modules',
description: 'Use render and publish bundles to change whole stages without breaking graph-safe sequencing.',
},
{
title: 'Use raw nodes last',
description: 'Drop to legacy, bridge, or native graph nodes only after the production path already exists on canvas.',
},
]
}
if (graphFamily === 'cad_file') {
return [
{
title: 'Start with the CAD intake assembly',
description: 'Build the import and preview chain first so downstream consumers always receive consistent artifacts.',
},
{
title: 'Extend with stage bundles',
description: 'Use reusable intake modules before inserting isolated conversion or utility nodes.',
},
{
title: 'Use raw nodes last',
description: 'Only drop to individual nodes for edge cases or targeted debugging once the intake baseline is present.',
},
]
}
return [
{
title: 'Split the graph by responsibility',
description: 'Keep mixed graphs organized by building complete stages first, then add isolated nodes only for orchestration glue.',
},
{
title: 'Prefer reusable modules over hand-wiring',
description: 'Use grouped bundles whenever a stage can be expressed as a reusable production slice.',
},
{
title: 'Use raw nodes last',
description: 'Reach for the full catalog only after the baseline path is readable and stable.',
},
]
}
export function getWorkflowAuthoringFlow(graphFamily: WorkflowGraphFamily): WorkflowAuthoringFlowStep[] {
const starterPathLabel = graphFamily === 'mixed' ? 'Starter Path' : STARTER_PATH_TITLES[graphFamily]
return [
{
index: 1,
title: 'Reference Path',
description:
graphFamily === 'order_line'
? 'Start from the canonical non-legacy still graph before changing individual stages.'
: 'Start from the canonical path when you want a full baseline instead of assembling from scratch.',
},
{
index: 2,
title: 'Production Modules',
description: 'Swap or extend whole production stages with reusable graph-safe bundles.',
},
{
index: 3,
title: starterPathLabel,
description: 'Fill gaps one required step at a time and verify the minimum viable chain stays intact.',
},
{
index: 4,
title: 'Raw Node Catalog',
description: 'Use individual nodes only for advanced authoring, experiments, or edge-case overrides.',
},
]
}
export function getWorkflowAuthoringPlan(
definitions: WorkflowNodeDefinition[],
graphFamily: WorkflowGraphFamily,
activeSteps: string[],
): WorkflowAuthoringPlan {
const activeStepSet = new Set(activeSteps)
const definitionsByStep = new Map(definitions.map(definition => [definition.step, definition]))
const title = graphFamily === 'mixed' ? 'Guided Authoring' : STARTER_PATH_TITLES[graphFamily]
const description =
graphFamily === 'mixed'
? 'Keep the graph readable by preferring complete paths and bundles before raw node-level edits.'
: STARTER_PATH_DESCRIPTIONS[graphFamily]
const referenceBundles = getWorkflowReferenceBundles(definitions, graphFamily)
.map(bundle => ({
...bundle,
presentCount: bundle.stepIds.filter(step => activeStepSet.has(step)).length,
totalCount: bundle.stepIds.length,
}))
const moduleBundles = getWorkflowModuleBundles(definitions, graphFamily)
.map(bundle => ({
...bundle,
presentCount: bundle.stepIds.filter(step => activeStepSet.has(step)).length,
totalCount: bundle.stepIds.length,
}))
const starterDefinitions =
graphFamily === 'mixed'
? []
: STARTER_NODE_STEP_ORDER[graphFamily]
.map(step => definitionsByStep.get(step))
.filter((definition): definition is WorkflowNodeDefinition => Boolean(definition))
const starterItems = starterDefinitions.map((definition, index) => ({
index: index + 1,
definition,
isPresent: activeStepSet.has(definition.step),
}))
const starterCompletedCount = starterItems.filter(item => item.isPresent).length
const gapFillDefinitions = starterItems
.filter(item => !item.isPresent)
.map(item => item.definition)
.slice(0, 3)
const stageProgress = (() => {
if (graphFamily === 'mixed') return [] as WorkflowAuthoringStageProgress[]
const stages: WorkflowAuthoringStageProgress[] = []
const referenceBundle = referenceBundles[0]
if (referenceBundle) {
stages.push({
id: referenceBundle.id,
title: referenceBundle.label,
description: referenceBundle.description,
present: referenceBundle.presentCount,
total: referenceBundle.totalCount,
actionLabel:
referenceBundle.presentCount === 0
? `Insert ${referenceBundle.shortLabel}`
: `Reapply ${referenceBundle.shortLabel}`,
actionKind: 'reference',
bundleId: referenceBundle.id,
})
}
for (const bundle of moduleBundles) {
if (referenceBundle && haveSameStepSet(bundle.stepIds, referenceBundle.stepIds)) {
continue
}
if (bundle.presentCount === bundle.totalCount) continue
stages.push({
id: bundle.id,
title: bundle.label,
description: bundle.description,
present: bundle.presentCount,
total: bundle.totalCount,
actionLabel:
bundle.presentCount === 0
? `Insert ${bundle.shortLabel}`
: `Complete ${bundle.shortLabel}`,
actionKind: 'module',
bundleId: bundle.id,
})
}
const nextMissingStarterDefinition = starterItems.find(item => !item.isPresent)?.definition
if (nextMissingStarterDefinition) {
stages.push({
id: nextMissingStarterDefinition.step,
title: `Next missing step: ${nextMissingStarterDefinition.label}`,
description: 'Use a single-step insert only when you need to patch a gap without dropping an entire stage bundle.',
present: 0,
total: 1,
actionLabel: `Add ${nextMissingStarterDefinition.label}`,
actionKind: 'step',
step: nextMissingStarterDefinition.step,
})
}
return stages
})()
return {
title,
description,
priorities: getAuthoringPriorities(graphFamily),
authoringFlow: getWorkflowAuthoringFlow(graphFamily),
referenceBundles,
moduleBundles,
starterTitle: STARTER_PATH_TITLES[graphFamily],
starterDescription: STARTER_PATH_DESCRIPTIONS[graphFamily],
starterItems,
starterCompletedCount,
stageProgress,
gapFillDefinitions,
}
}
@@ -0,0 +1,81 @@
import {
Boxes,
Compass,
Library,
Milestone,
type LucideIcon,
} from 'lucide-react'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
export type WorkflowAuthoringSection = 'overview' | 'paths' | 'modules' | 'starter' | 'nodes'
type WorkflowAuthoringSectionConfig = {
key: WorkflowAuthoringSection
label: string
helper: string
icon: LucideIcon
}
type WorkflowAuthoringSectionOptions = {
graphFamily: WorkflowGraphFamily
includeOverview?: boolean
hasReferencePaths?: boolean
hasModules?: boolean
hasStarter?: boolean
}
export function getWorkflowAuthoringSections({
graphFamily,
includeOverview = false,
hasReferencePaths = false,
hasModules = false,
hasStarter = false,
}: WorkflowAuthoringSectionOptions): WorkflowAuthoringSectionConfig[] {
const sections: WorkflowAuthoringSectionConfig[] = []
if (includeOverview) {
sections.push({
key: 'overview',
label: 'Overview',
helper: 'Start with guided authoring modes before dropping to raw nodes.',
icon: Compass,
})
}
if (hasReferencePaths) {
sections.push({
key: 'paths',
label: 'Paths',
helper: 'Insert complete canonical production paths.',
icon: Milestone,
})
}
if (hasModules) {
sections.push({
key: 'modules',
label: 'Modules',
helper: 'Insert reusable production bundles.',
icon: Boxes,
})
}
if (hasStarter || graphFamily !== 'mixed') {
sections.push({
key: 'starter',
label: 'Starter',
helper: 'Follow the canonical family-safe assembly path.',
icon: Milestone,
})
}
sections.push({
key: 'nodes',
label: 'Nodes',
helper: 'Browse the full node catalog.',
icon: Library,
})
return sections
}
@@ -0,0 +1,145 @@
import { useEffect, useMemo, useState } from 'react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import {
bindWorkflowAuthoringInsertActions,
type WorkflowAuthoringActions,
type WorkflowAuthoringInsertHandlers,
type WorkflowAuthoringPosition,
} from './workflowAuthoringActions'
import { getWorkflowAuthoringPlan, type WorkflowAuthoringPlan } from './workflowAuthoringGuidance'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import {
getWorkflowAuthoringSections,
type WorkflowAuthoringSection,
} from './workflowAuthoringSections'
type WorkflowAuthoringSurfaceModelOptions = {
definitions: WorkflowNodeDefinition[]
graphFamily: WorkflowGraphFamily
activeSteps: string[]
}
export type WorkflowAuthoringSurfaceModel = {
defaultSection: WorkflowAuthoringSection
sections: ReturnType<typeof getWorkflowAuthoringSections>
plan: WorkflowAuthoringPlan
}
type UseWorkflowAuthoringSurfaceOptions = WorkflowAuthoringSurfaceModelOptions & {
actions?: WorkflowAuthoringActions
preferredPosition?: WorkflowAuthoringPosition
onAfterInsert?: () => void
}
export type WorkflowAuthoringSurfaceController = WorkflowAuthoringSurfaceModel & {
activeSection: WorkflowAuthoringSection
activeSectionMeta: WorkflowAuthoringSurfaceModel['sections'][number] | null
insertBindings: WorkflowAuthoringInsertHandlers
setActiveSection: (section: WorkflowAuthoringSection) => void
}
export function getDefaultWorkflowAuthoringSection(
graphFamily: WorkflowGraphFamily,
): WorkflowAuthoringSection {
return graphFamily === 'mixed' ? 'nodes' : 'overview'
}
export function getWorkflowAuthoringSurfaceModel({
definitions,
graphFamily,
activeSteps,
}: WorkflowAuthoringSurfaceModelOptions): WorkflowAuthoringSurfaceModel {
const plan = getWorkflowAuthoringPlan(definitions, graphFamily, activeSteps)
const defaultSection = getDefaultWorkflowAuthoringSection(graphFamily)
const sections = getWorkflowAuthoringSections({
graphFamily,
includeOverview: graphFamily !== 'mixed',
hasReferencePaths: plan.referenceBundles.length > 0,
hasModules: plan.moduleBundles.length > 0,
hasStarter: graphFamily !== 'mixed' && plan.starterItems.length > 0,
})
return {
defaultSection,
sections,
plan,
}
}
export function getWorkflowAuthoringSurfaceSections({
definitions,
graphFamily,
activeSteps,
}: WorkflowAuthoringSurfaceModelOptions) {
return getWorkflowAuthoringSurfaceModel({
definitions,
graphFamily,
activeSteps,
}).sections
}
export function resolveWorkflowAuthoringSection(
activeSection: WorkflowAuthoringSection,
sections: WorkflowAuthoringSurfaceModel['sections'],
defaultSection: WorkflowAuthoringSection,
): WorkflowAuthoringSection {
if (sections.some(section => section.key === activeSection)) {
return activeSection
}
if (sections.some(section => section.key === defaultSection)) {
return defaultSection
}
return sections[0]?.key ?? 'nodes'
}
export function useWorkflowAuthoringSurface({
definitions,
graphFamily,
activeSteps,
actions,
preferredPosition,
onAfterInsert,
}: UseWorkflowAuthoringSurfaceOptions): WorkflowAuthoringSurfaceController {
const surfaceModel = useMemo(
() => getWorkflowAuthoringSurfaceModel({ definitions, graphFamily, activeSteps }),
[activeSteps, definitions, graphFamily],
)
const { defaultSection, plan, sections } = surfaceModel
const insertBindings = useMemo(
() =>
bindWorkflowAuthoringInsertActions(actions, {
preferredPosition,
onAfterInsert,
}),
[actions, onAfterInsert, preferredPosition],
)
const [activeSection, setActiveSection] = useState<WorkflowAuthoringSection>(defaultSection)
useEffect(() => {
setActiveSection(defaultSection)
}, [defaultSection])
useEffect(() => {
setActiveSection(currentSection =>
resolveWorkflowAuthoringSection(currentSection, sections, defaultSection),
)
}, [defaultSection, sections])
const activeSectionMeta = useMemo(
() => sections.find(section => section.key === activeSection) ?? null,
[activeSection, sections],
)
return {
defaultSection,
sections,
plan,
activeSection,
activeSectionMeta,
insertBindings,
setActiveSection,
}
}
@@ -24,11 +24,22 @@ export function inferWorkflowFamily(
): WorkflowGraphFamily {
const nodes = Array.isArray(config.nodes) ? config.nodes : []
if (nodes.length > 0) {
const families = new Set(nodes.map(node => getNodeFamily(node.step, nodeDefinitionsByStep)))
const families = new Set(
nodes
.map(node => getNodeFamily(node.step, nodeDefinitionsByStep))
.filter((family): family is Exclude<ReturnType<typeof getNodeFamily>, 'shared'> => family !== 'shared'),
)
if (families.size === 1) {
return Array.from(families)[0]
}
return 'mixed'
if (families.size > 1) {
return 'mixed'
}
}
const configuredFamily = config.ui?.family
if (configuredFamily === 'cad_file' || configuredFamily === 'order_line' || configuredFamily === 'mixed') {
return configuredFamily
}
const presetType = config.ui?.preset ?? 'custom'
@@ -8,7 +8,7 @@ import type {
WorkflowNodeDefinition,
WorkflowParams,
} from '../../api/workflows'
import { getNodeFamily, type WorkflowNodeDefinitionMap } from './workflowNodeLibrary'
import { getNodeFamily, type WorkflowGraphFamily, type WorkflowNodeDefinitionMap } from './workflowNodeLibrary'
export type WorkflowCanvasNodeData = {
label: string
@@ -17,6 +17,21 @@ export type WorkflowCanvasNodeData = {
description?: string
icon?: string
category?: StepCategory
inputContextLabel?: string | null
outputContextLabel?: string | null
inputPorts?: WorkflowCanvasPort[]
outputPorts?: WorkflowCanvasPort[]
requiredAnyInputs?: string[][]
editableFieldCount?: number
editableFieldLabels?: string[]
dynamicVariableHint?: string | null
}
export type WorkflowCanvasPort = {
id: string
label: string
roles: string[]
kind: 'required' | 'alternative' | 'provided'
}
export type WorkflowValidationResult = {
@@ -24,12 +39,21 @@ export type WorkflowValidationResult = {
warnings: string[]
}
export const WORKFLOW_NODE_WIDTH = 240
export const WORKFLOW_NODE_MIN_HEIGHT = 188
export const WORKFLOW_NODE_HORIZONTAL_GAP = 72
export const WORKFLOW_NODE_VERTICAL_GAP = 44
export const WORKFLOW_LAYOUT_PADDING_X = 56
export const WORKFLOW_LAYOUT_PADDING_Y = 48
type WorkflowNodeContractContext = 'cad_file' | 'order_line'
type WorkflowSemanticState = {
availableValues: Set<string>
executedSteps: Set<string>
}
const TEMPLATE_INPUT_PARAM_PREFIX = 'template_input__'
const CAD_FILE_ENTRY_STEPS = new Set([
'resolve_step_path',
'occ_object_extract',
@@ -44,7 +68,6 @@ const ORDER_LINE_SETUP_REQUIRED_STEPS = new Set([
'resolve_template',
'material_map_resolve',
'auto_populate_materials',
'glb_bbox',
'blender_still',
'blender_turntable',
'output_save',
@@ -67,6 +90,20 @@ const ROOT_CONTEXT_VALUES: Record<WorkflowNodeContractContext, string[]> = {
const ORDER_LINE_SETUP_COMPATIBILITY_VALUES = ['cad_materials', 'glb_preview', 'bbox']
const OUTPUT_SAVE_ALTERNATIVE_INPUTS = ['rendered_image', 'rendered_frames', 'rendered_video']
const NOTIFY_ALTERNATIVE_INPUTS = ['rendered_image', 'rendered_frames', 'rendered_video', 'workflow_result', 'blend_asset']
const CONTRACT_TOKEN_LABELS: Record<string, string> = {
api: 'API',
bbox: 'Bounding Box',
cad: 'CAD',
fps: 'FPS',
glb: 'GLB',
gpu: 'GPU',
id: 'ID',
occ: 'OCC',
step: 'STEP',
stl: 'STL',
usd: 'USD',
}
function getContractContext(
contract: Record<string, unknown> | undefined,
@@ -75,6 +112,14 @@ function getContractContext(
return value === 'cad_file' || value === 'order_line' ? value : null
}
export function getContractContextLabel(
contract: Record<string, unknown> | undefined,
): string | null {
const context = getContractContext(contract)
if (!context) return null
return context === 'cad_file' ? 'CAD File' : 'Order Line'
}
function getContractValues(
contract: Record<string, unknown> | undefined,
key: string,
@@ -101,13 +146,222 @@ function getContractAlternativeValues(
.filter(group => group.length > 0)
}
function formatContractValue(value: string): string {
export function formatContractValue(value: string): string {
return value
.split('_')
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.map(part => {
const normalized = part.trim().toLowerCase()
if (!normalized) return ''
return CONTRACT_TOKEN_LABELS[normalized] ?? (normalized.charAt(0).toUpperCase() + normalized.slice(1))
})
.filter(part => part.length > 0)
.join(' ')
}
function dedupeRoles(values: string[]): string[] {
return Array.from(new Set(values.filter(value => value.trim().length > 0)))
}
function createPortId(prefix: string, roles: string[]): string {
return `${prefix}:${roles.join('|')}`
}
function formatAlternativePortLabel(roles: string[]): string {
if (roles.length === 1) return formatContractValue(roles[0])
return `Any of ${roles.map(formatContractValue).join(' / ')}`
}
function getNodeAlternativeInputGroups(definition: WorkflowNodeDefinition | undefined): string[][] {
if (!definition) return []
const alternativeGroups = getContractAlternativeValues(
definition.input_contract as Record<string, unknown> | undefined,
'requires_any',
)
if (definition.step === 'output_save' && alternativeGroups.length === 0) {
alternativeGroups.push(OUTPUT_SAVE_ALTERNATIVE_INPUTS)
}
if (definition.step === 'notify') {
if (alternativeGroups.length === 0) {
alternativeGroups.push(NOTIFY_ALTERNATIVE_INPUTS)
} else {
alternativeGroups[0] = Array.from(new Set([...alternativeGroups[0], 'blend_asset']))
}
}
return alternativeGroups.map(group => dedupeRoles(group))
}
function getNodeRequiredInputRoles(definition: WorkflowNodeDefinition | undefined): string[] {
if (!definition) return []
const alternativeRoles = new Set(getNodeAlternativeInputGroups(definition).flat())
const directRoles = dedupeRoles([
...getContractValues(definition.input_contract as Record<string, unknown> | undefined, 'requires'),
...(definition.artifact_roles_consumed ?? []),
])
return directRoles.filter(role => !alternativeRoles.has(role))
}
function getNodeProvidedOutputRoles(definition: WorkflowNodeDefinition | undefined): string[] {
if (!definition) return []
return dedupeRoles([
...getContractValues(definition.output_contract as Record<string, unknown> | undefined, 'provides'),
...(definition.artifact_roles_produced ?? []),
])
}
function getNodeEditableFieldLabels(definition: WorkflowNodeDefinition | undefined): string[] {
if (!definition) return []
return Array.from(
new Set(
definition.fields
.map(field => field.label?.trim())
.filter((label): label is string => Boolean(label)),
),
)
}
function getDynamicVariableHint(definition: WorkflowNodeDefinition | undefined): string | null {
if (!definition) return null
const providedRoles = new Set(getNodeProvidedOutputRoles(definition))
if (providedRoles.has('workflow_input_schema') || providedRoles.has('template_inputs')) {
return 'Template-selected variables appear after choosing a template.'
}
return null
}
export function buildWorkflowCanvasNodeData(
step: string,
params: WorkflowParams = {},
definition?: WorkflowNodeDefinition,
overrides?: Partial<WorkflowCanvasNodeData>,
): WorkflowCanvasNodeData {
const requiredInputRoles = getNodeRequiredInputRoles(definition)
const alternativeInputGroups = getNodeAlternativeInputGroups(definition)
const providedOutputRoles = getNodeProvidedOutputRoles(definition)
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,
inputContextLabel: getContractContextLabel(definition?.input_contract as Record<string, unknown> | undefined),
outputContextLabel: getContractContextLabel(definition?.output_contract as Record<string, unknown> | undefined),
inputPorts: [
...requiredInputRoles.map(role => ({
id: createPortId('input', [role]),
label: formatContractValue(role),
roles: [role],
kind: 'required' as const,
})),
...alternativeInputGroups.map(group => ({
id: createPortId('input-any', group),
label: formatAlternativePortLabel(group),
roles: group,
kind: 'alternative' as const,
})),
],
outputPorts: providedOutputRoles.map(role => ({
id: createPortId('output', [role]),
label: formatContractValue(role),
roles: [role],
kind: 'provided' as const,
})),
requiredAnyInputs: alternativeInputGroups,
editableFieldCount: definition?.fields.length ?? 0,
editableFieldLabels: getNodeEditableFieldLabels(definition),
dynamicVariableHint: getDynamicVariableHint(definition),
}
}
function getCompatiblePortAssignments(
sourceData: WorkflowCanvasNodeData | undefined,
targetData: WorkflowCanvasNodeData | undefined,
preferred?: {
sourceHandle?: string | null
targetHandle?: string | null
},
usedTargetHandleIds?: Set<string>,
): {
sourceHandle?: string
targetHandle?: string
} {
const sourcePorts = sourceData?.outputPorts ?? []
const targetPorts = targetData?.inputPorts ?? []
if (sourcePorts.length === 0 || targetPorts.length === 0) {
return {}
}
const preferredTarget = preferred?.targetHandle
? targetPorts.find(port => port.id === preferred.targetHandle)
: undefined
const preferredSource = preferred?.sourceHandle
? sourcePorts.find(port => port.id === preferred.sourceHandle)
: undefined
if (preferredTarget && preferredSource) {
const matches = preferredTarget.roles.some(role => preferredSource.roles.includes(role))
if (matches) {
return {
sourceHandle: preferredSource.id,
targetHandle: preferredTarget.id,
}
}
}
if (preferredTarget) {
const matchingSource = sourcePorts.find(port =>
preferredTarget.roles.some(role => port.roles.includes(role)),
)
if (matchingSource) {
return {
sourceHandle: matchingSource.id,
targetHandle: preferredTarget.id,
}
}
}
const compatibleTargets = targetPorts
.filter(port =>
sourcePorts.some(sourcePort => port.roles.some(role => sourcePort.roles.includes(role))),
)
.sort((left, right) => {
const leftUsed = usedTargetHandleIds?.has(left.id) ? 1 : 0
const rightUsed = usedTargetHandleIds?.has(right.id) ? 1 : 0
return leftUsed - rightUsed
})
const resolvedTarget = compatibleTargets[0]
if (!resolvedTarget) {
return {}
}
const resolvedSource =
preferredSource && resolvedTarget.roles.some(role => preferredSource.roles.includes(role))
? preferredSource
: sourcePorts.find(port => resolvedTarget.roles.some(role => port.roles.includes(role)))
if (!resolvedSource) {
return {}
}
return {
sourceHandle: resolvedSource.id,
targetHandle: resolvedTarget.id,
}
}
function inferWorkflowContextKind(
nodes: Node[],
nodeDefinitionsByStep: WorkflowNodeDefinitionMap,
@@ -119,12 +373,17 @@ function inferWorkflowContextKind(
const step = ((node.data as WorkflowCanvasNodeData | undefined)?.step as string | undefined) ?? inferStepFromNodeType(node.type)
const definition = nodeDefinitionsByStep[step]
if (definition) {
families.add(definition.family)
if (definition.family === 'cad_file' || definition.family === 'order_line') {
families.add(definition.family)
}
continue
}
if (!definitionsLoaded) {
families.add(getNodeFamily(step, nodeDefinitionsByStep))
const fallbackFamily = getNodeFamily(step, nodeDefinitionsByStep)
if (fallbackFamily === 'cad_file' || fallbackFamily === 'order_line') {
families.add(fallbackFamily)
}
}
}
@@ -132,6 +391,54 @@ function inferWorkflowContextKind(
return Array.from(families)[0]
}
function readConfiguredWorkflowFamily(config: WorkflowConfig): WorkflowGraphFamily | null {
const family = config.ui?.family
return family === 'cad_file' || family === 'order_line' || family === 'mixed' ? family : null
}
function inferFallbackWorkflowFamily(
config: WorkflowConfig,
nodeDefinitionsByStep: WorkflowNodeDefinitionMap,
): WorkflowGraphFamily {
const configuredFamily = readConfiguredWorkflowFamily(config)
if (configuredFamily) return configuredFamily
const nodes = Array.isArray(config.nodes) ? config.nodes : []
if (nodes.length > 0) {
const families = new Set(
nodes
.map(node => getNodeFamily(node.step, nodeDefinitionsByStep))
.filter((family): family is WorkflowNodeContractContext => family === 'cad_file' || family === 'order_line'),
)
if (families.size === 1) {
return Array.from(families)[0]
}
if (families.size > 1) {
return 'mixed'
}
}
return config.ui?.preset === 'custom' ? 'mixed' : 'order_line'
}
export function deriveWorkflowAuthoringFamily(
workflow: WorkflowDefinition,
nodes: Node[],
nodeDefinitionsByStep: WorkflowNodeDefinitionMap,
definitionsLoaded: boolean,
): WorkflowGraphFamily {
const configuredFamily = readConfiguredWorkflowFamily(workflow.config)
if (configuredFamily === 'cad_file' || configuredFamily === 'order_line') {
return configuredFamily
}
const inferredFamily = inferWorkflowContextKind(nodes, nodeDefinitionsByStep, definitionsLoaded)
if (inferredFamily) return inferredFamily
if (configuredFamily === 'mixed') return 'mixed'
return inferFallbackWorkflowFamily(workflow.config, nodeDefinitionsByStep)
}
function getFieldKeys(definition: WorkflowNodeDefinition | undefined): Set<string> {
return new Set((definition?.fields ?? []).map(field => field.key))
}
@@ -158,6 +465,14 @@ export function resolveParamsForStepChange(
}
}
if (definition.step === 'resolve_template') {
for (const [key, value] of Object.entries(currentParams)) {
if (key.startsWith(TEMPLATE_INPUT_PARAM_PREFIX) && !isFieldValueEmpty(value)) {
nextParams[key] = value
}
}
}
return normalizeWorkflowParams(nextParams)
}
@@ -384,6 +699,12 @@ export function validateWorkflowDraft(
if (step === 'notify') {
requiredValues.delete('workflow_result')
requiredValues.delete('blend_asset')
if (alternativeRequiredGroups.length === 0) {
alternativeRequiredGroups.push(NOTIFY_ALTERNATIVE_INPUTS)
} else if (alternativeRequiredGroups.length > 0) {
alternativeRequiredGroups[0] = Array.from(new Set([...alternativeRequiredGroups[0], 'blend_asset']))
}
}
const compatibilityValues = new Set<string>()
@@ -391,6 +712,9 @@ export function validateWorkflowDraft(
for (const value of ORDER_LINE_SETUP_COMPATIBILITY_VALUES) {
compatibilityValues.add(value)
}
if (definition.legacy_compatible) {
compatibilityValues.add('material_assignments')
}
}
for (const group of alternativeRequiredGroups) {
@@ -437,24 +761,51 @@ export function workflowToGraph(
config: WorkflowConfig,
nodeDefinitionsByStep: Record<string, WorkflowNodeDefinition>,
): { nodes: Node[]; edges: Edge[] } {
const 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: buildWorkflowCanvasNodeData(
node.step,
node.params ?? {},
nodeDefinitionsByStep[node.step],
{ label: node.ui?.label },
) satisfies WorkflowCanvasNodeData,
}))
const nodeById = new Map(nodes.map(node => [node.id, node]))
const usedTargetHandlesByNodeId = new Map<string, Set<string>>()
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,
})),
nodes,
edges: config.edges.map((edge, index) => ({
id: `e_${edge.from}_${edge.to}_${index}`,
source: edge.from,
target: edge.to,
...(() => {
const sourceNode = nodeById.get(edge.from)
const targetNode = nodeById.get(edge.to)
const usedTargetHandles =
usedTargetHandlesByNodeId.get(edge.to) ??
(() => {
const nextSet = new Set<string>()
usedTargetHandlesByNodeId.set(edge.to, nextSet)
return nextSet
})()
const handles = getCompatiblePortAssignments(
sourceNode?.data as WorkflowCanvasNodeData | undefined,
targetNode?.data as WorkflowCanvasNodeData | undefined,
undefined,
usedTargetHandles,
)
if (handles.targetHandle) {
usedTargetHandles.add(handles.targetHandle)
}
return {
id: `e_${edge.from}_${edge.to}_${index}`,
source: edge.from,
target: edge.to,
sourceHandle: handles.sourceHandle,
targetHandle: handles.targetHandle,
}
})(),
})),
}
}
@@ -464,12 +815,19 @@ export function buildCurrentWorkflowConfig(
nodes: Node[],
edges: Edge[],
executionMode: WorkflowExecutionMode,
authoringFamily?: WorkflowGraphFamily,
): WorkflowConfig {
const nextFamily =
authoringFamily ??
readConfiguredWorkflowFamily(workflow.config) ??
inferFallbackWorkflowFamily(workflow.config, {})
return {
version: workflow.config.version ?? 1,
ui: {
...(workflow.config.ui ?? {}),
execution_mode: executionMode,
family: nextFamily,
},
nodes: nodes.map(node => {
const step =
@@ -499,28 +857,238 @@ export function buildCurrentWorkflowConfig(
}
}
type WorkflowNodeBounds = {
left: number
right: number
top: number
bottom: number
}
function getNodeBounds(position: { x: number; y: number }): WorkflowNodeBounds {
return {
left: position.x,
right: position.x + WORKFLOW_NODE_WIDTH,
top: position.y,
bottom: position.y + WORKFLOW_NODE_MIN_HEIGHT,
}
}
function nodesOverlapAtPosition(
position: { x: number; y: number },
otherPosition: { x: number; y: number },
): boolean {
const a = getNodeBounds(position)
const b = getNodeBounds(otherPosition)
return !(
a.right + WORKFLOW_NODE_HORIZONTAL_GAP <= b.left ||
b.right + WORKFLOW_NODE_HORIZONTAL_GAP <= a.left ||
a.bottom + WORKFLOW_NODE_VERTICAL_GAP <= b.top ||
b.bottom + WORKFLOW_NODE_VERTICAL_GAP <= a.top
)
}
export function graphNeedsAutoLayout(nodes: Node[]): boolean {
if (nodes.length <= 1) return false
const invalidPosition = nodes.some(
node =>
!Number.isFinite(node.position.x) ||
!Number.isFinite(node.position.y),
)
if (invalidPosition) return true
const uniquePositions = new Set(nodes.map(node => `${Math.round(node.position.x)}:${Math.round(node.position.y)}`))
if (uniquePositions.size <= Math.ceil(nodes.length / 2)) return true
for (let index = 0; index < nodes.length; index += 1) {
for (let otherIndex = index + 1; otherIndex < nodes.length; otherIndex += 1) {
if (nodesOverlapAtPosition(nodes[index].position, nodes[otherIndex].position)) {
return true
}
}
}
return false
}
export function findOpenNodePosition(
nodes: Node[],
preferredPosition: { x: number; y: number },
): { x: number; y: number } {
const horizontalStep = WORKFLOW_NODE_WIDTH + WORKFLOW_NODE_HORIZONTAL_GAP
const verticalStep = WORKFLOW_NODE_MIN_HEIGHT + WORKFLOW_NODE_VERTICAL_GAP
const normalized = {
x: Math.max(WORKFLOW_LAYOUT_PADDING_X, preferredPosition.x),
y: Math.max(WORKFLOW_LAYOUT_PADDING_Y, preferredPosition.y),
}
const isPositionFree = (candidate: { x: number; y: number }) =>
nodes.every(node => !nodesOverlapAtPosition(candidate, node.position))
if (isPositionFree(normalized)) {
return normalized
}
for (let radius = 0; radius < 12; radius += 1) {
const horizontalRange = radius + 1
const verticalRange = radius + 1
for (let row = -verticalRange; row <= verticalRange; row += 1) {
for (let column = -horizontalRange; column <= horizontalRange; column += 1) {
const candidate = {
x: Math.max(WORKFLOW_LAYOUT_PADDING_X, normalized.x + column * horizontalStep),
y: Math.max(WORKFLOW_LAYOUT_PADDING_Y, normalized.y + row * verticalStep),
}
if (isPositionFree(candidate)) {
return candidate
}
}
}
}
return {
x: normalized.x + horizontalStep,
y: normalized.y + verticalStep,
}
}
export function shouldAutoLayoutAfterInsert(
nodes: Node[],
insertedNode: Node,
preferredPosition?: { x: number; y: number } | null,
): boolean {
if (graphNeedsAutoLayout(nodes)) return true
if (!preferredPosition) return false
const horizontalThreshold = Math.max(WORKFLOW_NODE_HORIZONTAL_GAP, WORKFLOW_NODE_WIDTH * 0.55)
const verticalThreshold = Math.max(WORKFLOW_NODE_VERTICAL_GAP, WORKFLOW_NODE_MIN_HEIGHT * 0.55)
return (
Math.abs(insertedNode.position.x - preferredPosition.x) > horizontalThreshold ||
Math.abs(insertedNode.position.y - preferredPosition.y) > verticalThreshold
)
}
type WorkflowCollisionDelta = {
x: number
y: number
}
function getCollisionResolutionDelta(
anchorPosition: { x: number; y: number },
overlappingPosition: { x: number; y: number },
): WorkflowCollisionDelta {
const anchor = getNodeBounds(anchorPosition)
const overlapping = getNodeBounds(overlappingPosition)
const anchorCenterX = (anchor.left + anchor.right) / 2
const anchorCenterY = (anchor.top + anchor.bottom) / 2
const overlappingCenterX = (overlapping.left + overlapping.right) / 2
const overlappingCenterY = (overlapping.top + overlapping.bottom) / 2
const sameColumnThreshold = (WORKFLOW_NODE_WIDTH + WORKFLOW_NODE_HORIZONTAL_GAP) * 0.45
const sameColumn = Math.abs(anchorCenterX - overlappingCenterX) <= sameColumnThreshold
const pushRight = anchor.right + WORKFLOW_NODE_HORIZONTAL_GAP - overlapping.left
const pushLeft = anchor.left - WORKFLOW_NODE_HORIZONTAL_GAP - overlapping.right
const pushDown = anchor.bottom + WORKFLOW_NODE_VERTICAL_GAP - overlapping.top
const pushUp = anchor.top - WORKFLOW_NODE_VERTICAL_GAP - overlapping.bottom
const horizontalDelta = overlappingCenterX >= anchorCenterX ? pushRight : pushLeft
const verticalDelta = overlappingCenterY >= anchorCenterY ? pushDown : pushUp
const horizontalMagnitude = Math.abs(horizontalDelta)
const verticalMagnitude = Math.abs(verticalDelta)
if (sameColumn && verticalMagnitude <= horizontalMagnitude * 1.25) {
return { x: 0, y: verticalDelta }
}
if (horizontalMagnitude < verticalMagnitude) {
return { x: horizontalDelta, y: 0 }
}
return { x: 0, y: verticalDelta }
}
export function resolveNodeCollisions(nodes: Node[], anchorNodeIds: string[]): Node[] {
if (nodes.length <= 1 || anchorNodeIds.length === 0) return nodes
const nextNodes = nodes.map(node => ({ ...node, position: { ...node.position } }))
const nodeIndexById = new Map(nextNodes.map((node, index) => [node.id, index]))
const lockedNodeIds = new Set(anchorNodeIds.filter(nodeId => nodeIndexById.has(nodeId)))
if (lockedNodeIds.size === 0) return nextNodes
const queue = [...lockedNodeIds]
const maxIterations = nextNodes.length * nextNodes.length * 4
let iterations = 0
while (queue.length > 0 && iterations < maxIterations) {
const anchorNodeId = queue.shift()!
const anchorIndex = nodeIndexById.get(anchorNodeId)
if (anchorIndex === undefined) continue
const anchorNode = nextNodes[anchorIndex]
for (let index = 0; index < nextNodes.length; index += 1) {
const candidate = nextNodes[index]
if (candidate.id === anchorNode.id || lockedNodeIds.has(candidate.id)) continue
if (!nodesOverlapAtPosition(anchorNode.position, candidate.position)) continue
const delta = getCollisionResolutionDelta(anchorNode.position, candidate.position)
let candidatePosition = {
x: Math.max(WORKFLOW_LAYOUT_PADDING_X, candidate.position.x + delta.x),
y: Math.max(WORKFLOW_LAYOUT_PADDING_Y, candidate.position.y + delta.y),
}
const nodesWithoutCandidate = nextNodes.filter(node => node.id !== candidate.id)
let attempts = 0
while (
nodesWithoutCandidate.some(node => nodesOverlapAtPosition(candidatePosition, node.position)) &&
attempts < 12
) {
candidatePosition = {
x: Math.max(WORKFLOW_LAYOUT_PADDING_X, candidatePosition.x + delta.x),
y: Math.max(WORKFLOW_LAYOUT_PADDING_Y, candidatePosition.y + delta.y),
}
attempts += 1
}
if (nodesWithoutCandidate.some(node => nodesOverlapAtPosition(candidatePosition, node.position))) {
candidatePosition = findOpenNodePosition(nodesWithoutCandidate, candidatePosition)
}
nextNodes[index] = {
...candidate,
position: candidatePosition,
}
queue.push(candidate.id)
iterations += 1
}
}
return nextNodes
}
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 horizontalSpacing = WORKFLOW_NODE_WIDTH + WORKFLOW_NODE_HORIZONTAL_GAP
const verticalSpacing = WORKFLOW_NODE_MIN_HEIGHT + WORKFLOW_NODE_VERTICAL_GAP
const nodeById = new Map(nodes.map(node => [node.id, node]))
const inDegree = new Map<string, number>()
const adjacency = new Map<string, string[]>()
const parentsByNodeId = new Map<string, string[]>()
const layerByNodeId = new Map<string, number>()
for (const node of nodes) {
inDegree.set(node.id, 0)
adjacency.set(node.id, [])
parentsByNodeId.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])
parentsByNodeId.set(edge.target, [...(parentsByNodeId.get(edge.target) ?? []), edge.source])
inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1)
}
@@ -561,7 +1129,62 @@ export function applyAutoLayout(nodes: Node[], edges: Edge[]) {
layers.set(layer, [...(layers.get(layer) ?? []), node])
}
const positioned = new Map<string, Node>()
const sortedLayers = [...layers.entries()].sort(([a], [b]) => a - b)
for (const [layer, layerNodes] of sortedLayers) {
const sortedNodes = [...layerNodes].sort((a, b) => {
const aParents = parentsByNodeId.get(a.id) ?? []
const bParents = parentsByNodeId.get(b.id) ?? []
const averageParentY = (parentIds: string[], fallbackNode: Node) => {
if (parentIds.length === 0) return fallbackNode.position.y
const total = parentIds.reduce((sum, parentId) => {
const parentNode = positioned.get(parentId) ?? nodeById.get(parentId)
return sum + (parentNode?.position.y ?? fallbackNode.position.y)
}, 0)
return total / parentIds.length
}
return (
averageParentY(aParents, a) - averageParentY(bParents, b) ||
a.position.y - b.position.y ||
a.position.x - b.position.x
)
})
let nextY = WORKFLOW_LAYOUT_PADDING_Y
for (const node of sortedNodes) {
const parentIds = parentsByNodeId.get(node.id) ?? []
const parentCenterY =
parentIds.length === 0
? WORKFLOW_LAYOUT_PADDING_Y + WORKFLOW_NODE_MIN_HEIGHT / 2
: parentIds.reduce((sum, parentId) => {
const parentNode = positioned.get(parentId) ?? nodeById.get(parentId)
const parentY = parentNode?.position.y ?? WORKFLOW_LAYOUT_PADDING_Y
return sum + parentY + WORKFLOW_NODE_MIN_HEIGHT / 2
}, 0) / parentIds.length
const desiredTop = Math.max(
WORKFLOW_LAYOUT_PADDING_Y,
Math.round(parentCenterY - WORKFLOW_NODE_MIN_HEIGHT / 2),
)
const resolvedTop = Math.max(desiredTop, nextY)
positioned.set(node.id, {
...node,
position: {
x: WORKFLOW_LAYOUT_PADDING_X + layer * horizontalSpacing,
y: resolvedTop,
},
})
nextY = resolvedTop + verticalSpacing
}
}
return nodes.map(node => {
const laidOutNode = positioned.get(node.id)
if (laidOutNode) return laidOutNode
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
@@ -573,8 +1196,8 @@ export function applyAutoLayout(nodes: Node[], edges: Edge[]) {
return {
...node,
position: {
x: PADDING_X + layer * HORIZONTAL_SPACING,
y: PADDING_Y + Math.max(index, 0) * VERTICAL_SPACING,
x: WORKFLOW_LAYOUT_PADDING_X + layer * horizontalSpacing,
y: WORKFLOW_LAYOUT_PADDING_Y + Math.max(index, 0) * verticalSpacing,
},
}
})
@@ -0,0 +1,156 @@
import type { Edge, Node } from '@xyflow/react'
import type { WorkflowNodeDefinition, WorkflowParams } from '../../api/workflows'
import { inferNodeLabel, inferNodeType, normalizeWorkflowParams, type WorkflowCanvasNodeData } from './workflowGraphDraft'
import {
AUTHORING_STAGE_LABELS,
type WorkflowAuthoringStage,
type WorkflowGraphFamily,
} from './workflowNodeLibrary'
export type WorkflowModuleBundleId =
| 'cad_intake_core'
| 'still_render_core'
| 'output_publish_notify'
export type WorkflowModuleBundleDefinition = {
id: WorkflowModuleBundleId
label: string
shortLabel: string
description: string
family: Exclude<WorkflowGraphFamily, 'mixed'>
stepIds: string[]
stage: string
stageId: WorkflowAuthoringStage
}
type WorkflowModuleBundleInsertionResult =
| {
ok: true
bundle: WorkflowModuleBundleDefinition
nodes: Node[]
edges: Edge[]
}
| {
ok: false
reason: string
}
const WORKFLOW_MODULE_BUNDLE_REGISTRY: WorkflowModuleBundleDefinition[] = [
{
id: 'cad_intake_core',
label: 'CAD Intake Core',
shortLabel: 'CAD Intake',
description: 'Resolve STEP, extract CAD structure, generate GLB, cache STL, and publish the preview thumbnail.',
family: 'cad_file',
stepIds: ['resolve_step_path', 'occ_object_extract', 'occ_glb_export', 'stl_cache_generate', 'thumbnail_save'],
stage: AUTHORING_STAGE_LABELS.cad_intake,
stageId: 'cad_intake',
},
{
id: 'still_render_core',
label: 'Still Render Core',
shortLabel: 'Still Render',
description: 'Prepare order-line context, resolve template and materials, compute geometry, and run the still render.',
family: 'order_line',
stepIds: ['order_line_setup', 'resolve_template', 'auto_populate_materials', 'glb_bbox', 'material_map_resolve', 'blender_still'],
stage: AUTHORING_STAGE_LABELS.render,
stageId: 'render',
},
{
id: 'output_publish_notify',
label: 'Publish And Notify',
shortLabel: 'Publish',
description: 'Persist the rendered output and emit downstream completion signals.',
family: 'order_line',
stepIds: ['output_save', 'notify'],
stage: AUTHORING_STAGE_LABELS.publish,
stageId: 'publish',
},
]
function buildNodeData(
step: string,
params: WorkflowParams = {},
definition?: WorkflowNodeDefinition,
): WorkflowCanvasNodeData {
return {
label: definition?.label ?? inferNodeLabel(step),
params: normalizeWorkflowParams(params),
step,
description: definition?.description,
icon: definition?.icon,
category: definition?.category,
}
}
export function getWorkflowModuleBundle(bundleId: WorkflowModuleBundleId) {
return WORKFLOW_MODULE_BUNDLE_REGISTRY.find(bundle => bundle.id === bundleId) ?? null
}
export function getWorkflowModuleBundles(
nodeDefinitions: WorkflowNodeDefinition[],
graphFamily: WorkflowGraphFamily,
): WorkflowModuleBundleDefinition[] {
const availableSteps = new Set(nodeDefinitions.map(definition => definition.step))
return WORKFLOW_MODULE_BUNDLE_REGISTRY.filter(bundle => {
if (graphFamily !== 'mixed' && bundle.family !== graphFamily) return false
return bundle.stepIds.every(step => availableSteps.has(step))
})
}
export function createWorkflowModuleBundleInsertion(args: {
bundleId: WorkflowModuleBundleId
graphFamily: WorkflowGraphFamily
nodeDefinitionsByStep: Record<string, WorkflowNodeDefinition>
existingNodes: Node[]
preferredPosition?: { x: number; y: number }
}): WorkflowModuleBundleInsertionResult {
const { bundleId, graphFamily, nodeDefinitionsByStep, existingNodes, preferredPosition } = args
const bundle = getWorkflowModuleBundle(bundleId)
if (!bundle) {
return { ok: false, reason: 'Unknown workflow module.' }
}
if (graphFamily !== 'mixed' && bundle.family !== graphFamily) {
return { ok: false, reason: `${bundle.label} does not belong to the active authoring family.` }
}
const missingStep = bundle.stepIds.find(step => !nodeDefinitionsByStep[step])
if (missingStep) {
return { ok: false, reason: `Workflow module is missing definition for ${missingStep}.` }
}
const anchorX = preferredPosition?.x ?? (existingNodes.length > 0 ? Math.max(...existingNodes.map(node => node.position.x)) + 220 : 120)
const anchorY = preferredPosition?.y ?? (existingNodes.length > 0 ? Math.max(...existingNodes.map(node => node.position.y)) + 60 : 140)
const timestamp = Date.now()
const spacingX = 220
const nodes = bundle.stepIds.map((step, index) => {
const definition = nodeDefinitionsByStep[step]
return {
id: `${bundle.id}_${step}_${timestamp}_${index}`,
type: definition?.node_type ?? inferNodeType(step),
position: {
x: anchorX + index * spacingX,
y: anchorY,
},
data: buildNodeData(step, definition?.defaults ?? {}, definition),
} satisfies Node
})
const edges = nodes.slice(1).map((node, index) => ({
id: `${nodes[index].id}->${node.id}`,
source: nodes[index].id,
target: node.id,
}) satisfies Edge)
return {
ok: true,
bundle,
nodes,
edges,
}
}
@@ -1,14 +1,17 @@
import type { StepCategory, WorkflowNodeDefinition, WorkflowNodeFamily } from '../../api/workflows'
import {
AUTHORING_STAGE_ORDER,
compareNodeDefinitions,
getDefinitionAuthoringStage,
getDefinitionFamily,
getDefinitionModuleLabel,
getDefinitionModuleNamespace,
getDefinitionSearchText,
getPrimaryLibraryGroup,
getDefinitionModuleNamespace,
getDefinitionModuleLabel,
isDefinitionAllowedForGraphFamily,
matchesNodeKindFilter,
NODE_CATEGORY_ORDER,
type WorkflowAuthoringStage,
type WorkflowGraphFamily,
type WorkflowNodeFamilyFilter,
type WorkflowNodeKindFilter,
@@ -30,23 +33,92 @@ export type WorkflowNodeCatalogCategorySection = {
export type WorkflowNodeCatalogModuleSection = {
namespace: string
label: string
stage: WorkflowAuthoringStage
definitions: WorkflowNodeDefinition[]
categories: WorkflowNodeCatalogCategorySection[]
familyCounts: Record<WorkflowNodeFamily, number>
runtimeCounts: Record<WorkflowNodeLibraryGroup, number>
}
export type WorkflowNodeCatalogGroupSection = {
group: WorkflowNodeLibraryGroup
export type WorkflowNodeCatalogStageSection = {
stage: WorkflowAuthoringStage
definitions: WorkflowNodeDefinition[]
modules: WorkflowNodeCatalogModuleSection[]
runtimeCounts: Record<WorkflowNodeLibraryGroup, number>
}
export type WorkflowNodeCatalogModuleStageSection = {
stage: WorkflowAuthoringStage
definitions: WorkflowNodeDefinition[]
categories: WorkflowNodeCatalogCategorySection[]
}
export type WorkflowNodeCatalogFamilyModuleSection = {
namespace: string
label: string
definitions: WorkflowNodeDefinition[]
stages: WorkflowAuthoringStage[]
stageSections: WorkflowNodeCatalogModuleStageSection[]
runtimeCounts: Record<WorkflowNodeLibraryGroup, number>
}
export type WorkflowNodeCatalogFamilySection = {
family: WorkflowNodeFamily
definitions: WorkflowNodeDefinition[]
modules: WorkflowNodeCatalogFamilyModuleSection[]
stageSections: WorkflowNodeCatalogStageSection[]
runtimeCounts: Record<WorkflowNodeLibraryGroup, number>
}
export type WorkflowNodeCatalogModuleFilter = {
namespace: string
label: string
count: number
stages: WorkflowAuthoringStage[]
}
export type WorkflowNodeCatalogModel = {
definitions: WorkflowNodeDefinition[]
moduleFilters: WorkflowNodeCatalogModuleFilter[]
familySections: WorkflowNodeCatalogFamilySection[]
stageSections: WorkflowNodeCatalogStageSection[]
runtimeCounts: Record<WorkflowNodeLibraryGroup, number>
}
function buildModuleSection(
stage: WorkflowAuthoringStage,
moduleDefinitions: WorkflowNodeDefinition[],
): WorkflowNodeCatalogModuleSection {
const definitions = [...moduleDefinitions].sort(compareNodeDefinitions)
return {
namespace: getDefinitionModuleNamespace(definitions[0]),
label: getDefinitionModuleLabel(definitions[0]),
stage,
definitions,
categories: NODE_CATEGORY_ORDER.map(category => ({
category,
definitions: definitions.filter(definition => definition.category === category),
})).filter(section => section.definitions.length > 0),
familyCounts: {
cad_file: definitions.filter(definition => getDefinitionFamily(definition) === 'cad_file').length,
shared: definitions.filter(definition => getDefinitionFamily(definition) === 'shared').length,
order_line: definitions.filter(definition => getDefinitionFamily(definition) === 'order_line').length,
},
runtimeCounts: {
legacy: definitions.filter(definition => getPrimaryLibraryGroup(definition) === 'legacy').length,
bridge: definitions.filter(definition => getPrimaryLibraryGroup(definition) === 'bridge').length,
graph: definitions.filter(definition => getPrimaryLibraryGroup(definition) === 'graph').length,
},
}
}
export function getAvailableFamilyFilters(
graphFamily: WorkflowGraphFamily,
): WorkflowNodeFamilyFilter[] {
return graphFamily === 'mixed'
? ['all', 'cad_file', 'order_line']
: [graphFamily]
? ['all', 'cad_file', 'shared', 'order_line']
: [graphFamily, 'shared']
}
export function filterWorkflowNodeDefinitions(
@@ -62,7 +134,12 @@ export function filterWorkflowNodeDefinitions(
return definitions
.filter(definition => isDefinitionAllowedForGraphFamily(definition, graphFamily))
.filter(definition => familyFilter === 'all' || getDefinitionFamily(definition) === familyFilter)
.filter(definition => {
if (familyFilter === 'all') return true
const family = getDefinitionFamily(definition)
if (familyFilter === 'shared') return family === 'shared'
return family === familyFilter || family === 'shared'
})
.filter(definition => matchesNodeKindFilter(definition, kindFilter))
.filter(definition => !normalizedQuery || getDefinitionSearchText(definition).includes(normalizedQuery))
.sort(compareNodeDefinitions)
@@ -70,48 +147,173 @@ export function filterWorkflowNodeDefinitions(
export function buildWorkflowNodeCatalog(
definitions: WorkflowNodeDefinition[],
): WorkflowNodeCatalogGroupSection[] {
const groupEntries = new Map<
WorkflowNodeLibraryGroup,
Map<string, WorkflowNodeDefinition[]>
>()
): WorkflowNodeCatalogStageSection[] {
const stageEntries = new Map<WorkflowAuthoringStage, Map<string, WorkflowNodeDefinition[]>>()
for (const definition of definitions) {
const group = getPrimaryLibraryGroup(definition)
const stage = getDefinitionAuthoringStage(definition)
const namespace = getDefinitionModuleNamespace(definition)
const modules = groupEntries.get(group) ?? new Map<string, WorkflowNodeDefinition[]>()
const modules = stageEntries.get(stage) ?? new Map<string, WorkflowNodeDefinition[]>()
modules.set(namespace, [...(modules.get(namespace) ?? []), definition])
groupEntries.set(group, modules)
stageEntries.set(stage, 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,
},
}
})
return AUTHORING_STAGE_ORDER
.map(stage => {
const moduleEntries = stageEntries.get(stage) ?? new Map<string, WorkflowNodeDefinition[]>()
const modules = Array.from(moduleEntries.values())
.map(moduleDefinitions => buildModuleSection(stage, moduleDefinitions))
.sort((a, b) => a.label.localeCompare(b.label))
const groupDefinitions = modules.flatMap(module => module.definitions)
const stageDefinitions = modules.flatMap(module => module.definitions)
return {
group,
definitions: groupDefinitions,
stage,
definitions: stageDefinitions,
modules,
runtimeCounts: {
legacy: stageDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'legacy').length,
bridge: stageDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'bridge').length,
graph: stageDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'graph').length,
},
}
})
.filter(section => section.definitions.length > 0)
}
function buildModuleStageSection(
stage: WorkflowAuthoringStage,
definitions: WorkflowNodeDefinition[],
): WorkflowNodeCatalogModuleStageSection {
const sortedDefinitions = [...definitions].sort(compareNodeDefinitions)
return {
stage,
definitions: sortedDefinitions,
categories: NODE_CATEGORY_ORDER.map(category => ({
category,
definitions: sortedDefinitions.filter(definition => definition.category === category),
})).filter(section => section.definitions.length > 0),
}
}
function buildFamilyModuleSections(
definitions: WorkflowNodeDefinition[],
): WorkflowNodeCatalogFamilyModuleSection[] {
const moduleEntries = new Map<
string,
{
label: string
stages: Map<WorkflowAuthoringStage, WorkflowNodeDefinition[]>
}
>()
for (const definition of definitions) {
const namespace = getDefinitionModuleNamespace(definition)
const stage = getDefinitionAuthoringStage(definition)
const existing = moduleEntries.get(namespace) ?? {
label: getDefinitionModuleLabel(definition),
stages: new Map<WorkflowAuthoringStage, WorkflowNodeDefinition[]>(),
}
existing.stages.set(stage, [...(existing.stages.get(stage) ?? []), definition])
moduleEntries.set(namespace, existing)
}
return Array.from(moduleEntries.entries())
.map(([namespace, entry]) => {
const stageSections = AUTHORING_STAGE_ORDER
.map(stage => {
const stageDefinitions = entry.stages.get(stage) ?? []
if (stageDefinitions.length === 0) return null
return buildModuleStageSection(stage, stageDefinitions)
})
.filter((section): section is WorkflowNodeCatalogModuleStageSection => Boolean(section))
const moduleDefinitions = stageSections.flatMap(section => section.definitions)
return {
namespace,
label: entry.label,
definitions: moduleDefinitions,
stages: stageSections.map(section => section.stage),
stageSections,
runtimeCounts: {
legacy: moduleDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'legacy').length,
bridge: moduleDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'bridge').length,
graph: moduleDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'graph').length,
},
}
})
.sort((a, b) => a.label.localeCompare(b.label))
}
function buildFamilySections(
definitions: WorkflowNodeDefinition[],
): WorkflowNodeCatalogFamilySection[] {
const familyEntries = new Map<WorkflowNodeFamily, WorkflowNodeDefinition[]>()
for (const definition of definitions) {
const family = getDefinitionFamily(definition)
familyEntries.set(family, [...(familyEntries.get(family) ?? []), definition])
}
return (['cad_file', 'shared', 'order_line'] as WorkflowNodeFamily[])
.map(family => {
const familyDefinitions = [...(familyEntries.get(family) ?? [])].sort(compareNodeDefinitions)
if (familyDefinitions.length === 0) return null
return {
family,
definitions: familyDefinitions,
modules: buildFamilyModuleSections(familyDefinitions),
stageSections: buildWorkflowNodeCatalog(familyDefinitions),
runtimeCounts: {
legacy: familyDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'legacy').length,
bridge: familyDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'bridge').length,
graph: familyDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'graph').length,
},
}
})
.filter((section): section is WorkflowNodeCatalogFamilySection => Boolean(section))
}
export function buildWorkflowNodeCatalogModel(
definitions: WorkflowNodeDefinition[],
): WorkflowNodeCatalogModel {
const stageSections = buildWorkflowNodeCatalog(definitions)
const familySections = buildFamilySections(definitions)
const moduleFilterEntries = new Map<string, WorkflowNodeCatalogModuleFilter>()
for (const section of stageSections) {
for (const module of section.modules) {
const existing = moduleFilterEntries.get(module.namespace)
if (existing) {
existing.count += module.definitions.length
if (!existing.stages.includes(section.stage)) {
existing.stages.push(section.stage)
}
continue
}
moduleFilterEntries.set(module.namespace, {
namespace: module.namespace,
label: module.label,
count: module.definitions.length,
stages: [section.stage],
})
}
}
const moduleFilters = Array.from(moduleFilterEntries.values())
.sort((a, b) => a.label.localeCompare(b.label))
return {
definitions,
moduleFilters,
familySections,
stageSections,
runtimeCounts: {
legacy: definitions.filter(definition => getPrimaryLibraryGroup(definition) === 'legacy').length,
bridge: definitions.filter(definition => getPrimaryLibraryGroup(definition) === 'bridge').length,
graph: definitions.filter(definition => getPrimaryLibraryGroup(definition) === 'graph').length,
},
}
}
@@ -1,9 +1,16 @@
import type { StepCategory, WorkflowNodeDefinition, WorkflowNodeFamily } from '../../api/workflows'
export type WorkflowNodeFamilyFilter = 'all' | WorkflowNodeFamily
export type WorkflowGraphFamily = WorkflowNodeFamily | 'mixed'
export type WorkflowGraphFamily = Exclude<WorkflowNodeFamily, 'shared'> | 'mixed'
export type WorkflowNodeKindFilter = 'all' | 'legacy' | 'bridge' | 'graph'
export type WorkflowNodeLibraryGroup = 'legacy' | 'bridge' | 'graph'
export type WorkflowAuthoringStage =
| 'cad_intake'
| 'scene_prep'
| 'materials'
| 'render'
| 'publish'
| 'orchestration'
export const CATEGORY_LABELS: Record<StepCategory, string> = {
input: 'Input',
@@ -24,6 +31,7 @@ export const NODE_CATEGORY_ORDER: StepCategory[] = ['input', 'processing', 'rend
export const FAMILY_FILTER_LABELS: Record<WorkflowNodeFamilyFilter, string> = {
all: 'All Nodes',
cad_file: 'CAD Intake',
shared: 'Shared',
order_line: 'Order Rendering',
}
@@ -52,13 +60,51 @@ export const NODE_LIBRARY_GROUP_DESCRIPTIONS: Record<WorkflowNodeLibraryGroup, s
graph: 'Native graph runtime nodes for the non-legacy editor flow.',
}
export const AUTHORING_STAGE_ORDER: WorkflowAuthoringStage[] = [
'cad_intake',
'scene_prep',
'materials',
'render',
'publish',
'orchestration',
]
export const AUTHORING_STAGE_LABELS: Record<WorkflowAuthoringStage, string> = {
cad_intake: 'CAD Intake',
scene_prep: 'Scene Prep',
materials: 'Materials',
render: 'Render',
publish: 'Publish',
orchestration: 'Orchestration',
}
export const AUTHORING_STAGE_DESCRIPTIONS: Record<WorkflowAuthoringStage, string> = {
cad_intake: 'Import CAD sources, extract geometry, and prepare downstream preview assets.',
scene_prep: 'Resolve context, templates, geometry metadata, and upstream render state.',
materials: 'Map, normalize, or override materials before render execution.',
render: 'Generate stills, thumbnails, or other rendered artifacts.',
publish: 'Persist outputs and emit downstream completion signals.',
orchestration: 'Support glue, control flow, or utility nodes that do not belong to a single production stage.',
}
export const AUTHORING_STAGE_STYLES: Record<WorkflowAuthoringStage, string> = {
cad_intake: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300',
scene_prep: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300',
materials: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
render: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300',
publish: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
orchestration: 'bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300',
}
export const FAMILY_FILTER_DESCRIPTIONS: Record<WorkflowNodeFamily, string> = {
cad_file: 'Start with a CAD file context and produce previews, caches, or derived assets.',
shared: 'Reusable nodes that can be dropped into either CAD-intake or order-rendering workflows.',
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',
shared: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300',
order_line: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
}
@@ -86,8 +132,16 @@ const CAD_FILE_NODE_STEPS = new Set([
'thumbnail_save',
])
const SHARED_NODE_STEPS = new Set([
'glb_bbox',
])
export function getNodeFamily(step: string, nodeDefinitionsByStep?: WorkflowNodeDefinitionMap): WorkflowNodeFamily {
return nodeDefinitionsByStep?.[step]?.family ?? (CAD_FILE_NODE_STEPS.has(step) ? 'cad_file' : 'order_line')
if (nodeDefinitionsByStep?.[step]?.family) {
return nodeDefinitionsByStep[step].family
}
if (SHARED_NODE_STEPS.has(step)) return 'shared'
return CAD_FILE_NODE_STEPS.has(step) ? 'cad_file' : 'order_line'
}
export function getDefinitionFamily(
@@ -103,7 +157,8 @@ export function isDefinitionAllowedForGraphFamily(
nodeDefinitionsByStep?: WorkflowNodeDefinitionMap,
): boolean {
if (graphFamily === 'mixed') return true
return getDefinitionFamily(definition, nodeDefinitionsByStep) === graphFamily
const family = getDefinitionFamily(definition, nodeDefinitionsByStep)
return family === 'shared' || family === graphFamily
}
export function compareNodeDefinitions(a: WorkflowNodeDefinition, b: WorkflowNodeDefinition) {
@@ -125,12 +180,47 @@ export function getDefinitionModuleLabel(definition: WorkflowNodeDefinition): st
.join(' ')
}
export function getDefinitionAuthoringStage(definition: WorkflowNodeDefinition): WorkflowAuthoringStage {
const moduleKey = definition.module_key.toLowerCase()
if (moduleKey.startsWith('cad.')) {
if (definition.category === 'rendering') return 'render'
if (definition.category === 'output') return 'publish'
return 'cad_intake'
}
if (
moduleKey.startsWith('order_line.')
|| moduleKey.startsWith('geometry.')
|| moduleKey.startsWith('context.')
) {
return 'scene_prep'
}
if (moduleKey.startsWith('materials.')) {
return 'materials'
}
if (moduleKey.startsWith('render.') || moduleKey.startsWith('rendering.')) {
return 'render'
}
if (moduleKey.startsWith('media.') || moduleKey.startsWith('notifications.')) {
return 'publish'
}
if (definition.category === 'rendering') return 'render'
if (definition.category === 'output') return 'publish'
if (definition.category === 'input' || definition.category === 'processing') return 'orchestration'
return 'orchestration'
}
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]}`
const groupLabel = `${FAMILY_FILTER_LABELS[family]} · ${AUTHORING_STAGE_LABELS[getDefinitionAuthoringStage(definition)]} · ${getDefinitionModuleLabel(definition)}`
groups.set(groupLabel, [...(groups.get(groupLabel) ?? []), definition])
}
@@ -145,6 +235,9 @@ export function groupDefinitionsByFamily(
cad_file: definitions
.filter(definition => getDefinitionFamily(definition, nodeDefinitionsByStep) === 'cad_file')
.sort(compareNodeDefinitions),
shared: definitions
.filter(definition => getDefinitionFamily(definition, nodeDefinitionsByStep) === 'shared')
.sort(compareNodeDefinitions),
order_line: definitions
.filter(definition => getDefinitionFamily(definition, nodeDefinitionsByStep) === 'order_line')
.sort(compareNodeDefinitions),
@@ -0,0 +1,60 @@
import type { WorkflowCanvasPort } from './workflowGraphDraft'
const DIRECT_PORT_BADGE_LABELS: Record<string, string> = {
bbox: 'Bounding Box',
blend_asset: 'Blend Asset',
cad_file_ref: 'CAD File Ref',
cad_materials: 'CAD Materials',
cad_file_record: 'CAD File',
glb_preview: 'GLB Preview',
glb_reuse_path: 'GLB Reuse Path',
material_assignments: 'Material Assignments',
material_catalog_updates: 'Catalog Updates',
media_asset: 'Media Asset',
notification_event: 'Notification Event',
order_line_context: 'Order Context',
order_line_record: 'Order Line',
output_profile: 'Output Profile',
render_template: 'Render Template',
rendered_frames: 'Rendered Frames',
rendered_image: 'Rendered Image',
rendered_video: 'Rendered Video',
step_path: 'STEP Path',
template_inputs: 'Template Inputs',
template_path: 'Template Path',
usd_render_path: 'USD Render Path',
workflow_input_schema: 'Input Schema',
workflow_result: 'Workflow Result',
}
const ALTERNATIVE_PORT_BADGE_LABELS: Record<string, string> = {
blend_asset: 'Blend Asset',
rendered_frames: 'Frames',
rendered_image: 'Image',
rendered_video: 'Video',
workflow_result: 'Workflow Result',
}
function getAlternativeRoleBadgeLabel(role: string): string {
return ALTERNATIVE_PORT_BADGE_LABELS[role] ?? DIRECT_PORT_BADGE_LABELS[role] ?? role
}
export function getWorkflowNodePortBadgeLabel(port: WorkflowCanvasPort): string {
if (port.kind === 'alternative') {
return `Any: ${port.roles.map(getAlternativeRoleBadgeLabel).join(' / ')}`
}
if (port.roles.length === 1) {
return DIRECT_PORT_BADGE_LABELS[port.roles[0]] ?? port.label
}
return port.label
}
export function getWorkflowNodePortTitle(port: WorkflowCanvasPort): string {
if (port.kind === 'alternative') {
return `Accepts any of: ${port.roles.map(role => DIRECT_PORT_BADGE_LABELS[role] ?? role).join(' / ')}`
}
return port.label
}
@@ -0,0 +1,203 @@
import type { Edge, Node } from '@xyflow/react'
import {
buildWorkflowBlueprintConfig,
type WorkflowNodeDefinition,
type WorkflowConfig,
type WorkflowParams,
} from '../../api/workflows'
import {
inferNodeLabel,
inferNodeType,
normalizeWorkflowParams,
type WorkflowCanvasNodeData,
} from './workflowGraphDraft'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
export type WorkflowReferenceBundleId = 'still_render_reference'
| 'cad_intake_reference'
export type WorkflowReferenceBundleDefinition = {
id: WorkflowReferenceBundleId
label: string
shortLabel: string
description: string
family: Exclude<WorkflowGraphFamily, 'mixed'>
stepIds: string[]
stage: string
}
type WorkflowReferenceBundleInsertionResult =
| {
ok: true
bundle: WorkflowReferenceBundleDefinition
nodes: Node[]
edges: Edge[]
}
| {
ok: false
reason: string
}
const WORKFLOW_REFERENCE_BUNDLE_REGISTRY: WorkflowReferenceBundleDefinition[] = [
{
id: 'cad_intake_reference',
label: 'CAD Intake Reference',
shortLabel: 'CAD Intake Reference',
description: 'Insert the canonical CAD intake path so extraction, geometry export, STL cache generation, and thumbnail publishing stay wired in a known-good order.',
family: 'cad_file',
stepIds: [
'resolve_step_path',
'occ_object_extract',
'occ_glb_export',
'stl_cache_generate',
'thumbnail_save',
],
stage: 'Reference Path',
},
{
id: 'still_render_reference',
label: 'Still Render Reference',
shortLabel: 'Still Reference',
description: 'Insert the complete canonical non-legacy still-render path, including material resolution, render, output, and notification branches.',
family: 'order_line',
stepIds: [
'order_line_setup',
'resolve_template',
'auto_populate_materials',
'glb_bbox',
'material_map_resolve',
'blender_still',
'output_save',
'notify',
],
stage: 'Reference Path',
},
]
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,
}
}
function getReferenceTemplate(bundleId: WorkflowReferenceBundleId) {
const toTemplate = (config: WorkflowConfig) => ({
nodes: config.nodes,
edges: config.edges,
})
switch (bundleId) {
case 'cad_intake_reference':
return toTemplate(buildWorkflowBlueprintConfig('cad_intake'))
case 'still_render_reference':
return toTemplate(buildWorkflowBlueprintConfig('still_graph_reference'))
default:
return null
}
}
export function getWorkflowReferenceBundle(bundleId: WorkflowReferenceBundleId) {
return WORKFLOW_REFERENCE_BUNDLE_REGISTRY.find(bundle => bundle.id === bundleId) ?? null
}
export function getWorkflowReferenceBundles(
nodeDefinitions: WorkflowNodeDefinition[],
graphFamily: WorkflowGraphFamily,
): WorkflowReferenceBundleDefinition[] {
const availableSteps = new Set(nodeDefinitions.map(definition => definition.step))
return WORKFLOW_REFERENCE_BUNDLE_REGISTRY.filter(bundle => {
if (graphFamily !== 'mixed' && bundle.family !== graphFamily) return false
return bundle.stepIds.every(step => availableSteps.has(step))
})
}
export function createWorkflowReferenceBundleInsertion(args: {
bundleId: WorkflowReferenceBundleId
graphFamily: WorkflowGraphFamily
nodeDefinitionsByStep: Record<string, WorkflowNodeDefinition>
existingNodes: Node[]
preferredPosition?: { x: number; y: number }
}): WorkflowReferenceBundleInsertionResult {
const { bundleId, graphFamily, nodeDefinitionsByStep, existingNodes, preferredPosition } = args
const bundle = getWorkflowReferenceBundle(bundleId)
if (!bundle) {
return { ok: false, reason: 'Unknown workflow reference path.' }
}
if (graphFamily !== 'mixed' && bundle.family !== graphFamily) {
return { ok: false, reason: `${bundle.label} does not belong to the active authoring family.` }
}
const missingStep = bundle.stepIds.find(step => !nodeDefinitionsByStep[step])
if (missingStep) {
return { ok: false, reason: `Workflow reference path is missing definition for ${missingStep}.` }
}
const template = getReferenceTemplate(bundleId)
if (!template) {
return { ok: false, reason: 'Workflow reference path template is unavailable.' }
}
const existingMaxX = existingNodes.length > 0 ? Math.max(...existingNodes.map(node => node.position.x)) : null
const existingMaxY = existingNodes.length > 0 ? Math.max(...existingNodes.map(node => node.position.y)) : null
const anchorX = preferredPosition?.x ?? (existingMaxX !== null ? existingMaxX + 260 : 120)
const anchorY = preferredPosition?.y ?? (existingMaxY !== null ? existingMaxY + 80 : 120)
const timestamp = Date.now()
const templatePositions = template.nodes.map(node => node.ui?.position ?? { x: 0, y: 0 })
const minX = Math.min(...templatePositions.map(position => position.x))
const minY = Math.min(...templatePositions.map(position => position.y))
const nodeIdMap = new Map<string, string>()
const nodes = template.nodes.map(node => {
const definition = nodeDefinitionsByStep[node.step]
const templatePosition = node.ui?.position ?? { x: 0, y: 0 }
const id = `${bundle.id}_${node.id}_${timestamp}`
nodeIdMap.set(node.id, id)
return {
id,
type: definition?.node_type ?? node.ui?.type ?? inferNodeType(node.step),
position: {
x: anchorX + (templatePosition.x - minX),
y: anchorY + (templatePosition.y - minY),
},
data: buildNodeData(
node.step,
node.params ?? definition?.defaults ?? {},
definition,
node.ui?.label
? {
label: node.ui.label,
}
: undefined,
),
} satisfies Node
})
const edges = template.edges.map(edge => ({
id: `${nodeIdMap.get(edge.from)}->${nodeIdMap.get(edge.to)}`,
source: nodeIdMap.get(edge.from) ?? edge.from,
target: nodeIdMap.get(edge.to) ?? edge.to,
}) satisfies Edge)
return {
ok: true,
bundle,
nodes,
edges,
}
}
@@ -0,0 +1,76 @@
import type { WorkflowRolloutSummary } from '../../api/workflows'
export interface WorkflowRolloutPresentation {
badgeLabel: string
badgeClassName: string
statusLabel: string
statusClassName: string
summary: string
}
export function getWorkflowRolloutPresentation(
rollout: WorkflowRolloutSummary,
): WorkflowRolloutPresentation {
if (rollout.linked_output_type_count === 0) {
return {
badgeLabel: 'Unlinked',
badgeClassName: 'bg-surface-muted text-content-muted',
statusLabel: 'Legacy Only',
statusClassName: 'bg-slate-100 text-slate-700',
summary: 'No output types are linked to this workflow yet.',
}
}
if (rollout.has_blocking_contracts) {
return {
badgeLabel: 'Contract Blocked',
badgeClassName: 'bg-red-100 text-red-700',
statusLabel: 'Do Not Promote',
statusClassName: 'bg-red-100 text-red-700',
summary: 'One or more linked output types are contract-invalid for this workflow.',
}
}
const rolloutModes = new Set(rollout.rollout_modes)
if (rolloutModes.has('graph')) {
return {
badgeLabel: rolloutModes.size > 1 ? 'Mixed Rollout' : 'Graph Authoritative',
badgeClassName: 'bg-status-success-bg text-status-success-text',
statusLabel: rollout.latest_rollout_ready ? 'Ready For Rollout' : 'Production: Graph',
statusClassName: rollout.latest_rollout_ready
? 'bg-emerald-100 text-emerald-700'
: 'bg-green-100 text-green-700',
summary:
rolloutModes.size > 1
? 'Some linked output types are already graph-authoritative while others still hold legacy or shadow.'
: 'Linked output types dispatch through the graph runtime with legacy fallback armed.',
}
}
if (rolloutModes.has('shadow')) {
return {
badgeLabel: 'Shadow',
badgeClassName: 'bg-status-info-bg text-status-info-text',
statusLabel:
rollout.latest_rollout_status === 'ready_for_rollout'
? 'Ready For Rollout'
: 'Legacy Authoritative',
statusClassName:
rollout.latest_rollout_status === 'ready_for_rollout'
? 'bg-emerald-100 text-emerald-700'
: 'bg-sky-100 text-sky-700',
summary:
rollout.latest_rollout_gate_verdict != null
? `Latest shadow verdict: ${rollout.latest_rollout_gate_verdict}.`
: 'Shadow runs can observe parity while legacy remains authoritative.',
}
}
return {
badgeLabel: 'Legacy Only',
badgeClassName: 'bg-surface-muted text-content-muted',
statusLabel: 'Production: Legacy',
statusClassName: 'bg-slate-100 text-slate-700',
summary: 'Linked output types keep this workflow attached for authoring, but production stays on legacy.',
}
}
+2 -13
View File
@@ -4,12 +4,10 @@
* Wraps the app with a single WebSocket connection. On incoming events it
* invalidates the relevant React Query caches so all subscribers refresh.
*/
import { createContext, useContext, useCallback } from 'react'
import { useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useWebSocket, type WSEvent } from '../hooks/useWebSocket'
const WebSocketContext = createContext<null>(null)
export function WebSocketProvider({ children }: { children: React.ReactNode }) {
const qc = useQueryClient()
@@ -49,14 +47,5 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
useWebSocket({ onEvent })
return (
<WebSocketContext.Provider value={null}>
{children}
</WebSocketContext.Provider>
)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function useWebSocketContext() {
return useContext(WebSocketContext)
return <>{children}</>
}
+33 -8
View File
@@ -22,7 +22,7 @@ interface UseWebSocketOptions {
const WS_BASE =
window.location.protocol === 'https:'
? `wss://${window.location.host}`
: `ws://${window.location.hostname}:8888`
: `ws://${window.location.host}`
const PING_INTERVAL_MS = 25_000
const MAX_BACKOFF_MS = 30_000
@@ -33,18 +33,32 @@ export function useWebSocket({ onEvent, enabled = true }: UseWebSocketOptions =
const backoffRef = useRef(1000)
const pingRef = useRef<ReturnType<typeof setInterval> | null>(null)
const reconnectRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const connectionVersionRef = useRef(0)
const onEventRef = useRef(onEvent)
onEventRef.current = onEvent
const cleanup = useCallback(() => {
connectionVersionRef.current += 1
if (pingRef.current) clearInterval(pingRef.current)
if (reconnectRef.current) clearTimeout(reconnectRef.current)
if (wsRef.current) {
wsRef.current.onclose = null
wsRef.current.onerror = null
wsRef.current.onmessage = null
wsRef.current.close()
wsRef.current = null
const ws = wsRef.current
wsRef.current = null
if (ws) {
if (ws.readyState === WebSocket.CONNECTING) {
ws.onopen = () => ws.close(1000, 'teardown')
ws.onclose = null
ws.onerror = null
ws.onmessage = null
return
}
ws.onclose = null
ws.onerror = null
ws.onmessage = null
if (ws.readyState === WebSocket.OPEN) {
ws.close(1000, 'teardown')
}
}
}, [])
@@ -54,9 +68,15 @@ export function useWebSocket({ onEvent, enabled = true }: UseWebSocketOptions =
const url = `${WS_BASE}/api/ws?token=${encodeURIComponent(token)}`
const ws = new WebSocket(url)
const connectionVersion = connectionVersionRef.current + 1
connectionVersionRef.current = connectionVersion
wsRef.current = ws
ws.onopen = () => {
if (connectionVersion !== connectionVersionRef.current) {
ws.close(1000, 'stale')
return
}
backoffRef.current = 1000 // reset backoff on successful connect
// Keep-alive pings
pingRef.current = setInterval(() => {
@@ -67,6 +87,7 @@ export function useWebSocket({ onEvent, enabled = true }: UseWebSocketOptions =
}
ws.onmessage = (evt) => {
if (connectionVersion !== connectionVersionRef.current) return
try {
const event = JSON.parse(evt.data) as WSEvent
onEventRef.current?.(event)
@@ -76,6 +97,7 @@ export function useWebSocket({ onEvent, enabled = true }: UseWebSocketOptions =
}
ws.onclose = () => {
if (connectionVersion !== connectionVersionRef.current) return
if (pingRef.current) clearInterval(pingRef.current)
// Schedule reconnect with exponential backoff
const delay = backoffRef.current
@@ -84,7 +106,10 @@ export function useWebSocket({ onEvent, enabled = true }: UseWebSocketOptions =
}
ws.onerror = () => {
ws.close()
if (connectionVersion !== connectionVersionRef.current) return
if (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) {
ws.close()
}
}
}, [token, enabled, cleanup])
+2
View File
@@ -47,6 +47,7 @@ export default function LoginPage() {
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
required
className="input-base w-full"
placeholder="admin@hartomat.com"
@@ -59,6 +60,7 @@ export default function LoginPage() {
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
className="input-base w-full pr-10"
/>
+3 -5
View File
@@ -8,7 +8,7 @@ import {
} from 'lucide-react'
import {
listMaterials, createMaterial, updateMaterial, deleteMaterial,
seedHartOMatMaterials, addAlias, deleteAlias, seedAliases,
seedHartOMatMaterials, addAlias, deleteAlias, listAliases, seedAliases,
batchCreateAliases,
} from '../api/materials'
import type { Material } from '../api/materials'
@@ -584,12 +584,10 @@ function AliasPill({
materials: Material[]
}) {
// We need the alias ID for deletion - find it from the material's aliases list
// Since we only have alias strings from MaterialOut, we need to query the ID
// We'll use a lazy approach: delete by fetching aliases for this material
// Since we only have alias strings from MaterialOut, we need to query the ID.
const handleDelete = async () => {
try {
const { listAliases: fetchAliases } = await import('../api/materials')
const aliases = await fetchAliases(materialId)
const aliases = await listAliases(materialId)
const found = aliases.find((a) => a.alias === alias)
if (found) {
onDelete.mutate(found.id)
+442 -93
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useMemo } from 'react'
import { useState, useEffect, useMemo, useLayoutEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import {
ReactFlow,
Background,
@@ -14,15 +15,21 @@ import '@xyflow/react/dist/style.css'
import { useThemeStore, resolveTheme } from '../store/theme'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { NewWorkflowModal } from '../components/workflows/NewWorkflowModal'
import { NodeCommandMenu } from '../components/workflows/NodeCommandMenu'
import { NodeCommandMenu, NODE_COMMAND_MENU_WIDTH } from '../components/workflows/NodeCommandMenu'
import { WorkflowCanvasToolbar } from '../components/workflows/WorkflowCanvasToolbar'
import { WorkflowCanvasUtilitySidebar } from '../components/workflows/WorkflowCanvasUtilitySidebar'
import { WorkflowEditorEmptyState } from '../components/workflows/WorkflowEditorEmptyState'
import { WorkflowListSidebar } from '../components/workflows/WorkflowListSidebar'
import { WorkflowValidationBanner } from '../components/workflows/WorkflowValidationBanner'
import {
WORKFLOW_NODE_MIN_HEIGHT,
WORKFLOW_NODE_WIDTH,
type WorkflowCanvasNodeData,
} from '../components/workflows/workflowGraphDraft'
import {
getWorkflowNodePortBadgeLabel,
getWorkflowNodePortTitle,
} from '../components/workflows/workflowNodePresentation'
import {
BLUEPRINT_DESCRIPTION,
BLUEPRINT_LABELS,
@@ -43,6 +50,7 @@ import {
type WorkflowExecutionMode,
type WorkflowPresetType,
} from '../api/workflows'
import { updateOutputType } from '../api/outputTypes'
import {
FileUp,
RefreshCw,
@@ -63,6 +71,12 @@ import {
EXECUTION_MODE_BADGE_STYLES,
EXECUTION_MODE_LABELS,
} from '../components/workflows/workflowRunPresentation'
import { getWorkflowRolloutPresentation } from '../components/workflows/workflowRolloutPresentation'
import {
getWorkflowAuthoringEntryAction,
type WorkflowAuthoringActions,
} from '../components/workflows/workflowAuthoringActions'
import { getWorkflowAuthoringSurfaceModel } from '../components/workflows/workflowAuthoringSurface'
import { useWorkflowCanvasController } from '../components/workflows/useWorkflowCanvasController'
function renderWorkflowIcon(iconName?: string, size = 14) {
@@ -88,33 +102,228 @@ function renderWorkflowIcon(iconName?: string, size = 14) {
// ─── Custom Node Components ──────────────────────────────────────────────────
interface BaseNodeProps {
label: string
data: WorkflowCanvasNodeData
icon: React.ReactNode
accentClass: string
description?: string
selected?: boolean
hasSource?: boolean
hasTarget?: boolean
}
function BaseNode({ label, icon, accentClass, description, selected, hasSource = true, hasTarget = true }: BaseNodeProps) {
function getHandleOffset(index: number, total: number) {
if (total <= 1) return '50%'
const topPadding = 22
const bottomPadding = 22
const usableHeight = WORKFLOW_NODE_MIN_HEIGHT - topPadding - bottomPadding
const step = usableHeight / (total - 1)
return `${topPadding + step * index}px`
}
type NodeBadge = {
id: string
label: string
title?: string
tone?: 'default' | 'muted'
}
function formatCountLabel(count: number, singular: string, plural: string) {
return `${count} ${count === 1 ? singular : plural}`
}
function getNodeConfigurationSummary(data: WorkflowCanvasNodeData) {
const inputPorts = data.inputPorts ?? []
const editableFieldCount = data.editableFieldCount ?? 0
const hasDynamicVariables = Boolean(data.dynamicVariableHint)
const inputSummary =
inputPorts.length > 0
? `Canvas expects ${formatCountLabel(inputPorts.length, 'input socket', 'input sockets')}.`
: 'Entry node, no upstream sockets required.'
if (editableFieldCount > 0) {
return `${inputSummary} Inspector exposes ${formatCountLabel(editableFieldCount, 'local variable', 'local variables')}.`
}
if (hasDynamicVariables) {
return `${inputSummary} Template-selected inspector variables appear after choosing a template.`
}
return `${inputSummary} No inspector variables, behavior comes from connections and runtime context.`
}
function BadgeList({
badges,
emptyLabel,
badgeClassName,
maxVisibleBadges = 3,
}: {
badges: NodeBadge[]
emptyLabel: string
badgeClassName: string
maxVisibleBadges?: number
}) {
if (badges.length === 0) {
return <p className="text-[10px] text-content-muted">{emptyLabel}</p>
}
const visibleBadges = badges.slice(0, maxVisibleBadges)
const hiddenCount = badges.length - visibleBadges.length
return (
<div className="flex flex-wrap gap-1 overflow-hidden">
{visibleBadges.map(badge => (
<span
key={badge.id}
className={`rounded-full border px-1.5 py-0.5 text-[9px] font-medium leading-4 ${
badge.tone === 'muted'
? 'border-border-default bg-surface text-content-muted'
: badgeClassName
}`}
title={badge.title ?? badge.label}
>
{badge.label}
</span>
))}
{hiddenCount > 0 && (
<span
className="rounded-full border border-border-default bg-surface px-1.5 py-0.5 text-[9px] font-medium leading-4 text-content-muted"
title={badges.slice(maxVisibleBadges).map(badge => badge.title ?? badge.label).join(', ')}
>
+{hiddenCount} more
</span>
)}
</div>
)
}
function NodeSummaryRow({
label,
badges,
emptyLabel,
badgeClassName,
maxVisibleBadges,
}: {
label: string
badges: NodeBadge[]
emptyLabel: string
badgeClassName: string
maxVisibleBadges?: number
}) {
return (
<div className="flex items-start gap-2 rounded-lg border border-border-default bg-surface-hover/50 px-2 py-1">
<p className="min-w-[4.6rem] pt-[1px] text-[9px] font-semibold uppercase tracking-wide text-content-muted">
{label}
</p>
<div className="min-w-0 flex-1">
<BadgeList
badges={badges}
emptyLabel={emptyLabel}
badgeClassName={badgeClassName}
maxVisibleBadges={maxVisibleBadges}
/>
</div>
</div>
)
}
function BaseNode({ data, icon, accentClass, selected }: BaseNodeProps) {
const inputPorts = data.inputPorts ?? []
const outputPorts = data.outputPorts ?? []
const editableFieldCount = data.editableFieldCount ?? 0
const configurationSummary = getNodeConfigurationSummary(data)
const variableBadges: NodeBadge[] = [
...(data.editableFieldLabels ?? []).map(label => ({
id: `variable:${label}`,
label,
title: label,
})),
...(data.dynamicVariableHint
? [
{
id: 'variable:dynamic-hint',
label: 'Template Inputs',
title: data.dynamicVariableHint,
tone: 'muted' as const,
},
]
: []),
]
const inputBadges: NodeBadge[] = inputPorts.map(port => ({
id: port.id,
label: getWorkflowNodePortBadgeLabel(port),
title: getWorkflowNodePortTitle(port),
}))
const outputBadges: NodeBadge[] = outputPorts.map(port => ({
id: port.id,
label: getWorkflowNodePortBadgeLabel(port),
title: getWorkflowNodePortTitle(port),
}))
return (
<div
className={`rounded-lg border-2 p-3 min-w-[140px] bg-surface shadow-sm transition-colors ${
className={`relative flex h-full flex-col rounded-xl border-2 bg-surface px-3 py-3 shadow-sm transition-colors ${
selected ? 'border-accent' : 'border-border-default'
}`}
style={{
width: WORKFLOW_NODE_WIDTH,
minHeight: WORKFLOW_NODE_MIN_HEIGHT,
height: WORKFLOW_NODE_MIN_HEIGHT,
}}
>
{hasTarget && (
<Handle type="target" position={Position.Left} className="w-3 h-3 bg-content-muted border-2 border-surface" />
)}
<div className={`flex items-center gap-2 mb-1 ${accentClass}`}>
{inputPorts.map((port, index) => (
<Handle
key={port.id}
id={port.id}
type="target"
position={Position.Left}
title={port.label}
className={`h-3 w-3 border-2 border-surface ${
port.kind === 'alternative' ? 'bg-sky-400' : 'bg-content-muted'
}`}
style={{ top: getHandleOffset(index, inputPorts.length) }}
/>
))}
{outputPorts.map((port, index) => (
<Handle
key={port.id}
id={port.id}
type="source"
position={Position.Right}
title={port.label}
className="h-3 w-3 bg-content-muted border-2 border-surface"
style={{ top: getHandleOffset(index, outputPorts.length) }}
/>
))}
<div className={`mb-1 min-h-[1.25rem] flex items-center gap-2 ${accentClass}`}>
{icon}
<span className="font-medium text-sm">{label}</span>
<span className="font-medium text-sm">{data.label}</span>
</div>
<div className="h-8 overflow-hidden">
{data.description && <p className="line-clamp-2 text-xs text-content-muted">{data.description}</p>}
</div>
<div className="mt-2 space-y-1.5 text-[10px] leading-4 text-content-secondary">
<NodeSummaryRow
label={`Sockets${inputPorts.length > 0 ? ` · ${inputPorts.length}` : ''}`}
badges={inputBadges}
emptyLabel="Entry node"
badgeClassName="border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/40 dark:bg-sky-900/40 dark:text-sky-300"
maxVisibleBadges={5}
/>
<NodeSummaryRow
label={`Variables${editableFieldCount > 0 ? ` · ${editableFieldCount}` : ''}`}
badges={variableBadges}
emptyLabel="No inspector vars"
badgeClassName="border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-900/40 dark:bg-violet-900/40 dark:text-violet-300"
/>
<NodeSummaryRow
label={`Outputs${outputPorts.length > 0 ? ` · ${outputPorts.length}` : ''}`}
badges={outputBadges}
emptyLabel="Terminal node"
badgeClassName="border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/40 dark:bg-emerald-900/40 dark:text-emerald-300"
maxVisibleBadges={4}
/>
</div>
<div className="mt-auto pt-2 text-[10px] text-content-muted">
{configurationSummary}
</div>
{description && <p className="text-xs text-content-muted">{description}</p>}
{hasSource && (
<Handle type="source" position={Position.Right} className="w-3 h-3 bg-content-muted border-2 border-surface" />
)}
</div>
)
}
@@ -122,12 +331,10 @@ function BaseNode({ label, icon, accentClass, description, selected, hasSource =
function InputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
return (
<BaseNode
label={data.label}
data={data}
icon={renderWorkflowIcon(data.icon)}
accentClass="text-green-600"
description={data.description}
selected={selected}
hasTarget={false}
/>
)
}
@@ -135,10 +342,9 @@ function InputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?
function ConvertNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
return (
<BaseNode
label={data.label}
data={data}
icon={renderWorkflowIcon(data.icon)}
accentClass="text-blue-600"
description={data.description}
selected={selected}
/>
)
@@ -147,10 +353,9 @@ function ConvertNode({ data, selected }: { data: WorkflowCanvasNodeData; selecte
function ProcessNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
return (
<BaseNode
label={data.label}
data={data}
icon={renderWorkflowIcon(data.icon)}
accentClass="text-sky-600"
description={data.description}
selected={selected}
/>
)
@@ -160,14 +365,14 @@ function RenderNode({ data, selected }: { data: WorkflowCanvasNodeData; selected
const params = data.params ?? {}
return (
<BaseNode
label={data.label}
data={{
...data,
description: params.render_engine
? `${params.render_engine} · ${params.samples ?? 256} samples`
: data.description,
}}
icon={renderWorkflowIcon(data.icon)}
accentClass="text-orange-600"
description={
params.render_engine
? `${params.render_engine} · ${params.samples ?? 256} samples`
: data.description
}
selected={selected}
/>
)
@@ -177,10 +382,12 @@ function RenderFramesNode({ data, selected }: { data: WorkflowCanvasNodeData; se
const params = data.params ?? {}
return (
<BaseNode
label={data.label}
data={{
...data,
description: params.fps ? `${params.fps} fps · ${params.duration_s ?? '?'}s` : data.description,
}}
icon={renderWorkflowIcon(data.icon)}
accentClass="text-orange-600"
description={params.fps ? `${params.fps} fps · ${params.duration_s ?? '?'}s` : data.description}
selected={selected}
/>
)
@@ -189,12 +396,10 @@ function RenderFramesNode({ data, selected }: { data: WorkflowCanvasNodeData; se
function OutputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
return (
<BaseNode
label={data.label}
data={data}
icon={renderWorkflowIcon(data.icon)}
accentClass="text-slate-600"
description={data.description}
selected={selected}
hasSource={false}
/>
)
}
@@ -223,6 +428,7 @@ interface FlowCanvasProps {
}
function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
const queryClient = useQueryClient()
const {
reactFlowWrapper,
nodeDefinitions,
@@ -242,7 +448,15 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
preflightMutation,
dispatchContextId,
setDispatchContextId,
isOrderLineGraph,
isOrderLineContextsLoading,
orderLineContextGroups,
dispatchContextLabel,
dispatchContextSummary,
dispatchContextMeta,
preflightResult,
preflightState,
hasFreshSuccessfulPreflight,
executionMode,
setExecutionMode,
nodeMenuAnchor,
@@ -250,16 +464,20 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
activeUtilityTab,
setActiveUtilityTab,
validation,
authoringFamily,
graphFamily,
onConnect,
onNodeClick,
onEdgeClick,
onPaneClick,
handleSelectionChange,
handleParamsChange,
handlePipelineStepChange,
handlePaneContextMenu,
handleNodeContextMenu,
insertNode,
insertModuleBundle,
insertReferenceBundle,
handleOpenToolbarNodeMenu,
handleAutoLayout,
handleDeleteSelectedEdges,
@@ -270,25 +488,76 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
handlePreflight,
setReactFlowInstance,
} = useWorkflowCanvasController({ workflow, onSave })
const nodeMenuStyle = useMemo(() => {
if (!nodeMenuAnchor || !reactFlowWrapper.current) return null
const [isCanvasReady, setIsCanvasReady] = useState(false)
const [nodeMenuElement, setNodeMenuElement] = useState<HTMLDivElement | null>(null)
const [nodeMenuSize, setNodeMenuSize] = useState({ width: NODE_COMMAND_MENU_WIDTH, height: 420 })
useEffect(() => {
const wrapper = reactFlowWrapper.current
if (!wrapper) return
const updateCanvasReadiness = () => {
const bounds = wrapper.getBoundingClientRect()
setIsCanvasReady(bounds.width > 0 && bounds.height > 0)
}
updateCanvasReadiness()
const resizeObserver = new ResizeObserver(() => {
updateCanvasReadiness()
})
resizeObserver.observe(wrapper)
return () => {
resizeObserver.disconnect()
}
}, [reactFlowWrapper, workflow.id])
useLayoutEffect(() => {
if (!nodeMenuElement) return
const measure = () => {
const bounds = nodeMenuElement.getBoundingClientRect()
setNodeMenuSize({
width: Math.max(Math.ceil(bounds.width), NODE_COMMAND_MENU_WIDTH),
height: Math.max(Math.ceil(bounds.height), 320),
})
}
measure()
const resizeObserver = new ResizeObserver(() => {
measure()
})
resizeObserver.observe(nodeMenuElement)
return () => {
resizeObserver.disconnect()
}
}, [nodeMenuElement, nodeMenuAnchor])
const nodeMenuRef = useCallback((element: HTMLDivElement | null) => {
setNodeMenuElement(element)
}, [])
const nodeMenuStyle = useMemo(() => {
if (!nodeMenuAnchor || typeof window === 'undefined') return null
const bounds = reactFlowWrapper.current.getBoundingClientRect()
const MENU_WIDTH = 380
const HORIZONTAL_MARGIN = 16
const VERTICAL_MARGIN = 16
const width = nodeMenuSize.width
const height = Math.min(nodeMenuSize.height, window.innerHeight - VERTICAL_MARGIN * 2)
const left = Math.min(
Math.max(nodeMenuAnchor.clientX - bounds.left, HORIZONTAL_MARGIN),
Math.max(bounds.width - MENU_WIDTH - HORIZONTAL_MARGIN, HORIZONTAL_MARGIN),
Math.max(nodeMenuAnchor.clientX, HORIZONTAL_MARGIN),
Math.max(window.innerWidth - width - HORIZONTAL_MARGIN, HORIZONTAL_MARGIN),
)
const top = Math.min(
Math.max(nodeMenuAnchor.clientY - bounds.top, VERTICAL_MARGIN),
Math.max(bounds.height - 160, VERTICAL_MARGIN),
Math.max(nodeMenuAnchor.clientY, VERTICAL_MARGIN),
Math.max(window.innerHeight - height - VERTICAL_MARGIN, VERTICAL_MARGIN),
)
return { left, top }
}, [nodeMenuAnchor])
}, [nodeMenuAnchor, nodeMenuSize.height, nodeMenuSize.width])
const { mode } = useThemeStore()
const isDark = resolveTheme(mode) === 'dark'
@@ -309,6 +578,46 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
})),
[edges, isDark],
)
const activeSteps = useMemo(
() =>
nodes
.map(node => (node.data as WorkflowCanvasNodeData | undefined)?.step)
.filter((step): step is string => Boolean(step)),
[nodes],
)
const authoringActions = useMemo<WorkflowAuthoringActions>(
() => ({
openNodeMenu: handleOpenToolbarNodeMenu,
insertNode,
insertModule: insertModuleBundle,
insertReferencePath: insertReferenceBundle,
}),
[handleOpenToolbarNodeMenu, insertModuleBundle, insertNode, insertReferenceBundle],
)
const authoringSurfaceModel = useMemo(
() => getWorkflowAuthoringSurfaceModel({ definitions: nodeDefinitions, graphFamily: authoringFamily, activeSteps }),
[activeSteps, authoringFamily, nodeDefinitions],
)
const authoringEntryAction = useMemo(
() => getWorkflowAuthoringEntryAction(authoringSurfaceModel),
[authoringSurfaceModel],
)
const rolloutPresentation = useMemo(
() => getWorkflowRolloutPresentation(workflow.rollout_summary),
[workflow.rollout_summary],
)
const rollbackOutputTypeMutation = useMutation({
mutationFn: ({ outputTypeId }: { outputTypeId: string }) =>
updateOutputType(outputTypeId, { workflow_rollout_mode: 'legacy_only' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['workflows'] })
queryClient.invalidateQueries({ queryKey: ['output-types'] })
toast.success('Output type rollout reverted to legacy')
},
onError: () => {
toast.error('Failed to revert output type rollout')
},
})
return (
<div className="flex flex-col flex-1 min-h-0">
@@ -320,90 +629,101 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
? BLUEPRINT_DESCRIPTION[getWorkflowBlueprint(workflow.config)!] ?? 'Reference workflow graph.'
: null
}
authoringFamilyLabel={GRAPH_FAMILY_LABELS[authoringFamily]}
authoringFamilyClassName={GRAPH_FAMILY_STYLES[authoringFamily]}
graphFamilyLabel={GRAPH_FAMILY_LABELS[graphFamily]}
graphFamilyClassName={GRAPH_FAMILY_STYLES[graphFamily]}
executionMode={executionMode}
executionModeLabel={EXECUTION_MODE_LABELS[executionMode]}
executionModeClassName={EXECUTION_MODE_BADGE_STYLES[executionMode]}
executionModeHint={EXECUTION_MODE_HINTS[executionMode]}
rolloutBadgeLabel={rolloutPresentation.badgeLabel}
rolloutBadgeClassName={rolloutPresentation.badgeClassName}
rolloutStatusLabel={rolloutPresentation.statusLabel}
rolloutStatusClassName={rolloutPresentation.statusClassName}
rolloutSummary={rolloutPresentation.summary}
linkedOutputTypeCount={workflow.rollout_summary.linked_output_type_count}
linkedOutputTypes={workflow.rollout_summary.linked_output_types}
dispatchContextKind={isOrderLineGraph ? 'order_line' : graphFamily === 'cad_file' ? 'cad_file' : null}
dispatchContextLabel={dispatchContextLabel}
dispatchContextId={dispatchContextId}
dispatchContextSummary={dispatchContextSummary}
dispatchContextMeta={dispatchContextMeta}
orderLineContextGroups={orderLineContextGroups}
executionModes={(['legacy', 'graph', 'shadow'] as WorkflowExecutionMode[]).map(mode => ({
value: mode,
label: EXECUTION_MODE_LABELS[mode],
}))}
selectedEdgeCount={selectedEdgeIds.length}
canAutoLayout={nodes.length > 0}
canPreflight={dispatchContextId.trim().length > 0}
canDispatch={dispatchContextId.trim().length > 0 && hasFreshSuccessfulPreflight}
hasValidationErrors={validation.errors.length > 0}
isPreflightPending={preflightMutation.isPending}
isDispatchPending={dispatchMutation.isPending}
isContextOptionsLoading={isOrderLineContextsLoading}
isSaving={isSaving}
rollbackPendingOutputTypeId={rollbackOutputTypeMutation.variables?.outputTypeId ?? null}
preflightState={preflightState}
authoringActions={authoringActions}
authoringEntryAction={authoringEntryAction}
onDispatchContextIdChange={setDispatchContextId}
onExecutionModeChange={value => setExecutionMode(value as WorkflowExecutionMode)}
onOpenNodeMenu={handleOpenToolbarNodeMenu}
onAutoLayout={handleAutoLayout}
onDeleteSelectedEdges={handleDeleteSelectedEdges}
onPreflight={handlePreflight}
onDispatch={handleDispatch}
onSave={handleSave}
onRollbackOutputType={outputTypeId => rollbackOutputTypeMutation.mutate({ outputTypeId })}
/>
<WorkflowValidationBanner errors={validation.errors} warnings={validation.warnings} />
{/* Canvas + Sidepanel */}
<div className="flex flex-1 min-h-0">
<div className="flex h-full flex-1 min-h-0 flex-col xl:flex-row">
<div
ref={reactFlowWrapper}
className="relative flex-1 min-h-[680px]"
className="relative flex min-h-[680px] min-w-0 flex-1 overflow-hidden xl:h-full"
onContextMenu={event => event.preventDefault()}
>
<ReactFlow
nodes={nodes}
edges={canvasEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
onEdgeClick={onEdgeClick}
onEdgeContextMenu={onEdgeContextMenu}
onEdgeDoubleClick={onEdgeDoubleClick}
onNodeContextMenu={handleNodeContextMenu}
onPaneClick={onPaneClick}
onPaneContextMenu={handlePaneContextMenu}
onInit={setReactFlowInstance}
nodeTypes={nodeTypes}
colorMode={isDark ? 'dark' : 'light'}
defaultEdgeOptions={{
interactionWidth: 44,
selectable: true,
focusable: true,
}}
deleteKeyCode={['Backspace', 'Delete']}
fitView
fitViewOptions={{ padding: 0.2 }}
className="h-full w-full"
>
<Background gap={16} />
<Controls />
<MiniMap nodeStrokeWidth={3} zoomable pannable />
</ReactFlow>
{nodeMenuAnchor && nodeMenuStyle && (
<div
className="absolute z-20"
style={{
left: nodeMenuStyle.left,
top: nodeMenuStyle.top,
{isCanvasReady ? (
<ReactFlow
nodes={nodes}
edges={canvasEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
onEdgeClick={onEdgeClick}
onEdgeContextMenu={onEdgeContextMenu}
onEdgeDoubleClick={onEdgeDoubleClick}
onNodeContextMenu={handleNodeContextMenu}
onPaneClick={onPaneClick}
onPaneContextMenu={handlePaneContextMenu}
onSelectionChange={handleSelectionChange}
onInit={setReactFlowInstance}
nodeTypes={nodeTypes}
colorMode={isDark ? 'dark' : 'light'}
defaultEdgeOptions={{
interactionWidth: 44,
selectable: true,
focusable: true,
}}
deleteKeyCode={['Backspace', 'Delete']}
fitView
fitViewOptions={{ padding: 0.2 }}
className="h-full w-full"
>
<NodeCommandMenu
definitions={nodeDefinitions}
graphFamily={graphFamily}
onClose={() => setNodeMenuAnchor(null)}
onSelectStep={step => insertNode(step, nodeMenuAnchor.flowPosition)}
renderIcon={renderWorkflowIcon}
/>
<Background gap={16} />
<Controls />
<MiniMap nodeStrokeWidth={3} zoomable pannable />
</ReactFlow>
) : (
<div className="flex h-full w-full items-center justify-center text-sm text-content-muted">
Preparing workflow canvas
</div>
)}
</div>
<WorkflowCanvasUtilitySidebar
@@ -414,8 +734,9 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
onNodeStepChange={handlePipelineStepChange}
nodeDefinitions={nodeDefinitions}
nodeDefinitionsByStep={nodeDefinitionsByStep}
graphFamily={graphFamily}
onInsertNode={step => insertNode(step)}
graphFamily={authoringFamily}
activeSteps={activeSteps}
authoringActions={authoringActions}
renderNodeIcon={renderWorkflowIcon}
workflowRuns={workflowRuns}
selectedRunId={selectedRun?.id ?? null}
@@ -426,6 +747,27 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
isPreflightPending={preflightMutation.isPending}
/>
</div>
{nodeMenuAnchor && nodeMenuStyle && createPortal(
<div
ref={nodeMenuRef}
className="fixed z-[100]"
style={{
left: nodeMenuStyle.left,
top: nodeMenuStyle.top,
}}
>
<NodeCommandMenu
definitions={nodeDefinitions}
graphFamily={authoringFamily}
activeSteps={activeSteps}
actions={authoringActions}
preferredPosition={nodeMenuAnchor.flowPosition}
onClose={() => setNodeMenuAnchor(null)}
renderIcon={renderWorkflowIcon}
/>
</div>,
document.body,
)}
</div>
)
}
@@ -555,6 +897,7 @@ export default function WorkflowEditor() {
const executionMode = wf.config.ui?.execution_mode ?? 'legacy'
const blueprint = getWorkflowBlueprint(wf.config)
const workflowFamily = inferWorkflowFamily(wf.config, nodeDefinitionsByStep)
const rolloutPresentation = getWorkflowRolloutPresentation(wf.rollout_summary)
return {
id: wf.id,
@@ -566,6 +909,12 @@ export default function WorkflowEditor() {
familyClassName: GRAPH_FAMILY_STYLES[workflowFamily],
executionModeLabel: EXECUTION_MODE_LABELS[executionMode],
executionModeClassName: EXECUTION_MODE_BADGE_STYLES[executionMode],
rolloutBadgeLabel: rolloutPresentation.badgeLabel,
rolloutBadgeClassName: rolloutPresentation.badgeClassName,
rolloutStatusLabel: rolloutPresentation.statusLabel,
rolloutStatusClassName: rolloutPresentation.statusClassName,
rolloutSummary: rolloutPresentation.summary,
linkedOutputTypeCount: wf.rollout_summary.linked_output_type_count,
blueprintLabel: blueprint ? BLUEPRINT_LABELS[blueprint] ?? 'Blueprint' : null,
isReference: isReferenceBlueprint(wf.config),
}