Files
HartOMat/frontend/src/api/workflows.ts
T

566 lines
18 KiB
TypeScript

import api from './client'
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 interface WorkflowDefinition {
id: string
name: string
output_type_id: string | null
config: WorkflowConfig
family: WorkflowNodeFamily | 'mixed' | null
is_active: boolean
created_at: string
}
export interface WorkflowConfig {
version: number
nodes: WorkflowNode[]
edges: WorkflowEdge[]
ui?: WorkflowUi
}
export interface WorkflowParams {
[key: string]: unknown
use_custom_render_settings?: boolean
render_engine?: 'cycles' | 'eevee'
samples?: number
resolution?: [number, number]
fps?: number
duration_s?: number
angles?: number[]
rotation_z?: number
width?: number
height?: number
}
export interface WorkflowNode {
id: string
step: string
params: WorkflowParams
ui?: WorkflowNodeUi
}
export interface WorkflowNodeUi {
type?: string
position?: { x: number; y: number }
label?: string
}
export interface WorkflowEdge {
from: string
to: string
}
export interface WorkflowUi {
preset?: WorkflowPresetType
execution_mode?: WorkflowExecutionMode
family?: WorkflowNodeFamily | 'mixed'
blueprint?: string
}
export interface WorkflowCreate {
name: string
output_type_id?: string | null
config: WorkflowConfig
is_active?: boolean
}
export interface WorkflowRun {
id: string
workflow_def_id: string | null
order_line_id: string | null
celery_task_id: string | null
execution_mode: WorkflowExecutionMode
status: 'pending' | 'running' | 'completed' | 'failed'
started_at: string | null
completed_at: string | null
error_message: string | null
created_at: string
node_results: WorkflowNodeResult[]
}
export interface WorkflowNodeResult {
id: string
node_name: string
status: string
output: Record<string, unknown> | null
log: string | null
duration_s: number | null
created_at: string
}
export interface WorkflowDispatchResponse {
workflow_run: WorkflowRun
context_id: string
execution_mode: WorkflowExecutionMode
dispatched: number
task_ids: string[]
}
export interface WorkflowPreflightIssue {
severity: 'error' | 'warning' | 'info'
code: string
message: string
node_id: string | null
step: string | null
}
export interface WorkflowPreflightNode {
node_id: string
step: string
label: string | null
execution_kind: WorkflowNodeExecutionKind
supported: boolean
status: 'ready' | 'warning' | 'error' | 'unsupported'
issues: WorkflowPreflightIssue[]
}
export interface WorkflowPreflightResponse {
workflow_id: string | null
context_id: string
context_kind: 'order_line' | 'cad_file' | null
expected_context_kind: 'order_line' | 'cad_file'
execution_mode: WorkflowExecutionMode
graph_dispatch_allowed: boolean
summary: string
resolved_order_line_id: string | null
resolved_cad_file_id: string | null
unsupported_node_ids: string[]
issues: WorkflowPreflightIssue[]
nodes: WorkflowPreflightNode[]
}
export interface WorkflowDraftPreflightRequest {
workflow_id?: string | null
context_id: string
config: WorkflowConfig
}
export interface WorkflowDraftDispatchRequest {
workflow_id?: string | null
context_id: string
config: WorkflowConfig
}
export interface WorkflowComparisonArtifact {
path: string | null
storage_key: string | null
exists: boolean
file_size_bytes: number | null
sha256: string | null
mime_type: string | null
image_width: number | null
image_height: number | null
}
export interface WorkflowRunComparison {
workflow_run_id: string
workflow_def_id: string | null
order_line_id: string | null
execution_mode: WorkflowExecutionMode
status: string
summary: string
authoritative_output: WorkflowComparisonArtifact
observer_output: WorkflowComparisonArtifact
exact_match: boolean | null
dimensions_match: boolean | null
mean_pixel_delta: number | null
}
export const getWorkflows = (): Promise<WorkflowDefinition[]> =>
api.get('/workflows').then(r => r.data.map(normalizeWorkflowDefinition))
export const getWorkflow = (id: string): Promise<WorkflowDefinition> =>
api.get(`/workflows/${id}`).then(r => normalizeWorkflowDefinition(r.data))
export const createWorkflow = (data: WorkflowCreate): Promise<WorkflowDefinition> =>
api.post('/workflows', data).then(r => normalizeWorkflowDefinition(r.data))
export const updateWorkflow = (id: string, data: Partial<WorkflowCreate>): Promise<WorkflowDefinition> =>
api.put(`/workflows/${id}`, data).then(r => normalizeWorkflowDefinition(r.data))
export const deleteWorkflow = (id: string): Promise<void> =>
api.delete(`/workflows/${id}`).then(() => undefined)
export const getWorkflowRuns = (workflowId: string): Promise<WorkflowRun[]> =>
api.get(`/workflows/${workflowId}/runs`).then(r => r.data)
export const dispatchWorkflow = (
workflowId: string,
contextId: string,
): Promise<WorkflowDispatchResponse> =>
api.post(`/workflows/${workflowId}/dispatch`, undefined, { params: { context_id: contextId } }).then(r => r.data)
export const dispatchWorkflowDraft = (
data: WorkflowDraftDispatchRequest,
): Promise<WorkflowDispatchResponse> =>
api.post('/workflows/dispatch', data).then(r => r.data)
export const preflightWorkflow = (
workflowId: string,
contextId: string,
): Promise<WorkflowPreflightResponse> =>
api.get(`/workflows/${workflowId}/preflight`, { params: { context_id: contextId } }).then(r => r.data)
export const preflightWorkflowDraft = (
data: WorkflowDraftPreflightRequest,
): Promise<WorkflowPreflightResponse> =>
api.post('/workflows/preflight', data).then(r => r.data)
export const getWorkflowRunComparison = (runId: string): Promise<WorkflowRunComparison> =>
api.get(`/workflows/runs/${runId}/comparison`).then(r => r.data)
// ─── Node Definitions / Pipeline Steps ───────────────────────────────────────
export type StepCategory = 'input' | 'processing' | 'rendering' | 'output'
export type WorkflowNodeFieldType = 'number' | 'select' | 'boolean' | 'text'
export type WorkflowNodeExecutionKind = 'native' | 'bridge'
export interface WorkflowNodeFieldOption {
value: string | number | boolean
label: string
}
export interface WorkflowNodeFieldDefinition {
key: string
label: string
type: WorkflowNodeFieldType
description: string
section: string
default: unknown
min: number | null
max: number | null
step: number | null
unit: string | null
options: WorkflowNodeFieldOption[]
}
export type WorkflowNodeFamily = 'cad_file' | 'order_line'
export interface WorkflowNodeDefinition {
step: string
label: string
family: WorkflowNodeFamily
module_key: string
category: StepCategory
description: string
node_type: string
icon: string
defaults: WorkflowParams
fields: WorkflowNodeFieldDefinition[]
execution_kind: WorkflowNodeExecutionKind
legacy_compatible: boolean
input_contract: Record<string, unknown>
output_contract: Record<string, unknown>
artifact_roles_produced: string[]
artifact_roles_consumed: string[]
legacy_source: string | null
}
export interface WorkflowNodeDefinitionsResponse {
definitions: WorkflowNodeDefinition[]
}
export interface PipelineStep {
name: string
label: string
category: StepCategory
description: string
}
export interface PipelineStepsResponse {
steps: PipelineStep[]
}
export const getNodeDefinitions = (): Promise<WorkflowNodeDefinitionsResponse> =>
api.get('/workflows/node-definitions').then(r => r.data)
export const getPipelineSteps = (): Promise<PipelineStepsResponse> =>
api.get('/workflows/pipeline-steps').then(r => r.data)
function buildStillGraphNodes(renderParams: WorkflowParams): { nodes: WorkflowNode[]; edges: WorkflowEdge[] } {
return {
nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 160 } } },
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 160 } } },
{
id: 'populate_materials',
step: 'auto_populate_materials',
params: {},
ui: { type: 'processNode', label: 'Auto Populate Materials', position: { x: 220, y: 320 } },
},
{ id: 'bbox', step: 'glb_bbox', params: {}, ui: { type: 'processNode', label: 'Compute Bounding Box', position: { x: 220, y: 40 } } },
{
id: 'resolve_materials',
step: 'material_map_resolve',
params: {},
ui: { type: 'processNode', label: 'Resolve Material Map', position: { x: 440, y: 200 } },
},
{
id: 'render',
step: 'blender_still',
params: { use_custom_render_settings: true, ...renderParams },
ui: { type: 'renderNode', label: 'Still Render', position: { x: 680, y: 160 } },
},
{ id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 920, y: 120 } } },
{ id: 'notify', step: 'notify', params: {}, ui: { type: 'outputNode', label: 'Notify Result', position: { x: 920, y: 220 } } },
],
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' },
],
}
}
function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams = {}): WorkflowConfig {
const renderParams = { ...params }
const resolution = Array.isArray(renderParams.resolution) ? renderParams.resolution : undefined
if (resolution && resolution.length === 2) {
renderParams.width = Number(resolution[0])
renderParams.height = Number(resolution[1])
delete renderParams.resolution
}
if (type === 'still') {
return {
version: 1,
ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } },
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 100 } } },
{ id: 'render', step: 'blender_still', params: renderParams, ui: { type: 'renderNode', label: 'Still Render', position: { x: 440, y: 100 } } },
{ id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 660, y: 100 } } },
],
edges: [
{ from: 'setup', to: 'template' },
{ from: 'template', to: 'render' },
{ from: 'render', to: 'output' },
],
}
}
if (type === 'still_graph') {
const { nodes, edges } = buildStillGraphNodes(renderParams)
return {
version: 1,
ui: { preset: type, execution_mode: 'graph', family: 'order_line' },
nodes,
edges,
}
}
if (type === 'turntable') {
return {
version: 1,
ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } },
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 100 } } },
{ id: 'turntable', step: 'blender_turntable', params: renderParams, ui: { type: 'renderFramesNode', label: 'Turntable Render', position: { x: 440, y: 100 } } },
{ id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 660, y: 100 } } },
],
edges: [
{ from: 'setup', to: 'template' },
{ from: 'template', to: 'turntable' },
{ from: 'turntable', to: 'output' },
],
}
}
if (type === 'multi_angle') {
const angles = (params.angles ?? [0, 45, 90]).map(Number)
const sharedParams = { ...renderParams }
delete sharedParams.angles
return {
version: 1,
ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 195 } } },
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 195 } } },
...angles.map((angle, index) => ({
id: `render_${index}`,
step: 'blender_still',
params: { ...sharedParams, rotation_z: angle },
ui: { type: 'renderNode', label: `Render ${angle}°`, position: { x: 440, y: index * 130 } },
})),
{ id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 700, y: 195 } } },
],
edges: [
{ from: 'setup', to: 'template' },
...angles.map((_, index) => ({ from: 'template', to: `render_${index}` })),
...angles.map((_, index) => ({ from: `render_${index}`, to: 'output' })),
],
}
}
if (type === 'still_with_exports') {
return {
version: 1,
ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } },
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 100 } } },
{ id: 'render', step: 'blender_still', params: renderParams, ui: { type: 'renderNode', label: 'Still Render', position: { x: 440, y: 100 } } },
{ id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 660, y: 70 } } },
{ id: 'blend', step: 'export_blend', params: {}, ui: { type: 'outputNode', label: 'Export Blend', position: { x: 660, y: 160 } } },
],
edges: [
{ from: 'setup', to: 'template' },
{ from: 'template', to: 'render' },
{ from: 'render', to: 'output' },
{ from: 'render', to: 'blend' },
],
}
}
return {
version: 1,
ui: { preset: 'custom', execution_mode: 'legacy', family: 'order_line' },
nodes: [
{
id: 'setup',
step: 'order_line_setup',
params: {},
ui: { label: 'Order Line Setup', position: { x: 120, y: 140 } },
},
],
edges: [],
}
}
function normalizeWorkflowDefinition(raw: WorkflowDefinition): WorkflowDefinition {
const config = normalizeWorkflowConfig(raw.config as unknown as Record<string, unknown>)
return {
...raw,
family: raw.family ?? inferWorkflowFamily(config),
config,
}
}
export function normalizeWorkflowConfig(raw: Record<string, unknown>): WorkflowConfig {
if ('version' in raw && Array.isArray(raw.nodes)) {
const rawUi = (raw.ui as WorkflowUi | undefined) ?? {}
const nodes = (raw.nodes as WorkflowNode[]).map(node => ({
...node,
params: { ...(node.params ?? {}) },
}))
const edges = Array.isArray(raw.edges) ? (raw.edges as WorkflowEdge[]) : []
return {
version: Number(raw.version ?? 1),
nodes,
edges,
ui: {
...rawUi,
execution_mode: rawUi.execution_mode ?? 'legacy',
family: rawUi.family ?? inferWorkflowFamily({ version: Number(raw.version ?? 1), nodes, edges }),
},
}
}
if (typeof raw.type === 'string') {
return migratePresetConfig(raw.type as WorkflowPresetType, (raw.params as WorkflowParams | undefined) ?? {})
}
return {
version: 1,
nodes: [],
edges: [],
ui: { preset: 'custom', execution_mode: 'legacy' },
}
}
export function createPresetWorkflowConfig(type: WorkflowPresetType, params: WorkflowParams = {}): WorkflowConfig {
return migratePresetConfig(type, params)
}
export function createStarterWorkflowConfig(family: WorkflowStarterFamily = 'order_line'): WorkflowConfig {
if (family === 'cad_file') {
return {
version: 1,
ui: {
preset: 'custom',
execution_mode: 'legacy',
family: 'cad_file',
blueprint: 'starter_cad_intake',
},
nodes: [
{
id: 'resolve_step',
step: 'resolve_step_path',
params: {},
ui: { type: 'inputNode', label: 'Resolve STEP Path', position: { x: 120, y: 140 } },
},
],
edges: [],
}
}
return {
version: 1,
ui: {
preset: 'custom',
execution_mode: 'legacy',
family: 'order_line',
blueprint: 'starter_order_rendering',
},
nodes: [
{
id: 'setup',
step: 'order_line_setup',
params: {},
ui: { type: 'processNode', label: 'Order Line Setup', position: { x: 120, y: 140 } },
},
],
edges: [],
}
}
export function getWorkflowPresetType(config: WorkflowConfig): WorkflowPresetType {
return config.ui?.preset ?? 'custom'
}
export function inferWorkflowFamily(config: WorkflowConfig): WorkflowNodeFamily | 'mixed' | null {
const families = new Set(
config.nodes
.map(node => {
switch (node.step) {
case 'resolve_step_path':
case 'occ_object_extract':
case 'occ_glb_export':
case 'stl_cache_generate':
case 'blender_render':
case 'threejs_render':
case 'thumbnail_save':
return 'cad_file'
case 'order_line_setup':
case 'resolve_template':
case 'material_map_resolve':
case 'auto_populate_materials':
case 'glb_bbox':
case 'blender_still':
case 'blender_turntable':
case 'output_save':
case 'export_blend':
case 'notify':
return 'order_line'
default:
return null
}
})
.filter((family): family is WorkflowNodeFamily => family !== null),
)
if (families.size === 0) return null
if (families.size > 1) return 'mixed'
return Array.from(families)[0]
}