feat: workflow graph system — blocks 1-20 complete checkpoint
Full graph-based workflow execution engine with: - WorkflowGraphRuntime with two-phase dispatch (collect → fire Celery tasks) - Node registry with contract validation, socket types, execution kinds - WorkflowRuntimeServices: preflight, context resolution, shadow-mode A/B - WorkflowRouter: CRUD + dispatch/preflight/run-history API endpoints - OutputTypeContracts: workflow binding, rollout-mode resolution - Admin router: output-type workflow binding endpoints - Frontend: complete workflow editor with drag-drop canvas, node inspector, module bundles, reference bundles, preflight panel, validation banner, authoring guidance, blueprint templates, shadow/rollout gate UI - Tests: comprehensive coverage for all workflow modules - Docs: NODE_CONTRACT_AUDIT and VALIDATION_ERROR_INVENTORY All 20 blocks from NEXT_20_BLOCK_BATCH_PLAN completed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -250,4 +250,27 @@ describe('output type contract helpers', () => {
|
||||
expect.objectContaining({ id: 'wf-1' }),
|
||||
])
|
||||
})
|
||||
|
||||
test('accepts graph workflows that advertise blend artifact support', () => {
|
||||
expect(getCompatibleWorkflowsForOutputTypeContract(
|
||||
[
|
||||
{
|
||||
id: 'wf-blend',
|
||||
name: 'Still + Blend Graph',
|
||||
family: 'order_line',
|
||||
supported_artifact_kinds: ['still_image', 'blend_asset'],
|
||||
},
|
||||
{
|
||||
id: 'wf-still',
|
||||
name: 'Still Graph',
|
||||
family: 'order_line',
|
||||
supported_artifact_kinds: ['still_image'],
|
||||
},
|
||||
],
|
||||
'order_line',
|
||||
'blend_asset',
|
||||
)).toEqual([
|
||||
expect.objectContaining({ id: 'wf-blend' }),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
buildWorkflowBlueprintConfig,
|
||||
createPresetWorkflowConfig,
|
||||
createStarterWorkflowConfig,
|
||||
normalizeWorkflowConfig,
|
||||
@@ -127,6 +128,59 @@ describe('workflow preset config builders', () => {
|
||||
expect(config.nodes.map(node => node.step)).toEqual(['order_line_setup'])
|
||||
})
|
||||
|
||||
test('builds alpha and blend graph blueprints for still-render authoring', () => {
|
||||
const alphaConfig = buildWorkflowBlueprintConfig('still_graph_alpha_reference')
|
||||
const blendConfig = buildWorkflowBlueprintConfig('still_graph_blend_reference')
|
||||
|
||||
expect(alphaConfig.ui?.execution_mode).toBe('graph')
|
||||
expect(alphaConfig.nodes.find(node => node.step === 'blender_still')?.params).toMatchObject({
|
||||
transparent_bg: true,
|
||||
use_custom_render_settings: false,
|
||||
})
|
||||
|
||||
expect(blendConfig.ui?.execution_mode).toBe('graph')
|
||||
expect(blendConfig.nodes.map(node => node.step)).toEqual(
|
||||
expect.arrayContaining(['blender_still', 'export_blend', 'output_save', 'notify']),
|
||||
)
|
||||
expect(blendConfig.nodes.find(node => node.id === 'save_blend')?.params).toMatchObject({
|
||||
expected_artifact_role: 'blend_export',
|
||||
})
|
||||
})
|
||||
|
||||
test('rebuilds new still graph reference variants during normalization', () => {
|
||||
const alphaConfig = normalizeWorkflowConfig({
|
||||
version: 1,
|
||||
ui: {
|
||||
preset: 'custom',
|
||||
execution_mode: 'graph',
|
||||
blueprint: 'still_graph_alpha_reference',
|
||||
},
|
||||
nodes: [],
|
||||
edges: [],
|
||||
})
|
||||
const blendConfig = normalizeWorkflowConfig({
|
||||
version: 1,
|
||||
ui: {
|
||||
preset: 'custom',
|
||||
execution_mode: 'graph',
|
||||
blueprint: 'still_graph_blend_reference',
|
||||
},
|
||||
nodes: [],
|
||||
edges: [],
|
||||
})
|
||||
|
||||
expect(alphaConfig.ui?.blueprint).toBe('still_graph_alpha_reference')
|
||||
expect(alphaConfig.nodes.find(node => node.step === 'blender_still')?.params).toMatchObject({
|
||||
transparent_bg: true,
|
||||
})
|
||||
|
||||
expect(blendConfig.ui?.blueprint).toBe('still_graph_blend_reference')
|
||||
expect(blendConfig.nodes.some(node => node.step === 'export_blend')).toBe(true)
|
||||
expect(blendConfig.nodes.find(node => node.id === 'save_blend')?.params).toMatchObject({
|
||||
expected_artifact_role: 'blend_export',
|
||||
})
|
||||
})
|
||||
|
||||
test('normalizes workflow rollout summary from the API payload', async () => {
|
||||
vi.mocked(api.get).mockResolvedValueOnce({
|
||||
data: [
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import type { Material } from '../../api/materials'
|
||||
import type { OutputType, OutputTypeContractCatalog } from '../../api/outputTypes'
|
||||
import type { PricingTier } from '../../api/pricing'
|
||||
import type { WorkflowDefinition } from '../../api/workflows'
|
||||
import OutputTypeTable from '../../components/admin/OutputTypeTable'
|
||||
|
||||
const listOutputTypesMock = vi.fn<() => Promise<OutputType[]>>()
|
||||
const getOutputTypeContractCatalogMock = vi.fn<() => Promise<OutputTypeContractCatalog>>()
|
||||
const listMaterialsMock = vi.fn<() => Promise<Material[]>>()
|
||||
const listPricingTiersMock = vi.fn<() => Promise<PricingTier[]>>()
|
||||
const getWorkflowsMock = vi.fn<() => Promise<WorkflowDefinition[]>>()
|
||||
|
||||
vi.mock('../../api/outputTypes', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../api/outputTypes')>('../../api/outputTypes')
|
||||
return {
|
||||
...actual,
|
||||
listOutputTypes: () => listOutputTypesMock(),
|
||||
getOutputTypeContractCatalog: () => getOutputTypeContractCatalogMock(),
|
||||
getCachedOutputTypeContractCatalog: () => actual.getCachedOutputTypeContractCatalog(),
|
||||
createOutputType: vi.fn(),
|
||||
updateOutputType: vi.fn(),
|
||||
deleteOutputType: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../api/materials', () => ({
|
||||
listMaterials: () => listMaterialsMock(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/pricing', () => ({
|
||||
listPricingTiers: () => listPricingTiersMock(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/workflows', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../api/workflows')>('../../api/workflows')
|
||||
return {
|
||||
...actual,
|
||||
getWorkflows: () => getWorkflowsMock(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function renderTable() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<OutputTypeTable />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
return { queryClient }
|
||||
}
|
||||
|
||||
describe('OutputTypeTable', () => {
|
||||
test('does not flag valid linked workflows as unresolved', async () => {
|
||||
const workflowId = '9a4e65ff-ecb8-4840-9c52-c07b45236d2d'
|
||||
const now = '2026-04-15T07:00:00Z'
|
||||
|
||||
listOutputTypesMock.mockResolvedValue([
|
||||
{
|
||||
id: '29af9864-a4b3-4fd5-a02b-53232ce81fa7',
|
||||
name: '[Workflow Golden] Canonical Still Graph',
|
||||
description: null,
|
||||
renderer: 'blender',
|
||||
render_settings: {},
|
||||
invocation_overrides: {
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
samples: 64,
|
||||
},
|
||||
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: 'gpu',
|
||||
pricing_tier_id: null,
|
||||
pricing_tier_name: null,
|
||||
price_per_item: null,
|
||||
workflow_definition_id: workflowId,
|
||||
workflow_rollout_mode: 'graph',
|
||||
workflow_name: '[Workflow Golden] Canonical Still Graph',
|
||||
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: workflowId,
|
||||
workflow_rollout_mode: 'graph',
|
||||
transparent_bg: false,
|
||||
cycles_device: 'gpu',
|
||||
material_override: null,
|
||||
allowed_override_keys: ['width', 'height', 'samples', 'engine'],
|
||||
invocation_overrides: {
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
samples: 64,
|
||||
engine: 'cycles',
|
||||
},
|
||||
},
|
||||
is_active: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
])
|
||||
|
||||
getOutputTypeContractCatalogMock.mockResolvedValue({
|
||||
workflow_families: ['cad_file', 'order_line'],
|
||||
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: ['thumbnail_image', 'model_export', 'package', 'custom'],
|
||||
order_line: ['still_image', 'turntable_video', 'blend_asset', 'model_export', 'package', 'custom'],
|
||||
},
|
||||
allowed_output_formats_by_family: {
|
||||
cad_file: ['png', 'jpg', 'webp', 'glb', 'gltf', 'stl', 'obj', 'usd', 'usdz'],
|
||||
order_line: ['png', 'jpg', 'webp', 'mp4', 'webm', 'blend', 'glb', 'gltf', 'stl', 'obj', 'usd', 'usdz'],
|
||||
},
|
||||
allowed_invocation_override_keys_by_artifact_kind: {
|
||||
still_image: ['width', 'height', 'engine', 'samples'],
|
||||
turntable_video: ['width', 'height', 'engine', 'samples', 'frame_count', 'fps', 'turntable_axis'],
|
||||
thumbnail_image: ['width', 'height'],
|
||||
blend_asset: [],
|
||||
model_export: [],
|
||||
package: [],
|
||||
custom: [],
|
||||
},
|
||||
default_output_format_by_artifact_kind: {
|
||||
still_image: 'png',
|
||||
turntable_video: 'mp4',
|
||||
thumbnail_image: 'png',
|
||||
blend_asset: 'blend',
|
||||
model_export: 'glb',
|
||||
package: 'zip',
|
||||
custom: 'png',
|
||||
},
|
||||
parameter_ownership: {
|
||||
output_type_profile_keys: [],
|
||||
template_runtime_keys: [],
|
||||
workflow_node_keys_by_step: {},
|
||||
},
|
||||
})
|
||||
|
||||
listMaterialsMock.mockResolvedValue([])
|
||||
listPricingTiersMock.mockResolvedValue([])
|
||||
getWorkflowsMock.mockResolvedValue([
|
||||
{
|
||||
id: workflowId,
|
||||
name: '[Workflow Golden] Canonical Still Graph',
|
||||
output_type_id: null,
|
||||
config: {
|
||||
version: 1,
|
||||
nodes: [
|
||||
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 0 } } },
|
||||
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 200, y: 0 } } },
|
||||
{ id: 'render', step: 'blender_still', params: {}, ui: { label: 'Still Render', position: { x: 400, y: 0 } } },
|
||||
{ id: 'output', step: 'output_save', params: {}, ui: { label: 'Save Output', position: { x: 600, y: 0 } } },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'setup', to: 'template' },
|
||||
{ from: 'template', to: 'render' },
|
||||
{ from: 'render', to: 'output' },
|
||||
],
|
||||
ui: {
|
||||
execution_mode: 'graph',
|
||||
family: 'order_line',
|
||||
blueprint: 'still_graph_reference',
|
||||
},
|
||||
},
|
||||
family: 'order_line',
|
||||
supported_artifact_kinds: ['still_image'],
|
||||
rollout_summary: {
|
||||
linked_output_type_count: 1,
|
||||
active_output_type_count: 1,
|
||||
linked_output_type_names: ['[Workflow Golden] Canonical Still Graph'],
|
||||
linked_output_types: [],
|
||||
rollout_modes: ['graph'],
|
||||
has_blocking_contracts: false,
|
||||
blocking_reasons: [],
|
||||
latest_run: null,
|
||||
latest_shadow_run: null,
|
||||
latest_rollout_gate_verdict: null,
|
||||
latest_rollout_ready: null,
|
||||
latest_rollout_status: null,
|
||||
latest_rollout_reasons: [],
|
||||
},
|
||||
is_active: true,
|
||||
created_at: now,
|
||||
},
|
||||
])
|
||||
|
||||
renderTable()
|
||||
|
||||
const [rowLabel] = await screen.findAllByText('[Workflow Golden] Canonical Still Graph')
|
||||
const row = rowLabel.closest('tr')
|
||||
expect(row).not.toBeNull()
|
||||
const scoped = within(row as HTMLTableRowElement)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(scoped.getByText('Graph drives production with legacy fallback armed.')).toBeInTheDocument()
|
||||
})
|
||||
expect(scoped.getByText('Graph Authoritative')).toBeInTheDocument()
|
||||
expect(scoped.queryByText(/The selected workflow definition could not be resolved\./)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { WorkflowListSidebar } from '../../components/workflows/WorkflowListSidebar'
|
||||
|
||||
describe('WorkflowListSidebar', () => {
|
||||
test('separates workflow selection from destructive actions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelectWorkflow = vi.fn()
|
||||
const onCreateWorkflow = vi.fn()
|
||||
const onDeleteWorkflow = vi.fn()
|
||||
|
||||
render(
|
||||
<WorkflowListSidebar
|
||||
isLoading={false}
|
||||
selectedId="wf-selected"
|
||||
onSelectWorkflow={onSelectWorkflow}
|
||||
onCreateWorkflow={onCreateWorkflow}
|
||||
onDeleteWorkflow={onDeleteWorkflow}
|
||||
sections={[
|
||||
{
|
||||
key: 'order_line',
|
||||
label: 'Order Rendering',
|
||||
className: 'bg-emerald-100 text-emerald-700',
|
||||
items: [
|
||||
{
|
||||
id: 'wf-selected',
|
||||
name: 'Still Graph Blueprint',
|
||||
isActive: true,
|
||||
presetLabel: 'Still (Graph)',
|
||||
presetClassName: 'bg-emerald-100 text-emerald-700',
|
||||
familyLabel: 'Order Rendering',
|
||||
familyClassName: 'bg-emerald-100 text-emerald-700',
|
||||
executionModeLabel: 'Graph',
|
||||
executionModeClassName: 'bg-green-100 text-green-700',
|
||||
rolloutBadgeLabel: 'Shadow',
|
||||
rolloutBadgeClassName: 'bg-sky-100 text-sky-700',
|
||||
rolloutStatusLabel: 'Legacy Authoritative',
|
||||
rolloutStatusClassName: 'bg-sky-100 text-sky-700',
|
||||
rolloutSummary: 'Shadow verdict: pass.',
|
||||
linkedOutputTypeCount: 2,
|
||||
blueprintLabel: 'Still Graph',
|
||||
isReference: true,
|
||||
},
|
||||
{
|
||||
id: 'wf-secondary',
|
||||
name: 'Still Legacy',
|
||||
isActive: false,
|
||||
presetLabel: 'Still',
|
||||
presetClassName: 'bg-orange-100 text-orange-700',
|
||||
familyLabel: 'Order Rendering',
|
||||
familyClassName: 'bg-emerald-100 text-emerald-700',
|
||||
executionModeLabel: 'Legacy',
|
||||
executionModeClassName: 'bg-slate-100 text-slate-700',
|
||||
rolloutBadgeLabel: 'Legacy',
|
||||
rolloutBadgeClassName: 'bg-slate-100 text-slate-700',
|
||||
rolloutStatusLabel: 'Ready',
|
||||
rolloutStatusClassName: 'bg-green-100 text-green-700',
|
||||
rolloutSummary: 'Legacy remains authoritative.',
|
||||
linkedOutputTypeCount: 1,
|
||||
blueprintLabel: null,
|
||||
isReference: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
|
||||
const selectedWorkflowButton = screen
|
||||
.getAllByRole('button', { name: /still graph blueprint/i })
|
||||
.find(button => button.getAttribute('aria-pressed') === 'true')
|
||||
const deleteButton = screen.getByRole('button', {
|
||||
name: /delete workflow still graph blueprint/i,
|
||||
})
|
||||
|
||||
expect(selectedWorkflowButton).toBeDefined()
|
||||
expect(selectedWorkflowButton).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(deleteButton.closest('button')).toBe(deleteButton)
|
||||
|
||||
await user.click(deleteButton)
|
||||
|
||||
expect(onDeleteWorkflow).toHaveBeenCalledWith('wf-selected', 'Still Graph Blueprint')
|
||||
expect(onSelectWorkflow).not.toHaveBeenCalled()
|
||||
|
||||
const secondaryWorkflowButton = screen
|
||||
.getAllByRole('button', { name: /still legacy/i })
|
||||
.find(button => button.getAttribute('aria-pressed') === 'false')
|
||||
|
||||
expect(secondaryWorkflowButton).toBeDefined()
|
||||
|
||||
await user.click(secondaryWorkflowButton!)
|
||||
|
||||
expect(onSelectWorkflow).toHaveBeenCalledWith('wf-secondary')
|
||||
})
|
||||
})
|
||||
@@ -71,6 +71,331 @@ const notifyDefinition: WorkflowNodeDefinition = {
|
||||
legacy_source: 'legacy.notify',
|
||||
}
|
||||
|
||||
const orderLineSetupDefinition: WorkflowNodeDefinition = {
|
||||
step: 'order_line_setup',
|
||||
label: 'Order Line Setup',
|
||||
family: 'order_line',
|
||||
module_key: 'orders.setup',
|
||||
category: 'input',
|
||||
description: 'Resolve order-line context and setup metadata.',
|
||||
node_type: 'inputNode',
|
||||
icon: 'package-search',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'native',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line', requires: ['order_line_record'] },
|
||||
output_contract: { context: 'order_line', provides: ['order_line_context'] },
|
||||
artifact_roles_consumed: [],
|
||||
artifact_roles_produced: ['order_line_context'],
|
||||
legacy_source: 'legacy.order_line_setup',
|
||||
}
|
||||
|
||||
const outputSaveDefinition: WorkflowNodeDefinition = {
|
||||
step: 'output_save',
|
||||
label: 'Save Output',
|
||||
family: 'order_line',
|
||||
module_key: 'media.save_output',
|
||||
category: 'output',
|
||||
description: 'Persist the selected render artifact.',
|
||||
node_type: 'outputNode',
|
||||
icon: 'download',
|
||||
defaults: {
|
||||
expected_artifact_role: '',
|
||||
require_upstream_artifact: false,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
key: 'expected_artifact_role',
|
||||
label: 'Expected Artifact Role',
|
||||
type: 'select',
|
||||
description: 'Restrict the accepted upstream artifact.',
|
||||
section: 'Output',
|
||||
default: '',
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
unit: null,
|
||||
options: [
|
||||
{ value: '', label: 'Any Connected Artifact' },
|
||||
{ value: 'render_output', label: 'Still Output' },
|
||||
{ value: 'turntable_output', label: 'Turntable Output' },
|
||||
],
|
||||
allow_blank: true,
|
||||
max_length: null,
|
||||
text_format: null,
|
||||
},
|
||||
{
|
||||
key: 'require_upstream_artifact',
|
||||
label: 'Require Upstream Artifact',
|
||||
type: 'boolean',
|
||||
description: 'Fail when no matching artifact is wired.',
|
||||
section: 'Output',
|
||||
default: false,
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
unit: null,
|
||||
options: [],
|
||||
allow_blank: true,
|
||||
max_length: null,
|
||||
text_format: null,
|
||||
},
|
||||
],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line', requires: ['order_line_context'], requires_any: ['rendered_image'] },
|
||||
output_contract: { context: 'order_line', provides: ['workflow_result'] },
|
||||
artifact_roles_consumed: ['rendered_image'],
|
||||
artifact_roles_produced: ['workflow_result'],
|
||||
legacy_source: 'legacy.output_save',
|
||||
}
|
||||
|
||||
const exportBlendDefinition: WorkflowNodeDefinition = {
|
||||
step: 'export_blend',
|
||||
label: 'Export Blend',
|
||||
family: 'order_line',
|
||||
module_key: 'media.export_blend',
|
||||
category: 'output',
|
||||
description: 'Persist the generated .blend file.',
|
||||
node_type: 'outputNode',
|
||||
icon: 'download',
|
||||
defaults: {
|
||||
output_name_suffix: '',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
key: 'output_name_suffix',
|
||||
label: 'Output Name Suffix',
|
||||
type: 'text',
|
||||
description: 'Optional suffix appended to the emitted filename.',
|
||||
section: 'Output',
|
||||
default: '',
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
unit: null,
|
||||
options: [],
|
||||
allow_blank: true,
|
||||
max_length: 64,
|
||||
text_format: 'safe_filename_suffix',
|
||||
},
|
||||
],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line', requires: ['order_line_context', 'render_template'] },
|
||||
output_contract: { context: 'order_line', provides: ['blend_asset'] },
|
||||
artifact_roles_consumed: ['order_line_context', 'render_template'],
|
||||
artifact_roles_produced: ['blend_asset'],
|
||||
legacy_source: 'legacy.export_blend',
|
||||
}
|
||||
|
||||
const blenderStillDefinition: WorkflowNodeDefinition = {
|
||||
step: 'blender_still',
|
||||
label: 'Render Still',
|
||||
family: 'order_line',
|
||||
module_key: 'render.production.still',
|
||||
category: 'rendering',
|
||||
description: 'Render a still image.',
|
||||
node_type: 'renderNode',
|
||||
icon: 'camera',
|
||||
defaults: { use_custom_render_settings: false },
|
||||
fields: [
|
||||
{
|
||||
key: 'use_custom_render_settings',
|
||||
label: 'Custom Render Settings',
|
||||
type: 'boolean',
|
||||
description: 'Enable explicit render overrides.',
|
||||
section: 'Render',
|
||||
default: false,
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
unit: null,
|
||||
options: [],
|
||||
allow_blank: true,
|
||||
max_length: null,
|
||||
text_format: null,
|
||||
},
|
||||
{
|
||||
key: 'samples',
|
||||
label: 'Samples',
|
||||
type: 'number',
|
||||
description: 'Render samples.',
|
||||
section: 'Render',
|
||||
default: 256,
|
||||
min: 1,
|
||||
max: 4096,
|
||||
step: 1,
|
||||
unit: null,
|
||||
options: [],
|
||||
allow_blank: true,
|
||||
max_length: null,
|
||||
text_format: null,
|
||||
},
|
||||
{
|
||||
key: 'noise_threshold',
|
||||
label: 'Noise Threshold',
|
||||
type: 'text',
|
||||
description: 'Adaptive sampling threshold.',
|
||||
section: 'Denoising',
|
||||
default: '',
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
unit: null,
|
||||
options: [],
|
||||
allow_blank: true,
|
||||
max_length: null,
|
||||
text_format: null,
|
||||
},
|
||||
{
|
||||
key: 'focal_length_mm',
|
||||
label: 'Focal Length',
|
||||
type: 'number',
|
||||
description: 'Lens override.',
|
||||
section: 'Camera',
|
||||
default: null,
|
||||
min: 1,
|
||||
max: 500,
|
||||
step: 0.1,
|
||||
unit: 'mm',
|
||||
options: [],
|
||||
allow_blank: true,
|
||||
max_length: null,
|
||||
text_format: null,
|
||||
},
|
||||
{
|
||||
key: 'target_collection',
|
||||
label: 'Target Collection',
|
||||
type: 'text',
|
||||
description: 'Collection name.',
|
||||
section: 'Scene',
|
||||
default: 'Product',
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
unit: null,
|
||||
options: [],
|
||||
allow_blank: true,
|
||||
max_length: null,
|
||||
text_format: null,
|
||||
},
|
||||
],
|
||||
execution_kind: 'native',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line', requires: ['order_line_context', 'render_template', 'bbox'] },
|
||||
output_contract: { context: 'order_line', provides: ['rendered_image'] },
|
||||
artifact_roles_consumed: ['order_line_context', 'render_template', 'bbox'],
|
||||
artifact_roles_produced: ['rendered_image'],
|
||||
legacy_source: 'legacy.blender_still',
|
||||
}
|
||||
|
||||
const blenderTurntableDefinition: WorkflowNodeDefinition = {
|
||||
step: 'blender_turntable',
|
||||
label: 'Render Turntable',
|
||||
family: 'order_line',
|
||||
module_key: 'render.production.turntable',
|
||||
category: 'rendering',
|
||||
description: 'Render a turntable animation.',
|
||||
node_type: 'renderNode',
|
||||
icon: 'video',
|
||||
defaults: { use_custom_render_settings: false },
|
||||
fields: [
|
||||
{
|
||||
key: 'use_custom_render_settings',
|
||||
label: 'Custom Render Settings',
|
||||
type: 'boolean',
|
||||
description: 'Enable explicit render overrides.',
|
||||
section: 'Render',
|
||||
default: false,
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
unit: null,
|
||||
options: [],
|
||||
allow_blank: true,
|
||||
max_length: null,
|
||||
text_format: null,
|
||||
},
|
||||
{
|
||||
key: 'fps',
|
||||
label: 'Frames Per Second',
|
||||
type: 'number',
|
||||
description: 'Playback speed.',
|
||||
section: 'Animation',
|
||||
default: 24,
|
||||
min: 1,
|
||||
max: 120,
|
||||
step: 1,
|
||||
unit: null,
|
||||
options: [],
|
||||
allow_blank: true,
|
||||
max_length: null,
|
||||
text_format: null,
|
||||
},
|
||||
{
|
||||
key: 'turntable_axis',
|
||||
label: 'Turntable Axis',
|
||||
type: 'select',
|
||||
description: 'Rotation axis.',
|
||||
section: 'Animation',
|
||||
default: 'z',
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
unit: null,
|
||||
options: [
|
||||
{ value: 'x', label: 'X' },
|
||||
{ value: 'y', label: 'Y' },
|
||||
{ value: 'z', label: 'Z' },
|
||||
],
|
||||
allow_blank: false,
|
||||
max_length: null,
|
||||
text_format: null,
|
||||
},
|
||||
{
|
||||
key: 'bg_color',
|
||||
label: 'Background Color',
|
||||
type: 'text',
|
||||
description: 'Background override.',
|
||||
section: 'Render',
|
||||
default: '#ffffff',
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
unit: null,
|
||||
options: [],
|
||||
allow_blank: true,
|
||||
max_length: null,
|
||||
text_format: null,
|
||||
},
|
||||
{
|
||||
key: 'camera_orbit',
|
||||
label: 'Camera Orbit',
|
||||
type: 'boolean',
|
||||
description: 'Orbit camera around the product.',
|
||||
section: 'Scene',
|
||||
default: true,
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
unit: null,
|
||||
options: [],
|
||||
allow_blank: true,
|
||||
max_length: null,
|
||||
text_format: null,
|
||||
},
|
||||
],
|
||||
execution_kind: 'native',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line', requires: ['order_line_context', 'render_template', 'bbox'] },
|
||||
output_contract: { context: 'order_line', provides: ['rendered_video'] },
|
||||
artifact_roles_consumed: ['order_line_context', 'render_template', 'bbox'],
|
||||
artifact_roles_produced: ['rendered_video'],
|
||||
legacy_source: 'legacy.blender_turntable',
|
||||
}
|
||||
|
||||
function createRenderTemplate(overrides: Partial<RenderTemplate> = {}): RenderTemplate {
|
||||
return {
|
||||
id: '0d87b85f-c454-4d61-a124-d5b59e6a43a2',
|
||||
@@ -256,14 +581,60 @@ describe('WorkflowNodeInspector', () => {
|
||||
)
|
||||
|
||||
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(
|
||||
'This node accepts one upstream artifact from any of: rendered image / rendered frames / rendered video / workflow result / blend asset.',
|
||||
).length,
|
||||
).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(/0 local variables by design/i).length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Alternative Groups')).toBeInTheDocument()
|
||||
expect(screen.getByText('Group 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Validation Watchpoints')).toBeInTheDocument()
|
||||
expect(screen.getByText('Watch Artifact Flow')).toBeInTheDocument()
|
||||
expect(screen.getByText('Watch Runtime Gap')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByText('Any of: Rendered Image / Rendered Frames / Rendered Video / Workflow Result / Blend Asset').length,
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('explains context-entry nodes as context-supplied instead of misconfigured', async () => {
|
||||
listRenderTemplates.mockResolvedValue([])
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WorkflowNodeInspector
|
||||
params={{}}
|
||||
onChange={vi.fn()}
|
||||
nodeDefinition={orderLineSetupDefinition}
|
||||
step="order_line_setup"
|
||||
nodeDefinitions={[orderLineSetupDefinition]}
|
||||
graphFamily="order_line"
|
||||
onStepChange={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Context Entry')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
'Workflow context supplies Order Line Record. No additional upstream sockets are required.',
|
||||
).length,
|
||||
).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Workflow context already provides Order Line Record.').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(/0 local variables by design/i).length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Validation Watchpoints')).toBeInTheDocument()
|
||||
expect(screen.getByText('Watch Context')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Socket 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('summarizes wired inputs and inspector variables separately', async () => {
|
||||
listRenderTemplates.mockResolvedValue([
|
||||
createRenderTemplate({
|
||||
@@ -298,10 +669,289 @@ describe('WorkflowNodeInspector', () => {
|
||||
})
|
||||
await user.selectOptions(templateOverride, '0d87b85f-c454-4d61-a124-d5b59e6a43a2')
|
||||
|
||||
expect(await screen.findByText(/1 canvas socket is required/i)).toBeInTheDocument()
|
||||
expect(await screen.findByText(/1 required socket is exposed on the canvas/i)).toBeInTheDocument()
|
||||
expect(screen.getAllByText('This node waits for Order Line Context from upstream.').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Required Sockets')).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()
|
||||
expect(screen.getByText('Watch Legacy Drift')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('shows automatic template variable coverage before a template override is selected', async () => {
|
||||
listRenderTemplates.mockResolvedValue([
|
||||
createRenderTemplate({
|
||||
id: 'template-a',
|
||||
name: 'Bearing Studio',
|
||||
output_type_names: ['Still'],
|
||||
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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
createRenderTemplate({
|
||||
id: 'template-b',
|
||||
name: 'Shadow Studio',
|
||||
output_type_names: ['Shadow Still'],
|
||||
workflow_input_schema: [
|
||||
{
|
||||
key: 'shadow_density',
|
||||
label: 'Shadow Density',
|
||||
type: 'number',
|
||||
section: 'Template Inputs',
|
||||
description: 'Shadow catcher strength.',
|
||||
default: 0.65,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
unit: null,
|
||||
options: [],
|
||||
allow_blank: false,
|
||||
},
|
||||
{
|
||||
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: 'dramatic', label: 'Dramatic' },
|
||||
],
|
||||
allow_blank: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
])
|
||||
|
||||
renderInspector({})
|
||||
|
||||
expect(await screen.findByText('Automatic Resolution Coverage')).toBeInTheDocument()
|
||||
expect(screen.getByText(/2 active templates expose 2 unique workflow variables/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('Potential Template Variables')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Studio Variant').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText(/Available via Bearing Studio, Shadow Studio \(Still, Shadow Still\)\./i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Available via Shadow Studio \(Shadow Still\)\./i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('disables contract-owned still render fields until custom render settings are enabled', async () => {
|
||||
listRenderTemplates.mockResolvedValue([])
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WorkflowNodeInspector
|
||||
params={{ use_custom_render_settings: false }}
|
||||
onChange={vi.fn()}
|
||||
nodeDefinition={blenderStillDefinition}
|
||||
step="blender_still"
|
||||
nodeDefinitions={[blenderStillDefinition]}
|
||||
graphFamily="order_line"
|
||||
onStepChange={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Render Override Scope')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Samples')).toBeDisabled()
|
||||
expect(screen.getByLabelText('Noise Threshold')).toBeDisabled()
|
||||
expect(screen.getByLabelText('Focal Length (mm)')).toBeDisabled()
|
||||
expect(screen.getByLabelText('Target Collection')).toBeDisabled()
|
||||
})
|
||||
|
||||
test('disables contract-owned turntable fields until custom render settings are enabled', async () => {
|
||||
listRenderTemplates.mockResolvedValue([])
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WorkflowNodeInspector
|
||||
params={{ use_custom_render_settings: false }}
|
||||
onChange={vi.fn()}
|
||||
nodeDefinition={blenderTurntableDefinition}
|
||||
step="blender_turntable"
|
||||
nodeDefinitions={[blenderTurntableDefinition]}
|
||||
graphFamily="order_line"
|
||||
onStepChange={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByLabelText('Frames Per Second')).toBeDisabled()
|
||||
expect(screen.getByLabelText('Turntable Axis')).toBeDisabled()
|
||||
expect(screen.getByLabelText('Background Color')).toBeDisabled()
|
||||
expect(screen.getByLabelText('Camera Orbit')).toBeDisabled()
|
||||
})
|
||||
|
||||
test('explains output handoff semantics for output_save nodes', async () => {
|
||||
listRenderTemplates.mockResolvedValue([])
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WorkflowNodeInspector
|
||||
params={{ expected_artifact_role: 'render_output', require_upstream_artifact: true }}
|
||||
onChange={vi.fn()}
|
||||
nodeDefinition={outputSaveDefinition}
|
||||
step="output_save"
|
||||
nodeDefinitions={[outputSaveDefinition]}
|
||||
graphFamily="order_line"
|
||||
onStepChange={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Output Handoff')).toBeInTheDocument()
|
||||
expect(screen.getByText(/does not render files itself/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('render_output')).toBeInTheDocument()
|
||||
expect(screen.getByText(/shadow runs stay observer-only/i)).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
'This node requires Order Line Context and one additional upstream artifact from any of: rendered image.',
|
||||
).length,
|
||||
).toBeGreaterThan(0)
|
||||
expect(screen.getByText(/1 required socket and 1 alternative group are exposed on the canvas/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('Required Sockets')).toBeInTheDocument()
|
||||
expect(screen.getByText('Alternative Groups')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('explains blend delivery semantics for export_blend nodes', async () => {
|
||||
listRenderTemplates.mockResolvedValue([])
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WorkflowNodeInspector
|
||||
params={{ output_name_suffix: 'studio' }}
|
||||
onChange={vi.fn()}
|
||||
nodeDefinition={exportBlendDefinition}
|
||||
step="export_blend"
|
||||
nodeDefinitions={[exportBlendDefinition]}
|
||||
graphFamily="order_line"
|
||||
onStepChange={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByText('Blend Delivery').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText(/does not render pixels itself/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('studio')).toBeInTheDocument()
|
||||
expect(screen.getByText(/save output/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('explains notification handoff semantics for notify nodes', async () => {
|
||||
listRenderTemplates.mockResolvedValue([])
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WorkflowNodeInspector
|
||||
params={{ channel: 'audit_log', require_armed_render: true }}
|
||||
onChange={vi.fn()}
|
||||
nodeDefinition={{
|
||||
...notifyDefinition,
|
||||
fields: [
|
||||
{
|
||||
key: 'channel',
|
||||
label: 'Channel',
|
||||
type: 'select',
|
||||
description: 'Notification channel.',
|
||||
section: 'Notification',
|
||||
default: 'audit_log',
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
unit: null,
|
||||
options: [{ value: 'audit_log', label: 'Audit Log' }],
|
||||
allow_blank: false,
|
||||
max_length: null,
|
||||
text_format: null,
|
||||
},
|
||||
{
|
||||
key: 'require_armed_render',
|
||||
label: 'Require Armed Render',
|
||||
type: 'boolean',
|
||||
description: 'Fail when no render task hands off notifications.',
|
||||
section: 'Notification',
|
||||
default: false,
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
unit: null,
|
||||
options: [],
|
||||
allow_blank: true,
|
||||
max_length: null,
|
||||
text_format: null,
|
||||
},
|
||||
],
|
||||
}}
|
||||
step="notify"
|
||||
nodeDefinitions={[notifyDefinition]}
|
||||
graphFamily="order_line"
|
||||
onStepChange={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByText('Notification Handoff').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText(/does not emit independently/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('audit_log')).toBeInTheDocument()
|
||||
expect(screen.getByText(/shadow runs suppress user notifications entirely/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -166,13 +166,14 @@ 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).toHaveLength(2)
|
||||
expect(plan.moduleBundles).toHaveLength(4)
|
||||
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',
|
||||
'scene_prep_core',
|
||||
'materials_core',
|
||||
'output_publish_notify',
|
||||
'order_line_setup',
|
||||
])
|
||||
@@ -209,6 +210,8 @@ describe('workflow authoring guidance', () => {
|
||||
])
|
||||
expect(surface.plan.referenceBundles[0]?.id).toBe('still_render_reference')
|
||||
expect(surface.plan.moduleBundles.map(bundle => bundle.id)).toEqual([
|
||||
'scene_prep_core',
|
||||
'materials_core',
|
||||
'still_render_core',
|
||||
'output_publish_notify',
|
||||
])
|
||||
|
||||
@@ -12,6 +12,7 @@ import { NodeCommandMenu } from '../../components/workflows/NodeCommandMenu'
|
||||
import { NodeDefinitionsPanel } from '../../components/workflows/NodeDefinitionsPanel'
|
||||
import { WorkflowCanvasToolbar } from '../../components/workflows/WorkflowCanvasToolbar'
|
||||
import { WorkflowNodeContractCard } from '../../components/workflows/WorkflowNodeContractCard'
|
||||
import { WorkflowValidationBanner } from '../../components/workflows/WorkflowValidationBanner'
|
||||
import { WorkflowPreflightPanel } from '../../components/workflows/WorkflowPreflightPanel'
|
||||
import { WorkflowRunsPanel } from '../../components/workflows/WorkflowRunsPanel'
|
||||
import {
|
||||
@@ -392,13 +393,29 @@ describe('WorkflowNodeContractCard', () => {
|
||||
runtimeClassName="bg-green-100 text-green-700"
|
||||
legacyCompatible
|
||||
legacySource="legacy.still_render"
|
||||
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']}
|
||||
contract={{
|
||||
inputContextLabel: 'Order Line',
|
||||
outputContextLabel: 'Order Line',
|
||||
contextInputs: [],
|
||||
requiredInputs: ['order_line', 'render_template'],
|
||||
requiredAnyInputs: [['rendered_image', 'rendered_frames']],
|
||||
consumedArtifacts: ['cad_preview'],
|
||||
providedOutputs: ['render_image'],
|
||||
producedArtifacts: ['png_output'],
|
||||
editableFieldCount: 0,
|
||||
editableFieldLabels: [],
|
||||
dynamicVariableHint: null,
|
||||
authoringPatternLabel: 'Connection-Driven',
|
||||
authoringPatternDescription:
|
||||
'Configure this node by wiring upstream artifacts. It has no local inspector variables.',
|
||||
}}
|
||||
validationWatchpoints={[
|
||||
{
|
||||
kind: 'legacy-drift',
|
||||
label: 'Legacy Drift',
|
||||
reason: 'Template resolution must stay aligned with the legacy path.',
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -410,8 +427,30 @@ describe('WorkflowNodeContractCard', () => {
|
||||
expect(screen.getByText('Render Template')).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.getAllByText('Render Image').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Png Output')).toBeInTheDocument()
|
||||
expect(screen.getByText('Validation Watchpoints')).toBeInTheDocument()
|
||||
expect(screen.getByText('Watch Legacy Drift')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkflowValidationBanner', () => {
|
||||
test('surfaces validation categories before preflight', () => {
|
||||
render(
|
||||
<WorkflowValidationBanner
|
||||
errors={[
|
||||
'Node "Render Still" is missing upstream input "Render Template".',
|
||||
'Node "Order Line Setup" expects Order Line context, but this workflow is CAD File based.',
|
||||
]}
|
||||
warnings={[
|
||||
'Node "Render Still" has no earlier "Resolve Template" node. Render defaults may drift from legacy behavior.',
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByText('Artifact Flow: 1').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Context: 1').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Legacy Drift: 1').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -473,8 +512,20 @@ describe('WorkflowCanvasToolbar', () => {
|
||||
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' },
|
||||
{
|
||||
value: 'line-1',
|
||||
label: 'Product A · Still',
|
||||
meta: 'ORD-1001 · pending',
|
||||
isRenderable: true,
|
||||
renderabilityReason: null,
|
||||
},
|
||||
{
|
||||
value: 'line-2',
|
||||
label: 'Product B · Still',
|
||||
meta: 'ORD-1001 · completed',
|
||||
isRenderable: false,
|
||||
renderabilityReason: 'order_closed',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
@@ -498,7 +549,7 @@ describe('WorkflowCanvasToolbar', () => {
|
||||
authoringEntryAction={{
|
||||
label: 'Author',
|
||||
title: 'Open guided workflow authoring browser',
|
||||
helper: 'Open reference paths, production modules, starter steps, and raw nodes.',
|
||||
helper: 'Open reference paths, stage modules, starter steps, and raw nodes.',
|
||||
icon: () => null,
|
||||
}}
|
||||
onDispatchContextIdChange={onDispatchContextIdChange}
|
||||
@@ -523,7 +574,7 @@ describe('WorkflowCanvasToolbar', () => {
|
||||
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('Add nodes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Preflight ready')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Delete (2)' })).toBeEnabled()
|
||||
const rollbackButtons = screen.getAllByRole('button', { name: 'Set Legacy' })
|
||||
@@ -594,8 +645,8 @@ describe('WorkflowCanvasToolbar', () => {
|
||||
authoringActions={{ openNodeMenu: vi.fn() }}
|
||||
authoringEntryAction={{
|
||||
label: 'Node',
|
||||
title: 'Open raw node browser',
|
||||
helper: 'Open the searchable node catalog directly on the canvas.',
|
||||
title: 'Open raw node catalog',
|
||||
helper: 'Open the searchable raw node catalog directly on the canvas.',
|
||||
icon: () => null,
|
||||
}}
|
||||
onDispatchContextIdChange={vi.fn()}
|
||||
@@ -634,12 +685,13 @@ describe('NodeCommandMenu', () => {
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Graph' }))
|
||||
await user.type(screen.getByPlaceholderText('Search nodes'), 'blender{enter}')
|
||||
await user.type(screen.getByPlaceholderText('Search raw 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()
|
||||
expect(screen.getByText('Guided First')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('supports module insertion directly from the canvas authoring menu', async () => {
|
||||
@@ -664,27 +716,31 @@ describe('NodeCommandMenu', () => {
|
||||
)
|
||||
|
||||
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.getByRole('button', { name: 'Reference Paths' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Stage Modules' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Starter Steps' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Raw Nodes' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Recommended Path')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Insert Still Reference' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Reference Baselines')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Stage Modules').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByRole('button', { name: 'Insert Scene Prep' }).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByRole('button', { name: 'Insert Materials' }).length).toBeGreaterThan(0)
|
||||
|
||||
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()
|
||||
await user.click(screen.getByRole('button', { name: 'Reference Paths' }))
|
||||
expect(screen.getAllByText('Reference Paths').length).toBeGreaterThan(0)
|
||||
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: 'Stage Modules' }))
|
||||
expect(screen.getAllByText('Stage Modules').length).toBeGreaterThan(0)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Insert Still Render Core' }))
|
||||
|
||||
@@ -697,33 +753,36 @@ describe('NodeDefinitionsPanel', () => {
|
||||
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()
|
||||
expect(screen.getByRole('button', { name: 'Reference Paths' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Stage Modules' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Raw Nodes' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Paths' }))
|
||||
expect(screen.getByText('Reference Paths')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Reference Paths' }))
|
||||
expect(screen.getAllByText('Reference Paths').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Still Render Reference')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Modules' }))
|
||||
expect(screen.getByText('Production Modules')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Stage Modules' }))
|
||||
expect(screen.getAllByText('Stage Modules').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Still Render Core')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Nodes' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Raw 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.getByText('Stage Coverage')).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.getAllByText('Blender Still').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Wiring').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Variables').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(/Watch Artifact Flow/).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)
|
||||
@@ -759,18 +818,22 @@ describe('NodeDefinitionsPanel', () => {
|
||||
).toBeTruthy()
|
||||
expect(screen.getByText('Still Render Reference')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Reapply Still Reference' })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button', { name: 'Insert Scene Prep' }).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByRole('button', { name: 'Insert Materials' }).length).toBeGreaterThan(0)
|
||||
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 Scene Prep' })[0] as HTMLElement)
|
||||
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('scene_prep_core')
|
||||
expect(onInsertModule).toHaveBeenCalledWith('output_publish_notify')
|
||||
expect(onSelectStep).toHaveBeenCalledWith('order_line_setup')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Starter' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Starter Steps' }))
|
||||
expect(screen.getByText('Starter Path')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Still-render assembly').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('1/8 present').length).toBeGreaterThan(0)
|
||||
@@ -810,11 +873,11 @@ describe('NodeDefinitionsPanel', () => {
|
||||
expect(onInsertModule).not.toHaveBeenCalled()
|
||||
expect(onSelectStep).toHaveBeenCalledWith('occ_object_extract')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Paths' }))
|
||||
expect(screen.getByText('Reference Paths')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Reference Paths' }))
|
||||
expect(screen.getAllByText('Reference Paths').length).toBeGreaterThan(0)
|
||||
expect(screen.getByRole('button', { name: 'Insert CAD Intake Reference' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Starter' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Starter Steps' }))
|
||||
expect(screen.getByText('Starter Path')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('CAD intake assembly').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('1/5 present').length).toBeGreaterThan(0)
|
||||
@@ -833,7 +896,7 @@ describe('NodeDefinitionsPanel', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Nodes' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Raw 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' }))
|
||||
@@ -854,7 +917,7 @@ describe('NodeDefinitionsPanel', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Modules' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Stage Modules' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Insert Still Render Core' }))
|
||||
|
||||
expect(onInsertModule).toHaveBeenCalledWith('still_render_core')
|
||||
@@ -909,7 +972,7 @@ describe('workflowAuthoringActions', () => {
|
||||
expect(guidedEntry.label).toBe('Author')
|
||||
expect(guidedEntry.title).toContain('guided workflow authoring')
|
||||
expect(rawEntry.label).toBe('Node')
|
||||
expect(rawEntry.title).toContain('raw node browser')
|
||||
expect(rawEntry.title).toContain('raw node catalog')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -953,14 +1016,109 @@ 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.getAllByText('Missing cad_preview artifact.').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Mode: graph')).toBeInTheDocument()
|
||||
expect(screen.getByText('Blocking: 0')).toBeInTheDocument()
|
||||
expect(screen.getByText('Warnings: 2')).toBeInTheDocument()
|
||||
expect(screen.getByText('Next Actions')).toBeInTheDocument()
|
||||
expect(screen.getByText('Warnings can be addressed incrementally')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
'Add the missing upstream node or connection so the required artifact is available before this step.',
|
||||
).length,
|
||||
).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Artifact Flow: 2').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Type: Artifact Flow').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Unsupported Node IDs')).toBeInTheDocument()
|
||||
expect(screen.getByText('node-legacy-1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Code: missing-artifact')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Code: missing-artifact').length).toBeGreaterThan(0)
|
||||
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()
|
||||
})
|
||||
|
||||
test('separates context, runtime-gap, and legacy-drift guidance', () => {
|
||||
const richerPreflight: WorkflowPreflightResponse = {
|
||||
workflow_id: 'wf-2',
|
||||
context_id: 'bad-context',
|
||||
context_kind: null,
|
||||
expected_context_kind: 'order_line',
|
||||
execution_mode: 'graph',
|
||||
graph_dispatch_allowed: false,
|
||||
summary: 'Preflight found blocking issues that would prevent a safe graph dispatch.',
|
||||
resolved_order_line_id: null,
|
||||
resolved_cad_file_id: null,
|
||||
unsupported_node_ids: ['node-native-gap'],
|
||||
issues: [
|
||||
{
|
||||
severity: 'error',
|
||||
code: 'context_not_found',
|
||||
message: 'Context ID did not match an existing order line or CAD file.',
|
||||
node_id: null,
|
||||
step: null,
|
||||
},
|
||||
],
|
||||
nodes: [
|
||||
{
|
||||
node_id: 'node-native-gap',
|
||||
step: 'notify',
|
||||
label: 'Notify Result',
|
||||
execution_kind: 'bridge',
|
||||
supported: false,
|
||||
status: 'unsupported',
|
||||
issues: [
|
||||
{
|
||||
severity: 'error',
|
||||
code: 'unsupported_node',
|
||||
message: "Graph runtime has no executable implementation for step 'notify'.",
|
||||
node_id: 'node-native-gap',
|
||||
step: 'notify',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
node_id: 'node-template',
|
||||
step: 'blender_still',
|
||||
label: 'Blender Still',
|
||||
execution_kind: 'native',
|
||||
supported: true,
|
||||
status: 'warning',
|
||||
issues: [
|
||||
{
|
||||
severity: 'warning',
|
||||
code: 'missing_resolve_template',
|
||||
message: 'No earlier resolve_template node found. Render defaults may drift from legacy behavior.',
|
||||
node_id: 'node-template',
|
||||
step: 'blender_still',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
render(<WorkflowPreflightPanel preflight={richerPreflight} isLoading={false} />)
|
||||
|
||||
expect(screen.getAllByText('Context: 1').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Runtime Gap: 1').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Legacy Drift: 1').length).toBeGreaterThan(0)
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
'Pick an existing order line or CAD file. The supplied ID does not resolve to a stored workflow context.',
|
||||
).length,
|
||||
).toBeGreaterThan(0)
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
'Keep this path on legacy/bridge execution for now, or replace the node with a supported graph module.',
|
||||
).length,
|
||||
).toBeGreaterThan(0)
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
'Add a "Resolve Template" node before render or export nodes to keep graph behavior aligned with legacy.',
|
||||
).length,
|
||||
).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Type: Context').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Type: Runtime Gap').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Type: Legacy Drift').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,12 +3,16 @@ import { describe, expect, test } from 'vitest'
|
||||
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import {
|
||||
applyAutoLayout,
|
||||
buildWorkflowCanvasNodeData,
|
||||
findOpenNodePosition,
|
||||
graphNeedsAutoLayout,
|
||||
resolveParamsForStepChange,
|
||||
resolveNodeCollisions,
|
||||
validateWorkflowDraft,
|
||||
WORKFLOW_NODE_HORIZONTAL_GAP,
|
||||
WORKFLOW_NODE_MIN_HEIGHT,
|
||||
WORKFLOW_NODE_WIDTH,
|
||||
WORKFLOW_NODE_VERTICAL_GAP,
|
||||
workflowToGraph,
|
||||
} from '../../components/workflows/workflowGraphDraft'
|
||||
@@ -472,6 +476,53 @@ describe('validateWorkflowDraft', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildWorkflowCanvasNodeData', () => {
|
||||
test('reuses normalized contract sockets for alternative and provided roles', () => {
|
||||
const nodeData = buildWorkflowCanvasNodeData('output_save', {}, {
|
||||
...definitions.output_save,
|
||||
input_contract: {
|
||||
context: 'order_line',
|
||||
requires: ['order_line_context'],
|
||||
},
|
||||
output_contract: {
|
||||
context: 'order_line',
|
||||
provides: ['media_asset', 'workflow_result'],
|
||||
},
|
||||
artifact_roles_consumed: ['rendered_image', 'rendered_frames', 'rendered_video', 'blend_asset'],
|
||||
artifact_roles_produced: ['media_asset', 'workflow_result'],
|
||||
})
|
||||
|
||||
expect(nodeData.inputPorts).toEqual([
|
||||
{
|
||||
id: 'input:order_line_context',
|
||||
label: 'Order Line Context',
|
||||
roles: ['order_line_context'],
|
||||
kind: 'required',
|
||||
},
|
||||
{
|
||||
id: 'input-any:rendered_image|rendered_frames|rendered_video|blend_asset',
|
||||
label: 'Any of: Rendered Image / Rendered Frames / Rendered Video / Blend Asset',
|
||||
roles: ['rendered_image', 'rendered_frames', 'rendered_video', 'blend_asset'],
|
||||
kind: 'alternative',
|
||||
},
|
||||
])
|
||||
expect(nodeData.outputPorts).toEqual([
|
||||
{
|
||||
id: 'output:media_asset',
|
||||
label: 'Media Asset',
|
||||
roles: ['media_asset'],
|
||||
kind: 'provided',
|
||||
},
|
||||
{
|
||||
id: 'output:workflow_result',
|
||||
label: 'Workflow Result',
|
||||
roles: ['workflow_result'],
|
||||
kind: 'provided',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveParamsForStepChange', () => {
|
||||
test('keeps only parameters supported by the target step schema', () => {
|
||||
const next = resolveParamsForStepChange(definitions.blender_still, {
|
||||
@@ -503,6 +554,18 @@ describe('resolveParamsForStepChange', () => {
|
||||
})
|
||||
|
||||
describe('resolveNodeCollisions', () => {
|
||||
test('prefers structured placement to the right or below before searching left/up', () => {
|
||||
const nodes = [
|
||||
createPositionedNode('anchor', 'order_line_setup', 56, 48, 'Anchor'),
|
||||
createPositionedNode('right', 'resolve_template', 56 + WORKFLOW_NODE_WIDTH + WORKFLOW_NODE_HORIZONTAL_GAP, 48, 'Right'),
|
||||
]
|
||||
|
||||
const nextPosition = findOpenNodePosition(nodes, { x: 56, y: 48 })
|
||||
|
||||
expect(nextPosition.x).toBe(56)
|
||||
expect(nextPosition.y).toBeGreaterThanOrEqual(48 + WORKFLOW_NODE_MIN_HEIGHT + WORKFLOW_NODE_VERTICAL_GAP)
|
||||
})
|
||||
|
||||
test('pushes overlapping nodes away from a settled anchor without moving the anchor', () => {
|
||||
const nodes = [
|
||||
createPositionedNode('anchor', 'order_line_setup', 56, 48, 'Anchor'),
|
||||
@@ -537,7 +600,53 @@ describe('resolveNodeCollisions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyAutoLayout', () => {
|
||||
test('separates disconnected components into deterministic vertical bands', () => {
|
||||
const nodes = [
|
||||
createPositionedNode('a', 'resolve_step_path', 0, 0, 'Resolve STEP Path'),
|
||||
createPositionedNode('b', 'glb_bbox', 0, 0, 'Compute Bounding Box'),
|
||||
createPositionedNode('c', 'order_line_setup', 0, 0, 'Order Line Setup'),
|
||||
createPositionedNode('d', 'resolve_template', 0, 0, 'Resolve Template'),
|
||||
]
|
||||
const edges = [createEdge('a', 'b'), createEdge('c', 'd')]
|
||||
|
||||
const laidOut = applyAutoLayout(nodes, edges)
|
||||
const firstComponentBottom = Math.max(
|
||||
...laidOut
|
||||
.filter(node => node.id === 'a' || node.id === 'b')
|
||||
.map(node => node.position.y + WORKFLOW_NODE_MIN_HEIGHT),
|
||||
)
|
||||
const secondComponentTop = Math.min(
|
||||
...laidOut
|
||||
.filter(node => node.id === 'c' || node.id === 'd')
|
||||
.map(node => node.position.y),
|
||||
)
|
||||
|
||||
expect(secondComponentTop - firstComponentBottom).toBeGreaterThanOrEqual(
|
||||
WORKFLOW_NODE_MIN_HEIGHT + WORKFLOW_NODE_VERTICAL_GAP * 2,
|
||||
)
|
||||
expect(laidOut.find(node => node.id === 'a')?.position.x).toBe(laidOut.find(node => node.id === 'c')?.position.x)
|
||||
expect(laidOut.find(node => node.id === 'b')?.position.x).toBeGreaterThan(
|
||||
laidOut.find(node => node.id === 'a')?.position.x ?? 0,
|
||||
)
|
||||
expect(laidOut.find(node => node.id === 'd')?.position.x).toBeGreaterThan(
|
||||
laidOut.find(node => node.id === 'c')?.position.x ?? 0,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workflowToGraph', () => {
|
||||
test('keeps workflow-root record requirements out of canvas input sockets', () => {
|
||||
const data = buildWorkflowCanvasNodeData('order_line_setup', {}, definitions.order_line_setup)
|
||||
|
||||
expect(data.contextInputs).toEqual(['order_line_record'])
|
||||
expect(data.inputPorts).toEqual([])
|
||||
expect(data.authoringPatternLabel).toBe('Context Entry')
|
||||
expect(data.variableSummaryText).toBe(
|
||||
'This node has 0 local variables by design. Its behavior is driven entirely by connections and runtime context.',
|
||||
)
|
||||
})
|
||||
|
||||
test('derives explicit input and output ports from the node contract', () => {
|
||||
const data = buildWorkflowCanvasNodeData('blender_still', {}, definitions.blender_still)
|
||||
|
||||
@@ -549,6 +658,9 @@ describe('workflowToGraph', () => {
|
||||
])
|
||||
expect(data.outputPorts?.map(port => port.label)).toEqual(['Rendered Image'])
|
||||
expect(data.editableFieldCount).toBe(2)
|
||||
expect(data.socketRequirementDescription).toBe(
|
||||
'Wire 4 required upstream sockets: Order Line Context, Render Template, Material Assignments, and Bounding Box.',
|
||||
)
|
||||
})
|
||||
|
||||
test('assigns semantic handle ids to edges based on matching contracts', () => {
|
||||
|
||||
@@ -160,6 +160,25 @@ const definitions: WorkflowNodeDefinition[] = [
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.notify',
|
||||
},
|
||||
{
|
||||
step: 'export_blend',
|
||||
label: 'Export Blend',
|
||||
family: 'order_line',
|
||||
module_key: 'media.export_blend',
|
||||
category: 'output',
|
||||
description: 'Export blend.',
|
||||
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_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.export_blend',
|
||||
},
|
||||
]
|
||||
|
||||
const cadDefinitions: WorkflowNodeDefinition[] = [
|
||||
@@ -302,12 +321,18 @@ 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'])
|
||||
expect(bundles.map(bundle => bundle.id)).toEqual([
|
||||
'scene_prep_core',
|
||||
'materials_core',
|
||||
'still_render_core',
|
||||
'output_publish_notify',
|
||||
'blend_export_publish',
|
||||
])
|
||||
})
|
||||
|
||||
test('creates a connected bundle insertion graph for still-render authoring', () => {
|
||||
test('creates a connected bundle insertion graph for scene-prep authoring', () => {
|
||||
const insertion = createWorkflowModuleBundleInsertion({
|
||||
bundleId: 'still_render_core',
|
||||
bundleId: 'scene_prep_core',
|
||||
graphFamily: 'order_line',
|
||||
nodeDefinitionsByStep: Object.fromEntries(definitions.map(definition => [definition.step, definition])),
|
||||
existingNodes: [],
|
||||
@@ -317,22 +342,44 @@ describe('workflowModuleBundles', () => {
|
||||
expect(insertion.ok).toBe(true)
|
||||
if (!insertion.ok) return
|
||||
|
||||
expect(insertion.nodes).toHaveLength(6)
|
||||
expect(insertion.edges).toHaveLength(5)
|
||||
expect(insertion.nodes).toHaveLength(3)
|
||||
expect(insertion.edges).toHaveLength(2)
|
||||
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.nodes[2].data).toMatchObject({ step: 'glb_bbox', label: 'Compute Bounding Box' })
|
||||
expect(insertion.edges[0]).toMatchObject({
|
||||
source: insertion.nodes[0].id,
|
||||
target: insertion.nodes[1].id,
|
||||
})
|
||||
})
|
||||
|
||||
test('creates a single-node still-render module for stage-level insertion', () => {
|
||||
const insertion = createWorkflowModuleBundleInsertion({
|
||||
bundleId: 'still_render_core',
|
||||
graphFamily: 'order_line',
|
||||
nodeDefinitionsByStep: Object.fromEntries(definitions.map(definition => [definition.step, definition])),
|
||||
existingNodes: [],
|
||||
preferredPosition: { x: 640, y: 180 },
|
||||
})
|
||||
|
||||
expect(insertion.ok).toBe(true)
|
||||
if (!insertion.ok) return
|
||||
|
||||
expect(insertion.nodes).toHaveLength(1)
|
||||
expect(insertion.edges).toHaveLength(0)
|
||||
expect(insertion.nodes[0].position).toEqual({ x: 640, y: 180 })
|
||||
expect(insertion.nodes[0].data).toMatchObject({ step: 'blender_still', label: 'Blender Still' })
|
||||
})
|
||||
|
||||
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'])
|
||||
expect(bundles.map(bundle => bundle.id)).toEqual([
|
||||
'still_render_reference',
|
||||
'still_render_alpha_reference',
|
||||
'still_render_blend_reference',
|
||||
])
|
||||
})
|
||||
|
||||
test('creates the canonical still-render reference graph with branched edges', () => {
|
||||
@@ -354,7 +401,13 @@ describe('workflowModuleBundles', () => {
|
||||
expect(insertion.nodes[5].data).toMatchObject({
|
||||
step: 'blender_still',
|
||||
label: 'Still Render',
|
||||
params: { use_custom_render_settings: true },
|
||||
params: {
|
||||
use_custom_render_settings: false,
|
||||
render_engine: 'cycles',
|
||||
samples: 256,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
})
|
||||
expect(insertion.edges).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -386,12 +439,13 @@ describe('workflowModuleBundles', () => {
|
||||
expect(insertion.ok).toBe(true)
|
||||
if (!insertion.ok) return
|
||||
|
||||
expect(insertion.nodes).toHaveLength(8)
|
||||
expect(insertion.edges).toHaveLength(7)
|
||||
expect(insertion.nodes).toHaveLength(9)
|
||||
expect(insertion.edges).toHaveLength(9)
|
||||
expect(insertion.nodes.map(node => node.data.step)).toEqual([
|
||||
'resolve_step_path',
|
||||
'occ_object_extract',
|
||||
'occ_glb_export',
|
||||
'glb_bbox',
|
||||
'stl_cache_generate',
|
||||
'blender_render',
|
||||
'threejs_render',
|
||||
@@ -402,11 +456,15 @@ describe('workflowModuleBundles', () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
source: insertion.nodes[2].id,
|
||||
target: insertion.nodes[4].id,
|
||||
target: insertion.nodes[5].id,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
source: insertion.nodes[2].id,
|
||||
target: insertion.nodes[5].id,
|
||||
target: insertion.nodes[6].id,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
source: insertion.nodes[3].id,
|
||||
target: insertion.nodes[6].id,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import {
|
||||
getWorkflowNodeContractPresentation,
|
||||
getWorkflowNodeContractSummary,
|
||||
getWorkflowNodeDeclaredOutputCount,
|
||||
getWorkflowNodeContractSignals,
|
||||
getWorkflowNodeInputSocketDescriptors,
|
||||
getWorkflowNodeInputMetric,
|
||||
getWorkflowNodeNoSettingsDescription,
|
||||
getWorkflowNodeOutputSocketDescriptors,
|
||||
getWorkflowNodeOutputMetric,
|
||||
getWorkflowNodeSocketRequirementDescription,
|
||||
getWorkflowNodeTotalVariableCount,
|
||||
getWorkflowNodeValidationWatchpoints,
|
||||
getWorkflowNodeVariableSummaryText,
|
||||
getWorkflowNodeVariableMetric,
|
||||
} from '../../components/workflows/workflowNodeContracts'
|
||||
|
||||
function buildDefinition(overrides: Partial<WorkflowNodeDefinition>): WorkflowNodeDefinition {
|
||||
return {
|
||||
step: 'test_step',
|
||||
label: 'Test Step',
|
||||
family: 'order_line',
|
||||
module_key: 'test.module',
|
||||
category: 'processing',
|
||||
description: 'Test definition',
|
||||
node_type: 'processNode',
|
||||
icon: 'box',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'native',
|
||||
legacy_compatible: false,
|
||||
input_contract: {},
|
||||
output_contract: {},
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('workflowNodeContracts', () => {
|
||||
test('normalizes output_save fallback alternative inputs into one contract summary', () => {
|
||||
const summary = getWorkflowNodeContractSummary(
|
||||
buildDefinition({
|
||||
step: 'output_save',
|
||||
input_contract: { context: 'order_line', requires: ['order_line_context'] },
|
||||
output_contract: { context: 'order_line', provides: ['media_asset', 'workflow_result'] },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(summary.requiredInputs).toEqual(['order_line_context'])
|
||||
expect(summary.requiredAnyInputs).toEqual([
|
||||
['rendered_image', 'rendered_frames', 'rendered_video', 'blend_asset'],
|
||||
])
|
||||
expect(summary.inputContextLabel).toBe('Order Line')
|
||||
expect(summary.outputContextLabel).toBe('Order Line')
|
||||
})
|
||||
|
||||
test('normalizes notify fallback alternatives and avoids duplicate required roles', () => {
|
||||
const summary = getWorkflowNodeContractSummary(
|
||||
buildDefinition({
|
||||
step: 'notify',
|
||||
input_contract: { context: 'order_line', requires: ['order_line_context', 'workflow_result'] },
|
||||
output_contract: { context: 'order_line', provides: ['notification_event'] },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(summary.requiredInputs).toEqual(['order_line_context'])
|
||||
expect(summary.requiredAnyInputs).toEqual([
|
||||
['rendered_image', 'rendered_frames', 'rendered_video', 'workflow_result', 'blend_asset'],
|
||||
])
|
||||
})
|
||||
|
||||
test('surfaces editable fields and dynamic template hints from declared contracts', () => {
|
||||
const summary = getWorkflowNodeContractSummary(
|
||||
buildDefinition({
|
||||
step: 'resolve_template',
|
||||
fields: [
|
||||
{
|
||||
key: 'samples',
|
||||
label: 'Samples',
|
||||
type: 'number',
|
||||
description: 'Render samples',
|
||||
section: 'Render',
|
||||
default: 64,
|
||||
min: 1,
|
||||
max: 1024,
|
||||
step: 1,
|
||||
unit: null,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
output_contract: { context: 'order_line', provides: ['render_template', 'template_inputs'] },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(summary.editableFieldCount).toBe(1)
|
||||
expect(summary.editableFieldLabels).toEqual(['Samples'])
|
||||
expect(summary.dynamicVariableHint).toBe('Template-selected variables appear after choosing a template.')
|
||||
expect(summary.authoringPatternLabel).toBe('Inspector-Driven')
|
||||
})
|
||||
|
||||
test('treats workflow-root records as context inputs instead of canvas sockets', () => {
|
||||
const summary = getWorkflowNodeContractSummary(
|
||||
buildDefinition({
|
||||
step: 'resolve_step_path',
|
||||
family: 'cad_file',
|
||||
input_contract: { context: 'cad_file', requires: ['cad_file_record'] },
|
||||
output_contract: { context: 'cad_file', provides: ['step_path'] },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(summary.contextInputs).toEqual(['cad_file_record'])
|
||||
expect(summary.requiredInputs).toEqual([])
|
||||
expect(summary.requiredAnyInputs).toEqual([])
|
||||
expect(summary.authoringPatternLabel).toBe('Context Entry')
|
||||
})
|
||||
|
||||
test('builds consistent catalog metrics for context-only nodes', () => {
|
||||
const summary = getWorkflowNodeContractSummary(
|
||||
buildDefinition({
|
||||
step: 'order_line_setup',
|
||||
input_contract: { context: 'order_line', requires: ['order_line_record'] },
|
||||
output_contract: { context: 'order_line', provides: ['order_line_context'] },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(getWorkflowNodeInputMetric(summary)).toEqual({
|
||||
value: 'Context only (1)',
|
||||
title: 'Order Line Record',
|
||||
})
|
||||
expect(getWorkflowNodeVariableMetric(summary)).toEqual({
|
||||
value: '0 inspector',
|
||||
title: 'No local inspector variables',
|
||||
})
|
||||
expect(getWorkflowNodeOutputMetric(summary)).toEqual({
|
||||
value: '1 role',
|
||||
title: 'Order Line Context',
|
||||
})
|
||||
})
|
||||
|
||||
test('builds consistent catalog metrics for hybrid nodes with dynamic variables', () => {
|
||||
const summary = getWorkflowNodeContractSummary(
|
||||
buildDefinition({
|
||||
step: 'resolve_template',
|
||||
fields: [
|
||||
{
|
||||
key: 'template_id_override',
|
||||
label: 'Template Override',
|
||||
type: 'text',
|
||||
description: 'Template override',
|
||||
section: 'General',
|
||||
default: '',
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
unit: null,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
input_contract: { context: 'order_line', requires: ['order_line_context'] },
|
||||
output_contract: { context: 'order_line', provides: ['render_template', 'template_inputs'] },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(getWorkflowNodeInputMetric(summary)).toEqual({
|
||||
value: '1 socket',
|
||||
title: 'Required: 1, alternative groups: 0',
|
||||
})
|
||||
expect(getWorkflowNodeVariableMetric(summary)).toEqual({
|
||||
value: '2 inspector',
|
||||
title: 'Template Override',
|
||||
})
|
||||
expect(getWorkflowNodeOutputMetric(summary)).toEqual({
|
||||
value: '2 roles',
|
||||
title: 'Render Template, Template Inputs',
|
||||
})
|
||||
expect(getWorkflowNodeDeclaredOutputCount(summary)).toBe(2)
|
||||
expect(getWorkflowNodeTotalVariableCount(summary, ['Studio Variant'])).toBe(2)
|
||||
})
|
||||
|
||||
test('builds one shared presentation model for contract metrics and authoring copy', () => {
|
||||
const summary = getWorkflowNodeContractSummary(
|
||||
buildDefinition({
|
||||
step: 'resolve_template',
|
||||
fields: [
|
||||
{
|
||||
key: 'template_id_override',
|
||||
label: 'Template Override',
|
||||
type: 'text',
|
||||
description: 'Template override',
|
||||
section: 'General',
|
||||
default: '',
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
unit: null,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
input_contract: { context: 'order_line', requires: ['order_line_context'] },
|
||||
output_contract: { context: 'order_line', provides: ['render_template', 'template_inputs'] },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(getWorkflowNodeContractPresentation(summary, ['Studio Variant'])).toEqual({
|
||||
inputMetric: {
|
||||
value: '1 socket',
|
||||
title: 'Required: 1, alternative groups: 0',
|
||||
},
|
||||
variableMetric: {
|
||||
value: '2 inspector',
|
||||
title: 'Template Override',
|
||||
},
|
||||
outputMetric: {
|
||||
value: '2 roles',
|
||||
title: 'Render Template, Template Inputs',
|
||||
},
|
||||
socketRequirementDescription: 'This node waits for Order Line Context from upstream.',
|
||||
variableSummaryText: '2 local variables are edited in the inspector.',
|
||||
noSettingsDescription: 'This node waits for Order Line Context from upstream.',
|
||||
socketCount: 1,
|
||||
variableCount: 2,
|
||||
declaredOutputCount: 2,
|
||||
})
|
||||
})
|
||||
|
||||
test('derives reusable input and output socket descriptors from the shared contract summary', () => {
|
||||
const summary = getWorkflowNodeContractSummary(
|
||||
buildDefinition({
|
||||
step: 'output_save',
|
||||
input_contract: { context: 'order_line', requires: ['order_line_context'] },
|
||||
output_contract: { context: 'order_line', provides: ['media_asset', 'workflow_result'] },
|
||||
artifact_roles_produced: ['media_asset', 'workflow_result'],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(getWorkflowNodeInputSocketDescriptors(summary)).toEqual([
|
||||
{
|
||||
id: 'input:order_line_context',
|
||||
label: 'Order Line Context',
|
||||
roles: ['order_line_context'],
|
||||
kind: 'required',
|
||||
},
|
||||
{
|
||||
id: 'input-any:rendered_image|rendered_frames|rendered_video|blend_asset',
|
||||
label: 'Any of: Rendered Image / Rendered Frames / Rendered Video / Blend Asset',
|
||||
roles: ['rendered_image', 'rendered_frames', 'rendered_video', 'blend_asset'],
|
||||
kind: 'alternative',
|
||||
},
|
||||
])
|
||||
expect(getWorkflowNodeOutputSocketDescriptors(summary)).toEqual([
|
||||
{
|
||||
id: 'output:media_asset',
|
||||
label: 'Media Asset',
|
||||
roles: ['media_asset'],
|
||||
kind: 'provided',
|
||||
},
|
||||
{
|
||||
id: 'output:workflow_result',
|
||||
label: 'Workflow Result',
|
||||
roles: ['workflow_result'],
|
||||
kind: 'provided',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('describes context-only and single-socket authoring expectations clearly', () => {
|
||||
const contextSummary = getWorkflowNodeContractSummary(
|
||||
buildDefinition({
|
||||
step: 'order_line_setup',
|
||||
input_contract: { context: 'order_line', requires: ['order_line_record'] },
|
||||
output_contract: { context: 'order_line', provides: ['order_line_context'] },
|
||||
}),
|
||||
)
|
||||
const singleSocketSummary = getWorkflowNodeContractSummary(
|
||||
buildDefinition({
|
||||
step: 'occ_object_extract',
|
||||
family: 'cad_file',
|
||||
input_contract: { context: 'cad_file', requires: ['step_path'] },
|
||||
output_contract: { context: 'cad_file', provides: ['occ_object'] },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(getWorkflowNodeSocketRequirementDescription(contextSummary)).toBe(
|
||||
'Workflow context supplies Order Line Record. No additional upstream sockets are required.',
|
||||
)
|
||||
expect(getWorkflowNodeNoSettingsDescription(contextSummary)).toBe(
|
||||
'Workflow context already provides Order Line Record.',
|
||||
)
|
||||
expect(getWorkflowNodeSocketRequirementDescription(singleSocketSummary)).toBe(
|
||||
'This node waits for STEP Path from upstream.',
|
||||
)
|
||||
})
|
||||
|
||||
test('describes mixed required and alternative socket requirements clearly', () => {
|
||||
const mixedSummary = getWorkflowNodeContractSummary(
|
||||
buildDefinition({
|
||||
step: 'output_save',
|
||||
input_contract: { context: 'order_line', requires: ['order_line_context'] },
|
||||
output_contract: { context: 'order_line', provides: ['workflow_result'] },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(getWorkflowNodeSocketRequirementDescription(mixedSummary)).toBe(
|
||||
'This node requires Order Line Context and one additional upstream artifact from any of: rendered image / rendered frames / rendered video / blend asset.',
|
||||
)
|
||||
})
|
||||
|
||||
test('describes multi-socket render prerequisites without collapsing them into generic text', () => {
|
||||
const renderSummary = getWorkflowNodeContractSummary(
|
||||
buildDefinition({
|
||||
step: 'blender_still',
|
||||
input_contract: { context: 'order_line', requires: ['order_line_context', 'render_template', 'bbox'] },
|
||||
output_contract: { context: 'order_line', provides: ['rendered_image'] },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(getWorkflowNodeSocketRequirementDescription(renderSummary)).toBe(
|
||||
'Wire 3 required upstream sockets: Order Line Context, Render Template, and Bounding Box.',
|
||||
)
|
||||
})
|
||||
|
||||
test('builds variable summaries and validation watchpoints from the shared contract model', () => {
|
||||
const definition = buildDefinition({
|
||||
step: 'resolve_template',
|
||||
label: 'Resolve Template',
|
||||
execution_kind: 'bridge',
|
||||
input_contract: { context: 'order_line', requires: ['order_line_context'] },
|
||||
output_contract: { context: 'order_line', provides: ['render_template', 'template_inputs'] },
|
||||
artifact_roles_consumed: ['order_line_context'],
|
||||
artifact_roles_produced: ['render_template'],
|
||||
fields: [
|
||||
{
|
||||
key: 'template_id_override',
|
||||
label: 'Template Override',
|
||||
type: 'text',
|
||||
description: 'Template override',
|
||||
section: 'General',
|
||||
default: '',
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
unit: null,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
const summary = getWorkflowNodeContractSummary(definition)
|
||||
const watchpoints = getWorkflowNodeValidationWatchpoints(definition, summary)
|
||||
|
||||
expect(getWorkflowNodeVariableSummaryText(summary, ['Studio Variant'])).toBe(
|
||||
'2 local variables are edited in the inspector.',
|
||||
)
|
||||
expect(watchpoints.map(watchpoint => watchpoint.label)).toEqual([
|
||||
'Context',
|
||||
'Runtime Gap',
|
||||
'Legacy Drift',
|
||||
'Artifact Flow',
|
||||
])
|
||||
expect(getWorkflowNodeContractSignals(definition, summary).map(signal => signal.label)).toEqual([
|
||||
'Hybrid',
|
||||
'Template Inputs',
|
||||
'In Order Line',
|
||||
'Out Order Line',
|
||||
'Requires Order Line Context',
|
||||
'Provides Render Template',
|
||||
'Provides Template Inputs',
|
||||
'Consumes Order Line Context',
|
||||
'Produces Render Template',
|
||||
'Watch Context',
|
||||
'Watch Runtime Gap',
|
||||
])
|
||||
})
|
||||
})
|
||||
+109
-46
@@ -4,7 +4,12 @@ import type { OutputTypeArtifactKind, OutputTypeWorkflowRolloutMode } from './ou
|
||||
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 WorkflowBlueprintType =
|
||||
| 'cad_intake'
|
||||
| 'order_rendering'
|
||||
| 'still_graph_reference'
|
||||
| 'still_graph_alpha_reference'
|
||||
| 'still_graph_blend_reference'
|
||||
export type WorkflowCanonicalBlueprintType = WorkflowBlueprintType | 'starter_cad_intake' | 'starter_order_rendering'
|
||||
|
||||
export interface WorkflowRolloutLatestRun {
|
||||
@@ -173,6 +178,8 @@ export interface WorkflowOrderLineContextOption {
|
||||
value: string
|
||||
label: string
|
||||
meta: string
|
||||
is_renderable: boolean
|
||||
renderability_reason: string | null
|
||||
}
|
||||
|
||||
export interface WorkflowOrderLineContextGroup {
|
||||
@@ -379,49 +386,89 @@ function extractRenderParamsFromNodes(nodes: WorkflowNode[], step: string): Work
|
||||
return normalizeRenderParams(match?.params ?? {})
|
||||
}
|
||||
|
||||
function buildOrderLineStillGraphNodes(renderParams: WorkflowParams): { nodes: WorkflowNode[]; edges: WorkflowEdge[] } {
|
||||
function buildOrderLineStillGraphNodes(
|
||||
renderParams: WorkflowParams,
|
||||
options: {
|
||||
transparentBg?: boolean
|
||||
includeBlendExport?: boolean
|
||||
} = {},
|
||||
): { nodes: WorkflowNode[]; edges: WorkflowEdge[] } {
|
||||
const resolvedRenderParams = {
|
||||
use_custom_render_settings: false,
|
||||
...renderParams,
|
||||
...(options.transparentBg !== undefined ? { transparent_bg: options.transparentBg } : {}),
|
||||
}
|
||||
|
||||
const nodes: WorkflowNode[] = [
|
||||
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: resolvedRenderParams,
|
||||
}),
|
||||
buildWorkflowNode('output', 'output_save', 920, 120, {
|
||||
label: 'Save Output',
|
||||
type: 'outputNode',
|
||||
}),
|
||||
buildWorkflowNode('notify', 'notify', 920, 220, {
|
||||
label: 'Notify Result',
|
||||
type: 'outputNode',
|
||||
}),
|
||||
]
|
||||
|
||||
const edges: WorkflowEdge[] = [
|
||||
{ from: 'setup', to: 'template' },
|
||||
{ from: 'setup', to: 'populate_materials' },
|
||||
{ from: 'setup', to: 'bbox' },
|
||||
{ from: 'template', to: 'resolve_materials' },
|
||||
{ from: 'populate_materials', to: 'resolve_materials' },
|
||||
{ from: 'resolve_materials', to: 'render' },
|
||||
{ from: 'bbox', to: 'render' },
|
||||
{ from: 'template', to: 'render' },
|
||||
{ from: 'render', to: 'output' },
|
||||
{ from: 'render', to: 'notify' },
|
||||
]
|
||||
|
||||
if (options.includeBlendExport) {
|
||||
nodes.push(
|
||||
buildWorkflowNode('blend_export', 'export_blend', 920, 360, {
|
||||
label: 'Export Blend',
|
||||
type: 'outputNode',
|
||||
}),
|
||||
buildWorkflowNode('save_blend', 'output_save', 1160, 320, {
|
||||
label: 'Save Blend Output',
|
||||
type: 'outputNode',
|
||||
params: { expected_artifact_role: 'blend_export' },
|
||||
}),
|
||||
buildWorkflowNode('notify_blend', 'notify', 1160, 420, {
|
||||
label: 'Notify Blend Export',
|
||||
type: 'outputNode',
|
||||
}),
|
||||
)
|
||||
edges.push(
|
||||
{ from: 'setup', to: 'blend_export' },
|
||||
{ from: 'template', to: 'blend_export' },
|
||||
{ from: 'blend_export', to: 'save_blend' },
|
||||
{ from: 'blend_export', to: 'notify_blend' },
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: [
|
||||
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' },
|
||||
{ from: 'setup', to: 'populate_materials' },
|
||||
{ from: 'setup', to: 'bbox' },
|
||||
{ from: 'template', to: 'resolve_materials' },
|
||||
{ from: 'populate_materials', to: 'resolve_materials' },
|
||||
{ from: 'resolve_materials', to: 'render' },
|
||||
{ from: 'bbox', to: 'render' },
|
||||
{ from: 'template', to: 'render' },
|
||||
{ from: 'render', to: 'output' },
|
||||
{ from: 'render', to: 'notify' },
|
||||
],
|
||||
nodes,
|
||||
edges,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,12 +728,22 @@ export function buildWorkflowBlueprintConfig(blueprint: WorkflowBlueprintType):
|
||||
}
|
||||
}
|
||||
|
||||
const { nodes, edges } = buildOrderLineStillGraphNodes({
|
||||
const stillReferenceParams: WorkflowParams = {
|
||||
render_engine: 'cycles',
|
||||
samples: 256,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
})
|
||||
}
|
||||
const buildStillReference = () => {
|
||||
if (blueprint === 'still_graph_alpha_reference') {
|
||||
return buildOrderLineStillGraphNodes(stillReferenceParams, { transparentBg: true })
|
||||
}
|
||||
if (blueprint === 'still_graph_blend_reference') {
|
||||
return buildOrderLineStillGraphNodes(stillReferenceParams, { includeBlendExport: true })
|
||||
}
|
||||
return buildOrderLineStillGraphNodes(stillReferenceParams)
|
||||
}
|
||||
const { nodes, edges } = buildStillReference()
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
@@ -819,7 +876,13 @@ export function normalizeWorkflowConfig(raw: Record<string, unknown>): WorkflowC
|
||||
}
|
||||
}
|
||||
|
||||
if (rawUi.blueprint === 'cad_intake' || rawUi.blueprint === 'order_rendering' || rawUi.blueprint === 'still_graph_reference') {
|
||||
if (
|
||||
rawUi.blueprint === 'cad_intake' ||
|
||||
rawUi.blueprint === 'order_rendering' ||
|
||||
rawUi.blueprint === 'still_graph_reference' ||
|
||||
rawUi.blueprint === 'still_graph_alpha_reference' ||
|
||||
rawUi.blueprint === 'still_graph_blend_reference'
|
||||
) {
|
||||
const canonical = buildWorkflowBlueprintConfig(rawUi.blueprint)
|
||||
return {
|
||||
...canonical,
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
import { Boxes, Milestone, X } from 'lucide-react'
|
||||
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
|
||||
import { WorkflowAuthoringSectionContent } from './WorkflowAuthoringSectionContent'
|
||||
import { WorkflowAuthoringSectionSelector } from './WorkflowAuthoringSectionSelector'
|
||||
import {
|
||||
type WorkflowAuthoringActions,
|
||||
type WorkflowAuthoringPosition,
|
||||
} from './workflowAuthoringActions'
|
||||
import { useWorkflowAuthoringSurface } from './workflowAuthoringSurface'
|
||||
|
||||
export const NODE_COMMAND_MENU_WIDTH = 360
|
||||
export const NODE_COMMAND_MENU_WIDTH = 380
|
||||
|
||||
interface NodeCommandMenuProps {
|
||||
definitions: WorkflowNodeDefinition[]
|
||||
@@ -31,7 +32,8 @@ export function NodeCommandMenu({
|
||||
onClose,
|
||||
renderIcon,
|
||||
}: NodeCommandMenuProps) {
|
||||
const { activeSection, insertBindings, plan, sections, setActiveSection } =
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const { activeSection, activeSectionDetail, chrome, insertBindings, plan, sections, setActiveSection } =
|
||||
useWorkflowAuthoringSurface({
|
||||
definitions,
|
||||
graphFamily,
|
||||
@@ -52,19 +54,28 @@ export function NodeCommandMenu({
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!containerRef.current) return
|
||||
if (containerRef.current.contains(event.target as Node)) return
|
||||
onClose()
|
||||
}
|
||||
|
||||
window.addEventListener('pointerdown', handlePointerDown)
|
||||
return () => window.removeEventListener('pointerdown', handlePointerDown)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex max-h-[calc(100vh-2rem)] flex-col overflow-hidden rounded-2xl border border-border-default bg-surface shadow-2xl"
|
||||
ref={containerRef}
|
||||
className="flex max-h-[calc(100vh-1.5rem)] 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="border-b border-border-default px-3 py-2.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-content">Workflow Authoring</p>
|
||||
<p className="text-xs text-content-muted">
|
||||
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">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<p className="text-sm font-semibold text-content">{chrome.menuTitle}</p>
|
||||
<span className="rounded-full border border-border-default bg-surface-hover px-2 py-0.5">
|
||||
{activeSteps.length} on canvas
|
||||
</span>
|
||||
@@ -80,7 +91,13 @@ export function NodeCommandMenu({
|
||||
{plan.moduleBundles.length} modules
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded-full border border-border-default bg-surface-hover px-2 py-0.5">
|
||||
Esc closes
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
{chrome.menuHelper}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -93,29 +110,28 @@ export function NodeCommandMenu({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-3 py-3">
|
||||
<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 className="flex-1 overflow-y-auto px-3 py-2.5">
|
||||
<div className="space-y-2.5">
|
||||
<div className="sticky top-0 z-10 -mx-1 rounded-xl bg-surface/95 px-1 pb-2 pt-1 backdrop-blur">
|
||||
<div className="mb-2 flex items-center justify-between gap-2 rounded-xl border border-border-default bg-surface-hover/30 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-content-secondary">
|
||||
{chrome.guideEyebrow}
|
||||
</p>
|
||||
<p className="text-xs text-content-muted">
|
||||
{activeSectionDetail?.helper ?? chrome.guideHelper}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{activeSectionDetail?.label ?? activeSectionDetail?.tone ?? 'Guided'}
|
||||
</span>
|
||||
</div>
|
||||
<WorkflowAuthoringSectionSelector
|
||||
sections={sections}
|
||||
activeSection={activeSection}
|
||||
onSelectSection={setActiveSection}
|
||||
variant="pill"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<WorkflowAuthoringSectionContent
|
||||
@@ -126,7 +142,7 @@ export function NodeCommandMenu({
|
||||
actions={insertBindings}
|
||||
renderIcon={renderIcon}
|
||||
nodesVariant="menu"
|
||||
searchPlaceholder="Search nodes"
|
||||
searchPlaceholder="Search raw nodes"
|
||||
autoFocusSearch
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import { WorkflowAuthoringSectionContent } from './WorkflowAuthoringSectionContent'
|
||||
import { WorkflowAuthoringSectionSelector } from './WorkflowAuthoringSectionSelector'
|
||||
import { type WorkflowAuthoringActions } from './workflowAuthoringActions'
|
||||
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
|
||||
import { useWorkflowAuthoringSurface } from './workflowAuthoringSurface'
|
||||
@@ -30,7 +30,8 @@ export function NodeDefinitionsPanel({
|
||||
}: NodeDefinitionsPanelProps) {
|
||||
const {
|
||||
activeSection,
|
||||
activeSectionMeta,
|
||||
activeSectionDetail,
|
||||
chrome,
|
||||
defaultSection,
|
||||
insertBindings,
|
||||
plan: authoringPlan,
|
||||
@@ -45,17 +46,20 @@ export function NodeDefinitionsPanel({
|
||||
const authoringFlow: AuthoringFlowStep[] = authoringPlan.authoringFlow
|
||||
|
||||
const presentStepCount = activeSteps.length
|
||||
const isOverviewSection = activeSection === defaultSection || activeSection === 'overview'
|
||||
const isRawNodeSection = activeSectionDetail?.isRawNodeSection ?? activeSection === 'nodes'
|
||||
const activeSectionTone = activeSectionDetail?.tone ?? 'Guided'
|
||||
const activeSectionDescription = activeSectionDetail?.helper
|
||||
const browserStatusLabel = insertBindings.onSelectStep ? 'Insert mode' : 'Browse mode'
|
||||
const activeSectionLabel = activeSectionDetail?.summaryLabel ?? activeSection
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<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="flex flex-wrap 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>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-semibold text-content">{chrome.browserTitle}</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>
|
||||
@@ -63,66 +67,38 @@ export function NodeDefinitionsPanel({
|
||||
{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>
|
||||
|
||||
<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}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1 text-[11px] text-content-muted">
|
||||
<span>{browserStatusLabel}</span>
|
||||
<span>•</span>
|
||||
<span className="font-medium text-content">{activeSectionLabel}</span>
|
||||
<span>•</span>
|
||||
<span>{activeSectionTone}</span>
|
||||
{activeSectionDescription && (
|
||||
<>
|
||||
<span className="hidden sm:inline">•</span>
|
||||
<span className="hidden max-w-[18rem] truncate sm:inline" title={activeSectionDescription}>
|
||||
{activeSectionDescription}
|
||||
</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>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
Active
|
||||
{activeSectionTone}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === defaultSection && (
|
||||
<WorkflowAuthoringSectionSelector
|
||||
sections={sections}
|
||||
activeSection={activeSection}
|
||||
onSelectSection={setActiveSection}
|
||||
variant="card"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOverviewSection && (
|
||||
<div className="rounded-2xl border border-border-default bg-surface-hover/20 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
@@ -130,7 +106,7 @@ export function NodeDefinitionsPanel({
|
||||
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.
|
||||
Start high-level, then descend only when the path is already stable.
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
@@ -156,21 +132,8 @@ export function NodeDefinitionsPanel({
|
||||
</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>
|
||||
{isRawNodeSection ? (
|
||||
<div className="rounded-2xl border border-border-default bg-surface-hover/20 p-3">
|
||||
<WorkflowAuthoringSectionContent
|
||||
activeSection={activeSection}
|
||||
definitions={definitions}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { ArrowRight, Boxes, CheckCircle2, CircleDashed, Library, Milestone, Sparkles } from 'lucide-react'
|
||||
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
|
||||
import {
|
||||
AUTHORING_STAGE_DESCRIPTIONS,
|
||||
AUTHORING_STAGE_ORDER,
|
||||
AUTHORING_STAGE_STYLES,
|
||||
type WorkflowAuthoringStage,
|
||||
type WorkflowGraphFamily,
|
||||
} from './workflowNodeLibrary'
|
||||
import { getWorkflowAuthoringPlan } from './workflowAuthoringGuidance'
|
||||
import type { WorkflowModuleBundleId } from './workflowModuleBundles'
|
||||
import type { WorkflowReferenceBundleId } from './workflowReferenceBundles'
|
||||
@@ -15,6 +21,10 @@ type WorkflowAuthoringOverviewProps = {
|
||||
onSelectStep?: (step: string) => void
|
||||
}
|
||||
|
||||
function compareStages(left: WorkflowAuthoringStage, right: WorkflowAuthoringStage) {
|
||||
return AUTHORING_STAGE_ORDER.indexOf(left) - AUTHORING_STAGE_ORDER.indexOf(right)
|
||||
}
|
||||
|
||||
export function WorkflowAuthoringOverview({
|
||||
definitions,
|
||||
graphFamily,
|
||||
@@ -24,6 +34,7 @@ export function WorkflowAuthoringOverview({
|
||||
onSelectStep,
|
||||
}: WorkflowAuthoringOverviewProps) {
|
||||
const {
|
||||
authoringFlow,
|
||||
description,
|
||||
gapFillDefinitions,
|
||||
moduleBundles,
|
||||
@@ -33,24 +44,42 @@ export function WorkflowAuthoringOverview({
|
||||
title,
|
||||
} = getWorkflowAuthoringPlan(definitions, graphFamily, activeSteps)
|
||||
const priorityIcons = [Milestone, Boxes, Library] as const
|
||||
const orderedModuleBundles = [...moduleBundles].sort((left, right) => {
|
||||
const stageDelta = compareStages(left.stageId, right.stageId)
|
||||
if (stageDelta !== 0) return stageDelta
|
||||
return left.label.localeCompare(right.label)
|
||||
})
|
||||
const hasIncompleteStage = stageProgress.some(stage => stage.present < stage.total)
|
||||
const shouldExpandQuickStart = activeSteps.length === 0
|
||||
const shouldExpandRecommendedPath = activeSteps.length < 2
|
||||
const flowSummary = authoringFlow.map(item => item.title).join(' -> ')
|
||||
const incompleteStages = stageProgress.filter(stage => stage.present < stage.total).length
|
||||
|
||||
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>
|
||||
<details
|
||||
className="rounded-2xl border border-border-default bg-surface-hover/20 p-3"
|
||||
open={hasIncompleteStage}
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Stage Status
|
||||
</p>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{incompleteStages === 0
|
||||
? 'All stages covered'
|
||||
: `${incompleteStages} stage${incompleteStages === 1 ? '' : 's'} open`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-content-muted">{flowSummary}</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
Operational
|
||||
{hasIncompleteStage ? 'Needs attention' : 'Operational'}
|
||||
</span>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<div className="mt-3 grid gap-2">
|
||||
{stageProgress.map(stage => {
|
||||
@@ -61,12 +90,14 @@ export function WorkflowAuthoringOverview({
|
||||
return (
|
||||
<div
|
||||
key={stage.id}
|
||||
className="rounded-xl border border-border-default bg-surface px-3 py-3"
|
||||
className="rounded-xl border border-border-default bg-surface px-3 py-2.5"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<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'}`}>
|
||||
<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>
|
||||
@@ -74,7 +105,7 @@ export function WorkflowAuthoringOverview({
|
||||
{progressLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-content-muted">{stage.description}</p>
|
||||
<p className="mt-1 text-[11px] text-content-muted">{stage.description}</p>
|
||||
</div>
|
||||
|
||||
{stage.actionKind === 'reference' && stage.bundleId && onInsertReferencePath && (
|
||||
@@ -114,33 +145,40 @@ export function WorkflowAuthoringOverview({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<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">
|
||||
<details
|
||||
className="rounded-2xl border border-accent/20 bg-accent-light p-3"
|
||||
open={shouldExpandRecommendedPath}
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap 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>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{priorities.length} priorities
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm font-semibold text-content">{title}</p>
|
||||
<p className="mt-1 text-xs text-content-muted">{description}</p>
|
||||
<p className="mt-2 truncate text-[11px] text-content-secondary">{flowSummary}</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>
|
||||
</summary>
|
||||
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<div className="mt-3 grid gap-2">
|
||||
{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"
|
||||
className="rounded-xl border border-border-default bg-surface px-3 py-2.5"
|
||||
>
|
||||
<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">
|
||||
@@ -151,70 +189,150 @@ export function WorkflowAuthoringOverview({
|
||||
<Icon size={13} />
|
||||
{priority.title}
|
||||
</span>
|
||||
<p className="mt-0.5 text-xs text-content-muted">{priority.description}</p>
|
||||
<p className="mt-0.5 text-[11px] text-content-muted">{priority.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{(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>
|
||||
<details
|
||||
className="rounded-2xl border border-border-default bg-surface-hover/20 p-3"
|
||||
open={shouldExpandQuickStart}
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Quick Start
|
||||
</p>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{referenceBundles.length + orderedModuleBundles.length} insert options
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Insert the recommended baseline first, then add stage bundles only where the path should diverge.
|
||||
Start from a reference path, then add only the stage bundles you need.
|
||||
</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>
|
||||
</summary>
|
||||
|
||||
<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>
|
||||
))}
|
||||
<div className="mt-3 space-y-2.5">
|
||||
{referenceBundles.length > 0 && (
|
||||
<div className="rounded-xl border border-border-default bg-surface px-3 py-2.5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Reference Baselines
|
||||
</p>
|
||||
<span className="rounded-full border border-border-default bg-surface-hover/70 px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{referenceBundles.length} path{referenceBundles.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-content-muted">
|
||||
Insert a known-good full path before swapping single stages.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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 className="mt-2 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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{orderedModuleBundles.length > 0 && (
|
||||
<div className="rounded-xl border border-border-default bg-surface px-3 py-2.5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Stage Modules
|
||||
</p>
|
||||
<span className="rounded-full border border-border-default bg-surface-hover/70 px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{orderedModuleBundles.length} module{orderedModuleBundles.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-content-muted">
|
||||
Replace one production stage at a time without rewiring the full path.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid gap-2 md:grid-cols-2">
|
||||
{orderedModuleBundles.map(bundle => (
|
||||
<div
|
||||
key={bundle.id}
|
||||
className="rounded-xl border border-border-default bg-surface-hover/50 px-3 py-2.5"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${AUTHORING_STAGE_STYLES[bundle.stageId]}`}>
|
||||
{bundle.stage}
|
||||
</span>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{bundle.presentCount}/{bundle.totalCount} present
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-content">{bundle.label}</p>
|
||||
<p className="mt-0.5 text-[11px] text-content-muted">
|
||||
{bundle.description || AUTHORING_STAGE_DESCRIPTIONS[bundle.stageId]}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-[11px] text-content-secondary">
|
||||
{bundle.stepIds.join(' -> ')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onInsertModule?.(bundle.id)}
|
||||
disabled={!onInsertModule}
|
||||
className="inline-flex shrink-0 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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{graphFamily !== 'mixed' && onSelectStep && (
|
||||
{graphFamily !== 'mixed' && onSelectStep && gapFillDefinitions.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">
|
||||
Gap Fill
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Use starter-safe inserts only for missing links in the recommended chain.
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Gap Fill
|
||||
</p>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{gapFillDefinitions.length} starter-safe step{gapFillDefinitions.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-content-muted">
|
||||
Insert only the missing links for the canonical chain.
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
@@ -222,18 +340,18 @@ export function WorkflowAuthoringOverview({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<div className="mt-2 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>
|
||||
))}
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -33,54 +33,30 @@ export function WorkflowAuthoringSectionContent({
|
||||
searchPlaceholder,
|
||||
autoFocusSearch = false,
|
||||
}: WorkflowAuthoringSectionContentProps) {
|
||||
if (activeSection === 'overview') {
|
||||
return (
|
||||
const sharedProps = {
|
||||
definitions,
|
||||
graphFamily,
|
||||
activeSteps,
|
||||
}
|
||||
|
||||
const contentBySection: Record<WorkflowAuthoringSection, ReactNode> = {
|
||||
overview: (
|
||||
<WorkflowAuthoringOverview
|
||||
definitions={definitions}
|
||||
graphFamily={graphFamily}
|
||||
activeSteps={activeSteps}
|
||||
{...sharedProps}
|
||||
onInsertModule={actions?.onInsertModule}
|
||||
onInsertReferencePath={actions?.onInsertReferencePath}
|
||||
onSelectStep={actions?.onSelectStep}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (activeSection === 'paths') {
|
||||
return (
|
||||
),
|
||||
paths: (
|
||||
<WorkflowReferenceBundlePanel
|
||||
definitions={definitions}
|
||||
graphFamily={graphFamily}
|
||||
activeSteps={activeSteps}
|
||||
{...sharedProps}
|
||||
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 (
|
||||
),
|
||||
modules: <WorkflowModuleBundlePanel {...sharedProps} onInsertModule={actions?.onInsertModule} />,
|
||||
starter: <WorkflowStarterPathPanel {...sharedProps} onSelectStep={actions?.onSelectStep} />,
|
||||
nodes: (
|
||||
<WorkflowNodeCatalogBrowser
|
||||
definitions={definitions}
|
||||
graphFamily={graphFamily}
|
||||
@@ -90,8 +66,8 @@ export function WorkflowAuthoringSectionContent({
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
autoFocusSearch={autoFocusSearch}
|
||||
/>
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
return null
|
||||
return contentBySection[activeSection] ?? null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
import type {
|
||||
WorkflowAuthoringSection,
|
||||
WorkflowAuthoringSectionConfig,
|
||||
} from './workflowAuthoringSections'
|
||||
|
||||
type WorkflowAuthoringSectionSelectorProps = {
|
||||
sections: WorkflowAuthoringSectionConfig[]
|
||||
activeSection: WorkflowAuthoringSection
|
||||
onSelectSection: (section: WorkflowAuthoringSection) => void
|
||||
variant?: 'pill' | 'card'
|
||||
}
|
||||
|
||||
export function WorkflowAuthoringSectionSelector({
|
||||
sections,
|
||||
activeSection,
|
||||
onSelectSection,
|
||||
variant = 'pill',
|
||||
}: WorkflowAuthoringSectionSelectorProps) {
|
||||
if (variant === 'card') {
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{sections.map(section => {
|
||||
const Icon = section.icon
|
||||
const isActive = activeSection === section.key
|
||||
const toneLabel = section.tone === 'Escape Hatch' ? 'Direct' : section.tone
|
||||
|
||||
return (
|
||||
<button
|
||||
key={section.key}
|
||||
type="button"
|
||||
onClick={() => onSelectSection(section.key)}
|
||||
aria-label={section.label}
|
||||
className={`min-h-[76px] rounded-2xl border px-3 py-2.5 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 h-full items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<span className="inline-flex items-center gap-2 text-sm font-medium text-content">
|
||||
<Icon size={14} />
|
||||
{section.label}
|
||||
</span>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-content-muted">
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 font-medium ${
|
||||
section.tone === 'Escape Hatch'
|
||||
? 'border border-border-default bg-surface text-content-muted'
|
||||
: 'bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{toneLabel}
|
||||
</span>
|
||||
{section.summaryLabel && (
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
|
||||
{section.summaryLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-1 text-[11px] text-content-muted">
|
||||
{section.helper}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5 self-center">
|
||||
{isActive && <ArrowRight size={14} className="text-content-secondary" />}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<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={() => onSelectSection(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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Wand2 } from 'lucide-react'
|
||||
|
||||
type WorkflowBundleCatalogItem = {
|
||||
id: string
|
||||
label: string
|
||||
stage: string
|
||||
description: string
|
||||
stepIds: string[]
|
||||
presentCount: number
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
type WorkflowBundleCatalogProps<TBundle extends WorkflowBundleCatalogItem> = {
|
||||
bundles: TBundle[]
|
||||
emptyLabel: string
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
description: string
|
||||
countLabel: string
|
||||
insertLabel?: string
|
||||
onInsert?: (bundleId: TBundle['id']) => void
|
||||
}
|
||||
|
||||
export function WorkflowBundleCatalog<TBundle extends WorkflowBundleCatalogItem>({
|
||||
bundles,
|
||||
emptyLabel,
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
countLabel,
|
||||
insertLabel = 'Insert',
|
||||
onInsert,
|
||||
}: WorkflowBundleCatalogProps<TBundle>) {
|
||||
if (bundles.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 flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={14} className="text-accent" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
{title}
|
||||
</p>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{bundles.length} {countLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-content-muted">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{bundles.map(bundle => (
|
||||
<div
|
||||
key={bundle.id}
|
||||
className="rounded-xl border border-border-default bg-surface px-3 py-2.5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 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 border border-border-default bg-surface-hover/70 px-2 py-0.5 text-[11px] font-medium text-content-secondary">
|
||||
{bundle.stage}
|
||||
</span>
|
||||
<span className="rounded-full border border-border-default bg-surface-hover/70 px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{bundle.presentCount}/{bundle.totalCount} present
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-content-muted">{bundle.description}</p>
|
||||
<p className="truncate text-[11px] text-content-secondary">
|
||||
{bundle.stepIds.length > 0 ? bundle.stepIds.join(' -> ') : emptyLabel}
|
||||
</p>
|
||||
</div>
|
||||
{onInsert ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onInsert(bundle.id)}
|
||||
aria-label={`Insert ${bundle.label}`}
|
||||
className="inline-flex shrink-0 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} />
|
||||
{insertLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
ReactFlow,
|
||||
type Edge,
|
||||
type Node,
|
||||
} from '@xyflow/react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { updateOutputType } from '../../api/outputTypes'
|
||||
import type {
|
||||
WorkflowConfig,
|
||||
WorkflowDefinition,
|
||||
WorkflowExecutionMode,
|
||||
} from '../../api/workflows'
|
||||
import { useThemeStore, resolveTheme } from '../../store/theme'
|
||||
import { NodeCommandMenu, NODE_COMMAND_MENU_WIDTH } from './NodeCommandMenu'
|
||||
import { renderWorkflowIcon, workflowCanvasNodeTypes } from './WorkflowCanvasNodes'
|
||||
import { WorkflowCanvasToolbar } from './WorkflowCanvasToolbar'
|
||||
import { WorkflowCanvasUtilitySidebar } from './WorkflowCanvasUtilitySidebar'
|
||||
import { WorkflowValidationBanner } from './WorkflowValidationBanner'
|
||||
import {
|
||||
BLUEPRINT_DESCRIPTION,
|
||||
BLUEPRINT_LABELS,
|
||||
getWorkflowBlueprint,
|
||||
} from './workflowBlueprints'
|
||||
import {
|
||||
getWorkflowAuthoringEntryAction,
|
||||
type WorkflowAuthoringActions,
|
||||
} from './workflowAuthoringActions'
|
||||
import { type WorkflowCanvasNodeData } from './workflowGraphDraft'
|
||||
import {
|
||||
GRAPH_FAMILY_LABELS,
|
||||
GRAPH_FAMILY_STYLES,
|
||||
} from './workflowNodeLibrary'
|
||||
import {
|
||||
EXECUTION_MODE_BADGE_STYLES,
|
||||
EXECUTION_MODE_LABELS,
|
||||
} from './workflowRunPresentation'
|
||||
import { getWorkflowRolloutPresentation } from './workflowRolloutPresentation'
|
||||
import { getWorkflowAuthoringSurfaceModel } from './workflowAuthoringSurface'
|
||||
import { summarizeWorkflowDraftValidationBySeverity } from './workflowValidationPresentation'
|
||||
import { useWorkflowCanvasController } from './useWorkflowCanvasController'
|
||||
|
||||
const EXECUTION_MODE_HINTS: Record<WorkflowExecutionMode, string> = {
|
||||
legacy: 'Preset dispatcher remains authoritative for production runs.',
|
||||
graph: 'Production dispatch uses graph runtime with hard fallback to legacy on failure.',
|
||||
shadow: 'Currently stored and exposed, but production dispatch still falls back to legacy until shadow parity lands.',
|
||||
}
|
||||
|
||||
type WorkflowCanvasProps = {
|
||||
workflow: WorkflowDefinition
|
||||
onSave: (config: WorkflowConfig) => void
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export function WorkflowCanvas({ workflow, onSave, isSaving }: WorkflowCanvasProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const {
|
||||
reactFlowWrapper,
|
||||
nodeDefinitions,
|
||||
nodeDefinitionsByStep,
|
||||
nodes,
|
||||
edges,
|
||||
onNodesChange,
|
||||
onEdgesChange,
|
||||
selectedEdgeIds,
|
||||
selectedNode,
|
||||
workflowRuns,
|
||||
selectedRun,
|
||||
setSelectedRunId,
|
||||
selectedRunComparison,
|
||||
isComparisonLoading,
|
||||
dispatchMutation,
|
||||
preflightMutation,
|
||||
dispatchContextId,
|
||||
setDispatchContextId,
|
||||
isOrderLineGraph,
|
||||
isOrderLineContextsLoading,
|
||||
orderLineContextGroups,
|
||||
dispatchContextLabel,
|
||||
dispatchContextSummary,
|
||||
dispatchContextMeta,
|
||||
preflightResult,
|
||||
preflightState,
|
||||
hasFreshSuccessfulPreflight,
|
||||
executionMode,
|
||||
setExecutionMode,
|
||||
nodeMenuAnchor,
|
||||
setNodeMenuAnchor,
|
||||
activeUtilityTab,
|
||||
setActiveUtilityTab,
|
||||
validation,
|
||||
authoringFamily,
|
||||
graphFamily,
|
||||
onConnect,
|
||||
onNodeClick,
|
||||
onEdgeClick,
|
||||
onPaneClick,
|
||||
handleSelectionChange,
|
||||
handleParamsChange,
|
||||
handlePipelineStepChange,
|
||||
handlePaneContextMenu,
|
||||
handleNodeContextMenu,
|
||||
insertNode,
|
||||
insertModuleBundle,
|
||||
insertReferenceBundle,
|
||||
handleOpenToolbarNodeMenu,
|
||||
handleAutoLayout,
|
||||
handleDeleteSelectedEdges,
|
||||
onEdgeContextMenu,
|
||||
onEdgeDoubleClick,
|
||||
handleSave,
|
||||
handleDispatch,
|
||||
handlePreflight,
|
||||
setReactFlowInstance,
|
||||
} = useWorkflowCanvasController({ workflow, onSave })
|
||||
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 horizontalMargin = 16
|
||||
const verticalMargin = 16
|
||||
const width = nodeMenuSize.width
|
||||
const height = Math.min(nodeMenuSize.height, window.innerHeight - verticalMargin * 2)
|
||||
|
||||
const left = Math.min(
|
||||
Math.max(nodeMenuAnchor.clientX, horizontalMargin),
|
||||
Math.max(window.innerWidth - width - horizontalMargin, horizontalMargin),
|
||||
)
|
||||
const top = Math.min(
|
||||
Math.max(nodeMenuAnchor.clientY, verticalMargin),
|
||||
Math.max(window.innerHeight - height - verticalMargin, verticalMargin),
|
||||
)
|
||||
|
||||
return { left, top }
|
||||
}, [nodeMenuAnchor, nodeMenuSize.height, nodeMenuSize.width])
|
||||
|
||||
const { mode } = useThemeStore()
|
||||
const isDark = resolveTheme(mode) === 'dark'
|
||||
const canvasEdges = useMemo(
|
||||
() =>
|
||||
edges.map(edge => ({
|
||||
...edge,
|
||||
selectable: true,
|
||||
focusable: true,
|
||||
interactionWidth: (edge as Edge & { interactionWidth?: number }).interactionWidth ?? 44,
|
||||
style: {
|
||||
...(edge.style ?? {}),
|
||||
strokeWidth: edge.selected ? 3.25 : ((edge.style?.strokeWidth as number | undefined) ?? 2.25),
|
||||
stroke: edge.selected ? (isDark ? '#f59e0b' : '#d97706') : edge.style?.stroke,
|
||||
opacity: edge.selected ? 1 : 0.95,
|
||||
},
|
||||
zIndex: edge.selected ? 20 : edge.zIndex,
|
||||
})),
|
||||
[edges, isDark],
|
||||
)
|
||||
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 validationSummary = useMemo(
|
||||
() =>
|
||||
summarizeWorkflowDraftValidationBySeverity({
|
||||
errors: validation.errors,
|
||||
warnings: validation.warnings,
|
||||
}),
|
||||
[validation.errors, validation.warnings],
|
||||
)
|
||||
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 min-h-0 flex-1 flex-col">
|
||||
<WorkflowCanvasToolbar
|
||||
workflowName={workflow.name}
|
||||
blueprintLabel={getWorkflowBlueprint(workflow.config) ? BLUEPRINT_LABELS[getWorkflowBlueprint(workflow.config)!] ?? 'Blueprint' : null}
|
||||
blueprintDescription={
|
||||
getWorkflowBlueprint(workflow.config)
|
||||
? 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}
|
||||
validationSummary={validationSummary}
|
||||
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)}
|
||||
onAutoLayout={handleAutoLayout}
|
||||
onDeleteSelectedEdges={handleDeleteSelectedEdges}
|
||||
onPreflight={handlePreflight}
|
||||
onDispatch={handleDispatch}
|
||||
onSave={handleSave}
|
||||
onRollbackOutputType={outputTypeId => rollbackOutputTypeMutation.mutate({ outputTypeId })}
|
||||
/>
|
||||
|
||||
<WorkflowValidationBanner errors={validation.errors} warnings={validation.warnings} />
|
||||
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col xl:flex-row">
|
||||
<div
|
||||
ref={reactFlowWrapper}
|
||||
className="relative flex min-h-[680px] min-w-0 flex-1 overflow-hidden xl:h-full"
|
||||
onContextMenu={event => event.preventDefault()}
|
||||
>
|
||||
{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={workflowCanvasNodeTypes}
|
||||
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>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm text-content-muted">
|
||||
Preparing workflow canvas…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<WorkflowCanvasUtilitySidebar
|
||||
activeTab={activeUtilityTab}
|
||||
onTabChange={setActiveUtilityTab}
|
||||
selectedNode={selectedNode ? { data: selectedNode.data as WorkflowCanvasNodeData | undefined } : null}
|
||||
onNodeParamsChange={handleParamsChange}
|
||||
onNodeStepChange={handlePipelineStepChange}
|
||||
nodeDefinitions={nodeDefinitions}
|
||||
nodeDefinitionsByStep={nodeDefinitionsByStep}
|
||||
graphFamily={authoringFamily}
|
||||
activeSteps={activeSteps}
|
||||
authoringActions={authoringActions}
|
||||
renderNodeIcon={renderWorkflowIcon}
|
||||
workflowRuns={workflowRuns}
|
||||
selectedRunId={selectedRun?.id ?? null}
|
||||
onSelectRun={setSelectedRunId}
|
||||
comparison={selectedRunComparison}
|
||||
isComparisonLoading={isComparisonLoading}
|
||||
preflightResult={preflightResult}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Handle, Position, type NodeTypes } from '@xyflow/react'
|
||||
import {
|
||||
Bell,
|
||||
Camera,
|
||||
Download,
|
||||
FileUp,
|
||||
Film,
|
||||
Layers,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
WORKFLOW_NODE_MIN_HEIGHT,
|
||||
WORKFLOW_NODE_WIDTH,
|
||||
type WorkflowCanvasNodeData,
|
||||
} from './workflowGraphDraft'
|
||||
import {
|
||||
getWorkflowNodePortBadgeLabel,
|
||||
getWorkflowNodePortTitle,
|
||||
} from './workflowNodePresentation'
|
||||
import { formatContractValue } from './workflowNodeContracts'
|
||||
|
||||
export function renderWorkflowIcon(iconName?: string, size = 14) {
|
||||
switch (iconName) {
|
||||
case 'file-up':
|
||||
return <FileUp size={size} />
|
||||
case 'film':
|
||||
return <Film size={size} />
|
||||
case 'layers':
|
||||
return <Layers size={size} />
|
||||
case 'download':
|
||||
return <Download size={size} />
|
||||
case 'bell':
|
||||
return <Bell size={size} />
|
||||
case 'camera':
|
||||
return <Camera size={size} />
|
||||
case 'refresh-cw':
|
||||
default:
|
||||
return <RefreshCw size={size} />
|
||||
}
|
||||
}
|
||||
|
||||
interface BaseNodeProps {
|
||||
data: WorkflowCanvasNodeData
|
||||
icon: ReactNode
|
||||
accentClass: string
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
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 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}
|
||||
</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.2rem] 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 contextInputs = data.contextInputs ?? []
|
||||
const requiredInputCount = inputPorts.filter(port => port.kind === 'required').length
|
||||
const alternativeInputCount = inputPorts.filter(port => port.kind === 'alternative').length
|
||||
const hasExplicitInspectorVariables = (data.editableFieldLabels?.length ?? 0) > 0
|
||||
const hasDynamicVariables = Boolean(data.dynamicVariableHint)
|
||||
const contextBadges: NodeBadge[] = contextInputs.map(input => ({
|
||||
id: `context:${input}`,
|
||||
label: formatContractValue(input),
|
||||
title: formatContractValue(input),
|
||||
}))
|
||||
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={`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,
|
||||
}}
|
||||
>
|
||||
{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 border-2 border-surface bg-content-muted"
|
||||
style={{ top: getHandleOffset(index, outputPorts.length) }}
|
||||
/>
|
||||
))}
|
||||
<div className={`mb-1 flex min-h-[1.25rem] items-center gap-2 ${accentClass}`}>
|
||||
{icon}
|
||||
<span className="text-sm font-medium">{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={`Context${contextInputs.length > 0 ? ` · ${contextInputs.length}` : ''}`}
|
||||
badges={contextBadges}
|
||||
emptyLabel="No workflow context"
|
||||
badgeClassName="border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/40 dark:bg-amber-900/40 dark:text-amber-300"
|
||||
maxVisibleBadges={3}
|
||||
/>
|
||||
<NodeSummaryRow
|
||||
label={
|
||||
inputPorts.length > 0
|
||||
? `Inputs · ${requiredInputCount}${alternativeInputCount > 0 ? ` + ${alternativeInputCount} alt` : ''}`
|
||||
: 'Inputs'
|
||||
}
|
||||
badges={inputBadges}
|
||||
emptyLabel="No upstream sockets"
|
||||
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${hasExplicitInspectorVariables ? ` · ${data.editableFieldLabels?.length ?? 0}` : hasDynamicVariables ? ' · dynamic' : ''}`}
|
||||
badges={variableBadges}
|
||||
emptyLabel={data.variableSummaryText ?? 'Connection-driven'}
|
||||
badgeClassName="border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-900/40 dark:bg-violet-900/40 dark:text-violet-300"
|
||||
maxVisibleBadges={2}
|
||||
/>
|
||||
<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">
|
||||
<p className="line-clamp-2 text-[10px] text-content-muted">
|
||||
{(inputPorts.length > 0 || contextInputs.length > 0
|
||||
? data.socketRequirementDescription
|
||||
: data.authoringPatternDescription) ??
|
||||
(inputPorts.length > 0
|
||||
? `Canvas requires ${inputPorts.length} upstream ${inputPorts.length === 1 ? 'connection' : 'connections'}.`
|
||||
: contextInputs.length > 0
|
||||
? 'Workflow context supplies the record for this node.'
|
||||
: 'This node can start without upstream graph inputs.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
||||
return (
|
||||
<BaseNode
|
||||
data={data}
|
||||
icon={renderWorkflowIcon(data.icon)}
|
||||
accentClass="text-green-600"
|
||||
selected={selected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ConvertNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
||||
return (
|
||||
<BaseNode
|
||||
data={data}
|
||||
icon={renderWorkflowIcon(data.icon)}
|
||||
accentClass="text-blue-600"
|
||||
selected={selected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProcessNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
||||
return (
|
||||
<BaseNode
|
||||
data={data}
|
||||
icon={renderWorkflowIcon(data.icon)}
|
||||
accentClass="text-sky-600"
|
||||
selected={selected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RenderNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
||||
const params = data.params ?? {}
|
||||
return (
|
||||
<BaseNode
|
||||
data={{
|
||||
...data,
|
||||
description: params.render_engine
|
||||
? `${params.render_engine} · ${params.samples ?? 256} samples`
|
||||
: data.description,
|
||||
}}
|
||||
icon={renderWorkflowIcon(data.icon)}
|
||||
accentClass="text-orange-600"
|
||||
selected={selected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RenderFramesNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
||||
const params = data.params ?? {}
|
||||
return (
|
||||
<BaseNode
|
||||
data={{
|
||||
...data,
|
||||
description: params.fps ? `${params.fps} fps · ${params.duration_s ?? '?'}s` : data.description,
|
||||
}}
|
||||
icon={renderWorkflowIcon(data.icon)}
|
||||
accentClass="text-orange-600"
|
||||
selected={selected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function OutputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
||||
return (
|
||||
<BaseNode
|
||||
data={data}
|
||||
icon={renderWorkflowIcon(data.icon)}
|
||||
accentClass="text-slate-600"
|
||||
selected={selected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const workflowCanvasNodeTypes: NodeTypes = {
|
||||
inputNode: InputNode as never,
|
||||
convertNode: ConvertNode as never,
|
||||
processNode: ProcessNode as never,
|
||||
renderNode: RenderNode as never,
|
||||
renderFramesNode: RenderFramesNode as never,
|
||||
outputNode: OutputNode as never,
|
||||
}
|
||||
@@ -3,6 +3,10 @@ import { GitBranch, LayoutGrid, Loader2, MousePointer2, Play, RefreshCw, Save, T
|
||||
|
||||
import type { WorkflowRolloutLinkedOutputType } from '../../api/workflows'
|
||||
import { getOutputTypeRolloutPresentation } from '../admin/outputTypeRolloutPresentation'
|
||||
import {
|
||||
getWorkflowValidationSummaryToneClassName,
|
||||
type WorkflowValidationSummaryItem,
|
||||
} from './workflowValidationPresentation'
|
||||
import type { WorkflowOrderLineContextGroup } from './useWorkflowCanvasController'
|
||||
import type { WorkflowAuthoringActions, WorkflowAuthoringEntryAction } from './workflowAuthoringActions'
|
||||
|
||||
@@ -11,6 +15,16 @@ type WorkflowExecutionModeOption = {
|
||||
label: string
|
||||
}
|
||||
|
||||
function getValidationCalloutLabel(
|
||||
hasValidationErrors: boolean,
|
||||
validationSummary: WorkflowValidationSummaryItem[],
|
||||
) {
|
||||
if (validationSummary.length === 0) return null
|
||||
return hasValidationErrors
|
||||
? 'Validation blocks save and runtime actions until the draft issues are cleared.'
|
||||
: 'Validation passed with watch items. Preflight before dispatching to confirm runtime readiness.'
|
||||
}
|
||||
|
||||
function ToolbarBadge({
|
||||
children,
|
||||
className = '',
|
||||
@@ -22,7 +36,7 @@ function ToolbarBadge({
|
||||
}) {
|
||||
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}`}
|
||||
className={`inline-flex items-center gap-1 rounded-full border border-border-default bg-surface px-2 py-0 text-[11px] font-medium leading-5 ${className}`}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
@@ -38,7 +52,7 @@ function ToolbarField({
|
||||
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">
|
||||
<label className="flex min-w-0 items-center gap-2 rounded-lg border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-secondary">
|
||||
<span className="whitespace-nowrap font-medium">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
@@ -64,7 +78,7 @@ function ToolbarActionButton({
|
||||
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 ${
|
||||
className={`flex items-center gap-1.5 rounded-lg px-2 py-1 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'
|
||||
@@ -106,6 +120,7 @@ interface WorkflowCanvasToolbarProps {
|
||||
canPreflight: boolean
|
||||
canDispatch: boolean
|
||||
hasValidationErrors: boolean
|
||||
validationSummary?: WorkflowValidationSummaryItem[]
|
||||
isPreflightPending: boolean
|
||||
isDispatchPending: boolean
|
||||
isContextOptionsLoading: boolean
|
||||
@@ -155,6 +170,7 @@ export function WorkflowCanvasToolbar({
|
||||
canPreflight,
|
||||
canDispatch,
|
||||
hasValidationErrors,
|
||||
validationSummary = [],
|
||||
isPreflightPending,
|
||||
isDispatchPending,
|
||||
isContextOptionsLoading,
|
||||
@@ -188,13 +204,18 @@ export function WorkflowCanvasToolbar({
|
||||
const showSplitFamilyBadges = authoringFamilyLabel !== graphFamilyLabel || authoringFamilyClassName !== graphFamilyClassName
|
||||
const selectedEdgeLabel = selectedEdgeCount > 1 ? `Delete (${selectedEdgeCount})` : 'Delete'
|
||||
const hasRolloutControls = linkedOutputTypes.length > 0
|
||||
const visibleValidationSummary = validationSummary.slice(0, 3)
|
||||
const hiddenValidationSummaryCount = Math.max(validationSummary.length - visibleValidationSummary.length, 0)
|
||||
const validationCalloutLabel = getValidationCalloutLabel(hasValidationErrors, validationSummary)
|
||||
const linkedOutputTypeLabel = `${linkedOutputTypeCount} linked output type${linkedOutputTypeCount === 1 ? '' : 's'}`
|
||||
const rolloutStatusTitle = `${rolloutSummary}${validationCalloutLabel ? ` ${validationCalloutLabel}` : ''}`
|
||||
|
||||
return (
|
||||
<div className="border-b border-border-default bg-surface px-3 py-2">
|
||||
<div className="border-b border-border-default bg-surface px-3 py-1.5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-start justify-between gap-1.5">
|
||||
<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">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1">
|
||||
<ToolbarBadge className="bg-surface-hover/60 text-content-secondary">
|
||||
<GitBranch size={13} />
|
||||
Workflow Canvas
|
||||
@@ -222,80 +243,120 @@ export function WorkflowCanvasToolbar({
|
||||
<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}`}>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${rolloutBadgeClassName}`}
|
||||
title={rolloutSummary}
|
||||
>
|
||||
{rolloutBadgeLabel}
|
||||
</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${rolloutStatusClassName}`}>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${rolloutStatusClassName}`}
|
||||
title={rolloutStatusTitle}
|
||||
>
|
||||
{rolloutStatusLabel}
|
||||
</span>
|
||||
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${preflightBadgeClassName}`}>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${preflightBadgeClassName}`}
|
||||
title={executionModeHint}
|
||||
>
|
||||
{preflightBadgeLabel}
|
||||
</span>
|
||||
<ToolbarBadge className="text-content-secondary" title={rolloutSummary}>
|
||||
{linkedOutputTypeLabel}
|
||||
</ToolbarBadge>
|
||||
{visibleValidationSummary.map(item => (
|
||||
<span
|
||||
key={`${item.severity}:${item.kind}`}
|
||||
className={`rounded-full border px-2 py-0.5 text-[11px] font-medium ${getWorkflowValidationSummaryToneClassName(item.severity)}`}
|
||||
title={`${item.label}: ${item.count}`}
|
||||
>
|
||||
{item.label}: {item.count}
|
||||
</span>
|
||||
))}
|
||||
{hiddenValidationSummaryCount > 0 && (
|
||||
<ToolbarBadge
|
||||
className="text-content-muted"
|
||||
title={`${hiddenValidationSummaryCount} additional validation category${hiddenValidationSummaryCount === 1 ? '' : 'ies'}`}
|
||||
>
|
||||
+{hiddenValidationSummaryCount} more
|
||||
</ToolbarBadge>
|
||||
)}
|
||||
</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>}
|
||||
{(validationCalloutLabel || blueprintDescription) && (
|
||||
<div className="flex flex-wrap items-center gap-1 text-[11px] text-content-muted">
|
||||
{validationCalloutLabel && (
|
||||
<ToolbarBadge className="text-content-muted" title={validationCalloutLabel}>
|
||||
{hasValidationErrors ? 'Validation blocked' : 'Validation watch'}
|
||||
</ToolbarBadge>
|
||||
)}
|
||||
{blueprintDescription && (
|
||||
<ToolbarBadge className="max-w-[22rem] text-content-muted" title={blueprintDescription}>
|
||||
<span className="truncate">{blueprintDescription}</span>
|
||||
</ToolbarBadge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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 className="flex flex-wrap items-center justify-end gap-1 self-start">
|
||||
<div className="flex flex-wrap items-center gap-1 rounded-xl border border-border-default bg-surface-hover/35 p-0.5">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1 rounded-xl border border-border-default bg-surface-hover/35 p-0.5">
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<div className="flex flex-wrap items-center justify-between gap-1.5 rounded-xl border border-border-default bg-surface-hover/20 px-2.5 py-1.5">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1">
|
||||
{dispatchContextKind === 'order_line' ? (
|
||||
<ToolbarField label={dispatchContextLabel}>
|
||||
<select
|
||||
@@ -314,7 +375,7 @@ export function WorkflowCanvasToolbar({
|
||||
<optgroup key={group.orderId} label={group.orderLabel}>
|
||||
{group.options.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{option.isRenderable ? option.label : `${option.label} (blocked)`}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
@@ -349,7 +410,7 @@ export function WorkflowCanvasToolbar({
|
||||
</ToolbarField>
|
||||
{dispatchContextSummary && (
|
||||
<ToolbarBadge
|
||||
className="max-w-[28rem] bg-surface-hover/60 text-content-secondary"
|
||||
className="max-w-[28rem] bg-surface text-content-secondary"
|
||||
title={dispatchContextMeta ? `${dispatchContextSummary} · ${dispatchContextMeta}` : dispatchContextSummary}
|
||||
>
|
||||
<span className="font-medium text-content">{dispatchContextLabel}</span>
|
||||
@@ -359,27 +420,27 @@ export function WorkflowCanvasToolbar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-[11px] text-content-muted">
|
||||
<div className="flex flex-wrap items-center gap-1 text-[11px] text-content-muted">
|
||||
<ToolbarBadge
|
||||
className="text-content-muted"
|
||||
title={blueprintDescription ?? 'Right-click anywhere on the canvas to open the searchable node picker.'}
|
||||
title="Right-click anywhere on the canvas to open the searchable node picker."
|
||||
>
|
||||
<MousePointer2 size={11} />
|
||||
Right-click to add
|
||||
Add nodes
|
||||
</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
|
||||
Delete edges
|
||||
</ToolbarBadge>
|
||||
</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">
|
||||
<summary className="flex cursor-pointer list-none flex-wrap items-center justify-between gap-2 px-3 py-1.5">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-content-muted">
|
||||
Rollout Controls
|
||||
|
||||
@@ -46,8 +46,11 @@ export function WorkflowListSidebar({
|
||||
}: WorkflowListSidebarProps) {
|
||||
const workflowCount = sections.reduce((count, section) => count + section.items.length, 0)
|
||||
|
||||
const formatLinkedOutputTypes = (count: number) =>
|
||||
`${count} output type${count === 1 ? '' : 's'}`
|
||||
|
||||
return (
|
||||
<aside className="flex w-56 flex-shrink-0 flex-col border-r border-border-default bg-surface">
|
||||
<aside className="flex w-52 flex-shrink-0 flex-col border-r border-border-default bg-surface xl:w-56">
|
||||
<div className="flex items-center justify-between border-b border-border-default p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-content-secondary">
|
||||
<GitBranch size={16} />
|
||||
@@ -91,73 +94,76 @@ export function WorkflowListSidebar({
|
||||
{section.items.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelectWorkflow(item.id)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onSelectWorkflow(item.id)
|
||||
}
|
||||
}}
|
||||
className={`group w-full rounded-lg border px-3 py-2.5 text-left transition-colors focus:outline-none focus:ring-2 focus:ring-accent ${
|
||||
className={`group relative w-full rounded-xl border transition-colors ${
|
||||
selectedId === item.id
|
||||
? 'border-accent/30 bg-accent-light'
|
||||
: 'border-transparent hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{item.isActive && (
|
||||
<span className="h-2 w-2 flex-shrink-0 rounded-full bg-green-500" title="Active" />
|
||||
)}
|
||||
<p className="truncate text-sm font-medium text-content">{item.name}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectWorkflow(item.id)}
|
||||
aria-pressed={selectedId === item.id}
|
||||
className="w-full rounded-xl px-3 py-2.5 pr-9 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
className={`mt-0.5 h-2 w-2 flex-shrink-0 rounded-full ${
|
||||
item.isActive ? 'bg-green-500' : 'bg-slate-300 dark:bg-slate-600'
|
||||
}`}
|
||||
title={item.isActive ? 'Active' : 'Inactive'}
|
||||
/>
|
||||
<p className="truncate text-sm font-medium text-content">{item.name}</p>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-[11px] text-content-muted">
|
||||
{formatLinkedOutputTypes(item.linkedOutputTypeCount)}
|
||||
{' • '}
|
||||
{item.rolloutStatusLabel}
|
||||
{!item.isActive ? ' • inactive' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`inline-flex flex-shrink-0 items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${item.rolloutBadgeClassName}`}>
|
||||
{item.rolloutBadgeLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
<span className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${item.presetClassName}`}>
|
||||
{item.presetLabel}
|
||||
</span>
|
||||
<span className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${item.executionModeClassName}`}>
|
||||
{item.executionModeLabel}
|
||||
</span>
|
||||
{item.blueprintLabel && (
|
||||
<span className="inline-flex items-center rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">
|
||||
{item.blueprintLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedId === item.id && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<p className="text-xs text-content-muted">{item.rolloutSummary}</p>
|
||||
{item.isReference && (
|
||||
<p className="text-xs text-content-muted">
|
||||
Canonical reference workflow for parity work.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{selectedId === item.id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
onDeleteWorkflow(item.id, item.name)
|
||||
}}
|
||||
className="flex-shrink-0 rounded p-0.5 text-content-muted opacity-0 hover:bg-red-100 hover:text-red-600 group-hover:opacity-100"
|
||||
title="Delete"
|
||||
onClick={() => onDeleteWorkflow(item.id, item.name)}
|
||||
className="absolute right-2 top-2 flex-shrink-0 rounded p-0.5 text-content-muted opacity-0 transition-opacity hover:bg-red-100 hover:text-red-600 focus:outline-none focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-red-500 group-hover:opacity-100"
|
||||
title={`Delete workflow ${item.name}`}
|
||||
aria-label={`Delete workflow ${item.name}`}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<span className={`mt-1 inline-block rounded-full px-1.5 py-0.5 text-xs font-medium ${item.presetClassName}`}>
|
||||
{item.presetLabel}
|
||||
</span>
|
||||
<span className={`ml-1 mt-1 inline-block rounded-full px-1.5 py-0.5 text-xs font-medium ${item.familyClassName}`}>
|
||||
{item.familyLabel}
|
||||
</span>
|
||||
{item.blueprintLabel && (
|
||||
<span className="ml-1 mt-1 inline-block rounded-full bg-slate-100 px-1.5 py-0.5 text-xs font-medium text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">
|
||||
{item.blueprintLabel}
|
||||
</span>
|
||||
)}
|
||||
<span className={`ml-1 mt-1 inline-block rounded-full px-1.5 py-0.5 text-xs font-medium ${item.executionModeClassName}`}>
|
||||
{item.executionModeLabel}
|
||||
</span>
|
||||
<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.
|
||||
</p>
|
||||
)}
|
||||
{!item.isActive && (
|
||||
<span className="ml-1 text-xs text-content-muted">(inactive)</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Boxes, Wand2 } from 'lucide-react'
|
||||
import { Boxes } from 'lucide-react'
|
||||
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
|
||||
import { getWorkflowAuthoringPlan } from './workflowAuthoringGuidance'
|
||||
import { WorkflowBundleCatalog } from './WorkflowBundleCatalog'
|
||||
import type { WorkflowModuleBundleId } from './workflowModuleBundles'
|
||||
|
||||
type WorkflowModuleBundlePanelProps = {
|
||||
@@ -20,64 +21,16 @@ export function WorkflowModuleBundlePanel({
|
||||
}: 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>
|
||||
<WorkflowBundleCatalog
|
||||
bundles={moduleBundles}
|
||||
emptyLabel="No workflow steps"
|
||||
icon={Boxes}
|
||||
title="Stage Modules"
|
||||
description="Insert reusable stage bundles without rebuilding the whole path."
|
||||
countLabel="bundles"
|
||||
insertLabel="Insert module"
|
||||
onInsert={onInsertModule}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { ArrowRight, Plus, Search } from 'lucide-react'
|
||||
import { ArrowRight, Plus } from 'lucide-react'
|
||||
|
||||
import type { StepCategory, WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import {
|
||||
AUTHORING_STAGE_DESCRIPTIONS,
|
||||
AUTHORING_STAGE_LABELS,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
getDefinitionFamily,
|
||||
getDefinitionModuleLabel,
|
||||
getDefinitionModuleNamespace,
|
||||
type WorkflowAuthoringStage,
|
||||
type WorkflowGraphFamily,
|
||||
type WorkflowNodeFamilyFilter,
|
||||
type WorkflowNodeKindFilter,
|
||||
@@ -29,6 +30,94 @@ import {
|
||||
getAvailableFamilyFilters,
|
||||
} from './workflowNodeCatalog'
|
||||
import { STARTER_NODE_STEP_ORDER, STARTER_PATH_TITLES } from './workflowAuthoringGuidance'
|
||||
import {
|
||||
CATEGORY_FILTER_LABELS,
|
||||
CATEGORY_FILTERS,
|
||||
type FilterPillOption,
|
||||
type WorkflowNodeCategoryFilter,
|
||||
WorkflowNodeCatalogFilterControls,
|
||||
} from './WorkflowNodeCatalogFilterControls'
|
||||
import { WorkflowNodeCatalogEmptyState } from './WorkflowNodeCatalogEmptyState'
|
||||
import { WorkflowNodeCatalogQuickInsert } from './WorkflowNodeCatalogQuickInsert'
|
||||
import {
|
||||
getWorkflowNodeContractPresentation,
|
||||
getWorkflowNodeContractSignals,
|
||||
getWorkflowNodeContractSummary,
|
||||
} from './workflowNodeContracts'
|
||||
import { WorkflowNodeContractSignalList } from './WorkflowNodeContractSignalList'
|
||||
|
||||
function ContractSummaryMetric({
|
||||
label,
|
||||
value,
|
||||
title,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
title?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border border-border-default bg-surface-hover/50 px-2 py-1"
|
||||
title={title}
|
||||
>
|
||||
<p className="text-[9px] font-semibold uppercase tracking-wide text-content-muted">{label}</p>
|
||||
<p className="mt-0.5 text-[11px] font-medium text-content">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RuntimeCoverageBadges({
|
||||
runtimeCounts,
|
||||
size = 'sm',
|
||||
}: {
|
||||
runtimeCounts: Partial<Record<WorkflowNodeLibraryGroup, number>>
|
||||
size?: 'sm' | 'xs'
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{(['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[]).map(group => {
|
||||
const count = runtimeCounts[group] ?? 0
|
||||
if (count === 0) return null
|
||||
const label = size === 'sm' ? NODE_LIBRARY_GROUP_LABELS[group] : NODE_KIND_FILTER_LABELS[group]
|
||||
|
||||
return (
|
||||
<span
|
||||
key={group}
|
||||
className={
|
||||
size === 'xs'
|
||||
? `rounded-full px-1.5 py-0.5 text-[10px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`
|
||||
: `inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`
|
||||
}
|
||||
title={size === 'sm' ? NODE_LIBRARY_GROUP_LABELS[group] : undefined}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span>{count}</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function StageCoverageBadges({
|
||||
stages,
|
||||
}: {
|
||||
stages: WorkflowAuthoringStage[]
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{stages.map(stage => (
|
||||
<span
|
||||
key={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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type WorkflowNodeCatalogBrowserProps = {
|
||||
definitions: WorkflowNodeDefinition[]
|
||||
@@ -42,76 +131,6 @@ type WorkflowNodeCatalogBrowserProps = {
|
||||
autoFocusSearch?: boolean
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function readContractList(contract: Record<string, unknown>, key: string) {
|
||||
const value = contract[key]
|
||||
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0) : []
|
||||
}
|
||||
|
||||
function readContractContext(contract: Record<string, unknown>) {
|
||||
return typeof contract.context === 'string' ? contract.context : null
|
||||
}
|
||||
|
||||
function formatContractLabel(value: string) {
|
||||
return value
|
||||
.split(/[_\s]+/)
|
||||
.filter(Boolean)
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -179,6 +198,7 @@ export function WorkflowNodeCatalogBrowser({
|
||||
|
||||
const catalogModel = useMemo(() => buildWorkflowNodeCatalogModel(visibleDefinitions), [visibleDefinitions])
|
||||
const moduleFilters = catalogModel.moduleFilters
|
||||
const stageSections = catalogModel.stageSections
|
||||
|
||||
useEffect(() => {
|
||||
if (moduleFilter !== 'all' && !moduleFilters.some(module => module.namespace === moduleFilter)) {
|
||||
@@ -189,6 +209,7 @@ export function WorkflowNodeCatalogBrowser({
|
||||
const familySections = catalogModel.familySections
|
||||
const firstVisibleDefinition = visibleDefinitions[0]
|
||||
const totalModuleCount = moduleFilters.length
|
||||
const shouldExpandStageCoverage = variant === 'panel' && query.trim().length === 0 && moduleFilter === 'all'
|
||||
const quickInsertDefinitions = useMemo(() => {
|
||||
const prioritizedSteps = STARTER_NODE_STEP_ORDER[graphFamily]
|
||||
if (prioritizedSteps.length === 0) return visibleDefinitions.slice(0, 4)
|
||||
@@ -219,190 +240,98 @@ export function WorkflowNodeCatalogBrowser({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-content-muted">
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
|
||||
{visibleDefinitions.length} nodes
|
||||
</span>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
|
||||
{totalModuleCount} modules
|
||||
</span>
|
||||
{graphFamily !== 'mixed' && (
|
||||
<span className={`rounded-full px-2 py-0.5 font-medium ${FAMILY_FILTER_STYLES[graphFamily]}`}>
|
||||
{FAMILY_FILTER_LABELS[graphFamily]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(moduleFilter !== 'all' || moduleQuery) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setModuleFilter('all')
|
||||
setModuleQuery('')
|
||||
}}
|
||||
className="text-[11px] font-medium text-accent hover:text-accent-hover"
|
||||
>
|
||||
Show all modules
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" />
|
||||
<input
|
||||
value={query}
|
||||
autoFocus={autoFocusSearch}
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' && firstVisibleDefinition && onSelectStep) {
|
||||
event.preventDefault()
|
||||
onSelectStep(firstVisibleDefinition.step)
|
||||
}
|
||||
}}
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full rounded-xl border border-border-default bg-surface px-8 py-2 text-sm text-content focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FilterPillGroup
|
||||
title="Runtime"
|
||||
options={runtimeFilterOptions}
|
||||
activeValue={kindFilter}
|
||||
onChange={setKindFilter}
|
||||
/>
|
||||
|
||||
<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">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Modules
|
||||
</p>
|
||||
<span className="text-[11px] text-content-muted">
|
||||
family + runtime scoped
|
||||
</span>
|
||||
</div>
|
||||
<div className="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"
|
||||
onClick={() => setModuleFilter('all')}
|
||||
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
|
||||
moduleFilter === 'all'
|
||||
? 'bg-accent text-white'
|
||||
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
All Modules
|
||||
</button>
|
||||
{moduleFilters.map(module => (
|
||||
<button
|
||||
key={module.namespace}
|
||||
type="button"
|
||||
onClick={() => setModuleFilter(module.namespace)}
|
||||
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
|
||||
moduleFilter === module.namespace
|
||||
? 'bg-accent text-white'
|
||||
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
|
||||
}`}
|
||||
title={`${module.label} · ${module.stages.map(stage => AUTHORING_STAGE_LABELS[stage]).join(' / ')}`}
|
||||
>
|
||||
{module.label}
|
||||
<span className="ml-1 opacity-70">{module.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<WorkflowNodeCatalogFilterControls
|
||||
visibleDefinitionCount={visibleDefinitions.length}
|
||||
totalModuleCount={totalModuleCount}
|
||||
graphFamily={graphFamily}
|
||||
familyFilterOptions={familyFilterOptions}
|
||||
runtimeFilterOptions={runtimeFilterOptions}
|
||||
categoryFilterOptions={categoryFilterOptions}
|
||||
familyFilter={familyFilter}
|
||||
onFamilyFilterChange={setFamilyFilter}
|
||||
kindFilter={kindFilter}
|
||||
onKindFilterChange={setKindFilter}
|
||||
categoryFilter={categoryFilter}
|
||||
onCategoryFilterChange={setCategoryFilter}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
onQueryEnter={() => {
|
||||
if (firstVisibleDefinition && onSelectStep) {
|
||||
onSelectStep(firstVisibleDefinition.step)
|
||||
}
|
||||
}}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
autoFocusSearch={autoFocusSearch}
|
||||
moduleFilters={moduleFilters}
|
||||
moduleFilter={moduleFilter}
|
||||
onModuleFilterChange={setModuleFilter}
|
||||
moduleQuery={moduleQuery}
|
||||
onModuleQueryChange={setModuleQuery}
|
||||
onClearModuleScope={() => {
|
||||
setModuleFilter('all')
|
||||
setModuleQuery('')
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[]).map(group => {
|
||||
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_LABELS[group]}
|
||||
>
|
||||
<span>{NODE_LIBRARY_GROUP_LABELS[group]}</span>
|
||||
<span>{count}</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
<RuntimeCoverageBadges runtimeCounts={catalogModel.runtimeCounts} />
|
||||
</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">
|
||||
{stageSections.length > 0 && (
|
||||
<details
|
||||
className="rounded-xl border border-border-default bg-surface-hover/35 p-3"
|
||||
open={shouldExpandStageCoverage}
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Quick Insert
|
||||
Stage Coverage
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Use Quick Insert for the first canonical steps, then use stage coverage to inspect which modules can fill the rest of the production path.
|
||||
</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
|
||||
{stageSections.length} active stages
|
||||
</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}
|
||||
</summary>
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||
{stageSections.map(section => (
|
||||
<div
|
||||
key={`stage-summary-${section.stage}`}
|
||||
className="rounded-lg border border-border-default bg-surface px-3 py-2"
|
||||
>
|
||||
{definition.label}
|
||||
</button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${AUTHORING_STAGE_STYLES[section.stage]}`}>
|
||||
{AUTHORING_STAGE_LABELS[section.stage]}
|
||||
</span>
|
||||
<span className="rounded-full border border-border-default bg-surface-hover px-1.5 py-0.5 text-[10px] text-content-muted">
|
||||
{section.modules.length} modules
|
||||
</span>
|
||||
<span className="rounded-full border border-border-default bg-surface-hover px-1.5 py-0.5 text-[10px] text-content-muted">
|
||||
{section.definitions.length} nodes
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-content-muted">
|
||||
{AUTHORING_STAGE_DESCRIPTIONS[section.stage]}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<WorkflowNodeCatalogQuickInsert
|
||||
title={quickInsertTitle}
|
||||
definitions={quickInsertDefinitions}
|
||||
onSelectStep={onSelectStep}
|
||||
/>
|
||||
|
||||
{visibleDefinitions.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-border-default bg-surface-hover/40 px-4 py-8 text-center">
|
||||
<p className="text-sm font-medium text-content">No matching nodes</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Adjust search, runtime, family, or module filters to bring nodes back into view.
|
||||
</p>
|
||||
{onEmptyAction && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEmptyAction}
|
||||
className="mt-3 rounded-lg border border-border-default px-3 py-1.5 text-xs font-medium text-content hover:bg-surface-hover"
|
||||
>
|
||||
{emptyActionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<WorkflowNodeCatalogEmptyState
|
||||
onResetFilters={onEmptyAction}
|
||||
resetLabel={emptyActionLabel}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
@@ -433,27 +362,16 @@ export function WorkflowNodeCatalogBrowser({
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
<RuntimeCoverageBadges runtimeCounts={familySection.runtimeCounts} size="xs" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
{familySection.modules.map(moduleGroup => (
|
||||
<div
|
||||
key={`${familySection.family}:${moduleGroup.namespace}`}
|
||||
className="rounded-lg border border-border-default bg-surface/80 p-3"
|
||||
>
|
||||
<div
|
||||
key={`${familySection.family}:${moduleGroup.namespace}`}
|
||||
className="rounded-lg border border-border-default bg-surface/80 p-3"
|
||||
>
|
||||
<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">
|
||||
@@ -463,29 +381,10 @@ export function WorkflowNodeCatalogBrowser({
|
||||
<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>
|
||||
))}
|
||||
<StageCoverageBadges stages={moduleGroup.stages} />
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
<RuntimeCoverageBadges runtimeCounts={moduleGroup.runtimeCounts} size="xs" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-content-muted">{moduleGroup.definitions.length}</span>
|
||||
@@ -529,10 +428,20 @@ export function WorkflowNodeCatalogBrowser({
|
||||
<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 contractSummary = getWorkflowNodeContractSummary(definition)
|
||||
const contractPresentation =
|
||||
getWorkflowNodeContractPresentation(contractSummary)
|
||||
const {
|
||||
inputMetric,
|
||||
variableMetric,
|
||||
outputMetric,
|
||||
socketRequirementDescription,
|
||||
variableSummaryText,
|
||||
} = contractPresentation
|
||||
const contractSignals = getWorkflowNodeContractSignals(
|
||||
definition,
|
||||
contractSummary,
|
||||
)
|
||||
const isActionable = Boolean(onSelectStep)
|
||||
|
||||
return (
|
||||
@@ -609,49 +518,46 @@ export function WorkflowNodeCatalogBrowser({
|
||||
{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>
|
||||
))}
|
||||
<WorkflowNodeContractSignalList
|
||||
signals={contractSignals}
|
||||
className="mt-2 flex flex-wrap gap-1.5 text-[10px]"
|
||||
/>
|
||||
|
||||
<div className="mt-2 grid gap-1.5 sm:grid-cols-3">
|
||||
<ContractSummaryMetric
|
||||
label="Inputs"
|
||||
value={inputMetric.value}
|
||||
title={inputMetric.title}
|
||||
/>
|
||||
<ContractSummaryMetric
|
||||
label="Variables"
|
||||
value={variableMetric.value}
|
||||
title={variableMetric.title}
|
||||
/>
|
||||
<ContractSummaryMetric
|
||||
label="Outputs"
|
||||
value={outputMetric.value}
|
||||
title={outputMetric.title}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid gap-1.5 lg:grid-cols-2">
|
||||
<div className="rounded-lg border border-border-default bg-surface-hover/50 px-2 py-1.5">
|
||||
<p className="text-[9px] font-semibold uppercase tracking-wide text-content-muted">
|
||||
Wiring
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-content-secondary">
|
||||
{socketRequirementDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border-default bg-surface-hover/50 px-2 py-1.5">
|
||||
<p className="text-[9px] font-semibold uppercase tracking-wide text-content-muted">
|
||||
Variables
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-content-muted">
|
||||
{variableSummaryText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
type WorkflowNodeCatalogEmptyStateProps = {
|
||||
onResetFilters?: () => void
|
||||
resetLabel?: string
|
||||
}
|
||||
|
||||
export function WorkflowNodeCatalogEmptyState({
|
||||
onResetFilters,
|
||||
resetLabel = 'Clear Filters',
|
||||
}: WorkflowNodeCatalogEmptyStateProps) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-border-default bg-surface-hover/40 px-4 py-8 text-center">
|
||||
<p className="text-sm font-medium text-content">No matching nodes</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Adjust search or filter scope, or step back to reference paths and stage modules for guided assembly.
|
||||
</p>
|
||||
{onResetFilters && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResetFilters}
|
||||
className="mt-3 rounded-lg border border-border-default px-3 py-1.5 text-xs font-medium text-content hover:bg-surface-hover"
|
||||
>
|
||||
{resetLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
import type { StepCategory } from '../../api/workflows'
|
||||
import {
|
||||
FAMILY_FILTER_DESCRIPTIONS,
|
||||
FAMILY_FILTER_LABELS,
|
||||
FAMILY_FILTER_STYLES,
|
||||
NODE_KIND_FILTER_LABELS,
|
||||
type WorkflowGraphFamily,
|
||||
type WorkflowNodeFamilyFilter,
|
||||
type WorkflowNodeKindFilter,
|
||||
} from './workflowNodeLibrary'
|
||||
|
||||
export type WorkflowNodeCategoryFilter = 'all' | StepCategory
|
||||
|
||||
export const CATEGORY_FILTERS: WorkflowNodeCategoryFilter[] = [
|
||||
'all',
|
||||
'input',
|
||||
'processing',
|
||||
'rendering',
|
||||
'output',
|
||||
]
|
||||
|
||||
export const CATEGORY_FILTER_LABELS: Record<WorkflowNodeCategoryFilter, string> = {
|
||||
all: 'All Categories',
|
||||
input: 'Input',
|
||||
processing: 'Processing',
|
||||
rendering: 'Rendering',
|
||||
output: 'Output',
|
||||
}
|
||||
|
||||
export type FilterPillOption<T extends string> = {
|
||||
value: T
|
||||
label: string
|
||||
}
|
||||
|
||||
function FilterPillGroup<T extends string>({
|
||||
title,
|
||||
options,
|
||||
activeValue,
|
||||
onChange,
|
||||
helper,
|
||||
}: {
|
||||
title: string
|
||||
options: FilterPillOption<T>[]
|
||||
activeValue: T
|
||||
onChange: (value: T) => void
|
||||
helper?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-content-secondary">
|
||||
{title}
|
||||
</p>
|
||||
{helper && <p className="text-[11px] text-content-muted">{helper}</p>}
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
type ModuleFilter = {
|
||||
namespace: string
|
||||
label: string
|
||||
count: number
|
||||
stages: string[]
|
||||
}
|
||||
|
||||
type WorkflowNodeCatalogFilterControlsProps = {
|
||||
visibleDefinitionCount: number
|
||||
totalModuleCount: number
|
||||
graphFamily: WorkflowGraphFamily
|
||||
familyFilterOptions: FilterPillOption<WorkflowNodeFamilyFilter>[]
|
||||
runtimeFilterOptions: FilterPillOption<WorkflowNodeKindFilter>[]
|
||||
categoryFilterOptions: FilterPillOption<WorkflowNodeCategoryFilter>[]
|
||||
familyFilter: WorkflowNodeFamilyFilter
|
||||
onFamilyFilterChange: (value: WorkflowNodeFamilyFilter) => void
|
||||
kindFilter: WorkflowNodeKindFilter
|
||||
onKindFilterChange: (value: WorkflowNodeKindFilter) => void
|
||||
categoryFilter: WorkflowNodeCategoryFilter
|
||||
onCategoryFilterChange: (value: WorkflowNodeCategoryFilter) => void
|
||||
query: string
|
||||
onQueryChange: (value: string) => void
|
||||
onQueryEnter: () => void
|
||||
searchPlaceholder: string
|
||||
autoFocusSearch: boolean
|
||||
moduleFilters: ModuleFilter[]
|
||||
moduleFilter: string
|
||||
onModuleFilterChange: (value: string) => void
|
||||
moduleQuery: string
|
||||
onModuleQueryChange: (value: string) => void
|
||||
onClearModuleScope: () => void
|
||||
}
|
||||
|
||||
export function WorkflowNodeCatalogFilterControls({
|
||||
visibleDefinitionCount,
|
||||
totalModuleCount,
|
||||
graphFamily,
|
||||
familyFilterOptions,
|
||||
runtimeFilterOptions,
|
||||
categoryFilterOptions,
|
||||
familyFilter,
|
||||
onFamilyFilterChange,
|
||||
kindFilter,
|
||||
onKindFilterChange,
|
||||
categoryFilter,
|
||||
onCategoryFilterChange,
|
||||
query,
|
||||
onQueryChange,
|
||||
onQueryEnter,
|
||||
searchPlaceholder,
|
||||
autoFocusSearch,
|
||||
moduleFilters,
|
||||
moduleFilter,
|
||||
onModuleFilterChange,
|
||||
moduleQuery,
|
||||
onModuleQueryChange,
|
||||
onClearModuleScope,
|
||||
}: WorkflowNodeCatalogFilterControlsProps) {
|
||||
const familyDescription =
|
||||
familyFilter === 'all'
|
||||
? 'Show all workflow families in one catalog view.'
|
||||
: familyFilter === 'cad_file'
|
||||
? FAMILY_FILTER_DESCRIPTIONS.cad_file
|
||||
: FAMILY_FILTER_DESCRIPTIONS.order_line
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-content-muted">
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
|
||||
{visibleDefinitionCount} nodes
|
||||
</span>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
|
||||
{totalModuleCount} modules
|
||||
</span>
|
||||
{graphFamily !== 'mixed' && (
|
||||
<span className={`rounded-full px-2 py-0.5 font-medium ${FAMILY_FILTER_STYLES[graphFamily]}`}>
|
||||
{FAMILY_FILTER_LABELS[graphFamily]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(moduleFilter !== 'all' || moduleQuery) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearModuleScope}
|
||||
className="text-[11px] font-medium text-accent hover:text-accent-hover"
|
||||
>
|
||||
Show all modules
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" />
|
||||
<input
|
||||
value={query}
|
||||
autoFocus={autoFocusSearch}
|
||||
onChange={event => onQueryChange(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
onQueryEnter()
|
||||
}
|
||||
}}
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full rounded-xl border border-border-default bg-surface px-8 py-2 text-sm text-content focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
|
||||
<div className="space-y-2">
|
||||
<FilterPillGroup
|
||||
title="Runtime"
|
||||
options={runtimeFilterOptions}
|
||||
activeValue={kindFilter}
|
||||
onChange={onKindFilterChange}
|
||||
/>
|
||||
|
||||
<FilterPillGroup
|
||||
title="Family"
|
||||
options={familyFilterOptions}
|
||||
activeValue={familyFilter}
|
||||
onChange={onFamilyFilterChange}
|
||||
helper={familyDescription}
|
||||
/>
|
||||
|
||||
<FilterPillGroup
|
||||
title="Category"
|
||||
options={categoryFilterOptions}
|
||||
activeValue={categoryFilter}
|
||||
onChange={onCategoryFilterChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{moduleFilters.length > 0 && (
|
||||
<div className="space-y-2 rounded-xl border border-border-default bg-surface-hover/25 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Module Scope
|
||||
</p>
|
||||
<span className="text-[11px] text-content-muted">runtime + family 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 => onModuleQueryChange(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"
|
||||
onClick={() => onModuleFilterChange('all')}
|
||||
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
|
||||
moduleFilter === 'all'
|
||||
? 'bg-accent text-white'
|
||||
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
All Modules
|
||||
</button>
|
||||
{moduleFilters.map(module => (
|
||||
<button
|
||||
key={module.namespace}
|
||||
type="button"
|
||||
onClick={() => onModuleFilterChange(module.namespace)}
|
||||
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
|
||||
moduleFilter === module.namespace
|
||||
? 'bg-accent text-white'
|
||||
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
|
||||
}`}
|
||||
title={`${module.label} · ${module.stages.join(' / ')}`}
|
||||
>
|
||||
{module.label}
|
||||
<span className="ml-1 opacity-70">{module.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
|
||||
type WorkflowNodeCatalogQuickInsertProps = {
|
||||
title: string
|
||||
definitions: WorkflowNodeDefinition[]
|
||||
onSelectStep?: (step: string) => void
|
||||
}
|
||||
|
||||
export function WorkflowNodeCatalogQuickInsert({
|
||||
title,
|
||||
definitions,
|
||||
onSelectStep,
|
||||
}: WorkflowNodeCatalogQuickInsertProps) {
|
||||
if (definitions.length === 0) return null
|
||||
|
||||
return (
|
||||
<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">
|
||||
Start from <span className="font-medium text-content">{title}</span> before drilling into individual nodes.
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{definitions.length} starters
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{definitions.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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import { formatContractValue } from './workflowGraphDraft'
|
||||
import {
|
||||
formatContractAlternativeGroup,
|
||||
formatContractValue,
|
||||
getWorkflowNodeContractPresentation,
|
||||
getWorkflowNodeContractSignals,
|
||||
type WorkflowNodeContractSummary,
|
||||
type WorkflowNodeValidationWatchpoint,
|
||||
} from './workflowNodeContracts'
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import { WorkflowNodeContractSignalList } from './WorkflowNodeContractSignalList'
|
||||
|
||||
interface WorkflowNodeContractCardProps {
|
||||
nodeDefinition?: WorkflowNodeDefinition
|
||||
moduleLabel: string
|
||||
moduleKey: string
|
||||
familyLabel: string
|
||||
@@ -9,13 +19,8 @@ interface WorkflowNodeContractCardProps {
|
||||
runtimeClassName: string
|
||||
legacyCompatible: boolean
|
||||
legacySource?: string | null
|
||||
inputContextLabel?: string | null
|
||||
outputContextLabel?: string | null
|
||||
requiredInputs: string[]
|
||||
requiredAnyInputs: string[][]
|
||||
consumedArtifacts: string[]
|
||||
providedOutputs: string[]
|
||||
producedArtifacts: string[]
|
||||
contract: WorkflowNodeContractSummary
|
||||
validationWatchpoints?: WorkflowNodeValidationWatchpoint[]
|
||||
}
|
||||
|
||||
function formatContractRole(role: string): string {
|
||||
@@ -57,14 +62,33 @@ function ContractAlternativeGroups({
|
||||
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(' / ')}
|
||||
{formatContractAlternativeGroup(group)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContractMetricCard({
|
||||
label,
|
||||
value,
|
||||
helper,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
helper: string
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border-default bg-surface px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-content-muted">{label}</p>
|
||||
<p className="mt-1 text-sm font-medium text-content">{value}</p>
|
||||
<p className="mt-1 text-xs text-content-muted">{helper}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkflowNodeContractCard({
|
||||
nodeDefinition,
|
||||
moduleLabel,
|
||||
moduleKey,
|
||||
familyLabel,
|
||||
@@ -73,14 +97,52 @@ export function WorkflowNodeContractCard({
|
||||
runtimeClassName,
|
||||
legacyCompatible,
|
||||
legacySource,
|
||||
inputContextLabel,
|
||||
outputContextLabel,
|
||||
requiredInputs,
|
||||
requiredAnyInputs,
|
||||
consumedArtifacts,
|
||||
providedOutputs,
|
||||
producedArtifacts,
|
||||
contract,
|
||||
validationWatchpoints = [],
|
||||
}: WorkflowNodeContractCardProps) {
|
||||
const {
|
||||
inputContextLabel,
|
||||
outputContextLabel,
|
||||
contextInputs = [],
|
||||
requiredInputs = [],
|
||||
requiredAnyInputs = [],
|
||||
consumedArtifacts = [],
|
||||
providedOutputs = [],
|
||||
producedArtifacts = [],
|
||||
authoringPatternLabel = 'Hybrid',
|
||||
authoringPatternDescription = 'Wire required upstream artifacts on the canvas and configure local variables in the inspector.',
|
||||
} = contract
|
||||
const contractPresentation = getWorkflowNodeContractPresentation(contract)
|
||||
const {
|
||||
inputMetric,
|
||||
variableMetric,
|
||||
outputMetric,
|
||||
socketRequirementDescription,
|
||||
variableSummaryText,
|
||||
noSettingsDescription,
|
||||
socketCount,
|
||||
variableCount: inspectorVariableCount,
|
||||
declaredOutputCount: providedRoleCount,
|
||||
} = contractPresentation
|
||||
const operationalSignals = getWorkflowNodeContractSignals(
|
||||
nodeDefinition,
|
||||
contract,
|
||||
{
|
||||
contextInputs: 1,
|
||||
requiredInputs: 1,
|
||||
providedOutputs: 1,
|
||||
consumedArtifacts: 1,
|
||||
producedArtifacts: 1,
|
||||
watchpoints: 0,
|
||||
},
|
||||
).filter(signal => signal.id !== 'authoring-pattern')
|
||||
const watchpointSignals = validationWatchpoints.map(watchpoint => ({
|
||||
id: `watch:${watchpoint.kind}`,
|
||||
label: `Watch ${watchpoint.label}`,
|
||||
title: watchpoint.reason,
|
||||
tone: 'watch' as const,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-xl border border-border-default bg-surface-hover/40 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -100,6 +162,9 @@ export function WorkflowNodeContractCard({
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${runtimeClassName}`}>
|
||||
{runtimeLabel}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-secondary">
|
||||
{authoringPatternLabel}
|
||||
</span>
|
||||
{legacyCompatible && (
|
||||
<span className="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">
|
||||
Legacy Safe
|
||||
@@ -112,15 +177,58 @@ export function WorkflowNodeContractCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-content-muted">{authoringPatternDescription}</p>
|
||||
<p className="text-xs text-content-muted">{variableSummaryText}</p>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<ContractMetricCard
|
||||
label="Sockets"
|
||||
value={socketCount === 0 ? 'Context / none' : inputMetric.value}
|
||||
helper={socketRequirementDescription}
|
||||
/>
|
||||
<ContractMetricCard
|
||||
label="Variables"
|
||||
value={inspectorVariableCount === 0 ? 'No local variables' : variableMetric.value}
|
||||
helper={variableSummaryText}
|
||||
/>
|
||||
<ContractMetricCard
|
||||
label="Outputs"
|
||||
value={providedRoleCount === 0 ? 'No declared outputs' : outputMetric.value}
|
||||
helper={
|
||||
providedOutputs.length > 0
|
||||
? providedOutputs.map(formatContractRole).join(', ')
|
||||
: producedArtifacts.length > 0
|
||||
? producedArtifacts.map(formatContractRole).join(', ')
|
||||
: 'No declared downstream roles yet.'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{operationalSignals.length > 0 && (
|
||||
<div className="space-y-2 rounded-lg border border-border-default bg-surface px-3 py-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Operational Signals
|
||||
</p>
|
||||
<WorkflowNodeContractSignalList signals={operationalSignals} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2 rounded-lg border border-border-default bg-surface px-3 py-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">Inputs</p>
|
||||
{inputContextLabel && <p className="text-xs text-content-muted">Context: {inputContextLabel}</p>}
|
||||
{contextInputs.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">Context Provided</p>
|
||||
<ContractRolePills roles={contextInputs} />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-content-muted">{socketRequirementDescription}</p>
|
||||
{requiredInputs.length > 0 ? (
|
||||
<ContractRolePills roles={requiredInputs} />
|
||||
) : (
|
||||
<p className="text-xs text-content-muted">No declared upstream requirements.</p>
|
||||
)}
|
||||
) : requiredAnyInputs.length === 0 ? (
|
||||
<p className="text-xs text-content-muted">{noSettingsDescription}</p>
|
||||
) : null}
|
||||
{requiredAnyInputs.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">Alternative Inputs</p>
|
||||
@@ -133,6 +241,17 @@ export function WorkflowNodeContractCard({
|
||||
<ContractRolePills roles={consumedArtifacts} />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">Inspector Variables</p>
|
||||
{contract.editableFieldLabels.length > 0 ? (
|
||||
<ContractRolePills roles={contract.editableFieldLabels} />
|
||||
) : (
|
||||
<p className="text-xs text-content-muted">{variableSummaryText}</p>
|
||||
)}
|
||||
{contract.dynamicVariableHint && (
|
||||
<p className="text-xs text-content-muted">{contract.dynamicVariableHint}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-lg border border-border-default bg-surface px-3 py-2">
|
||||
@@ -151,6 +270,18 @@ export function WorkflowNodeContractCard({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{watchpointSignals.length > 0 && (
|
||||
<div className="space-y-2 rounded-lg border border-dashed border-border-default bg-surface px-3 py-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Validation Watchpoints
|
||||
</p>
|
||||
<p className="text-xs text-content-muted">
|
||||
The most likely preflight categories this node can influence while authoring or debugging graph runs.
|
||||
</p>
|
||||
<WorkflowNodeContractSignalList signals={watchpointSignals} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { WorkflowNodeContractSignal } from './workflowNodeContracts'
|
||||
|
||||
const SIGNAL_TONE_STYLES: Record<WorkflowNodeContractSignal['tone'], string> = {
|
||||
default: 'border-border-default bg-surface-hover/80 text-content-secondary',
|
||||
watch: 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300',
|
||||
}
|
||||
|
||||
type WorkflowNodeContractSignalListProps = {
|
||||
signals: WorkflowNodeContractSignal[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function WorkflowNodeContractSignalList({
|
||||
signals,
|
||||
className = 'flex flex-wrap gap-1.5 text-[10px]',
|
||||
}: WorkflowNodeContractSignalListProps) {
|
||||
if (signals.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{signals.map(signal => (
|
||||
<span
|
||||
key={signal.id}
|
||||
className={`rounded-full border px-1.5 py-0.5 ${SIGNAL_TONE_STYLES[signal.tone]}`}
|
||||
title={signal.title}
|
||||
>
|
||||
{signal.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, type ChangeEvent } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { getCachedOutputTypeContractCatalog } from '../../api/outputTypes'
|
||||
import { listRenderTemplates } from '../../api/renderTemplates'
|
||||
import type { WorkflowNodeDefinition, WorkflowNodeFieldDefinition, WorkflowParams } from '../../api/workflows'
|
||||
import {
|
||||
@@ -12,12 +13,16 @@ import {
|
||||
isDefinitionAllowedForGraphFamily,
|
||||
type WorkflowGraphFamily,
|
||||
} from './workflowNodeLibrary'
|
||||
import {
|
||||
TEMPLATE_INPUT_PARAM_PREFIX,
|
||||
getWorkflowNodeContractPresentation,
|
||||
formatContractValue,
|
||||
getWorkflowNodeContractSummary,
|
||||
getWorkflowNodeInputSocketDescriptors,
|
||||
getWorkflowNodeValidationWatchpoints,
|
||||
getWorkflowVariableLabels,
|
||||
} from './workflowNodeContracts'
|
||||
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) => {
|
||||
@@ -27,49 +32,6 @@ function groupFieldsBySection(fields: WorkflowNodeFieldDefinition[]) {
|
||||
}, {})
|
||||
}
|
||||
|
||||
function getContractValues(contract: Record<string, unknown> | undefined, key: string): string[] {
|
||||
const value = contract?.[key]
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
|
||||
}
|
||||
|
||||
function 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)
|
||||
@@ -82,55 +44,32 @@ function clearDynamicTemplateInputParams(params: WorkflowParams): WorkflowParams
|
||||
)
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
|
||||
function describeCanvasSocketBreakdown(requiredCount: number, alternativeCount: number): string {
|
||||
if (requiredCount > 0 && alternativeCount > 0) {
|
||||
return `${formatCountNoun(requiredCount, 'required socket', 'required sockets')} and ${formatCountNoun(alternativeCount, 'alternative group', 'alternative groups')} are exposed on the canvas.`
|
||||
}
|
||||
if (requiredCount > 0) {
|
||||
return `${formatCountNoun(requiredCount, 'required socket', 'required sockets')} ${requiredCount === 1 ? 'is' : 'are'} exposed on the canvas.`
|
||||
}
|
||||
return `${formatCountNoun(alternativeCount, 'alternative group', 'alternative groups')} ${alternativeCount === 1 ? 'is' : 'are'} exposed on the canvas.`
|
||||
}
|
||||
|
||||
function describeTemplateCoverage(
|
||||
templateNames: string[],
|
||||
outputTypeNames: string[],
|
||||
): string {
|
||||
if (outputTypeNames.length > 0) {
|
||||
return `${templateNames.join(', ')} (${outputTypeNames.join(', ')})`
|
||||
}
|
||||
return templateNames.join(', ')
|
||||
}
|
||||
|
||||
const RENDER_OVERRIDE_CONTROL_STEPS = new Set(['blender_still', 'blender_turntable'])
|
||||
|
||||
type WorkflowNodeInspectorProps = {
|
||||
params: WorkflowParams
|
||||
onChange: (params: WorkflowParams) => void
|
||||
@@ -151,6 +90,10 @@ export function WorkflowNodeInspector({
|
||||
graphFamily,
|
||||
}: WorkflowNodeInspectorProps) {
|
||||
const customRenderSettingsEnabled = Boolean(params.use_custom_render_settings)
|
||||
const isRenderOverrideControlledNode = RENDER_OVERRIDE_CONTROL_STEPS.has(step ?? '')
|
||||
const isOutputSaveNode = step === 'output_save'
|
||||
const isNotifyNode = step === 'notify'
|
||||
const isExportBlendNode = step === 'export_blend'
|
||||
const selectableNodeDefinitions = useMemo(
|
||||
() =>
|
||||
nodeDefinitions.filter(definition =>
|
||||
@@ -173,6 +116,45 @@ export function WorkflowNodeInspector({
|
||||
() => renderTemplates.find(template => template.id === selectedTemplateId) ?? null,
|
||||
[renderTemplates, selectedTemplateId],
|
||||
)
|
||||
const activeRenderTemplates = useMemo(
|
||||
() => renderTemplates.filter(template => template.is_active),
|
||||
[renderTemplates],
|
||||
)
|
||||
const templatesWithWorkflowInputs = useMemo(
|
||||
() => activeRenderTemplates.filter(template => (template.workflow_input_schema?.length ?? 0) > 0),
|
||||
[activeRenderTemplates],
|
||||
)
|
||||
const automaticTemplateVariableLabels = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set(
|
||||
templatesWithWorkflowInputs.flatMap(template =>
|
||||
getWorkflowVariableLabels(template.workflow_input_schema ?? []),
|
||||
),
|
||||
),
|
||||
),
|
||||
[templatesWithWorkflowInputs],
|
||||
)
|
||||
const automaticTemplateCoverage = useMemo(() => {
|
||||
const coverage = new Map<string, { templateNames: string[]; outputTypeNames: string[] }>()
|
||||
templatesWithWorkflowInputs.forEach(template => {
|
||||
getWorkflowVariableLabels(template.workflow_input_schema ?? []).forEach(label => {
|
||||
const entry = coverage.get(label) ?? { templateNames: [], outputTypeNames: [] }
|
||||
if (!entry.templateNames.includes(template.name)) {
|
||||
entry.templateNames.push(template.name)
|
||||
}
|
||||
;(template.output_type_names ?? []).forEach(outputTypeName => {
|
||||
if (!entry.outputTypeNames.includes(outputTypeName)) {
|
||||
entry.outputTypeNames.push(outputTypeName)
|
||||
}
|
||||
})
|
||||
coverage.set(label, entry)
|
||||
})
|
||||
})
|
||||
return Array.from(coverage.entries())
|
||||
.map(([label, sources]) => ({ label, ...sources }))
|
||||
.sort((left, right) => left.label.localeCompare(right.label))
|
||||
}, [templatesWithWorkflowInputs])
|
||||
|
||||
const effectiveFields = useMemo(() => {
|
||||
const baseFields = [...(nodeDefinition?.fields ?? [])]
|
||||
@@ -251,23 +233,40 @@ export function WorkflowNodeInspector({
|
||||
}
|
||||
|
||||
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 contractSummary = getWorkflowNodeContractSummary(nodeDefinition)
|
||||
const contractCatalog = getCachedOutputTypeContractCatalog()
|
||||
const renderOverrideFieldKeys = useMemo(() => {
|
||||
if (!isRenderOverrideControlledNode || !step) {
|
||||
return new Set<string>()
|
||||
}
|
||||
|
||||
return new Set(
|
||||
(contractCatalog.parameter_ownership.workflow_node_keys_by_step[step] ?? []).filter(
|
||||
fieldKey => fieldKey !== 'use_custom_render_settings',
|
||||
),
|
||||
)
|
||||
}, [contractCatalog.parameter_ownership.workflow_node_keys_by_step, isRenderOverrideControlledNode, step])
|
||||
const contextInputs = contractSummary.contextInputs
|
||||
const staticVariableLabels = contractSummary.editableFieldLabels
|
||||
const dynamicTemplateVariableLabels = getWorkflowVariableLabels(selectedTemplate?.workflow_input_schema ?? [])
|
||||
const inputSocketDescriptors = getWorkflowNodeInputSocketDescriptors(contractSummary)
|
||||
const requiredSocketDescriptors = inputSocketDescriptors.filter(
|
||||
descriptor => descriptor.kind === 'required',
|
||||
)
|
||||
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
|
||||
const alternativeSocketDescriptors = inputSocketDescriptors.filter(
|
||||
descriptor => descriptor.kind === 'alternative',
|
||||
)
|
||||
const validationWatchpoints = getWorkflowNodeValidationWatchpoints(nodeDefinition, contractSummary)
|
||||
const contractPresentation = getWorkflowNodeContractPresentation(
|
||||
contractSummary,
|
||||
dynamicTemplateVariableLabels,
|
||||
)
|
||||
const {
|
||||
socketRequirementDescription,
|
||||
variableSummaryText,
|
||||
noSettingsDescription,
|
||||
variableCount: totalVariableCount,
|
||||
} = contractPresentation
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -315,6 +314,7 @@ export function WorkflowNodeInspector({
|
||||
|
||||
{nodeDefinition && (
|
||||
<WorkflowNodeContractCard
|
||||
nodeDefinition={nodeDefinition}
|
||||
moduleLabel={getDefinitionModuleLabel(nodeDefinition)}
|
||||
moduleKey={nodeDefinition.module_key}
|
||||
familyLabel={FAMILY_FILTER_LABELS[getDefinitionFamily(nodeDefinition)]}
|
||||
@@ -327,13 +327,8 @@ export function WorkflowNodeInspector({
|
||||
}
|
||||
legacyCompatible={nodeDefinition.legacy_compatible}
|
||||
legacySource={nodeDefinition.legacy_source}
|
||||
inputContextLabel={inputContextLabel}
|
||||
outputContextLabel={outputContextLabel}
|
||||
requiredInputs={requiredInputs}
|
||||
requiredAnyInputs={requiredAnyInputs}
|
||||
consumedArtifacts={consumedArtifacts}
|
||||
providedOutputs={providedOutputs}
|
||||
producedArtifacts={producedArtifacts}
|
||||
contract={contractSummary}
|
||||
validationWatchpoints={validationWatchpoints}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -344,7 +339,7 @@ export function WorkflowNodeInspector({
|
||||
Authoring Model
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-content">
|
||||
Canvas connections define upstream artifacts, inspector fields define local node variables.
|
||||
{contractSummary.authoringPatternDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -353,34 +348,62 @@ export function WorkflowNodeInspector({
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">
|
||||
Wired Inputs
|
||||
</p>
|
||||
{inputDescriptions.length > 0 ? (
|
||||
{inputSocketDescriptors.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.
|
||||
{describeCanvasSocketBreakdown(
|
||||
requiredSocketDescriptors.length,
|
||||
alternativeSocketDescriptors.length,
|
||||
)} 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'
|
||||
}`}
|
||||
<p className="mt-2 text-xs text-content">
|
||||
{socketRequirementDescription}
|
||||
</p>
|
||||
{contextInputs.length > 0 && (
|
||||
<p className="mt-2 text-xs text-content-muted">
|
||||
Workflow context already supplies: {contextInputs.map(formatContractValue).join(', ')}.
|
||||
</p>
|
||||
)}
|
||||
{requiredSocketDescriptors.length > 0 && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">
|
||||
Required Sockets
|
||||
</p>
|
||||
{requiredSocketDescriptors.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"
|
||||
>
|
||||
Socket {index + 1}
|
||||
</span>
|
||||
<span className="min-w-0 text-xs text-content">{descriptor.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className="inline-flex rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium 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>
|
||||
)}
|
||||
{alternativeSocketDescriptors.length > 0 && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">
|
||||
Alternative Groups
|
||||
</p>
|
||||
{alternativeSocketDescriptors.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 bg-sky-100 px-1.5 py-0.5 text-[10px] font-medium text-sky-700 dark:bg-sky-900/40 dark:text-sky-300">
|
||||
Group {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.
|
||||
{socketRequirementDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -391,7 +414,7 @@ export function WorkflowNodeInspector({
|
||||
{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.
|
||||
{variableSummaryText}
|
||||
</p>
|
||||
{staticVariableLabels.length > 0 && (
|
||||
<p className="mt-2 text-xs text-content">
|
||||
@@ -406,7 +429,7 @@ export function WorkflowNodeInspector({
|
||||
</>
|
||||
) : (
|
||||
<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.
|
||||
{variableSummaryText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -434,6 +457,105 @@ export function WorkflowNodeInspector({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRenderOverrideControlledNode && (
|
||||
<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">
|
||||
Render Override Scope
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Render, scene, camera, and animation overrides are inherited from Output Type and
|
||||
Template until
|
||||
{' '}
|
||||
<span className="font-medium text-content">Custom Render Settings</span>
|
||||
{' '}
|
||||
is enabled.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOutputSaveNode && (
|
||||
<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">
|
||||
Output Handoff
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
This node does not render files itself. A connected render or export node hands off
|
||||
the matching artifact and this node turns that handoff into the authoritative saved
|
||||
workflow result.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-content-muted">
|
||||
Filter:{' '}
|
||||
<span className="text-content">
|
||||
{String(params.expected_artifact_role || '').trim() || 'Any connected artifact'}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Require Upstream Artifact decides whether a missing matching handoff becomes a hard
|
||||
failure or just an informational no-op. Shadow runs stay observer-only and do not
|
||||
publish user-visible assets.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExportBlendNode && (
|
||||
<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">
|
||||
Blend Delivery
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
This node publishes the resolved order-line scene as a downloadable
|
||||
{' '}
|
||||
<span className="font-medium text-content">Blend Asset</span>
|
||||
. It does not render pixels itself.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-content-muted">
|
||||
Scene content still comes from upstream order-line context and the selected render
|
||||
template. The only local override on this node is the optional filename suffix.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-content-muted">
|
||||
Filename suffix:{' '}
|
||||
<span className="text-content">
|
||||
{String(params.output_name_suffix || '').trim() || 'None'}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Downstream
|
||||
{' '}
|
||||
<span className="font-medium text-content">Save Output</span>
|
||||
{' '}
|
||||
or
|
||||
{' '}
|
||||
<span className="font-medium text-content">Notify</span>
|
||||
{' '}
|
||||
nodes can subscribe to the emitted blend artifact.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNotifyNode && (
|
||||
<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">
|
||||
Notification Handoff
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
This node completes only when a connected render or export task arms notification
|
||||
handoff. It does not emit independently.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-content-muted">
|
||||
Channel:{' '}
|
||||
<span className="text-content">
|
||||
{String(params.channel || 'audit_log')}
|
||||
</span>
|
||||
{' '}
|
||||
(current runtime delivery target)
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Require Armed Render turns a missing handoff into a failure instead of a skipped
|
||||
notification. Shadow runs suppress user notifications entirely.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -443,6 +565,26 @@ export function WorkflowNodeInspector({
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Leave the field empty to keep legacy category/output-type resolution.
|
||||
</p>
|
||||
{templatesWithWorkflowInputs.length > 0 && (
|
||||
<>
|
||||
<p className="mt-3 text-xs font-medium uppercase tracking-wide text-content-secondary">
|
||||
Automatic Resolution Coverage
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Automatic resolution can still select templates with workflow-defined inputs. Use the list below to see
|
||||
which variables exist before forcing a specific template.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-content">
|
||||
{templatesWithWorkflowInputs.length} active template
|
||||
{templatesWithWorkflowInputs.length === 1 ? '' : 's'} expose
|
||||
{' '}
|
||||
{automaticTemplateVariableLabels.length}
|
||||
{' '}
|
||||
unique workflow variable
|
||||
{automaticTemplateVariableLabels.length === 1 ? '' : 's'}.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -456,11 +598,37 @@ export function WorkflowNodeInspector({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isResolveTemplateNode && !selectedTemplate && automaticTemplateCoverage.length > 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">Potential Template Variables</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
These variables exist on active templates today. They only become editable on this node after choosing the
|
||||
matching template override.
|
||||
</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
{automaticTemplateCoverage.map(variable => (
|
||||
<div
|
||||
key={variable.label}
|
||||
className="rounded-lg border border-border-default bg-surface px-3 py-2"
|
||||
>
|
||||
<p className="text-sm text-content">{variable.label}</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Available via {describeTemplateCoverage(variable.templateNames, variable.outputTypeNames)}.
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.keys(fieldsBySection).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">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.
|
||||
{noSettingsDescription}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-content-muted">
|
||||
{socketRequirementDescription}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -476,10 +644,10 @@ export function WorkflowNodeInspector({
|
||||
const fieldId = `workflow-node-field-${field.key}`
|
||||
const fieldOptions = field.options ?? []
|
||||
const disableRenderOverrideField =
|
||||
(step === 'blender_still' || step === 'blender_turntable') &&
|
||||
isRenderOverrideControlledNode &&
|
||||
!customRenderSettingsEnabled &&
|
||||
field.key !== 'use_custom_render_settings' &&
|
||||
(field.section === 'Render' || field.section === 'Output')
|
||||
renderOverrideFieldKeys.has(field.key)
|
||||
|
||||
return (
|
||||
<div key={field.key}>
|
||||
|
||||
@@ -2,12 +2,60 @@ import { Loader2 } from 'lucide-react'
|
||||
|
||||
import type { WorkflowPreflightResponse } from '../../api/workflows'
|
||||
import { getPreflightStatusClassName } from './workflowRunPresentation'
|
||||
import {
|
||||
getWorkflowPreflightActionHint,
|
||||
getWorkflowValidationSummaryToneClassName,
|
||||
getWorkflowValidationKind,
|
||||
getWorkflowValidationKindLabel,
|
||||
summarizeWorkflowPreflightIssues,
|
||||
} from './workflowValidationPresentation'
|
||||
|
||||
interface WorkflowPreflightPanelProps {
|
||||
preflight: WorkflowPreflightResponse | null
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
type PreflightActionItem = {
|
||||
id: string
|
||||
severity: 'error' | 'warning' | 'info'
|
||||
kind: string
|
||||
kindLabel: string
|
||||
title: string
|
||||
hint: string
|
||||
}
|
||||
|
||||
function collectActionItems(preflight: WorkflowPreflightResponse): PreflightActionItem[] {
|
||||
const items: PreflightActionItem[] = []
|
||||
|
||||
for (const issue of preflight.issues) {
|
||||
const kind = getWorkflowValidationKind(issue)
|
||||
items.push({
|
||||
id: `global-${issue.code}-${issue.message}`,
|
||||
severity: issue.severity,
|
||||
kind,
|
||||
kindLabel: getWorkflowValidationKindLabel(kind),
|
||||
title: issue.message,
|
||||
hint: getWorkflowPreflightActionHint(issue),
|
||||
})
|
||||
}
|
||||
|
||||
for (const node of preflight.nodes) {
|
||||
for (const issue of node.issues) {
|
||||
const kind = getWorkflowValidationKind(issue)
|
||||
items.push({
|
||||
id: `${node.node_id}-${issue.code}-${issue.message}`,
|
||||
severity: issue.severity,
|
||||
kind,
|
||||
kindLabel: getWorkflowValidationKindLabel(kind),
|
||||
title: `${node.label ?? node.node_id}: ${issue.message}`,
|
||||
hint: getWorkflowPreflightActionHint(issue),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
export function WorkflowPreflightPanel({
|
||||
preflight,
|
||||
isLoading,
|
||||
@@ -16,6 +64,20 @@ export function WorkflowPreflightPanel({
|
||||
return null
|
||||
}
|
||||
|
||||
const actionItems = preflight ? collectActionItems(preflight) : []
|
||||
const blockingItems = actionItems.filter(item => item.severity === 'error')
|
||||
const warningItems = actionItems.filter(item => item.severity === 'warning')
|
||||
const issueTypeCounts = actionItems.reduce<Record<string, number>>((counts, item) => {
|
||||
counts[item.kind] = (counts[item.kind] ?? 0) + 1
|
||||
return counts
|
||||
}, {})
|
||||
const validationSummary = preflight
|
||||
? summarizeWorkflowPreflightIssues([
|
||||
...preflight.issues,
|
||||
...preflight.nodes.flatMap(node => node.issues),
|
||||
])
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -41,12 +103,26 @@ export function WorkflowPreflightPanel({
|
||||
<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">
|
||||
Blocking: {blockingItems.length}
|
||||
</span>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
|
||||
Warnings: {warningItems.length}
|
||||
</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>
|
||||
{Object.entries(issueTypeCounts).map(([kind, count]) => (
|
||||
<span
|
||||
key={kind}
|
||||
className="rounded-full border border-border-default bg-surface px-2 py-0.5"
|
||||
>
|
||||
{getWorkflowValidationKindLabel(kind as ReturnType<typeof getWorkflowValidationKind>)}: {count}
|
||||
</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}
|
||||
@@ -54,6 +130,19 @@ export function WorkflowPreflightPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{validationSummary.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 text-[11px]">
|
||||
{validationSummary.map(item => (
|
||||
<span
|
||||
key={`${item.severity}:${item.kind}`}
|
||||
className={`rounded-full border px-2 py-0.5 font-medium ${getWorkflowValidationSummaryToneClassName(item.severity)}`}
|
||||
>
|
||||
{item.label}: {item.count}
|
||||
</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>}
|
||||
@@ -61,6 +150,35 @@ export function WorkflowPreflightPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionItems.length > 0 && (
|
||||
<div className="space-y-2 rounded-md border border-border-default bg-surface px-2.5 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium text-content">Next Actions</p>
|
||||
<span className="text-[11px] text-content-muted">
|
||||
{blockingItems.length > 0 ? 'Resolve blocking items first' : 'Warnings can be addressed incrementally'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(blockingItems.length > 0 ? blockingItems : warningItems).slice(0, 3).map(item => (
|
||||
<div key={item.id} className="rounded-md border border-border-default bg-surface-hover/60 px-2 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-medium text-content">{item.title}</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="rounded-full border border-border-default bg-surface px-1.5 py-0.5 text-[11px] font-medium text-content-muted">
|
||||
{item.kindLabel}
|
||||
</span>
|
||||
<span className={`rounded-full px-1.5 py-0.5 text-[11px] font-medium ${getPreflightStatusClassName(item.severity)}`}>
|
||||
{item.severity}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-content-muted">{item.hint}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
@@ -83,9 +201,11 @@ export function WorkflowPreflightPanel({
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2 text-[11px] text-content-muted">
|
||||
<span>Code: {issue.code}</span>
|
||||
<span>Type: {getWorkflowValidationKindLabel(getWorkflowValidationKind(issue))}</span>
|
||||
{issue.step && <span>Step: {issue.step}</span>}
|
||||
{issue.node_id && <span>Node: {issue.node_id}</span>}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-content-muted">{getWorkflowPreflightActionHint(issue)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -117,9 +237,14 @@ export function WorkflowPreflightPanel({
|
||||
{node.issues.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{node.issues.map(issue => (
|
||||
<p key={`${node.node_id}-${issue.code}-${issue.message}`} className="text-xs text-content-muted">
|
||||
{issue.message}
|
||||
</p>
|
||||
<div key={`${node.node_id}-${issue.code}-${issue.message}`} className="rounded-md border border-border-default bg-surface-hover/40 px-2 py-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-content-muted">
|
||||
<span className="font-medium text-content">{issue.message}</span>
|
||||
<span>Code: {issue.code}</span>
|
||||
<span>Type: {getWorkflowValidationKindLabel(getWorkflowValidationKind(issue))}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-content-muted">{getWorkflowPreflightActionHint(issue)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Milestone, Wand2 } from 'lucide-react'
|
||||
import { Milestone } from 'lucide-react'
|
||||
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
|
||||
import { getWorkflowAuthoringPlan } from './workflowAuthoringGuidance'
|
||||
import { WorkflowBundleCatalog } from './WorkflowBundleCatalog'
|
||||
import type { WorkflowReferenceBundleId } from './workflowReferenceBundles'
|
||||
|
||||
type WorkflowReferenceBundlePanelProps = {
|
||||
@@ -20,64 +21,16 @@ export function WorkflowReferenceBundlePanel({
|
||||
}: 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>
|
||||
<WorkflowBundleCatalog
|
||||
bundles={referenceBundles}
|
||||
emptyLabel="No workflow steps"
|
||||
icon={Milestone}
|
||||
title="Reference Paths"
|
||||
description="Insert a complete canonical production route before making targeted stage swaps."
|
||||
countLabel="paths"
|
||||
insertLabel="Insert path"
|
||||
onInsert={onInsertReferencePath}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,26 +23,31 @@ export function WorkflowStarterPathPanel({
|
||||
|
||||
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>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Starter Path
|
||||
</p>
|
||||
<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>
|
||||
<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>
|
||||
<p className="mt-1 text-[11px] 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
|
||||
Guided
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="grid gap-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"
|
||||
className="flex items-center justify-between gap-2 rounded-xl border border-border-default bg-surface px-3 py-2.5"
|
||||
>
|
||||
<div className="min-w-0 flex items-start gap-2">
|
||||
<span className="mt-0.5 text-content-secondary">
|
||||
@@ -62,7 +67,9 @@ export function WorkflowStarterPathPanel({
|
||||
{definition.category}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-2 text-xs text-content-muted">{definition.description}</p>
|
||||
<p className="mt-0.5 line-clamp-1 text-[11px] text-content-muted">
|
||||
{definition.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,7 +77,7 @@ export function WorkflowStarterPathPanel({
|
||||
<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"
|
||||
className="shrink-0 rounded-lg border border-border-default px-2.5 py-1.5 text-xs font-medium text-content hover:bg-surface-hover"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Plus size={12} />
|
||||
|
||||
@@ -22,21 +22,23 @@ export function WorkflowUtilityRail<T extends string>({
|
||||
onTabChange,
|
||||
children,
|
||||
}: WorkflowUtilityRailProps<T>) {
|
||||
const activeTabLabel = tabs.find(tab => tab.key === activeTab)?.label ?? 'Tools'
|
||||
|
||||
return (
|
||||
<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 items-center justify-between gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-xl border border-border-default bg-surface-hover text-content-secondary">
|
||||
<PanelRight size={15} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-content">Utility Rail</p>
|
||||
<p className="text-xs text-content-muted">
|
||||
Context-aware tools without sacrificing canvas space.
|
||||
</p>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-content">Tools</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{activeTabLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
{tabs.map(tab => {
|
||||
const Icon = tab.icon
|
||||
const isActive = activeTab === tab.key
|
||||
|
||||
@@ -1,10 +1,59 @@
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
|
||||
import {
|
||||
getWorkflowValidationSummaryToneClassName,
|
||||
summarizeWorkflowDraftValidationBySeverity,
|
||||
summarizeWorkflowDraftValidationMessages,
|
||||
} from './workflowValidationPresentation'
|
||||
|
||||
interface WorkflowValidationBannerProps {
|
||||
errors: string[]
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
function ValidationCategoryPills({
|
||||
messages,
|
||||
}: {
|
||||
messages: string[]
|
||||
}) {
|
||||
const categories = summarizeWorkflowDraftValidationMessages(messages)
|
||||
if (categories.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 text-[10px]">
|
||||
{categories.map(category => (
|
||||
<span
|
||||
key={category.kind}
|
||||
className="rounded-full border border-current/15 bg-white/40 px-1.5 py-0.5 font-medium dark:bg-white/5"
|
||||
>
|
||||
{category.label}: {category.count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ValidationSummaryPills({
|
||||
errors,
|
||||
warnings,
|
||||
}: WorkflowValidationBannerProps) {
|
||||
const categories = summarizeWorkflowDraftValidationBySeverity({ errors, warnings })
|
||||
if (categories.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 text-[10px]">
|
||||
{categories.map(category => (
|
||||
<span
|
||||
key={`${category.severity}:${category.kind}`}
|
||||
className={`rounded-full border px-1.5 py-0.5 font-medium ${getWorkflowValidationSummaryToneClassName(category.severity)}`}
|
||||
>
|
||||
{category.label}: {category.count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkflowValidationBanner({
|
||||
errors,
|
||||
warnings,
|
||||
@@ -15,12 +64,14 @@ export function WorkflowValidationBanner({
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border-b border-border-default bg-surface px-4 py-3">
|
||||
<ValidationSummaryPills errors={errors} warnings={warnings} />
|
||||
{errors.length > 0 && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900/40 dark:bg-red-950/20 dark:text-red-300">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<AlertTriangle size={14} />
|
||||
{errors.length} validation error{errors.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
<ValidationCategoryPills messages={errors} />
|
||||
<ul className="mt-2 space-y-1 text-xs">
|
||||
{errors.map(error => (
|
||||
<li key={error}>{error}</li>
|
||||
@@ -33,6 +84,7 @@ export function WorkflowValidationBanner({
|
||||
<div className="font-medium">
|
||||
{warnings.length} warning{warnings.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
<ValidationCategoryPills messages={warnings} />
|
||||
<ul className="mt-2 space-y-1 text-xs">
|
||||
{warnings.map(warning => (
|
||||
<li key={warning}>{warning}</li>
|
||||
|
||||
@@ -71,6 +71,8 @@ export type WorkflowOrderLineContextOption = {
|
||||
value: string
|
||||
label: string
|
||||
meta: string
|
||||
isRenderable: boolean
|
||||
renderabilityReason: string | null
|
||||
}
|
||||
|
||||
export type WorkflowOrderLineContextGroup = {
|
||||
@@ -89,6 +91,8 @@ function normalizeOrderLineContextGroups(
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
meta: option.meta,
|
||||
isRenderable: option.is_renderable,
|
||||
renderabilityReason: option.renderability_reason,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
@@ -169,7 +173,13 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}, [dispatchContextId, graphFamily, isOrderLineGraph, selectedOrderLineContext])
|
||||
const dispatchContextMeta = useMemo(() => {
|
||||
if (isOrderLineGraph) return selectedOrderLineContext?.meta ?? null
|
||||
if (isOrderLineGraph) {
|
||||
if (!selectedOrderLineContext) return null
|
||||
if (!selectedOrderLineContext.isRenderable && selectedOrderLineContext.renderabilityReason) {
|
||||
return `${selectedOrderLineContext.meta} · blocked: ${selectedOrderLineContext.renderabilityReason}`
|
||||
}
|
||||
return selectedOrderLineContext.meta
|
||||
}
|
||||
if (graphFamily !== 'cad_file') return null
|
||||
const trimmed = dispatchContextId.trim()
|
||||
if (!trimmed) return null
|
||||
@@ -242,6 +252,10 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setDispatchContextId('')
|
||||
}, [workflow.id])
|
||||
|
||||
useEffect(() => {
|
||||
const graph = workflowToGraph(workflow.config, nodeDefinitionsByStep)
|
||||
const nextNodes = graphNeedsAutoLayout(graph.nodes) ? applyAutoLayout(graph.nodes, graph.edges) : graph.nodes
|
||||
@@ -260,7 +274,9 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
useEffect(() => {
|
||||
if (!isOrderLineGraph) return
|
||||
if (dispatchContextId.trim()) return
|
||||
const firstOption = orderLineContextGroups[0]?.options[0]
|
||||
const firstOption =
|
||||
orderLineContextGroups.flatMap(group => group.options).find(option => option.isRenderable) ??
|
||||
orderLineContextGroups[0]?.options[0]
|
||||
if (firstOption) {
|
||||
setDispatchContextId(firstOption.value)
|
||||
}
|
||||
@@ -330,24 +346,65 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
return
|
||||
}
|
||||
|
||||
const nextEdgeId = `e_${connection.source}_${connection.target}_${matchingTargetPort.id}`
|
||||
let replacedEdgeLabel: string | null = null
|
||||
let rejectedDuplicate = false
|
||||
|
||||
setEdges(currentEdges => {
|
||||
const duplicateEdge = currentEdges.some(
|
||||
edge => edge.source === connection.source && edge.target === connection.target,
|
||||
edge =>
|
||||
edge.source === connection.source &&
|
||||
edge.target === connection.target &&
|
||||
edge.sourceHandle === matchingSourcePort.id &&
|
||||
edge.targetHandle === matchingTargetPort.id,
|
||||
)
|
||||
if (duplicateEdge) {
|
||||
toast.error('A connection between these nodes already exists.')
|
||||
rejectedDuplicate = true
|
||||
return currentEdges
|
||||
}
|
||||
|
||||
const conflictingTargetEdge = currentEdges.find(
|
||||
edge =>
|
||||
edge.target === connection.target &&
|
||||
edge.targetHandle === matchingTargetPort.id &&
|
||||
edge.id !== nextEdgeId,
|
||||
)
|
||||
if (conflictingTargetEdge) {
|
||||
replacedEdgeLabel = matchingTargetPort.label
|
||||
}
|
||||
|
||||
const dedupedEdges = currentEdges.filter(
|
||||
edge =>
|
||||
!(edge.source === connection.source && edge.target === connection.target) &&
|
||||
!(conflictingTargetEdge && edge.id === conflictingTargetEdge.id),
|
||||
)
|
||||
|
||||
return addEdge(
|
||||
{
|
||||
...connection,
|
||||
id: nextEdgeId,
|
||||
sourceHandle: matchingSourcePort.id,
|
||||
targetHandle: matchingTargetPort.id,
|
||||
},
|
||||
currentEdges,
|
||||
)
|
||||
dedupedEdges,
|
||||
).map(edge => ({
|
||||
...edge,
|
||||
selected: edge.id === nextEdgeId,
|
||||
}))
|
||||
})
|
||||
|
||||
if (rejectedDuplicate) {
|
||||
toast.error('This connection is already present.')
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedNodeId(null)
|
||||
setSelectedEdgeIds([nextEdgeId])
|
||||
setNodeMenuAnchor(null)
|
||||
|
||||
if (replacedEdgeLabel) {
|
||||
toast.success(`Reassigned ${replacedEdgeLabel} to the new upstream node.`)
|
||||
}
|
||||
},
|
||||
[nodes, setEdges],
|
||||
)
|
||||
|
||||
@@ -65,15 +65,15 @@ export function getWorkflowAuthoringEntryAction(
|
||||
return {
|
||||
label: 'Author',
|
||||
title: 'Open guided workflow authoring browser',
|
||||
helper: 'Open reference paths, production modules, starter steps, and raw nodes.',
|
||||
helper: 'Open reference paths, stage 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.',
|
||||
title: 'Open raw node catalog',
|
||||
helper: 'Open the searchable raw node catalog directly on the canvas.',
|
||||
icon: Library,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,8 +103,8 @@ function getAuthoringPriorities(graphFamily: WorkflowGraphFamily): WorkflowAutho
|
||||
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: 'Swap stages with stage modules',
|
||||
description: 'Use scene-prep, materials, render, and publish bundles to change whole production stages without breaking graph-safe sequencing.',
|
||||
},
|
||||
{
|
||||
title: 'Use raw nodes last',
|
||||
@@ -160,7 +160,7 @@ export function getWorkflowAuthoringFlow(graphFamily: WorkflowGraphFamily): Work
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
title: 'Production Modules',
|
||||
title: 'Stage Modules',
|
||||
description: 'Swap or extend whole production stages with reusable graph-safe bundles.',
|
||||
},
|
||||
{
|
||||
@@ -186,7 +186,7 @@ export function getWorkflowAuthoringPlan(
|
||||
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.'
|
||||
? 'Keep the graph readable by preferring complete paths and stage bundles before raw node-level edits.'
|
||||
: STARTER_PATH_DESCRIPTIONS[graphFamily]
|
||||
const referenceBundles = getWorkflowReferenceBundles(definitions, graphFamily)
|
||||
.map(bundle => ({
|
||||
|
||||
@@ -10,11 +10,13 @@ import type { WorkflowGraphFamily } from './workflowNodeLibrary'
|
||||
|
||||
export type WorkflowAuthoringSection = 'overview' | 'paths' | 'modules' | 'starter' | 'nodes'
|
||||
|
||||
type WorkflowAuthoringSectionConfig = {
|
||||
export type WorkflowAuthoringSectionConfig = {
|
||||
key: WorkflowAuthoringSection
|
||||
label: string
|
||||
helper: string
|
||||
icon: LucideIcon
|
||||
tone: 'Guided' | 'Escape Hatch'
|
||||
summaryLabel?: string
|
||||
}
|
||||
|
||||
type WorkflowAuthoringSectionOptions = {
|
||||
@@ -40,41 +42,47 @@ export function getWorkflowAuthoringSections({
|
||||
label: 'Overview',
|
||||
helper: 'Start with guided authoring modes before dropping to raw nodes.',
|
||||
icon: Compass,
|
||||
tone: 'Guided',
|
||||
})
|
||||
}
|
||||
|
||||
if (hasReferencePaths) {
|
||||
sections.push({
|
||||
key: 'paths',
|
||||
label: 'Paths',
|
||||
label: 'Reference Paths',
|
||||
helper: 'Insert complete canonical production paths.',
|
||||
icon: Milestone,
|
||||
tone: 'Guided',
|
||||
})
|
||||
}
|
||||
|
||||
if (hasModules) {
|
||||
sections.push({
|
||||
key: 'modules',
|
||||
label: 'Modules',
|
||||
helper: 'Insert reusable production bundles.',
|
||||
label: 'Stage Modules',
|
||||
helper: 'Insert reusable production bundles for one stage at a time.',
|
||||
icon: Boxes,
|
||||
tone: 'Guided',
|
||||
})
|
||||
}
|
||||
|
||||
if (hasStarter || graphFamily !== 'mixed') {
|
||||
sections.push({
|
||||
key: 'starter',
|
||||
label: 'Starter',
|
||||
helper: 'Follow the canonical family-safe assembly path.',
|
||||
label: 'Starter Steps',
|
||||
helper: 'Follow the canonical family-safe assembly path step by step.',
|
||||
icon: Milestone,
|
||||
tone: 'Guided',
|
||||
})
|
||||
}
|
||||
|
||||
sections.push({
|
||||
key: 'nodes',
|
||||
label: 'Nodes',
|
||||
helper: 'Browse the full node catalog.',
|
||||
label: 'Raw Nodes',
|
||||
helper: 'Browse the full raw node catalog after the path is in place.',
|
||||
icon: Library,
|
||||
tone: 'Escape Hatch',
|
||||
summaryLabel: 'Raw Node Catalog',
|
||||
})
|
||||
|
||||
return sections
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getWorkflowAuthoringPlan, type WorkflowAuthoringPlan } from './workflow
|
||||
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
|
||||
import {
|
||||
getWorkflowAuthoringSections,
|
||||
type WorkflowAuthoringSectionConfig,
|
||||
type WorkflowAuthoringSection,
|
||||
} from './workflowAuthoringSections'
|
||||
|
||||
@@ -21,11 +22,29 @@ type WorkflowAuthoringSurfaceModelOptions = {
|
||||
}
|
||||
|
||||
export type WorkflowAuthoringSurfaceModel = {
|
||||
chrome: WorkflowAuthoringChrome
|
||||
defaultSection: WorkflowAuthoringSection
|
||||
sections: ReturnType<typeof getWorkflowAuthoringSections>
|
||||
plan: WorkflowAuthoringPlan
|
||||
}
|
||||
|
||||
export type WorkflowAuthoringSectionDetail = WorkflowAuthoringSectionConfig & {
|
||||
isDefaultSection: boolean
|
||||
isOverviewSection: boolean
|
||||
isRawNodeSection: boolean
|
||||
summaryLabel: string
|
||||
}
|
||||
|
||||
export type WorkflowAuthoringChrome = {
|
||||
browserEyebrow: string
|
||||
browserTitle: string
|
||||
browserHelper: string
|
||||
menuTitle: string
|
||||
menuHelper: string
|
||||
guideEyebrow: string
|
||||
guideHelper: string
|
||||
}
|
||||
|
||||
type UseWorkflowAuthoringSurfaceOptions = WorkflowAuthoringSurfaceModelOptions & {
|
||||
actions?: WorkflowAuthoringActions
|
||||
preferredPosition?: WorkflowAuthoringPosition
|
||||
@@ -34,6 +53,7 @@ type UseWorkflowAuthoringSurfaceOptions = WorkflowAuthoringSurfaceModelOptions &
|
||||
|
||||
export type WorkflowAuthoringSurfaceController = WorkflowAuthoringSurfaceModel & {
|
||||
activeSection: WorkflowAuthoringSection
|
||||
activeSectionDetail: WorkflowAuthoringSectionDetail | null
|
||||
activeSectionMeta: WorkflowAuthoringSurfaceModel['sections'][number] | null
|
||||
insertBindings: WorkflowAuthoringInsertHandlers
|
||||
setActiveSection: (section: WorkflowAuthoringSection) => void
|
||||
@@ -45,6 +65,38 @@ export function getDefaultWorkflowAuthoringSection(
|
||||
return graphFamily === 'mixed' ? 'nodes' : 'overview'
|
||||
}
|
||||
|
||||
export function getWorkflowAuthoringChrome(): WorkflowAuthoringChrome {
|
||||
return {
|
||||
browserEyebrow: 'Node Library',
|
||||
browserTitle: 'Authoring Browser',
|
||||
browserHelper:
|
||||
'Start with reference paths or stage modules. Drop to starter steps and raw nodes only when precision is required.',
|
||||
menuTitle: 'Workflow Authoring',
|
||||
menuHelper:
|
||||
'Guided first: prefer paths and modules before starter steps or raw nodes.',
|
||||
guideEyebrow: 'Guided First',
|
||||
guideHelper:
|
||||
'Choose the highest-level insert that solves the job, then descend only if needed.',
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkflowAuthoringSectionDetail(
|
||||
activeSection: WorkflowAuthoringSection,
|
||||
sections: WorkflowAuthoringSurfaceModel['sections'],
|
||||
defaultSection: WorkflowAuthoringSection,
|
||||
): WorkflowAuthoringSectionDetail | null {
|
||||
const sectionMeta = sections.find(section => section.key === activeSection)
|
||||
if (!sectionMeta) return null
|
||||
|
||||
return {
|
||||
...sectionMeta,
|
||||
isDefaultSection: activeSection === defaultSection,
|
||||
isOverviewSection: activeSection === 'overview',
|
||||
isRawNodeSection: activeSection === 'nodes',
|
||||
summaryLabel: sectionMeta.summaryLabel ?? sectionMeta.label,
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkflowAuthoringSurfaceModel({
|
||||
definitions,
|
||||
graphFamily,
|
||||
@@ -61,6 +113,7 @@ export function getWorkflowAuthoringSurfaceModel({
|
||||
})
|
||||
|
||||
return {
|
||||
chrome: getWorkflowAuthoringChrome(),
|
||||
defaultSection,
|
||||
sections,
|
||||
plan,
|
||||
@@ -107,7 +160,7 @@ export function useWorkflowAuthoringSurface({
|
||||
() => getWorkflowAuthoringSurfaceModel({ definitions, graphFamily, activeSteps }),
|
||||
[activeSteps, definitions, graphFamily],
|
||||
)
|
||||
const { defaultSection, plan, sections } = surfaceModel
|
||||
const { chrome, defaultSection, plan, sections } = surfaceModel
|
||||
const insertBindings = useMemo(
|
||||
() =>
|
||||
bindWorkflowAuthoringInsertActions(actions, {
|
||||
@@ -132,12 +185,18 @@ export function useWorkflowAuthoringSurface({
|
||||
() => sections.find(section => section.key === activeSection) ?? null,
|
||||
[activeSection, sections],
|
||||
)
|
||||
const activeSectionDetail = useMemo(
|
||||
() => getWorkflowAuthoringSectionDetail(activeSection, sections, defaultSection),
|
||||
[activeSection, defaultSection, sections],
|
||||
)
|
||||
|
||||
return {
|
||||
chrome,
|
||||
defaultSection,
|
||||
sections,
|
||||
plan,
|
||||
activeSection,
|
||||
activeSectionDetail,
|
||||
activeSectionMeta,
|
||||
insertBindings,
|
||||
setActiveSection,
|
||||
|
||||
@@ -6,6 +6,8 @@ export const BLUEPRINT_LABELS: Record<string, string> = {
|
||||
cad_intake: 'Reference Blueprint',
|
||||
order_rendering: 'Reference Blueprint',
|
||||
still_graph_reference: 'Graph Reference',
|
||||
still_graph_alpha_reference: 'Alpha Reference',
|
||||
still_graph_blend_reference: 'Blend Reference',
|
||||
starter_cad_intake: 'Starter',
|
||||
starter_order_rendering: 'Starter',
|
||||
}
|
||||
@@ -14,6 +16,8 @@ export const BLUEPRINT_DESCRIPTION: Record<string, string> = {
|
||||
cad_intake: 'Canonical CAD-file workflow for intake, preview generation, and material discovery.',
|
||||
order_rendering: 'Canonical order-line workflow for production rendering, exports, and notifications.',
|
||||
still_graph_reference: 'Reference still-render graph that stays parallel to the legacy workflow while native nodes reach parity.',
|
||||
still_graph_alpha_reference: 'Reference still-render graph with transparent-alpha defaults so PNG/overlay outputs stay authorable without legacy fallbacks.',
|
||||
still_graph_blend_reference: 'Reference still-render graph with explicit .blend export delivery branches for workflow-first output bindings.',
|
||||
starter_cad_intake: 'Minimal CAD-file starter graph.',
|
||||
starter_order_rendering: 'Minimal order-line starter graph.',
|
||||
}
|
||||
@@ -57,7 +61,13 @@ export function getWorkflowBlueprint(config: WorkflowConfig): string | null {
|
||||
|
||||
export function isReferenceBlueprint(config: WorkflowConfig): boolean {
|
||||
const blueprint = getWorkflowBlueprint(config)
|
||||
return blueprint === 'cad_intake' || blueprint === 'order_rendering' || blueprint === 'still_graph_reference'
|
||||
return (
|
||||
blueprint === 'cad_intake' ||
|
||||
blueprint === 'order_rendering' ||
|
||||
blueprint === 'still_graph_reference' ||
|
||||
blueprint === 'still_graph_alpha_reference' ||
|
||||
blueprint === 'still_graph_blend_reference'
|
||||
)
|
||||
}
|
||||
|
||||
export function cloneWorkflowConfig(config: WorkflowConfig, options?: { stripBlueprint?: boolean }): WorkflowConfig {
|
||||
|
||||
@@ -8,6 +8,15 @@ import type {
|
||||
WorkflowNodeDefinition,
|
||||
WorkflowParams,
|
||||
} from '../../api/workflows'
|
||||
import {
|
||||
TEMPLATE_INPUT_PARAM_PREFIX,
|
||||
formatContractValue,
|
||||
getContractContext,
|
||||
getWorkflowNodeContractPresentation,
|
||||
getWorkflowNodeContractSummary,
|
||||
getWorkflowNodeInputSocketDescriptors,
|
||||
getWorkflowNodeOutputSocketDescriptors,
|
||||
} from './workflowNodeContracts'
|
||||
import { getNodeFamily, type WorkflowGraphFamily, type WorkflowNodeDefinitionMap } from './workflowNodeLibrary'
|
||||
|
||||
export type WorkflowCanvasNodeData = {
|
||||
@@ -19,20 +28,20 @@ export type WorkflowCanvasNodeData = {
|
||||
category?: StepCategory
|
||||
inputContextLabel?: string | null
|
||||
outputContextLabel?: string | null
|
||||
contextInputs?: string[]
|
||||
inputPorts?: WorkflowCanvasPort[]
|
||||
outputPorts?: WorkflowCanvasPort[]
|
||||
requiredAnyInputs?: string[][]
|
||||
editableFieldCount?: number
|
||||
editableFieldLabels?: string[]
|
||||
dynamicVariableHint?: string | null
|
||||
authoringPatternLabel?: string
|
||||
authoringPatternDescription?: string
|
||||
socketRequirementDescription?: string
|
||||
variableSummaryText?: string
|
||||
}
|
||||
|
||||
export type WorkflowCanvasPort = {
|
||||
id: string
|
||||
label: string
|
||||
roles: string[]
|
||||
kind: 'required' | 'alternative' | 'provided'
|
||||
}
|
||||
export type WorkflowCanvasPort = ReturnType<typeof getWorkflowNodeInputSocketDescriptors>[number]
|
||||
|
||||
export type WorkflowValidationResult = {
|
||||
errors: string[]
|
||||
@@ -52,8 +61,6 @@ type WorkflowSemanticState = {
|
||||
executedSteps: Set<string>
|
||||
}
|
||||
|
||||
const TEMPLATE_INPUT_PARAM_PREFIX = 'template_input__'
|
||||
|
||||
const CAD_FILE_ENTRY_STEPS = new Set([
|
||||
'resolve_step_path',
|
||||
'occ_object_extract',
|
||||
@@ -89,164 +96,15 @@ 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,
|
||||
): WorkflowNodeContractContext | null {
|
||||
const value = contract?.context
|
||||
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,
|
||||
): string[] {
|
||||
const value = contract?.[key]
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
|
||||
}
|
||||
|
||||
function getContractAlternativeValues(
|
||||
contract: Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
): string[][] {
|
||||
const value = contract?.[key]
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
if (value.every(entry => typeof entry === 'string' && entry.trim().length > 0)) {
|
||||
return [value as string[]]
|
||||
}
|
||||
|
||||
return value
|
||||
.filter((entry): entry is string[] => Array.isArray(entry))
|
||||
.map(group => group.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0))
|
||||
.filter(group => group.length > 0)
|
||||
}
|
||||
|
||||
export function formatContractValue(value: string): string {
|
||||
return value
|
||||
.split('_')
|
||||
.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)
|
||||
const contractSummary = getWorkflowNodeContractSummary(definition)
|
||||
const contractPresentation = getWorkflowNodeContractPresentation(contractSummary)
|
||||
const alternativeInputGroups = contractSummary.requiredAnyInputs
|
||||
|
||||
return {
|
||||
label: overrides?.label ?? definition?.label ?? inferNodeLabel(step),
|
||||
@@ -255,32 +113,19 @@ export function buildWorkflowCanvasNodeData(
|
||||
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,
|
||||
})),
|
||||
inputContextLabel: contractSummary.inputContextLabel,
|
||||
outputContextLabel: contractSummary.outputContextLabel,
|
||||
contextInputs: contractSummary.contextInputs,
|
||||
inputPorts: getWorkflowNodeInputSocketDescriptors(contractSummary),
|
||||
outputPorts: getWorkflowNodeOutputSocketDescriptors(contractSummary),
|
||||
requiredAnyInputs: alternativeInputGroups,
|
||||
editableFieldCount: definition?.fields.length ?? 0,
|
||||
editableFieldLabels: getNodeEditableFieldLabels(definition),
|
||||
dynamicVariableHint: getDynamicVariableHint(definition),
|
||||
editableFieldCount: contractSummary.editableFieldCount,
|
||||
editableFieldLabels: contractSummary.editableFieldLabels,
|
||||
dynamicVariableHint: contractSummary.dynamicVariableHint,
|
||||
authoringPatternLabel: contractSummary.authoringPatternLabel,
|
||||
authoringPatternDescription: contractSummary.authoringPatternDescription,
|
||||
socketRequirementDescription: contractPresentation.socketRequirementDescription,
|
||||
variableSummaryText: contractPresentation.variableSummaryText,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,30 +527,9 @@ export function validateWorkflowDraft(
|
||||
warnings.push(`Node "${label}" has no earlier "Resolve Template" node. Render defaults may drift from legacy behavior.`)
|
||||
}
|
||||
|
||||
const requiredValues = new Set([
|
||||
...getContractValues(definition.input_contract as Record<string, unknown> | undefined, 'requires'),
|
||||
...(definition.artifact_roles_consumed ?? []),
|
||||
])
|
||||
const alternativeRequiredGroups = getContractAlternativeValues(
|
||||
definition.input_contract as Record<string, unknown> | undefined,
|
||||
'requires_any',
|
||||
)
|
||||
|
||||
if (step === 'output_save') {
|
||||
if (alternativeRequiredGroups.length === 0) {
|
||||
alternativeRequiredGroups.push(OUTPUT_SAVE_ALTERNATIVE_INPUTS)
|
||||
}
|
||||
}
|
||||
|
||||
if (step === 'notify') {
|
||||
requiredValues.delete('workflow_result')
|
||||
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 contractSummary = getWorkflowNodeContractSummary(definition)
|
||||
const requiredValues = new Set(contractSummary.requiredInputs)
|
||||
const alternativeRequiredGroups = contractSummary.requiredAnyInputs
|
||||
|
||||
const compatibilityValues = new Set<string>()
|
||||
if (executedSteps.has('order_line_setup') || step === 'order_line_setup') {
|
||||
@@ -737,10 +561,7 @@ export function validateWorkflowDraft(
|
||||
if (outputContext) {
|
||||
availableValues.add(outputContext)
|
||||
}
|
||||
for (const value of getContractValues(definition.output_contract as Record<string, unknown> | undefined, 'provides')) {
|
||||
availableValues.add(value)
|
||||
}
|
||||
for (const value of definition.artifact_roles_produced ?? []) {
|
||||
for (const value of contractSummary.providedOutputs) {
|
||||
availableValues.add(value)
|
||||
}
|
||||
if (step === 'order_line_setup') {
|
||||
@@ -888,6 +709,29 @@ function nodesOverlapAtPosition(
|
||||
)
|
||||
}
|
||||
|
||||
function getOrderedSearchOffsets(radius: number): Array<{ column: number; row: number }> {
|
||||
if (radius <= 0) {
|
||||
return [{ column: 0, row: 0 }]
|
||||
}
|
||||
|
||||
const positiveColumns = Array.from({ length: radius }, (_, index) => index + 1)
|
||||
const negativeColumns = Array.from({ length: radius }, (_, index) => -(index + 1))
|
||||
const positiveRows = Array.from({ length: radius }, (_, index) => index + 1)
|
||||
const negativeRows = Array.from({ length: radius }, (_, index) => -(index + 1))
|
||||
const columns = [0, ...positiveColumns, ...negativeColumns]
|
||||
const rows = [0, ...positiveRows, ...negativeRows]
|
||||
const offsets: Array<{ column: number; row: number }> = []
|
||||
|
||||
for (const row of rows) {
|
||||
for (const column of columns) {
|
||||
if (Math.max(Math.abs(column), Math.abs(row)) !== radius) continue
|
||||
offsets.push({ column, row })
|
||||
}
|
||||
}
|
||||
|
||||
return offsets
|
||||
}
|
||||
|
||||
export function graphNeedsAutoLayout(nodes: Node[]): boolean {
|
||||
if (nodes.length <= 1) return false
|
||||
|
||||
@@ -930,11 +774,8 @@ export function findOpenNodePosition(
|
||||
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) {
|
||||
for (let radius = 1; radius < 12; radius += 1) {
|
||||
for (const { column, row } of getOrderedSearchOffsets(radius)) {
|
||||
const candidate = {
|
||||
x: Math.max(WORKFLOW_LAYOUT_PADDING_X, normalized.x + column * horizontalStep),
|
||||
y: Math.max(WORKFLOW_LAYOUT_PADDING_Y, normalized.y + row * verticalStep),
|
||||
@@ -942,12 +783,11 @@ export function findOpenNodePosition(
|
||||
if (isPositionFree(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
x: normalized.x + horizontalStep,
|
||||
x: normalized.x,
|
||||
y: normalized.y + verticalStep,
|
||||
}
|
||||
}
|
||||
@@ -1066,9 +906,7 @@ export function resolveNodeCollisions(nodes: Node[], anchorNodeIds: string[]): N
|
||||
return nextNodes
|
||||
}
|
||||
|
||||
export function applyAutoLayout(nodes: Node[], edges: Edge[]) {
|
||||
if (nodes.length === 0) return nodes
|
||||
|
||||
function layoutConnectedComponent(nodes: Node[], edges: Edge[]): Node[] {
|
||||
const horizontalSpacing = WORKFLOW_NODE_WIDTH + WORKFLOW_NODE_HORIZONTAL_GAP
|
||||
const verticalSpacing = WORKFLOW_NODE_MIN_HEIGHT + WORKFLOW_NODE_VERTICAL_GAP
|
||||
|
||||
@@ -1202,3 +1040,103 @@ export function applyAutoLayout(nodes: Node[], edges: Edge[]) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getWeaklyConnectedComponentIds(nodes: Node[], edges: Edge[]): string[][] {
|
||||
const adjacency = new Map<string, Set<string>>()
|
||||
const knownNodeIds = new Set(nodes.map(node => node.id))
|
||||
|
||||
for (const node of nodes) {
|
||||
adjacency.set(node.id, new Set())
|
||||
}
|
||||
|
||||
for (const edge of edges) {
|
||||
if (!knownNodeIds.has(edge.source) || !knownNodeIds.has(edge.target)) continue
|
||||
adjacency.get(edge.source)?.add(edge.target)
|
||||
adjacency.get(edge.target)?.add(edge.source)
|
||||
}
|
||||
|
||||
const visited = new Set<string>()
|
||||
const components: string[][] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
if (visited.has(node.id)) continue
|
||||
|
||||
const queue = [node.id]
|
||||
const component: string[] = []
|
||||
visited.add(node.id)
|
||||
|
||||
while (queue.length > 0) {
|
||||
const nodeId = queue.shift()!
|
||||
component.push(nodeId)
|
||||
|
||||
for (const neighbor of adjacency.get(nodeId) ?? []) {
|
||||
if (visited.has(neighbor)) continue
|
||||
visited.add(neighbor)
|
||||
queue.push(neighbor)
|
||||
}
|
||||
}
|
||||
|
||||
components.push(component)
|
||||
}
|
||||
|
||||
return components
|
||||
}
|
||||
|
||||
export function applyAutoLayout(nodes: Node[], edges: Edge[]) {
|
||||
if (nodes.length === 0) return nodes
|
||||
|
||||
const nodeById = new Map(nodes.map(node => [node.id, node]))
|
||||
const edgeByComponent = getWeaklyConnectedComponentIds(nodes, edges)
|
||||
.map(componentIds => {
|
||||
const componentNodeIds = new Set(componentIds)
|
||||
const componentNodes = componentIds
|
||||
.map(nodeId => nodeById.get(nodeId))
|
||||
.filter((node): node is Node => Boolean(node))
|
||||
const componentEdges = edges.filter(
|
||||
edge => componentNodeIds.has(edge.source) && componentNodeIds.has(edge.target),
|
||||
)
|
||||
|
||||
return {
|
||||
nodes: componentNodes,
|
||||
edges: componentEdges,
|
||||
}
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const leftTop = Math.min(...left.nodes.map(node => node.position.y))
|
||||
const rightTop = Math.min(...right.nodes.map(node => node.position.y))
|
||||
const topDelta = leftTop - rightTop
|
||||
if (topDelta !== 0) return topDelta
|
||||
|
||||
const leftLeft = Math.min(...left.nodes.map(node => node.position.x))
|
||||
const rightLeft = Math.min(...right.nodes.map(node => node.position.x))
|
||||
return leftLeft - rightLeft
|
||||
})
|
||||
|
||||
const positionedNodes = new Map<string, Node>()
|
||||
let nextComponentTop = WORKFLOW_LAYOUT_PADDING_Y
|
||||
const componentGapY = WORKFLOW_NODE_MIN_HEIGHT + WORKFLOW_NODE_VERTICAL_GAP * 2
|
||||
|
||||
for (const component of edgeByComponent) {
|
||||
const laidOutComponent = layoutConnectedComponent(component.nodes, component.edges)
|
||||
const minX = Math.min(...laidOutComponent.map(node => node.position.x))
|
||||
const minY = Math.min(...laidOutComponent.map(node => node.position.y))
|
||||
const normalizedComponent = laidOutComponent.map(node => ({
|
||||
...node,
|
||||
position: {
|
||||
x: WORKFLOW_LAYOUT_PADDING_X + (node.position.x - minX),
|
||||
y: nextComponentTop + (node.position.y - minY),
|
||||
},
|
||||
}))
|
||||
|
||||
for (const node of normalizedComponent) {
|
||||
positionedNodes.set(node.id, node)
|
||||
}
|
||||
|
||||
const componentBottom = Math.max(
|
||||
...normalizedComponent.map(node => node.position.y + WORKFLOW_NODE_MIN_HEIGHT),
|
||||
)
|
||||
nextComponentTop = componentBottom + componentGapY
|
||||
}
|
||||
|
||||
return nodes.map(node => positionedNodes.get(node.id) ?? node)
|
||||
}
|
||||
|
||||
@@ -10,8 +10,11 @@ import {
|
||||
|
||||
export type WorkflowModuleBundleId =
|
||||
| 'cad_intake_core'
|
||||
| 'scene_prep_core'
|
||||
| 'materials_core'
|
||||
| 'still_render_core'
|
||||
| 'output_publish_notify'
|
||||
| 'blend_export_publish'
|
||||
|
||||
export type WorkflowModuleBundleDefinition = {
|
||||
id: WorkflowModuleBundleId
|
||||
@@ -47,13 +50,33 @@ const WORKFLOW_MODULE_BUNDLE_REGISTRY: WorkflowModuleBundleDefinition[] = [
|
||||
stage: AUTHORING_STAGE_LABELS.cad_intake,
|
||||
stageId: 'cad_intake',
|
||||
},
|
||||
{
|
||||
id: 'scene_prep_core',
|
||||
label: 'Scene Prep Core',
|
||||
shortLabel: 'Scene Prep',
|
||||
description: 'Prepare order-line runtime context, resolve the active template, and compute shared geometry metadata before material or render stages are attached.',
|
||||
family: 'order_line',
|
||||
stepIds: ['order_line_setup', 'resolve_template', 'glb_bbox'],
|
||||
stage: AUTHORING_STAGE_LABELS.scene_prep,
|
||||
stageId: 'scene_prep',
|
||||
},
|
||||
{
|
||||
id: 'materials_core',
|
||||
label: 'Materials Core',
|
||||
shortLabel: 'Materials',
|
||||
description: 'Populate CAD material candidates and resolve the production material map as a reusable graph stage.',
|
||||
family: 'order_line',
|
||||
stepIds: ['auto_populate_materials', 'material_map_resolve'],
|
||||
stage: AUTHORING_STAGE_LABELS.materials,
|
||||
stageId: 'materials',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
description: 'Execute the still-render stage against already-prepared scene, template, geometry, and material inputs.',
|
||||
family: 'order_line',
|
||||
stepIds: ['order_line_setup', 'resolve_template', 'auto_populate_materials', 'glb_bbox', 'material_map_resolve', 'blender_still'],
|
||||
stepIds: ['blender_still'],
|
||||
stage: AUTHORING_STAGE_LABELS.render,
|
||||
stageId: 'render',
|
||||
},
|
||||
@@ -67,6 +90,16 @@ const WORKFLOW_MODULE_BUNDLE_REGISTRY: WorkflowModuleBundleDefinition[] = [
|
||||
stage: AUTHORING_STAGE_LABELS.publish,
|
||||
stageId: 'publish',
|
||||
},
|
||||
{
|
||||
id: 'blend_export_publish',
|
||||
label: 'Blend Export Publish',
|
||||
shortLabel: 'Blend Publish',
|
||||
description: 'Publish a .blend artifact, persist it through the standard output handoff, and emit the completion notification branch.',
|
||||
family: 'order_line',
|
||||
stepIds: ['export_blend', 'output_save', 'notify'],
|
||||
stage: AUTHORING_STAGE_LABELS.publish,
|
||||
stageId: 'publish',
|
||||
},
|
||||
]
|
||||
|
||||
function buildNodeData(
|
||||
|
||||
@@ -0,0 +1,729 @@
|
||||
import type { WorkflowNodeDefinition, WorkflowNodeFieldDefinition } from '../../api/workflows'
|
||||
|
||||
export const TEMPLATE_INPUT_PARAM_PREFIX = 'template_input__'
|
||||
|
||||
const OUTPUT_SAVE_ALTERNATIVE_INPUTS = ['rendered_image', 'rendered_frames', 'rendered_video', 'blend_asset']
|
||||
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',
|
||||
}
|
||||
|
||||
export type WorkflowNodeContractSummary = {
|
||||
inputContextLabel: string | null
|
||||
outputContextLabel: string | null
|
||||
contextInputs: string[]
|
||||
requiredInputs: string[]
|
||||
requiredAnyInputs: string[][]
|
||||
providedOutputs: string[]
|
||||
consumedArtifacts: string[]
|
||||
producedArtifacts: string[]
|
||||
editableFieldCount: number
|
||||
editableFieldLabels: string[]
|
||||
dynamicVariableHint: string | null
|
||||
authoringPatternLabel: string
|
||||
authoringPatternDescription: string
|
||||
}
|
||||
|
||||
export type WorkflowNodeContractMetric = {
|
||||
value: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export type WorkflowNodeContractPresentation = {
|
||||
inputMetric: WorkflowNodeContractMetric
|
||||
variableMetric: WorkflowNodeContractMetric
|
||||
outputMetric: WorkflowNodeContractMetric
|
||||
socketRequirementDescription: string
|
||||
variableSummaryText: string
|
||||
noSettingsDescription: string
|
||||
socketCount: number
|
||||
variableCount: number
|
||||
declaredOutputCount: number
|
||||
}
|
||||
|
||||
export type WorkflowNodeSocketDescriptor = {
|
||||
id: string
|
||||
label: string
|
||||
roles: string[]
|
||||
kind: 'required' | 'alternative' | 'provided'
|
||||
}
|
||||
|
||||
export type WorkflowNodeValidationWatchpoint = {
|
||||
kind: 'context' | 'setup-chain' | 'data-source' | 'runtime-gap' | 'legacy-drift' | 'artifact-flow'
|
||||
label: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export type WorkflowNodeContractSignal = {
|
||||
id: string
|
||||
label: string
|
||||
title?: string
|
||||
tone: 'default' | 'watch'
|
||||
}
|
||||
|
||||
function definitionHasField(
|
||||
definition: WorkflowNodeDefinition | undefined,
|
||||
fieldKey: string,
|
||||
): boolean {
|
||||
return (definition?.fields ?? []).some(field => field.key === fieldKey)
|
||||
}
|
||||
|
||||
const ROOT_CONTEXT_INPUTS_BY_CONTEXT: Record<'cad_file' | 'order_line', string[]> = {
|
||||
cad_file: ['cad_file_record'],
|
||||
order_line: ['order_line_record'],
|
||||
}
|
||||
|
||||
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('|')}`
|
||||
}
|
||||
|
||||
export function formatContractValue(value: string): string {
|
||||
return value
|
||||
.split('_')
|
||||
.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(' ')
|
||||
}
|
||||
|
||||
export function formatContractValues(values: string[]): string {
|
||||
return values.map(formatContractValue).join(', ')
|
||||
}
|
||||
|
||||
export function formatContractAlternativeGroup(values: string[]): string {
|
||||
return `Any of: ${values.map(formatContractValue).join(' / ')}`
|
||||
}
|
||||
|
||||
function formatContractSentenceList(values: string[]): string {
|
||||
const formatted = values.map(formatContractValue)
|
||||
if (formatted.length <= 1) {
|
||||
return formatted[0] ?? ''
|
||||
}
|
||||
if (formatted.length === 2) {
|
||||
return `${formatted[0]} and ${formatted[1]}`
|
||||
}
|
||||
return `${formatted.slice(0, -1).join(', ')}, and ${formatted[formatted.length - 1]}`
|
||||
}
|
||||
|
||||
export function getContractValues(
|
||||
contract: Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
): string[] {
|
||||
const value = contract?.[key]
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
|
||||
}
|
||||
|
||||
export function getContractAlternativeValues(
|
||||
contract: Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
): string[][] {
|
||||
const value = contract?.[key]
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
if (value.every(entry => typeof entry === 'string' && entry.trim().length > 0)) {
|
||||
return [value as string[]]
|
||||
}
|
||||
|
||||
return value
|
||||
.filter((entry): entry is string[] => Array.isArray(entry))
|
||||
.map(group => group.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0))
|
||||
.filter(group => group.length > 0)
|
||||
}
|
||||
|
||||
export function getContractContext(
|
||||
contract: Record<string, unknown> | undefined,
|
||||
): 'cad_file' | 'order_line' | null {
|
||||
const value = contract?.context
|
||||
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'
|
||||
}
|
||||
|
||||
export function getWorkflowNodeAlternativeInputGroups(
|
||||
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))
|
||||
}
|
||||
|
||||
export function getWorkflowNodeRequiredInputRoles(
|
||||
definition: WorkflowNodeDefinition | undefined,
|
||||
): string[] {
|
||||
if (!definition) return []
|
||||
|
||||
const contextInputs = new Set(getWorkflowNodeContextInputs(definition))
|
||||
const alternativeRoles = new Set(getWorkflowNodeAlternativeInputGroups(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) && !contextInputs.has(role))
|
||||
}
|
||||
|
||||
export function getWorkflowNodeContextInputs(
|
||||
definition: WorkflowNodeDefinition | undefined,
|
||||
): string[] {
|
||||
if (!definition) return []
|
||||
|
||||
const context = getContractContext(definition.input_contract as Record<string, unknown> | undefined)
|
||||
if (!context) return []
|
||||
|
||||
const directRoles = dedupeRoles(
|
||||
getContractValues(definition.input_contract as Record<string, unknown> | undefined, 'requires'),
|
||||
)
|
||||
const rootContextInputs = new Set(ROOT_CONTEXT_INPUTS_BY_CONTEXT[context] ?? [])
|
||||
|
||||
return directRoles.filter(role => rootContextInputs.has(role))
|
||||
}
|
||||
|
||||
export function getWorkflowNodeProvidedOutputRoles(
|
||||
definition: WorkflowNodeDefinition | undefined,
|
||||
): string[] {
|
||||
if (!definition) return []
|
||||
|
||||
return dedupeRoles([
|
||||
...getContractValues(definition.output_contract as Record<string, unknown> | undefined, 'provides'),
|
||||
...(definition.artifact_roles_produced ?? []),
|
||||
])
|
||||
}
|
||||
|
||||
export function getWorkflowVariableLabels(fields: WorkflowNodeFieldDefinition[]): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
fields
|
||||
.map(field => field.label?.trim())
|
||||
.filter((label): label is string => Boolean(label)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function getWorkflowNodeEditableFieldLabels(
|
||||
definition: WorkflowNodeDefinition | undefined,
|
||||
): string[] {
|
||||
return getWorkflowVariableLabels(definition?.fields ?? [])
|
||||
}
|
||||
|
||||
export function getWorkflowNodeDynamicVariableHint(
|
||||
definition: WorkflowNodeDefinition | undefined,
|
||||
): string | null {
|
||||
if (!definition) return null
|
||||
|
||||
const providedRoles = new Set(getWorkflowNodeProvidedOutputRoles(definition))
|
||||
if (providedRoles.has('workflow_input_schema') || providedRoles.has('template_inputs')) {
|
||||
return 'Template-selected variables appear after choosing a template.'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function getWorkflowNodeContractSummary(
|
||||
definition: WorkflowNodeDefinition | undefined,
|
||||
): WorkflowNodeContractSummary {
|
||||
const contextInputs = getWorkflowNodeContextInputs(definition)
|
||||
const requiredInputs = getWorkflowNodeRequiredInputRoles(definition)
|
||||
const requiredAnyInputs = getWorkflowNodeAlternativeInputGroups(definition)
|
||||
const editableFieldLabels = getWorkflowNodeEditableFieldLabels(definition)
|
||||
const dynamicVariableHint = getWorkflowNodeDynamicVariableHint(definition)
|
||||
const inspectorVariableCount =
|
||||
editableFieldLabels.length + (dynamicVariableHint ? 1 : 0)
|
||||
const upstreamSocketCount = requiredInputs.length + requiredAnyInputs.length
|
||||
|
||||
let authoringPatternLabel = 'Hybrid'
|
||||
let authoringPatternDescription =
|
||||
'Wire required upstream artifacts on the canvas and configure local variables in the inspector.'
|
||||
|
||||
if (contextInputs.length > 0 && upstreamSocketCount === 0 && inspectorVariableCount === 0) {
|
||||
authoringPatternLabel = 'Context Entry'
|
||||
authoringPatternDescription =
|
||||
'The workflow context supplies the required record. No upstream canvas wiring is needed before this node can run.'
|
||||
} else if (upstreamSocketCount > 0 && inspectorVariableCount === 0) {
|
||||
authoringPatternLabel = 'Connection-Driven'
|
||||
authoringPatternDescription =
|
||||
'Configure this node by wiring upstream artifacts. It has no local inspector variables.'
|
||||
} else if (upstreamSocketCount === 0) {
|
||||
authoringPatternLabel = 'Inspector-Driven'
|
||||
authoringPatternDescription =
|
||||
'Configure this node entirely in the inspector. It does not require upstream artifact wiring.'
|
||||
}
|
||||
|
||||
return {
|
||||
inputContextLabel: getContractContextLabel(definition?.input_contract as Record<string, unknown> | undefined),
|
||||
outputContextLabel: getContractContextLabel(definition?.output_contract as Record<string, unknown> | undefined),
|
||||
contextInputs,
|
||||
requiredInputs,
|
||||
requiredAnyInputs,
|
||||
providedOutputs: getWorkflowNodeProvidedOutputRoles(definition),
|
||||
consumedArtifacts: definition?.artifact_roles_consumed ?? [],
|
||||
producedArtifacts: definition?.artifact_roles_produced ?? [],
|
||||
editableFieldCount: definition?.fields.length ?? 0,
|
||||
editableFieldLabels,
|
||||
dynamicVariableHint,
|
||||
authoringPatternLabel,
|
||||
authoringPatternDescription,
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkflowNodeInputSocketDescriptors(
|
||||
contract: WorkflowNodeContractSummary,
|
||||
): WorkflowNodeSocketDescriptor[] {
|
||||
return [
|
||||
...contract.requiredInputs.map(role => ({
|
||||
id: createPortId('input', [role]),
|
||||
label: formatContractValue(role),
|
||||
roles: [role],
|
||||
kind: 'required' as const,
|
||||
})),
|
||||
...contract.requiredAnyInputs.map(group => ({
|
||||
id: createPortId('input-any', group),
|
||||
label: formatContractAlternativeGroup(group),
|
||||
roles: group,
|
||||
kind: 'alternative' as const,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
export function getWorkflowNodeOutputSocketDescriptors(
|
||||
contract: WorkflowNodeContractSummary,
|
||||
): WorkflowNodeSocketDescriptor[] {
|
||||
return contract.providedOutputs.map(role => ({
|
||||
id: createPortId('output', [role]),
|
||||
label: formatContractValue(role),
|
||||
roles: [role],
|
||||
kind: 'provided' as const,
|
||||
}))
|
||||
}
|
||||
|
||||
export function getWorkflowNodeSocketCount(contract: WorkflowNodeContractSummary): number {
|
||||
return contract.requiredInputs.length + contract.requiredAnyInputs.length
|
||||
}
|
||||
|
||||
export function getWorkflowNodeInspectorVariableLabels(
|
||||
contract: WorkflowNodeContractSummary,
|
||||
dynamicVariableLabels: string[] = [],
|
||||
): string[] {
|
||||
return Array.from(new Set([...contract.editableFieldLabels, ...dynamicVariableLabels]))
|
||||
}
|
||||
|
||||
export function getWorkflowNodeVariableCount(contract: WorkflowNodeContractSummary): number {
|
||||
return getWorkflowNodeInspectorVariableLabels(contract).length + (contract.dynamicVariableHint ? 1 : 0)
|
||||
}
|
||||
|
||||
export function getWorkflowNodeTotalVariableCount(
|
||||
contract: WorkflowNodeContractSummary,
|
||||
dynamicVariableLabels: string[] = [],
|
||||
): number {
|
||||
return getWorkflowNodeInspectorVariableLabels(contract, dynamicVariableLabels).length
|
||||
}
|
||||
|
||||
export function getWorkflowNodeDeclaredOutputCount(contract: WorkflowNodeContractSummary): number {
|
||||
return contract.providedOutputs.length
|
||||
}
|
||||
|
||||
export function getWorkflowNodeInputMetric(
|
||||
contract: WorkflowNodeContractSummary,
|
||||
): WorkflowNodeContractMetric {
|
||||
const socketCount = getWorkflowNodeSocketCount(contract)
|
||||
|
||||
if (contract.contextInputs.length > 0 && socketCount === 0) {
|
||||
return {
|
||||
value: `Context only (${contract.contextInputs.length})`,
|
||||
title: formatContractValues(contract.contextInputs),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
value: `${socketCount} socket${socketCount === 1 ? '' : 's'}`,
|
||||
title:
|
||||
socketCount > 0
|
||||
? `Required: ${contract.requiredInputs.length}, alternative groups: ${contract.requiredAnyInputs.length}`
|
||||
: 'No upstream inputs required',
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkflowNodeVariableMetric(
|
||||
contract: WorkflowNodeContractSummary,
|
||||
): WorkflowNodeContractMetric {
|
||||
const variableCount = getWorkflowNodeVariableCount(contract)
|
||||
return {
|
||||
value: `${variableCount} inspector`,
|
||||
title:
|
||||
contract.editableFieldLabels.length > 0
|
||||
? contract.editableFieldLabels.join(', ')
|
||||
: contract.dynamicVariableHint ?? 'No local inspector variables',
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkflowNodeOutputMetric(
|
||||
contract: WorkflowNodeContractSummary,
|
||||
): WorkflowNodeContractMetric {
|
||||
const outputCount = contract.providedOutputs.length
|
||||
return {
|
||||
value: `${outputCount} role${outputCount === 1 ? '' : 's'}`,
|
||||
title:
|
||||
outputCount > 0
|
||||
? formatContractValues(contract.providedOutputs)
|
||||
: 'No downstream output roles',
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkflowNodeSocketRequirementDescription(
|
||||
contract: WorkflowNodeContractSummary,
|
||||
): string {
|
||||
const socketCount = getWorkflowNodeSocketCount(contract)
|
||||
const requiredSocketCount = contract.requiredInputs.length
|
||||
const alternativeGroupCount = contract.requiredAnyInputs.length
|
||||
|
||||
if (socketCount === 0) {
|
||||
if (contract.contextInputs.length > 0) {
|
||||
return `Workflow context supplies ${formatContractValues(contract.contextInputs)}. No additional upstream sockets are required.`
|
||||
}
|
||||
return 'This entry node does not declare additional upstream sockets.'
|
||||
}
|
||||
|
||||
if (requiredSocketCount === 1 && alternativeGroupCount === 0) {
|
||||
return `This node waits for ${formatContractValue(contract.requiredInputs[0])} from upstream.`
|
||||
}
|
||||
|
||||
if (requiredSocketCount > 1 && alternativeGroupCount === 0) {
|
||||
return `Wire ${requiredSocketCount} required upstream sockets: ${formatContractSentenceList(contract.requiredInputs)}.`
|
||||
}
|
||||
|
||||
if (requiredSocketCount === 0 && alternativeGroupCount === 1) {
|
||||
return `This node accepts one upstream artifact from ${formatContractAlternativeGroup(contract.requiredAnyInputs[0]).toLowerCase()}.`
|
||||
}
|
||||
|
||||
if (requiredSocketCount === 0 && alternativeGroupCount > 1) {
|
||||
return `Connect one upstream artifact for each of the ${alternativeGroupCount} alternative socket groups before dispatch.`
|
||||
}
|
||||
|
||||
if (requiredSocketCount === 1 && alternativeGroupCount === 1) {
|
||||
return `This node requires ${formatContractValue(contract.requiredInputs[0])} and one additional upstream artifact from ${formatContractAlternativeGroup(contract.requiredAnyInputs[0]).toLowerCase()}.`
|
||||
}
|
||||
|
||||
return `Wire ${requiredSocketCount} required upstream sockets and satisfy ${alternativeGroupCount} alternative input group${alternativeGroupCount === 1 ? '' : 's'} before dispatch.`
|
||||
}
|
||||
|
||||
export function getWorkflowNodeVariableSummaryText(
|
||||
contract: WorkflowNodeContractSummary,
|
||||
dynamicVariableLabels: string[] = [],
|
||||
): string {
|
||||
const variableCount = getWorkflowNodeTotalVariableCount(contract, dynamicVariableLabels)
|
||||
|
||||
if (variableCount > 0) {
|
||||
return `${variableCount} local variable${variableCount === 1 ? ' is' : 's are'} edited in the inspector.`
|
||||
}
|
||||
|
||||
if (contract.dynamicVariableHint) {
|
||||
return contract.dynamicVariableHint
|
||||
}
|
||||
|
||||
return 'This node has 0 local variables by design. Its behavior is driven entirely by connections and runtime context.'
|
||||
}
|
||||
|
||||
export function getWorkflowNodeNoSettingsDescription(
|
||||
contract: WorkflowNodeContractSummary,
|
||||
): string {
|
||||
if (contract.contextInputs.length > 0) {
|
||||
return `Workflow context already provides ${formatContractValues(contract.contextInputs)}.`
|
||||
}
|
||||
|
||||
return getWorkflowNodeSocketRequirementDescription(contract)
|
||||
}
|
||||
|
||||
export function getWorkflowNodeContractPresentation(
|
||||
contract: WorkflowNodeContractSummary,
|
||||
dynamicVariableLabels: string[] = [],
|
||||
): WorkflowNodeContractPresentation {
|
||||
return {
|
||||
inputMetric: getWorkflowNodeInputMetric(contract),
|
||||
variableMetric: getWorkflowNodeVariableMetric(contract),
|
||||
outputMetric: getWorkflowNodeOutputMetric(contract),
|
||||
socketRequirementDescription: getWorkflowNodeSocketRequirementDescription(contract),
|
||||
variableSummaryText: getWorkflowNodeVariableSummaryText(contract, dynamicVariableLabels),
|
||||
noSettingsDescription: getWorkflowNodeNoSettingsDescription(contract),
|
||||
socketCount: getWorkflowNodeSocketCount(contract),
|
||||
variableCount: getWorkflowNodeTotalVariableCount(contract, dynamicVariableLabels),
|
||||
declaredOutputCount: getWorkflowNodeDeclaredOutputCount(contract),
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkflowNodeValidationWatchpoints(
|
||||
definition: WorkflowNodeDefinition | undefined,
|
||||
contract: WorkflowNodeContractSummary,
|
||||
): WorkflowNodeValidationWatchpoint[] {
|
||||
if (!definition) return []
|
||||
|
||||
const watchpoints: WorkflowNodeValidationWatchpoint[] = []
|
||||
const consumedRoles = new Set([
|
||||
...contract.requiredInputs,
|
||||
...contract.requiredAnyInputs.flat(),
|
||||
...contract.consumedArtifacts,
|
||||
])
|
||||
const producedRoles = new Set([
|
||||
...contract.providedOutputs,
|
||||
...contract.producedArtifacts,
|
||||
])
|
||||
|
||||
if (contract.inputContextLabel) {
|
||||
watchpoints.push({
|
||||
kind: 'context',
|
||||
label: 'Context',
|
||||
reason: `${definition.label} expects ${contract.inputContextLabel} workflow context before it can run.`,
|
||||
})
|
||||
}
|
||||
|
||||
if (contract.contextInputs.includes('order_line_record') || producedRoles.has('order_line_context')) {
|
||||
watchpoints.push({
|
||||
kind: 'setup-chain',
|
||||
label: 'Setup Chain',
|
||||
reason: 'This node participates in order-line setup and will fail if setup prerequisites are missing or not renderable.',
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
['cad_file_record', 'step_path', 'glb_preview', 'glb_reuse_path'].some(
|
||||
role => consumedRoles.has(role) || producedRoles.has(role),
|
||||
)
|
||||
) {
|
||||
watchpoints.push({
|
||||
kind: 'data-source',
|
||||
label: 'Data Source',
|
||||
reason: 'This node depends on stored CAD or derived geometry files being present on disk and resolvable at runtime.',
|
||||
})
|
||||
}
|
||||
|
||||
if (definition.execution_kind === 'bridge') {
|
||||
watchpoints.push({
|
||||
kind: 'runtime-gap',
|
||||
label: 'Runtime Gap',
|
||||
reason: 'This step still runs through bridge execution. Verify native graph coverage before promoting it to full graph rollout.',
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
definition.step === 'resolve_template' ||
|
||||
consumedRoles.has('render_template') ||
|
||||
producedRoles.has('render_template') ||
|
||||
producedRoles.has('template_inputs')
|
||||
) {
|
||||
watchpoints.push({
|
||||
kind: 'legacy-drift',
|
||||
label: 'Legacy Drift',
|
||||
reason: 'Template resolution and render-template handoff must stay aligned with the legacy production path.',
|
||||
})
|
||||
}
|
||||
|
||||
if (getWorkflowNodeSocketCount(contract) > 0 || contract.consumedArtifacts.length > 0) {
|
||||
watchpoints.push({
|
||||
kind: 'artifact-flow',
|
||||
label: 'Artifact Flow',
|
||||
reason: 'This node relies on upstream artifacts or sockets. Missing connections will surface as artifact-flow issues during preflight.',
|
||||
})
|
||||
}
|
||||
|
||||
return watchpoints.filter(
|
||||
(watchpoint, index, items) => items.findIndex(item => item.kind === watchpoint.kind) === index,
|
||||
)
|
||||
}
|
||||
|
||||
export function getWorkflowNodeContractSignals(
|
||||
definition: WorkflowNodeDefinition | undefined,
|
||||
contract: WorkflowNodeContractSummary,
|
||||
limits: Partial<{
|
||||
contextInputs: number
|
||||
requiredInputs: number
|
||||
providedOutputs: number
|
||||
consumedArtifacts: number
|
||||
producedArtifacts: number
|
||||
watchpoints: number
|
||||
}> = {},
|
||||
): WorkflowNodeContractSignal[] {
|
||||
const {
|
||||
contextInputs = 1,
|
||||
requiredInputs = 2,
|
||||
providedOutputs = 2,
|
||||
consumedArtifacts = 1,
|
||||
producedArtifacts = 1,
|
||||
watchpoints = 2,
|
||||
} = limits
|
||||
const signals: WorkflowNodeContractSignal[] = [
|
||||
{
|
||||
id: 'authoring-pattern',
|
||||
label: contract.authoringPatternLabel,
|
||||
title: contract.authoringPatternDescription,
|
||||
tone: 'default',
|
||||
},
|
||||
]
|
||||
|
||||
if (
|
||||
definition?.step === 'resolve_template' ||
|
||||
contract.providedOutputs.includes('render_template') ||
|
||||
contract.providedOutputs.includes('template_inputs') ||
|
||||
contract.producedArtifacts.includes('template_inputs')
|
||||
) {
|
||||
signals.push({
|
||||
id: 'template-contract',
|
||||
label: 'Template Inputs',
|
||||
title: 'This module resolves render-template state and can expose template-defined workflow variables.',
|
||||
tone: 'default',
|
||||
})
|
||||
}
|
||||
|
||||
if (definitionHasField(definition, 'use_custom_render_settings')) {
|
||||
signals.push({
|
||||
id: 'render-overrides',
|
||||
label: 'Render Overrides',
|
||||
title: 'Output Type and Template stay authoritative until Custom Render Settings is enabled.',
|
||||
tone: 'default',
|
||||
})
|
||||
}
|
||||
|
||||
if (definitionHasField(definition, 'transparent_bg')) {
|
||||
signals.push({
|
||||
id: 'alpha-output',
|
||||
label: 'Alpha Output',
|
||||
title: 'This module can emit transparent renders for PNG or compositing-driven output types.',
|
||||
tone: 'default',
|
||||
})
|
||||
}
|
||||
|
||||
if (contract.providedOutputs.includes('blend_asset') || contract.producedArtifacts.includes('blend_asset')) {
|
||||
signals.push({
|
||||
id: 'blend-delivery',
|
||||
label: 'Blend Delivery',
|
||||
title: 'This module emits a downstream blend asset instead of final pixels.',
|
||||
tone: 'default',
|
||||
})
|
||||
}
|
||||
|
||||
if (definition?.step === 'output_save') {
|
||||
signals.push({
|
||||
id: 'publish-handoff',
|
||||
label: 'Publish Handoff',
|
||||
title: 'This module persists the connected artifact as the authoritative workflow result.',
|
||||
tone: 'default',
|
||||
})
|
||||
}
|
||||
|
||||
if (definition?.step === 'notify') {
|
||||
signals.push({
|
||||
id: 'notification-handoff',
|
||||
label: 'Notification Handoff',
|
||||
title: 'This module depends on an armed upstream render or export handoff before it can emit notifications.',
|
||||
tone: 'default',
|
||||
})
|
||||
}
|
||||
|
||||
if (contract.inputContextLabel) {
|
||||
signals.push({
|
||||
id: `input-context:${contract.inputContextLabel}`,
|
||||
label: `In ${contract.inputContextLabel}`,
|
||||
tone: 'default',
|
||||
})
|
||||
}
|
||||
|
||||
contract.contextInputs.slice(0, contextInputs).forEach(input => {
|
||||
signals.push({
|
||||
id: `context-input:${input}`,
|
||||
label: `Context ${formatContractValue(input)}`,
|
||||
tone: 'default',
|
||||
})
|
||||
})
|
||||
|
||||
if (contract.outputContextLabel) {
|
||||
signals.push({
|
||||
id: `output-context:${contract.outputContextLabel}`,
|
||||
label: `Out ${contract.outputContextLabel}`,
|
||||
tone: 'default',
|
||||
})
|
||||
}
|
||||
|
||||
contract.requiredInputs.slice(0, requiredInputs).forEach(input => {
|
||||
signals.push({
|
||||
id: `requires:${input}`,
|
||||
label: `Requires ${formatContractValue(input)}`,
|
||||
tone: 'default',
|
||||
})
|
||||
})
|
||||
|
||||
contract.providedOutputs.slice(0, providedOutputs).forEach(output => {
|
||||
signals.push({
|
||||
id: `provides:${output}`,
|
||||
label: `Provides ${formatContractValue(output)}`,
|
||||
tone: 'default',
|
||||
})
|
||||
})
|
||||
|
||||
contract.consumedArtifacts.slice(0, consumedArtifacts).forEach(artifact => {
|
||||
signals.push({
|
||||
id: `consumes:${artifact}`,
|
||||
label: `Consumes ${formatContractValue(artifact)}`,
|
||||
tone: 'default',
|
||||
})
|
||||
})
|
||||
|
||||
contract.producedArtifacts.slice(0, producedArtifacts).forEach(artifact => {
|
||||
signals.push({
|
||||
id: `produces:${artifact}`,
|
||||
label: `Produces ${formatContractValue(artifact)}`,
|
||||
tone: 'default',
|
||||
})
|
||||
})
|
||||
|
||||
getWorkflowNodeValidationWatchpoints(definition, contract)
|
||||
.slice(0, watchpoints)
|
||||
.forEach(watchpoint => {
|
||||
signals.push({
|
||||
id: `watch:${watchpoint.kind}`,
|
||||
label: `Watch ${watchpoint.label}`,
|
||||
title: watchpoint.reason,
|
||||
tone: 'watch',
|
||||
})
|
||||
})
|
||||
|
||||
return signals
|
||||
}
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
} from './workflowGraphDraft'
|
||||
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
|
||||
|
||||
export type WorkflowReferenceBundleId = 'still_render_reference'
|
||||
export type WorkflowReferenceBundleId =
|
||||
| 'still_render_reference'
|
||||
| 'still_render_alpha_reference'
|
||||
| 'still_render_blend_reference'
|
||||
| 'cad_intake_reference'
|
||||
|
||||
export type WorkflowReferenceBundleDefinition = {
|
||||
@@ -73,6 +76,43 @@ const WORKFLOW_REFERENCE_BUNDLE_REGISTRY: WorkflowReferenceBundleDefinition[] =
|
||||
],
|
||||
stage: 'Reference Path',
|
||||
},
|
||||
{
|
||||
id: 'still_render_alpha_reference',
|
||||
label: 'Still Render Alpha Reference',
|
||||
shortLabel: 'Still Alpha',
|
||||
description: 'Insert the canonical still-render path with transparent-alpha defaults so compositing-ready PNG outputs stay workflow-first.',
|
||||
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',
|
||||
},
|
||||
{
|
||||
id: 'still_render_blend_reference',
|
||||
label: 'Still Render + Blend Reference',
|
||||
shortLabel: 'Still + Blend',
|
||||
description: 'Insert the canonical still-render path plus explicit .blend export delivery nodes so still and blend output-types can bind to the same graph family cleanly.',
|
||||
family: 'order_line',
|
||||
stepIds: [
|
||||
'order_line_setup',
|
||||
'resolve_template',
|
||||
'auto_populate_materials',
|
||||
'glb_bbox',
|
||||
'material_map_resolve',
|
||||
'blender_still',
|
||||
'output_save',
|
||||
'notify',
|
||||
'export_blend',
|
||||
],
|
||||
stage: 'Reference Path',
|
||||
},
|
||||
]
|
||||
|
||||
function buildNodeData(
|
||||
@@ -102,6 +142,10 @@ function getReferenceTemplate(bundleId: WorkflowReferenceBundleId) {
|
||||
return toTemplate(buildWorkflowBlueprintConfig('cad_intake'))
|
||||
case 'still_render_reference':
|
||||
return toTemplate(buildWorkflowBlueprintConfig('still_graph_reference'))
|
||||
case 'still_render_alpha_reference':
|
||||
return toTemplate(buildWorkflowBlueprintConfig('still_graph_alpha_reference'))
|
||||
case 'still_render_blend_reference':
|
||||
return toTemplate(buildWorkflowBlueprintConfig('still_graph_blend_reference'))
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import type { WorkflowPreflightIssue } from '../../api/workflows'
|
||||
|
||||
export type WorkflowValidationKind =
|
||||
| 'context'
|
||||
| 'setup-chain'
|
||||
| 'data-source'
|
||||
| 'runtime-gap'
|
||||
| 'legacy-drift'
|
||||
| 'artifact-flow'
|
||||
| 'general'
|
||||
|
||||
export type WorkflowValidationSeverity = 'error' | 'warning' | 'info'
|
||||
|
||||
export type WorkflowValidationSummaryItem = {
|
||||
kind: WorkflowValidationKind
|
||||
label: string
|
||||
count: number
|
||||
severity: WorkflowValidationSeverity
|
||||
}
|
||||
|
||||
export function getWorkflowValidationKind(
|
||||
issue: Pick<WorkflowPreflightIssue, 'code' | 'message'>,
|
||||
): WorkflowValidationKind {
|
||||
switch (issue.code) {
|
||||
case 'invalid_context_id':
|
||||
case 'context_not_found':
|
||||
case 'context_kind_mismatch':
|
||||
case 'invalid_context_kind':
|
||||
case 'cad_file_only_node':
|
||||
return 'context'
|
||||
case 'order_line_missing':
|
||||
case 'order_line_not_renderable':
|
||||
case 'order_line_skipped':
|
||||
case 'missing_order_line_setup':
|
||||
case 'setup_not_ready':
|
||||
return 'setup-chain'
|
||||
case 'cad_file_missing_path':
|
||||
case 'cad_file_step_missing':
|
||||
case 'bbox_unresolved':
|
||||
return 'data-source'
|
||||
case 'unsupported_node':
|
||||
return 'runtime-gap'
|
||||
case 'missing_resolve_template':
|
||||
case 'template_missing':
|
||||
return 'legacy-drift'
|
||||
default:
|
||||
if (
|
||||
issue.code.toLowerCase().includes('artifact') ||
|
||||
issue.message.toLowerCase().includes('artifact')
|
||||
) {
|
||||
return 'artifact-flow'
|
||||
}
|
||||
return 'general'
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkflowValidationKindLabel(kind: WorkflowValidationKind): string {
|
||||
switch (kind) {
|
||||
case 'context':
|
||||
return 'Context'
|
||||
case 'setup-chain':
|
||||
return 'Setup Chain'
|
||||
case 'data-source':
|
||||
return 'Data Source'
|
||||
case 'runtime-gap':
|
||||
return 'Runtime Gap'
|
||||
case 'legacy-drift':
|
||||
return 'Legacy Drift'
|
||||
case 'artifact-flow':
|
||||
return 'Artifact Flow'
|
||||
default:
|
||||
return 'General'
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkflowPreflightActionHint(
|
||||
issue: Pick<WorkflowPreflightIssue, 'code' | 'message'>,
|
||||
): string {
|
||||
const message = issue.message.toLowerCase()
|
||||
|
||||
switch (issue.code) {
|
||||
case 'invalid_context_id':
|
||||
return 'Use a valid UUID from the matching order line or CAD file record before running preflight again.'
|
||||
case 'context_not_found':
|
||||
return 'Pick an existing order line or CAD file. The supplied ID does not resolve to a stored workflow context.'
|
||||
case 'order_line_missing':
|
||||
return 'Reload the order-line context or repair the underlying record before dispatching this graph.'
|
||||
case 'order_line_not_renderable':
|
||||
return 'Fix the order-line render prerequisites first, then rerun preflight to confirm the setup path is renderable.'
|
||||
case 'order_line_skipped':
|
||||
return 'This order line would be skipped by legacy setup logic. Clear the skip reason before promoting the graph path.'
|
||||
case 'missing_order_line_setup':
|
||||
return 'Insert an "Order Line Setup" node earlier in the graph and reconnect the downstream chain.'
|
||||
case 'missing_resolve_template':
|
||||
return 'Add a "Resolve Template" node before render or export nodes to keep graph behavior aligned with legacy.'
|
||||
case 'context_kind_mismatch':
|
||||
case 'invalid_context_kind':
|
||||
return 'Use a context ID that matches the workflow family, or split the workflow into the correct family.'
|
||||
case 'cad_file_only_node':
|
||||
return 'Move this node into a cad_file workflow, or replace it with an order-line-safe module for render graphs.'
|
||||
case 'setup_not_ready':
|
||||
return 'Repair the order-line setup prerequisites and rerun preflight until the setup chain reports ready.'
|
||||
case 'unsupported_node':
|
||||
return 'Keep this path on legacy/bridge execution for now, or replace the node with a supported graph module.'
|
||||
case 'template_missing':
|
||||
return 'Assign an active render template or use a template override on the resolve_template node.'
|
||||
case 'bbox_unresolved':
|
||||
return 'Ensure a reusable GLB exists upstream or provide a valid GLB override for the bounding-box node.'
|
||||
case 'cad_file_missing_path':
|
||||
case 'cad_file_step_missing':
|
||||
return 'Repair the CAD source path before dispatching the graph.'
|
||||
default:
|
||||
if (
|
||||
message.includes('artifact') ||
|
||||
message.includes('produced upstream') ||
|
||||
message.includes('available before this step') ||
|
||||
message.includes('missing upstream')
|
||||
) {
|
||||
return 'Add the missing upstream node or connection so the required artifact is available before this step.'
|
||||
}
|
||||
return 'Resolve this issue before relying on graph dispatch for this workflow.'
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkflowDraftValidationKind(
|
||||
message: string,
|
||||
): WorkflowValidationKind {
|
||||
const normalized = message.toLowerCase()
|
||||
|
||||
if (
|
||||
normalized.includes('context') ||
|
||||
normalized.includes('cad-file and order-line') ||
|
||||
normalized.includes('workflow is cad file based') ||
|
||||
normalized.includes('workflow is order line based')
|
||||
) {
|
||||
return 'context'
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('order line setup') ||
|
||||
normalized.includes('renderable') ||
|
||||
normalized.includes('requires an order-line workflow')
|
||||
) {
|
||||
return 'setup-chain'
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('step path') ||
|
||||
normalized.includes('cad preview') ||
|
||||
normalized.includes('cad input') ||
|
||||
normalized.includes('glb') ||
|
||||
normalized.includes('bbox') ||
|
||||
normalized.includes('geometry')
|
||||
) {
|
||||
return 'data-source'
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('resolve template') ||
|
||||
normalized.includes('legacy behavior') ||
|
||||
normalized.includes('drift from legacy')
|
||||
) {
|
||||
return 'legacy-drift'
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('upstream input') ||
|
||||
normalized.includes('upstream artifact') ||
|
||||
normalized.includes('socket') ||
|
||||
normalized.includes('artifact')
|
||||
) {
|
||||
return 'artifact-flow'
|
||||
}
|
||||
|
||||
return 'general'
|
||||
}
|
||||
|
||||
export function summarizeWorkflowDraftValidationMessages(
|
||||
messages: string[],
|
||||
): Array<{ kind: WorkflowValidationKind; label: string; count: number }> {
|
||||
const counts = new Map<WorkflowValidationKind, number>()
|
||||
|
||||
messages.forEach(message => {
|
||||
const kind = getWorkflowDraftValidationKind(message)
|
||||
counts.set(kind, (counts.get(kind) ?? 0) + 1)
|
||||
})
|
||||
|
||||
return Array.from(counts.entries()).map(([kind, count]) => ({
|
||||
kind,
|
||||
label: getWorkflowValidationKindLabel(kind),
|
||||
count,
|
||||
}))
|
||||
}
|
||||
|
||||
function summarizeValidationEntries(
|
||||
entries: Array<{ kind: WorkflowValidationKind; severity: WorkflowValidationSeverity }>,
|
||||
): WorkflowValidationSummaryItem[] {
|
||||
const counts = new Map<string, WorkflowValidationSummaryItem>()
|
||||
|
||||
entries.forEach(entry => {
|
||||
const key = `${entry.severity}:${entry.kind}`
|
||||
const current = counts.get(key)
|
||||
|
||||
if (current) {
|
||||
current.count += 1
|
||||
return
|
||||
}
|
||||
|
||||
counts.set(key, {
|
||||
kind: entry.kind,
|
||||
label: getWorkflowValidationKindLabel(entry.kind),
|
||||
count: 1,
|
||||
severity: entry.severity,
|
||||
})
|
||||
})
|
||||
|
||||
return Array.from(counts.values()).sort((left, right) => {
|
||||
const severityOrder: Record<WorkflowValidationSeverity, number> = {
|
||||
error: 0,
|
||||
warning: 1,
|
||||
info: 2,
|
||||
}
|
||||
return (
|
||||
severityOrder[left.severity] - severityOrder[right.severity] ||
|
||||
left.label.localeCompare(right.label)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function summarizeWorkflowDraftValidationBySeverity({
|
||||
errors,
|
||||
warnings,
|
||||
infos = [],
|
||||
}: {
|
||||
errors: string[]
|
||||
warnings: string[]
|
||||
infos?: string[]
|
||||
}): WorkflowValidationSummaryItem[] {
|
||||
return summarizeValidationEntries([
|
||||
...errors.map(message => ({
|
||||
kind: getWorkflowDraftValidationKind(message),
|
||||
severity: 'error' as const,
|
||||
})),
|
||||
...warnings.map(message => ({
|
||||
kind: getWorkflowDraftValidationKind(message),
|
||||
severity: 'warning' as const,
|
||||
})),
|
||||
...infos.map(message => ({
|
||||
kind: getWorkflowDraftValidationKind(message),
|
||||
severity: 'info' as const,
|
||||
})),
|
||||
])
|
||||
}
|
||||
|
||||
export function summarizeWorkflowPreflightIssues(
|
||||
issues: Array<Pick<WorkflowPreflightIssue, 'code' | 'message' | 'severity'>>,
|
||||
): WorkflowValidationSummaryItem[] {
|
||||
return summarizeValidationEntries(
|
||||
issues.map(issue => ({
|
||||
kind: getWorkflowValidationKind(issue),
|
||||
severity: issue.severity,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
export function getWorkflowValidationSummaryToneClassName(
|
||||
severity: WorkflowValidationSeverity,
|
||||
): string {
|
||||
switch (severity) {
|
||||
case 'error':
|
||||
return 'border-red-200 bg-red-50 text-red-700 dark:border-red-900/40 dark:bg-red-950/20 dark:text-red-300'
|
||||
case 'warning':
|
||||
return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300'
|
||||
default:
|
||||
return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/40 dark:bg-sky-950/20 dark:text-sky-300'
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Toaster } from 'sonner'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
import { useThemeStore, applyTheme, resolveTheme, type ThemeMode, type AccentKey } from './store/theme'
|
||||
|
||||
/* ---------------------------------------------------------------
|
||||
|
||||
@@ -1,37 +1,10 @@
|
||||
import { useState, useEffect, useMemo, useLayoutEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
Handle,
|
||||
Position,
|
||||
type Node,
|
||||
type Edge,
|
||||
type NodeTypes,
|
||||
} from '@xyflow/react'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
import { useThemeStore, resolveTheme } from '../store/theme'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { WorkflowCanvas } from '../components/workflows/WorkflowCanvas'
|
||||
import { NewWorkflowModal } from '../components/workflows/NewWorkflowModal'
|
||||
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,
|
||||
compareWorkflows,
|
||||
getWorkflowBlueprint,
|
||||
@@ -47,19 +20,8 @@ import {
|
||||
getWorkflowPresetType,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowConfig,
|
||||
type WorkflowExecutionMode,
|
||||
type WorkflowPresetType,
|
||||
} from '../api/workflows'
|
||||
import { updateOutputType } from '../api/outputTypes'
|
||||
import {
|
||||
FileUp,
|
||||
RefreshCw,
|
||||
Camera,
|
||||
Film,
|
||||
Layers,
|
||||
Download,
|
||||
Bell,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
GRAPH_FAMILY_LABELS,
|
||||
@@ -72,707 +34,6 @@ import {
|
||||
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) {
|
||||
switch (iconName) {
|
||||
case 'file-up':
|
||||
return <FileUp size={size} />
|
||||
case 'film':
|
||||
return <Film size={size} />
|
||||
case 'layers':
|
||||
return <Layers size={size} />
|
||||
case 'download':
|
||||
return <Download size={size} />
|
||||
case 'bell':
|
||||
return <Bell size={size} />
|
||||
case 'camera':
|
||||
return <Camera size={size} />
|
||||
case 'refresh-cw':
|
||||
default:
|
||||
return <RefreshCw size={size} />
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Custom Node Components ──────────────────────────────────────────────────
|
||||
|
||||
interface BaseNodeProps {
|
||||
data: WorkflowCanvasNodeData
|
||||
icon: React.ReactNode
|
||||
accentClass: string
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
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={`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,
|
||||
}}
|
||||
>
|
||||
{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">{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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
||||
return (
|
||||
<BaseNode
|
||||
data={data}
|
||||
icon={renderWorkflowIcon(data.icon)}
|
||||
accentClass="text-green-600"
|
||||
selected={selected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ConvertNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
||||
return (
|
||||
<BaseNode
|
||||
data={data}
|
||||
icon={renderWorkflowIcon(data.icon)}
|
||||
accentClass="text-blue-600"
|
||||
selected={selected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProcessNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
||||
return (
|
||||
<BaseNode
|
||||
data={data}
|
||||
icon={renderWorkflowIcon(data.icon)}
|
||||
accentClass="text-sky-600"
|
||||
selected={selected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RenderNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
||||
const params = data.params ?? {}
|
||||
return (
|
||||
<BaseNode
|
||||
data={{
|
||||
...data,
|
||||
description: params.render_engine
|
||||
? `${params.render_engine} · ${params.samples ?? 256} samples`
|
||||
: data.description,
|
||||
}}
|
||||
icon={renderWorkflowIcon(data.icon)}
|
||||
accentClass="text-orange-600"
|
||||
selected={selected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RenderFramesNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
||||
const params = data.params ?? {}
|
||||
return (
|
||||
<BaseNode
|
||||
data={{
|
||||
...data,
|
||||
description: params.fps ? `${params.fps} fps · ${params.duration_s ?? '?'}s` : data.description,
|
||||
}}
|
||||
icon={renderWorkflowIcon(data.icon)}
|
||||
accentClass="text-orange-600"
|
||||
selected={selected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function OutputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
||||
return (
|
||||
<BaseNode
|
||||
data={data}
|
||||
icon={renderWorkflowIcon(data.icon)}
|
||||
accentClass="text-slate-600"
|
||||
selected={selected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
inputNode: InputNode as any,
|
||||
convertNode: ConvertNode as any,
|
||||
processNode: ProcessNode as any,
|
||||
renderNode: RenderNode as any,
|
||||
renderFramesNode: RenderFramesNode as any,
|
||||
outputNode: OutputNode as any,
|
||||
}
|
||||
|
||||
const EXECUTION_MODE_HINTS: Record<WorkflowExecutionMode, string> = {
|
||||
legacy: 'Preset dispatcher remains authoritative for production runs.',
|
||||
graph: 'Production dispatch uses graph runtime with hard fallback to legacy on failure.',
|
||||
shadow: 'Currently stored and exposed, but production dispatch still falls back to legacy until shadow parity lands.',
|
||||
}
|
||||
|
||||
// ─── Flow Canvas ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface FlowCanvasProps {
|
||||
workflow: WorkflowDefinition
|
||||
onSave: (config: WorkflowConfig) => void
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const {
|
||||
reactFlowWrapper,
|
||||
nodeDefinitions,
|
||||
nodeDefinitionsByStep,
|
||||
nodes,
|
||||
edges,
|
||||
onNodesChange,
|
||||
onEdgesChange,
|
||||
selectedEdgeIds,
|
||||
selectedNode,
|
||||
workflowRuns,
|
||||
selectedRun,
|
||||
setSelectedRunId,
|
||||
selectedRunComparison,
|
||||
isComparisonLoading,
|
||||
dispatchMutation,
|
||||
preflightMutation,
|
||||
dispatchContextId,
|
||||
setDispatchContextId,
|
||||
isOrderLineGraph,
|
||||
isOrderLineContextsLoading,
|
||||
orderLineContextGroups,
|
||||
dispatchContextLabel,
|
||||
dispatchContextSummary,
|
||||
dispatchContextMeta,
|
||||
preflightResult,
|
||||
preflightState,
|
||||
hasFreshSuccessfulPreflight,
|
||||
executionMode,
|
||||
setExecutionMode,
|
||||
nodeMenuAnchor,
|
||||
setNodeMenuAnchor,
|
||||
activeUtilityTab,
|
||||
setActiveUtilityTab,
|
||||
validation,
|
||||
authoringFamily,
|
||||
graphFamily,
|
||||
onConnect,
|
||||
onNodeClick,
|
||||
onEdgeClick,
|
||||
onPaneClick,
|
||||
handleSelectionChange,
|
||||
handleParamsChange,
|
||||
handlePipelineStepChange,
|
||||
handlePaneContextMenu,
|
||||
handleNodeContextMenu,
|
||||
insertNode,
|
||||
insertModuleBundle,
|
||||
insertReferenceBundle,
|
||||
handleOpenToolbarNodeMenu,
|
||||
handleAutoLayout,
|
||||
handleDeleteSelectedEdges,
|
||||
onEdgeContextMenu,
|
||||
onEdgeDoubleClick,
|
||||
handleSave,
|
||||
handleDispatch,
|
||||
handlePreflight,
|
||||
setReactFlowInstance,
|
||||
} = useWorkflowCanvasController({ workflow, onSave })
|
||||
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 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, HORIZONTAL_MARGIN),
|
||||
Math.max(window.innerWidth - width - HORIZONTAL_MARGIN, HORIZONTAL_MARGIN),
|
||||
)
|
||||
const top = Math.min(
|
||||
Math.max(nodeMenuAnchor.clientY, VERTICAL_MARGIN),
|
||||
Math.max(window.innerHeight - height - VERTICAL_MARGIN, VERTICAL_MARGIN),
|
||||
)
|
||||
|
||||
return { left, top }
|
||||
}, [nodeMenuAnchor, nodeMenuSize.height, nodeMenuSize.width])
|
||||
|
||||
const { mode } = useThemeStore()
|
||||
const isDark = resolveTheme(mode) === 'dark'
|
||||
const canvasEdges = useMemo(
|
||||
() =>
|
||||
edges.map(edge => ({
|
||||
...edge,
|
||||
selectable: true,
|
||||
focusable: true,
|
||||
interactionWidth: (edge as Edge & { interactionWidth?: number }).interactionWidth ?? 44,
|
||||
style: {
|
||||
...(edge.style ?? {}),
|
||||
strokeWidth: edge.selected ? 3.25 : ((edge.style?.strokeWidth as number | undefined) ?? 2.25),
|
||||
stroke: edge.selected ? (isDark ? '#f59e0b' : '#d97706') : edge.style?.stroke,
|
||||
opacity: edge.selected ? 1 : 0.95,
|
||||
},
|
||||
zIndex: edge.selected ? 20 : edge.zIndex,
|
||||
})),
|
||||
[edges, isDark],
|
||||
)
|
||||
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">
|
||||
<WorkflowCanvasToolbar
|
||||
workflowName={workflow.name}
|
||||
blueprintLabel={getWorkflowBlueprint(workflow.config) ? BLUEPRINT_LABELS[getWorkflowBlueprint(workflow.config)!] ?? 'Blueprint' : null}
|
||||
blueprintDescription={
|
||||
getWorkflowBlueprint(workflow.config)
|
||||
? 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)}
|
||||
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 h-full flex-1 min-h-0 flex-col xl:flex-row">
|
||||
<div
|
||||
ref={reactFlowWrapper}
|
||||
className="relative flex min-h-[680px] min-w-0 flex-1 overflow-hidden xl:h-full"
|
||||
onContextMenu={event => event.preventDefault()}
|
||||
>
|
||||
{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"
|
||||
>
|
||||
<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
|
||||
activeTab={activeUtilityTab}
|
||||
onTabChange={setActiveUtilityTab}
|
||||
selectedNode={selectedNode ? { data: selectedNode.data as WorkflowCanvasNodeData | undefined } : null}
|
||||
onNodeParamsChange={handleParamsChange}
|
||||
onNodeStepChange={handlePipelineStepChange}
|
||||
nodeDefinitions={nodeDefinitions}
|
||||
nodeDefinitionsByStep={nodeDefinitionsByStep}
|
||||
graphFamily={authoringFamily}
|
||||
activeSteps={activeSteps}
|
||||
authoringActions={authoringActions}
|
||||
renderNodeIcon={renderWorkflowIcon}
|
||||
workflowRuns={workflowRuns}
|
||||
selectedRunId={selectedRun?.id ?? null}
|
||||
onSelectRun={setSelectedRunId}
|
||||
comparison={selectedRunComparison}
|
||||
isComparisonLoading={isComparisonLoading}
|
||||
preflightResult={preflightResult}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function WorkflowEditor() {
|
||||
const queryClient = useQueryClient()
|
||||
@@ -935,7 +196,7 @@ export default function WorkflowEditor() {
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Canvas or Empty State */}
|
||||
{selectedWorkflow ? (
|
||||
<FlowCanvas
|
||||
<WorkflowCanvas
|
||||
key={selectedWorkflow.id}
|
||||
workflow={selectedWorkflow}
|
||||
onSave={config => updateMutation.mutate({ id: selectedWorkflow.id, config })}
|
||||
|
||||
Reference in New Issue
Block a user