From 01e7cb231073823a0605bbcf46fefb093efbc120 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 23 Dec 2025 11:33:50 -0500 Subject: [PATCH] chore: remove broken workflow files temporarily MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Flowy workflow files have TypeScript errors that prevent builds. Removing them entirely until they can be properly fixed and tested. Files removed: - src/components/workflow/ - src/css/workflow.css - src/lib/workflow/ - src/propagators/WorkflowPropagator.ts - src/shapes/WorkflowBlockShapeUtil.tsx - src/tools/WorkflowBlockTool.ts The commented-out imports in Board.tsx and CustomToolbar.tsx remain as documentation of what needs to be re-added. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/workflow/WorkflowPalette.tsx | 497 ----------- src/css/workflow.css | 589 ------------- src/lib/workflow/blockRegistry.ts | 881 -------------------- src/lib/workflow/executor.ts | 731 ---------------- src/lib/workflow/portBindings.ts | 468 ----------- src/lib/workflow/serialization.ts | 659 --------------- src/lib/workflow/types.ts | 385 --------- src/lib/workflow/validation.ts | 466 ----------- src/propagators/WorkflowPropagator.ts | 404 --------- src/shapes/WorkflowBlockShapeUtil.tsx | 539 ------------ src/tools/WorkflowBlockTool.ts | 204 ----- 11 files changed, 5823 deletions(-) delete mode 100644 src/components/workflow/WorkflowPalette.tsx delete mode 100644 src/css/workflow.css delete mode 100644 src/lib/workflow/blockRegistry.ts delete mode 100644 src/lib/workflow/executor.ts delete mode 100644 src/lib/workflow/portBindings.ts delete mode 100644 src/lib/workflow/serialization.ts delete mode 100644 src/lib/workflow/types.ts delete mode 100644 src/lib/workflow/validation.ts delete mode 100644 src/propagators/WorkflowPropagator.ts delete mode 100644 src/shapes/WorkflowBlockShapeUtil.tsx delete mode 100644 src/tools/WorkflowBlockTool.ts diff --git a/src/components/workflow/WorkflowPalette.tsx b/src/components/workflow/WorkflowPalette.tsx deleted file mode 100644 index eba50fd..0000000 --- a/src/components/workflow/WorkflowPalette.tsx +++ /dev/null @@ -1,497 +0,0 @@ -/** - * WorkflowPalette - * - * A sidebar panel displaying available workflow blocks organized by category. - * Users can click on blocks to enter placement mode or drag them onto the canvas. - */ - -import React, { useState, useCallback, useMemo } from 'react' -import { useEditor } from 'tldraw' -import { - getAllBlockDefinitions, - getBlocksByCategory, -} from '@/lib/workflow/blockRegistry' -import { - BlockCategory, - BlockDefinition, - CATEGORY_INFO, -} from '@/lib/workflow/types' -import { setWorkflowBlockType } from '@/tools/WorkflowBlockTool' -import { executeWorkflow, resetWorkflow } from '@/lib/workflow/executor' -import { setAutoExecute, isAutoExecuteEnabled } from '@/propagators/WorkflowPropagator' - -// ============================================================================= -// Types -// ============================================================================= - -interface WorkflowPaletteProps { - isOpen: boolean - onClose: () => void -} - -// ============================================================================= -// Block Card Component -// ============================================================================= - -interface BlockCardProps { - definition: BlockDefinition - onSelect: (blockType: string) => void -} - -const BlockCard: React.FC = ({ definition, onSelect }) => { - const [isHovered, setIsHovered] = useState(false) - const categoryInfo = CATEGORY_INFO[definition.category] - - return ( -
onSelect(definition.type)} - onMouseEnter={() => setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - style={{ - padding: '10px 12px', - backgroundColor: isHovered ? 'rgba(0, 0, 0, 0.05)' : 'transparent', - borderRadius: 6, - cursor: 'pointer', - transition: 'background-color 0.15s ease', - display: 'flex', - alignItems: 'flex-start', - gap: 10, - borderLeft: `3px solid ${categoryInfo.color}`, - marginBottom: 4, - }} - > - {definition.icon} -
-
- {definition.name} -
-
- {definition.description} -
-
- {definition.inputs.length} inputs - {definition.outputs.length} outputs -
-
-
- ) -} - -// ============================================================================= -// Category Section Component -// ============================================================================= - -interface CategorySectionProps { - category: BlockCategory - blocks: BlockDefinition[] - isExpanded: boolean - onToggle: () => void - onSelectBlock: (blockType: string) => void -} - -const CategorySection: React.FC = ({ - category, - blocks, - isExpanded, - onToggle, - onSelectBlock, -}) => { - const categoryInfo = CATEGORY_INFO[category] - - return ( -
-
- - - {categoryInfo.label} - - - {blocks.length} - - - ▶ - -
- - {isExpanded && ( -
- {blocks.map((block) => ( - - ))} -
- )} -
- ) -} - -// ============================================================================= -// Main Palette Component -// ============================================================================= - -export const WorkflowPalette: React.FC = ({ - isOpen, - onClose, -}) => { - const editor = useEditor() - const [searchQuery, setSearchQuery] = useState('') - const [expandedCategories, setExpandedCategories] = useState>( - new Set(['trigger', 'action']) - ) - const [autoExecute, setAutoExecuteState] = useState(isAutoExecuteEnabled()) - const [isExecuting, setIsExecuting] = useState(false) - - // Get all blocks grouped by category - const blocksByCategory = useMemo(() => { - const categories: BlockCategory[] = ['trigger', 'action', 'condition', 'transformer', 'ai', 'output'] - const result: Record = {} as any - - for (const category of categories) { - const blocks = getBlocksByCategory(category) - - // Filter by search query - if (searchQuery) { - const query = searchQuery.toLowerCase() - result[category] = blocks.filter( - (b) => - b.name.toLowerCase().includes(query) || - b.description.toLowerCase().includes(query) || - b.type.toLowerCase().includes(query) - ) - } else { - result[category] = blocks - } - } - - return result - }, [searchQuery]) - - // Toggle category expansion - const toggleCategory = useCallback((category: BlockCategory) => { - setExpandedCategories((prev) => { - const next = new Set(prev) - if (next.has(category)) { - next.delete(category) - } else { - next.add(category) - } - return next - }) - }, []) - - // Handle block selection - const handleSelectBlock = useCallback( - (blockType: string) => { - setWorkflowBlockType(blockType) - editor.setCurrentTool('WorkflowBlock') - }, - [editor] - ) - - // Handle run workflow - const handleRunWorkflow = useCallback(async () => { - setIsExecuting(true) - try { - await executeWorkflow(editor, { - onProgress: (completed, total) => { - console.log(`Workflow progress: ${completed}/${total}`) - }, - }) - } finally { - setIsExecuting(false) - } - }, [editor]) - - // Handle reset workflow - const handleResetWorkflow = useCallback(() => { - resetWorkflow(editor) - }, [editor]) - - // Toggle auto-execute - const handleToggleAutoExecute = useCallback(() => { - const newValue = !autoExecute - setAutoExecuteState(newValue) - setAutoExecute(newValue) - }, [autoExecute]) - - if (!isOpen) return null - - const categories: BlockCategory[] = ['trigger', 'action', 'condition', 'transformer', 'ai', 'output'] - - return ( -
- {/* Header */} -
-
- - - Workflow Blocks - -
- -
- - {/* Search */} -
- setSearchQuery(e.target.value)} - style={{ - width: '100%', - padding: '8px 12px', - fontSize: 13, - border: '1px solid #e5e7eb', - borderRadius: 6, - outline: 'none', - backgroundColor: '#f9fafb', - }} - /> -
- - {/* Execution Controls */} -
- - -
- - {/* Auto-execute Toggle */} -
- - Real-time propagation - - -
- - {/* Block Categories */} -
- {categories.map((category) => { - const blocks = blocksByCategory[category] - if (blocks.length === 0 && searchQuery) return null - - return ( - toggleCategory(category)} - onSelectBlock={handleSelectBlock} - /> - ) - })} -
- - {/* Footer */} -
- Click a block, then click on canvas to place -
-
- ) -} - -export default WorkflowPalette diff --git a/src/css/workflow.css b/src/css/workflow.css deleted file mode 100644 index f3bd429..0000000 --- a/src/css/workflow.css +++ /dev/null @@ -1,589 +0,0 @@ -/** - * Workflow Builder Styles - * - * Styles for the Flowy-like workflow builder system including - * workflow blocks, ports, palette, and execution states. - */ - -/* ============================================================================= - Workflow Block Base Styles - ============================================================================= */ - -.workflow-block { - position: relative; - border-radius: 8px; - background: white; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - transition: box-shadow 0.2s ease, transform 0.1s ease; -} - -.workflow-block:hover { - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); -} - -.workflow-block.selected { - box-shadow: 0 0 0 2px var(--workflow-category-color, #6366f1), - 0 4px 16px rgba(0, 0, 0, 0.15); -} - -.workflow-block.executing { - animation: workflow-pulse 1.5s ease-in-out infinite; -} - -@keyframes workflow-pulse { - 0%, 100% { - box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.4), - 0 4px 16px rgba(0, 0, 0, 0.15); - } - 50% { - box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.2), - 0 4px 16px rgba(0, 0, 0, 0.15); - } -} - -/* ============================================================================= - Block Header - ============================================================================= */ - -.workflow-block-header { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 12px; - border-bottom: 1px solid #e5e7eb; - background: linear-gradient(180deg, rgba(0, 0, 0, 0.02) 0%, transparent 100%); - border-radius: 8px 8px 0 0; -} - -.workflow-block-icon { - font-size: 16px; - line-height: 1; -} - -.workflow-block-title { - flex: 1; - font-size: 13px; - font-weight: 600; - color: #1f2937; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.workflow-block-category { - padding: 2px 8px; - border-radius: 4px; - font-size: 10px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -/* ============================================================================= - Port Styles - ============================================================================= */ - -.workflow-port { - position: absolute; - width: 12px; - height: 12px; - border-radius: 50%; - border: 2px solid; - background: white; - cursor: crosshair; - transition: transform 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease; - z-index: 10; -} - -.workflow-port:hover { - transform: scale(1.3); -} - -.workflow-port.connected { - background-color: currentColor; -} - -.workflow-port.compatible { - box-shadow: 0 0 8px currentColor; -} - -.workflow-port.incompatible { - opacity: 0.3; - cursor: not-allowed; -} - -/* Port type colors */ -.workflow-port.type-text { color: #3b82f6; } -.workflow-port.type-number { color: #10b981; } -.workflow-port.type-boolean { color: #f59e0b; } -.workflow-port.type-object { color: #8b5cf6; } -.workflow-port.type-array { color: #06b6d4; } -.workflow-port.type-any { color: #6b7280; } -.workflow-port.type-file { color: #ec4899; } -.workflow-port.type-image { color: #f97316; } - -/* Port labels */ -.workflow-port-label { - position: absolute; - font-size: 11px; - color: #4b5563; - white-space: nowrap; - pointer-events: none; - display: flex; - align-items: center; - gap: 4px; -} - -.workflow-port-label.input { - left: 16px; -} - -.workflow-port-label.output { - right: 16px; - text-align: right; -} - -.workflow-port-label-dot { - width: 6px; - height: 6px; - border-radius: 50%; - opacity: 0.6; -} - -/* ============================================================================= - Execution States - ============================================================================= */ - -.workflow-execution-badge { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 8px; - border-radius: 4px; - font-size: 10px; - font-weight: 500; - text-transform: uppercase; -} - -.workflow-execution-badge.idle { - background: transparent; - color: #6b7280; -} - -.workflow-execution-badge.running { - background: #fef3c7; - border: 1px solid #f59e0b; - color: #92400e; -} - -.workflow-execution-badge.success { - background: #d1fae5; - border: 1px solid #10b981; - color: #065f46; -} - -.workflow-execution-badge.error { - background: #fee2e2; - border: 1px solid #ef4444; - color: #991b1b; -} - -/* ============================================================================= - Workflow Palette - ============================================================================= */ - -.workflow-palette { - position: fixed; - top: 60px; - left: 10px; - width: 280px; - max-height: calc(100vh - 120px); - background: white; - border-radius: 12px; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); - border: 1px solid #e5e7eb; - display: flex; - flex-direction: column; - z-index: 1000; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; -} - -.workflow-palette-header { - padding: 14px 16px; - border-bottom: 1px solid #e5e7eb; - display: flex; - align-items: center; - justify-content: space-between; -} - -.workflow-palette-title { - display: flex; - align-items: center; - gap: 8px; - font-size: 14px; - font-weight: 600; - color: #1f2937; -} - -.workflow-palette-close { - background: none; - border: none; - font-size: 18px; - color: #9ca3af; - cursor: pointer; - padding: 4px; - line-height: 1; -} - -.workflow-palette-close:hover { - color: #4b5563; -} - -.workflow-palette-search { - padding: 10px 12px; - border-bottom: 1px solid #e5e7eb; -} - -.workflow-palette-search input { - width: 100%; - padding: 8px 12px; - font-size: 13px; - border: 1px solid #e5e7eb; - border-radius: 6px; - outline: none; - background: #f9fafb; -} - -.workflow-palette-search input:focus { - border-color: #6366f1; - background: white; -} - -.workflow-palette-controls { - padding: 10px 12px; - border-bottom: 1px solid #e5e7eb; - display: flex; - gap: 8px; -} - -.workflow-palette-run-btn { - flex: 1; - padding: 8px 12px; - background: #10b981; - color: white; - border: none; - border-radius: 6px; - font-size: 12px; - font-weight: 500; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - transition: background-color 0.15s ease; -} - -.workflow-palette-run-btn:hover { - background: #059669; -} - -.workflow-palette-run-btn:disabled { - background: #9ca3af; - cursor: not-allowed; -} - -.workflow-palette-reset-btn { - padding: 8px 12px; - background: #f3f4f6; - color: #374151; - border: 1px solid #e5e7eb; - border-radius: 6px; - font-size: 12px; - font-weight: 500; - cursor: pointer; -} - -.workflow-palette-reset-btn:hover { - background: #e5e7eb; -} - -.workflow-palette-content { - flex: 1; - overflow-y: auto; - padding: 12px; -} - -.workflow-palette-footer { - padding: 10px 12px; - border-top: 1px solid #e5e7eb; - font-size: 11px; - color: #9ca3af; - text-align: center; -} - -/* ============================================================================= - Category Sections - ============================================================================= */ - -.workflow-category { - margin-bottom: 8px; -} - -.workflow-category-header { - padding: 8px 12px; - background: rgba(0, 0, 0, 0.02); - border-radius: 6px; - cursor: pointer; - display: flex; - align-items: center; - gap: 8px; - user-select: none; -} - -.workflow-category-header:hover { - background: rgba(0, 0, 0, 0.04); -} - -.workflow-category-dot { - width: 8px; - height: 8px; - border-radius: 50%; -} - -.workflow-category-label { - font-size: 12px; - font-weight: 600; - color: #374151; - flex: 1; -} - -.workflow-category-count { - font-size: 10px; - color: #9ca3af; - margin-right: 4px; -} - -.workflow-category-arrow { - font-size: 12px; - color: #9ca3af; - transition: transform 0.15s ease; -} - -.workflow-category.expanded .workflow-category-arrow { - transform: rotate(90deg); -} - -.workflow-category-blocks { - margin-top: 4px; - padding-left: 4px; -} - -/* ============================================================================= - Block Cards in Palette - ============================================================================= */ - -.workflow-block-card { - padding: 10px 12px; - border-radius: 6px; - cursor: pointer; - display: flex; - align-items: flex-start; - gap: 10px; - border-left: 3px solid; - margin-bottom: 4px; - transition: background-color 0.15s ease; -} - -.workflow-block-card:hover { - background: rgba(0, 0, 0, 0.05); -} - -.workflow-block-card-icon { - font-size: 18px; - line-height: 1; -} - -.workflow-block-card-content { - flex: 1; - min-width: 0; -} - -.workflow-block-card-name { - font-size: 13px; - font-weight: 500; - color: #1f2937; - margin-bottom: 2px; -} - -.workflow-block-card-description { - font-size: 11px; - color: #6b7280; - line-height: 1.4; -} - -.workflow-block-card-meta { - display: flex; - gap: 8px; - margin-top: 6px; - font-size: 10px; - color: #9ca3af; -} - -/* ============================================================================= - Connection Arrows - ============================================================================= */ - -.workflow-arrow { - stroke: #374151; - stroke-width: 2; - fill: none; -} - -.workflow-arrow.valid { - stroke: #10b981; -} - -.workflow-arrow.invalid { - stroke: #f59e0b; - stroke-dasharray: 4 2; -} - -.workflow-arrow.dragging { - stroke: #6366f1; - stroke-dasharray: 6 3; - animation: workflow-arrow-dash 0.5s linear infinite; -} - -@keyframes workflow-arrow-dash { - to { - stroke-dashoffset: -9; - } -} - -/* ============================================================================= - Toggle Switch - ============================================================================= */ - -.workflow-toggle { - width: 40px; - height: 22px; - border-radius: 11px; - border: none; - background: #d1d5db; - cursor: pointer; - position: relative; - transition: background-color 0.15s ease; -} - -.workflow-toggle.active { - background: #10b981; -} - -.workflow-toggle-knob { - position: absolute; - top: 2px; - left: 2px; - width: 18px; - height: 18px; - border-radius: 50%; - background: white; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); - transition: left 0.15s ease; -} - -.workflow-toggle.active .workflow-toggle-knob { - left: 20px; -} - -/* ============================================================================= - Animation Keyframes - ============================================================================= */ - -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -/* ============================================================================= - Dark Mode - ============================================================================= */ - -.dark .workflow-block { - background: #1f2937; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); -} - -.dark .workflow-block-header { - border-bottom-color: #374151; -} - -.dark .workflow-block-title { - color: #f3f4f6; -} - -.dark .workflow-port { - background: #1f2937; -} - -.dark .workflow-port.connected { - background-color: currentColor; -} - -.dark .workflow-port-label { - color: #9ca3af; -} - -.dark .workflow-palette { - background: #1f2937; - border-color: #374151; -} - -.dark .workflow-palette-header { - border-bottom-color: #374151; -} - -.dark .workflow-palette-title { - color: #f3f4f6; -} - -.dark .workflow-palette-search input { - background: #111827; - border-color: #374151; - color: #f3f4f6; -} - -.dark .workflow-palette-controls { - border-bottom-color: #374151; -} - -.dark .workflow-category-header { - background: rgba(255, 255, 255, 0.05); -} - -.dark .workflow-category-header:hover { - background: rgba(255, 255, 255, 0.08); -} - -.dark .workflow-category-label { - color: #e5e7eb; -} - -.dark .workflow-block-card:hover { - background: rgba(255, 255, 255, 0.08); -} - -.dark .workflow-block-card-name { - color: #f3f4f6; -} - -.dark .workflow-block-card-description { - color: #9ca3af; -} - -.dark .workflow-palette-footer { - border-top-color: #374151; -} diff --git a/src/lib/workflow/blockRegistry.ts b/src/lib/workflow/blockRegistry.ts deleted file mode 100644 index 89fd177..0000000 --- a/src/lib/workflow/blockRegistry.ts +++ /dev/null @@ -1,881 +0,0 @@ -/** - * Block Registry - * - * Defines all available workflow blocks with their ports, configuration, - * and metadata. Blocks are organized by category for the palette UI. - */ - -import { - BlockDefinition, - BlockCategory, - InputPort, - OutputPort, -} from './types' - -// ============================================================================= -// Block Registry Storage -// ============================================================================= - -const BLOCK_REGISTRY: Map = new Map() - -// ============================================================================= -// Helper Functions for Port Creation -// ============================================================================= - -function input( - id: string, - name: string, - type: 'text' | 'number' | 'boolean' | 'object' | 'array' | 'any' | 'file' | 'image', - options: Partial> = {} -): InputPort { - return { - id, - name, - type, - direction: 'input', - required: options.required ?? false, - accepts: options.accepts ?? [type, 'any'], - description: options.description, - defaultValue: options.defaultValue, - } -} - -function output( - id: string, - name: string, - type: 'text' | 'number' | 'boolean' | 'object' | 'array' | 'any' | 'file' | 'image', - options: Partial> = {} -): OutputPort { - return { - id, - name, - type, - direction: 'output', - produces: type, - required: false, - description: options.description, - defaultValue: options.defaultValue, - } -} - -// ============================================================================= -// TRIGGER BLOCKS -// ============================================================================= - -const ManualTrigger: BlockDefinition = { - type: 'trigger.manual', - category: 'trigger', - name: 'Manual Trigger', - description: 'Start workflow with a button click', - icon: '▶️', - color: '#f59e0b', - inputs: [], - outputs: [ - output('timestamp', 'Timestamp', 'number', { - description: 'Unix timestamp when triggered', - }), - output('trigger', 'Trigger Data', 'object', { - description: 'Metadata about the trigger event', - }), - ], - executor: 'trigger.manual', -} - -const ScheduleTrigger: BlockDefinition = { - type: 'trigger.schedule', - category: 'trigger', - name: 'Schedule', - description: 'Run on a schedule (cron expression)', - icon: '⏰', - color: '#f59e0b', - inputs: [], - outputs: [ - output('timestamp', 'Timestamp', 'number'), - output('scheduledTime', 'Scheduled Time', 'number'), - ], - configSchema: { - type: 'object', - properties: { - cron: { - type: 'string', - description: 'Cron expression (e.g., "0 * * * *" for every hour)', - default: '0 * * * *', - }, - timezone: { - type: 'string', - description: 'Timezone for schedule', - default: 'UTC', - }, - }, - }, - defaultConfig: { - cron: '0 * * * *', - timezone: 'UTC', - }, - executor: 'trigger.schedule', -} - -const WebhookTrigger: BlockDefinition = { - type: 'trigger.webhook', - category: 'trigger', - name: 'Webhook', - description: 'Trigger from external HTTP request', - icon: '🌐', - color: '#f59e0b', - inputs: [], - outputs: [ - output('body', 'Request Body', 'object'), - output('headers', 'Headers', 'object'), - output('method', 'Method', 'text'), - output('url', 'URL', 'text'), - ], - executor: 'trigger.webhook', -} - -// ============================================================================= -// ACTION BLOCKS -// ============================================================================= - -const HttpRequest: BlockDefinition = { - type: 'action.http', - category: 'action', - name: 'HTTP Request', - description: 'Make HTTP API calls', - icon: '🌐', - color: '#3b82f6', - inputs: [ - input('url', 'URL', 'text', { required: true, description: 'Request URL' }), - input('body', 'Body', 'object', { description: 'Request body (for POST/PUT)' }), - input('headers', 'Headers', 'object', { description: 'Additional headers' }), - ], - outputs: [ - output('response', 'Response', 'object', { description: 'Parsed response body' }), - output('status', 'Status', 'number', { description: 'HTTP status code' }), - output('headers', 'Response Headers', 'object'), - ], - configSchema: { - type: 'object', - properties: { - method: { - type: 'string', - enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], - default: 'GET', - }, - contentType: { - type: 'string', - enum: ['application/json', 'application/x-www-form-urlencoded', 'text/plain'], - default: 'application/json', - }, - timeout: { - type: 'number', - description: 'Request timeout in milliseconds', - default: 30000, - }, - }, - }, - defaultConfig: { - method: 'GET', - contentType: 'application/json', - timeout: 30000, - }, - executor: 'action.http', -} - -const CreateShape: BlockDefinition = { - type: 'action.create-shape', - category: 'action', - name: 'Create Shape', - description: 'Create a new shape on the canvas', - icon: '📐', - color: '#3b82f6', - inputs: [ - input('type', 'Shape Type', 'text', { required: true }), - input('x', 'X Position', 'number'), - input('y', 'Y Position', 'number'), - input('props', 'Properties', 'object'), - ], - outputs: [ - output('shapeId', 'Shape ID', 'text'), - output('shape', 'Shape', 'object'), - ], - executor: 'action.create-shape', -} - -const UpdateShape: BlockDefinition = { - type: 'action.update-shape', - category: 'action', - name: 'Update Shape', - description: 'Update properties of an existing shape', - icon: '✏️', - color: '#3b82f6', - inputs: [ - input('shapeId', 'Shape ID', 'text', { required: true }), - input('props', 'Properties', 'object', { required: true }), - ], - outputs: [ - output('success', 'Success', 'boolean'), - output('shape', 'Updated Shape', 'object'), - ], - executor: 'action.update-shape', -} - -const Delay: BlockDefinition = { - type: 'action.delay', - category: 'action', - name: 'Delay', - description: 'Wait for a specified duration', - icon: '⏳', - color: '#3b82f6', - inputs: [ - input('input', 'Pass Through', 'any', { description: 'Data to pass through after delay' }), - ], - outputs: [ - output('output', 'Output', 'any'), - ], - configSchema: { - type: 'object', - properties: { - duration: { - type: 'number', - description: 'Delay in milliseconds', - default: 1000, - }, - }, - }, - defaultConfig: { - duration: 1000, - }, - executor: 'action.delay', -} - -// ============================================================================= -// CONDITION BLOCKS -// ============================================================================= - -const IfCondition: BlockDefinition = { - type: 'condition.if', - category: 'condition', - name: 'If/Else', - description: 'Branch based on a boolean condition', - icon: '❓', - color: '#8b5cf6', - inputs: [ - input('condition', 'Condition', 'boolean', { required: true }), - input('value', 'Value', 'any', { required: true, description: 'Data to route' }), - ], - outputs: [ - output('true', 'If True', 'any', { description: 'Output when condition is true' }), - output('false', 'If False', 'any', { description: 'Output when condition is false' }), - ], - executor: 'condition.if', -} - -const SwitchCondition: BlockDefinition = { - type: 'condition.switch', - category: 'condition', - name: 'Switch', - description: 'Route based on value matching', - icon: '🔀', - color: '#8b5cf6', - inputs: [ - input('value', 'Value', 'any', { required: true }), - input('data', 'Data', 'any', { description: 'Data to route' }), - ], - outputs: [ - output('case1', 'Case 1', 'any'), - output('case2', 'Case 2', 'any'), - output('case3', 'Case 3', 'any'), - output('default', 'Default', 'any'), - ], - configSchema: { - type: 'object', - properties: { - cases: { - type: 'array', - items: { type: 'string' }, - default: ['value1', 'value2', 'value3'], - }, - }, - }, - defaultConfig: { - cases: ['value1', 'value2', 'value3'], - }, - executor: 'condition.switch', -} - -const Compare: BlockDefinition = { - type: 'condition.compare', - category: 'condition', - name: 'Compare', - description: 'Compare two values', - icon: '⚖️', - color: '#8b5cf6', - inputs: [ - input('left', 'Left Value', 'any', { required: true }), - input('right', 'Right Value', 'any', { required: true }), - ], - outputs: [ - output('result', 'Result', 'boolean'), - ], - configSchema: { - type: 'object', - properties: { - operator: { - type: 'string', - enum: ['==', '!=', '>', '<', '>=', '<=', 'contains', 'startsWith', 'endsWith'], - default: '==', - }, - }, - }, - defaultConfig: { - operator: '==', - }, - executor: 'condition.compare', -} - -// ============================================================================= -// TRANSFORMER BLOCKS -// ============================================================================= - -const JsonParse: BlockDefinition = { - type: 'transformer.json-parse', - category: 'transformer', - name: 'Parse JSON', - description: 'Parse JSON string to object', - icon: '📋', - color: '#10b981', - inputs: [ - input('input', 'JSON String', 'text', { required: true }), - ], - outputs: [ - output('output', 'Object', 'object'), - ], - executor: 'transformer.json-parse', -} - -const JsonStringify: BlockDefinition = { - type: 'transformer.json-stringify', - category: 'transformer', - name: 'Stringify JSON', - description: 'Convert object to JSON string', - icon: '📝', - color: '#10b981', - inputs: [ - input('input', 'Object', 'object', { required: true }), - ], - outputs: [ - output('output', 'JSON String', 'text'), - ], - configSchema: { - type: 'object', - properties: { - pretty: { type: 'boolean', default: false }, - indent: { type: 'number', default: 2 }, - }, - }, - defaultConfig: { pretty: false, indent: 2 }, - executor: 'transformer.json-stringify', -} - -const CodeTransformer: BlockDefinition = { - type: 'transformer.code', - category: 'transformer', - name: 'JavaScript', - description: 'Run custom JavaScript code', - icon: '💻', - color: '#10b981', - inputs: [ - input('input', 'Input', 'any', { required: true }), - input('context', 'Context', 'object', { description: 'Additional context data' }), - ], - outputs: [ - output('output', 'Output', 'any'), - ], - configSchema: { - type: 'object', - properties: { - code: { - type: 'string', - description: 'JavaScript code. Use `input` variable. Return value becomes output.', - default: 'return input;', - }, - }, - }, - defaultConfig: { - code: 'return input;', - }, - executor: 'transformer.code', -} - -const Template: BlockDefinition = { - type: 'transformer.template', - category: 'transformer', - name: 'Template', - description: 'String interpolation with variables', - icon: '📄', - color: '#10b981', - inputs: [ - input('data', 'Data', 'object', { required: true }), - ], - outputs: [ - output('output', 'Text', 'text'), - ], - configSchema: { - type: 'object', - properties: { - template: { - type: 'string', - description: 'Template string. Use {{key}} for interpolation.', - default: 'Hello, {{name}}!', - }, - }, - }, - defaultConfig: { - template: 'Hello, {{name}}!', - }, - executor: 'transformer.template', -} - -const GetProperty: BlockDefinition = { - type: 'transformer.get-property', - category: 'transformer', - name: 'Get Property', - description: 'Extract a property from an object', - icon: '🔍', - color: '#10b981', - inputs: [ - input('object', 'Object', 'object', { required: true }), - ], - outputs: [ - output('value', 'Value', 'any'), - ], - configSchema: { - type: 'object', - properties: { - path: { - type: 'string', - description: 'Property path (e.g., "data.user.name")', - default: '', - }, - }, - }, - defaultConfig: { path: '' }, - executor: 'transformer.get-property', -} - -const SetProperty: BlockDefinition = { - type: 'transformer.set-property', - category: 'transformer', - name: 'Set Property', - description: 'Set a property on an object', - icon: '✏️', - color: '#10b981', - inputs: [ - input('object', 'Object', 'object', { required: true }), - input('value', 'Value', 'any', { required: true }), - ], - outputs: [ - output('output', 'Object', 'object'), - ], - configSchema: { - type: 'object', - properties: { - path: { - type: 'string', - description: 'Property path to set', - default: '', - }, - }, - }, - defaultConfig: { path: '' }, - executor: 'transformer.set-property', -} - -const ArrayMap: BlockDefinition = { - type: 'transformer.array-map', - category: 'transformer', - name: 'Map Array', - description: 'Transform each item in an array', - icon: '🗂️', - color: '#10b981', - inputs: [ - input('array', 'Array', 'array', { required: true }), - ], - outputs: [ - output('output', 'Array', 'array'), - ], - configSchema: { - type: 'object', - properties: { - expression: { - type: 'string', - description: 'JavaScript expression. Use `item` and `index` variables.', - default: 'item', - }, - }, - }, - defaultConfig: { expression: 'item' }, - executor: 'transformer.array-map', -} - -const ArrayFilter: BlockDefinition = { - type: 'transformer.array-filter', - category: 'transformer', - name: 'Filter Array', - description: 'Filter array items by condition', - icon: '🔍', - color: '#10b981', - inputs: [ - input('array', 'Array', 'array', { required: true }), - ], - outputs: [ - output('output', 'Array', 'array'), - ], - configSchema: { - type: 'object', - properties: { - condition: { - type: 'string', - description: 'JavaScript condition. Use `item` and `index` variables.', - default: 'true', - }, - }, - }, - defaultConfig: { condition: 'true' }, - executor: 'transformer.array-filter', -} - -// ============================================================================= -// AI BLOCKS -// ============================================================================= - -const LLMPrompt: BlockDefinition = { - type: 'ai.llm', - category: 'ai', - name: 'LLM Prompt', - description: 'Send prompt to language model', - icon: '🤖', - color: '#ec4899', - inputs: [ - input('prompt', 'Prompt', 'text', { required: true }), - input('context', 'Context', 'text', { description: 'Additional context' }), - input('systemPrompt', 'System Prompt', 'text', { description: 'System instructions' }), - ], - outputs: [ - output('response', 'Response', 'text'), - output('usage', 'Usage', 'object', { description: 'Token usage stats' }), - ], - configSchema: { - type: 'object', - properties: { - model: { - type: 'string', - enum: ['llama3.1:8b', 'llama3.1:70b', 'claude-sonnet', 'gpt-4'], - default: 'llama3.1:8b', - }, - temperature: { - type: 'number', - minimum: 0, - maximum: 2, - default: 0.7, - }, - maxTokens: { - type: 'number', - default: 1000, - }, - }, - }, - defaultConfig: { - model: 'llama3.1:8b', - temperature: 0.7, - maxTokens: 1000, - }, - executor: 'ai.llm', -} - -const ImageGen: BlockDefinition = { - type: 'ai.image-gen', - category: 'ai', - name: 'Image Generation', - description: 'Generate image from text prompt', - icon: '🎨', - color: '#ec4899', - inputs: [ - input('prompt', 'Prompt', 'text', { required: true }), - input('negativePrompt', 'Negative Prompt', 'text'), - ], - outputs: [ - output('image', 'Image', 'image'), - output('url', 'Image URL', 'text'), - ], - configSchema: { - type: 'object', - properties: { - model: { - type: 'string', - enum: ['SDXL', 'SD3', 'FLUX'], - default: 'SDXL', - }, - width: { type: 'number', default: 512 }, - height: { type: 'number', default: 512 }, - steps: { type: 'number', default: 20 }, - }, - }, - defaultConfig: { - model: 'SDXL', - width: 512, - height: 512, - steps: 20, - }, - executor: 'ai.image-gen', -} - -const TextToSpeech: BlockDefinition = { - type: 'ai.tts', - category: 'ai', - name: 'Text to Speech', - description: 'Convert text to audio', - icon: '🔊', - color: '#ec4899', - inputs: [ - input('text', 'Text', 'text', { required: true }), - ], - outputs: [ - output('audio', 'Audio', 'file'), - output('url', 'Audio URL', 'text'), - ], - configSchema: { - type: 'object', - properties: { - voice: { - type: 'string', - default: 'alloy', - }, - speed: { - type: 'number', - minimum: 0.5, - maximum: 2, - default: 1, - }, - }, - }, - defaultConfig: { voice: 'alloy', speed: 1 }, - executor: 'ai.tts', -} - -const SpeechToText: BlockDefinition = { - type: 'ai.stt', - category: 'ai', - name: 'Speech to Text', - description: 'Transcribe audio to text', - icon: '🎤', - color: '#ec4899', - inputs: [ - input('audio', 'Audio', 'file', { required: true }), - ], - outputs: [ - output('text', 'Text', 'text'), - output('segments', 'Segments', 'array'), - ], - executor: 'ai.stt', -} - -// ============================================================================= -// OUTPUT BLOCKS -// ============================================================================= - -const DisplayOutput: BlockDefinition = { - type: 'output.display', - category: 'output', - name: 'Display', - description: 'Show result on canvas', - icon: '📺', - color: '#ef4444', - inputs: [ - input('value', 'Value', 'any', { required: true }), - ], - outputs: [], - configSchema: { - type: 'object', - properties: { - format: { - type: 'string', - enum: ['auto', 'json', 'text', 'markdown'], - default: 'auto', - }, - }, - }, - defaultConfig: { format: 'auto' }, - executor: 'output.display', -} - -const LogOutput: BlockDefinition = { - type: 'output.log', - category: 'output', - name: 'Log', - description: 'Log value to console', - icon: '📋', - color: '#ef4444', - inputs: [ - input('value', 'Value', 'any', { required: true }), - input('label', 'Label', 'text'), - ], - outputs: [ - output('passthrough', 'Pass Through', 'any'), - ], - executor: 'output.log', -} - -const NotifyOutput: BlockDefinition = { - type: 'output.notify', - category: 'output', - name: 'Notification', - description: 'Show browser notification', - icon: '🔔', - color: '#ef4444', - inputs: [ - input('message', 'Message', 'text', { required: true }), - input('title', 'Title', 'text'), - ], - outputs: [], - configSchema: { - type: 'object', - properties: { - type: { - type: 'string', - enum: ['info', 'success', 'warning', 'error'], - default: 'info', - }, - }, - }, - defaultConfig: { type: 'info' }, - executor: 'output.notify', -} - -const CreateMarkdown: BlockDefinition = { - type: 'output.markdown', - category: 'output', - name: 'Create Markdown', - description: 'Create a markdown shape on canvas', - icon: '📝', - color: '#ef4444', - inputs: [ - input('content', 'Content', 'text', { required: true }), - input('x', 'X Position', 'number'), - input('y', 'Y Position', 'number'), - ], - outputs: [ - output('shapeId', 'Shape ID', 'text'), - ], - executor: 'output.markdown', -} - -// ============================================================================= -// Register All Blocks -// ============================================================================= - -const ALL_BLOCKS: BlockDefinition[] = [ - // Triggers - ManualTrigger, - ScheduleTrigger, - WebhookTrigger, - // Actions - HttpRequest, - CreateShape, - UpdateShape, - Delay, - // Conditions - IfCondition, - SwitchCondition, - Compare, - // Transformers - JsonParse, - JsonStringify, - CodeTransformer, - Template, - GetProperty, - SetProperty, - ArrayMap, - ArrayFilter, - // AI - LLMPrompt, - ImageGen, - TextToSpeech, - SpeechToText, - // Outputs - DisplayOutput, - LogOutput, - NotifyOutput, - CreateMarkdown, -] - -// Register all blocks -for (const block of ALL_BLOCKS) { - BLOCK_REGISTRY.set(block.type, block) -} - -// ============================================================================= -// Registry Access Functions -// ============================================================================= - -/** - * Get a block definition by type - */ -export function getBlockDefinition(type: string): BlockDefinition { - const def = BLOCK_REGISTRY.get(type) - if (!def) { - throw new Error(`Unknown block type: ${type}`) - } - return def -} - -/** - * Check if a block type exists - */ -export function hasBlockDefinition(type: string): boolean { - return BLOCK_REGISTRY.has(type) -} - -/** - * Get all registered block definitions - */ -export function getAllBlockDefinitions(): BlockDefinition[] { - return Array.from(BLOCK_REGISTRY.values()) -} - -/** - * Get blocks filtered by category - */ -export function getBlocksByCategory(category: BlockCategory): BlockDefinition[] { - return getAllBlockDefinitions().filter(b => b.category === category) -} - -/** - * Register a new block definition - */ -export function registerBlock(definition: BlockDefinition): void { - if (BLOCK_REGISTRY.has(definition.type)) { - console.warn(`Block type "${definition.type}" is already registered. Overwriting.`) - } - BLOCK_REGISTRY.set(definition.type, definition) -} - -/** - * Get all block categories with their blocks - */ -export function getBlocksByCategories(): Record { - const result: Record = { - trigger: [], - action: [], - condition: [], - transformer: [], - output: [], - ai: [], - } - - for (const block of BLOCK_REGISTRY.values()) { - result[block.category].push(block) - } - - return result -} diff --git a/src/lib/workflow/executor.ts b/src/lib/workflow/executor.ts deleted file mode 100644 index a9aedc4..0000000 --- a/src/lib/workflow/executor.ts +++ /dev/null @@ -1,731 +0,0 @@ -/** - * Workflow Executor - * - * Executes workflow blocks either individually or as a complete workflow. - * Manages execution state, handles data propagation between blocks, - * and supports both manual and real-time execution modes. - */ - -import { Editor, TLShapeId } from 'tldraw' -import { - ExecutionContext, - BlockExecutionResult, - WorkflowBlockProps, - ExecutionState, -} from './types' -import { getBlockDefinition, hasBlockDefinition } from './blockRegistry' -import { - getBlockInputBindings, - getBlockOutputBindings, - getExecutionOrder, - buildWorkflowGraph, -} from './portBindings' -import { validateRequiredInputs, validateWorkflow } from './validation' -import { IWorkflowBlock } from '@/shapes/WorkflowBlockShapeUtil' - -// ============================================================================= -// Block Executors -// ============================================================================= - -type BlockExecutor = ( - context: ExecutionContext, - inputs: Record, - config: Record -) => Promise> - -const blockExecutors: Map = new Map() - -/** - * Register a block executor - */ -export function registerBlockExecutor( - blockType: string, - executor: BlockExecutor -): void { - blockExecutors.set(blockType, executor) -} - -// ============================================================================= -// Built-in Block Executors -// ============================================================================= - -// Trigger: Manual -registerBlockExecutor('trigger.manual', async (context) => { - return { timestamp: Date.now() } -}) - -// Trigger: Schedule -registerBlockExecutor('trigger.schedule', async (context, inputs, config) => { - return { - timestamp: Date.now(), - scheduledTime: config.time || '00:00', - interval: config.interval || 'daily', - } -}) - -// Trigger: Webhook -registerBlockExecutor('trigger.webhook', async (context, inputs, config) => { - return { - timestamp: Date.now(), - method: 'POST', - body: {}, - headers: {}, - } -}) - -// Action: HTTP Request -registerBlockExecutor('action.http', async (context, inputs, config) => { - const url = (inputs.url as string) || (config.url as string) - const method = (config.method as string) || 'GET' - const body = inputs.body as string | undefined - - if (!url) { - throw new Error('URL is required for HTTP request') - } - - try { - const response = await fetch(url, { - method, - headers: { - 'Content-Type': 'application/json', - }, - body: method !== 'GET' ? body : undefined, - }) - - const responseData = await response.text() - let parsedData: unknown = responseData - - try { - parsedData = JSON.parse(responseData) - } catch { - // Keep as text if not valid JSON - } - - return { - response: parsedData, - status: response.status, - headers: Object.fromEntries(response.headers.entries()), - } - } catch (error) { - throw new Error(`HTTP request failed: ${(error as Error).message}`) - } -}) - -// Action: Create Shape -registerBlockExecutor('action.createShape', async (context, inputs, config) => { - const shapeType = (config.shapeType as string) || 'text' - const position = (inputs.position as { x: number; y: number }) || { x: 100, y: 100 } - const content = inputs.content as string || '' - - // Create shape through editor - const newShape = context.editor.createShape({ - type: shapeType === 'text' ? 'text' : 'geo', - x: position.x, - y: position.y, - props: shapeType === 'text' - ? { text: content } - : { text: content, w: 200, h: 100 }, - }) - - return { - shapeId: newShape?.id || null, - created: true, - } -}) - -// Action: Update Shape -registerBlockExecutor('action.updateShape', async (context, inputs, config) => { - const shapeId = inputs.shapeId as TLShapeId - const updates = inputs.updates as Record - - if (!shapeId) { - throw new Error('Shape ID is required') - } - - context.editor.updateShape({ - id: shapeId, - type: context.editor.getShape(shapeId)?.type || 'geo', - props: updates, - }) - - return { updated: true, shapeId } -}) - -// Action: Delay -registerBlockExecutor('action.delay', async (context, inputs, config) => { - const duration = (config.duration as number) || 1000 - await new Promise(resolve => setTimeout(resolve, duration)) - return { passthrough: inputs.input, delayed: duration } -}) - -// Condition: If/Else -registerBlockExecutor('condition.if', async (context, inputs) => { - const condition = Boolean(inputs.condition) - const value = inputs.value - - return condition - ? { true: value } - : { false: value } -}) - -// Condition: Switch -registerBlockExecutor('condition.switch', async (context, inputs, config) => { - const value = inputs.value - const cases = (config.cases as Record) || {} - - for (const [caseValue, output] of Object.entries(cases)) { - if (String(value) === caseValue) { - return { match: output, matched: caseValue } - } - } - - return { default: value } -}) - -// Condition: Compare -registerBlockExecutor('condition.compare', async (context, inputs, config) => { - const a = inputs.a - const b = inputs.b - const operator = (config.operator as string) || 'equals' - - let result: boolean - - switch (operator) { - case 'equals': - result = a === b - break - case 'notEquals': - result = a !== b - break - case 'greaterThan': - result = Number(a) > Number(b) - break - case 'lessThan': - result = Number(a) < Number(b) - break - case 'contains': - result = String(a).includes(String(b)) - break - default: - result = a === b - } - - return { result } -}) - -// Transformer: JSON Parse -registerBlockExecutor('transformer.jsonParse', async (context, inputs) => { - const input = inputs.input as string - try { - return { output: JSON.parse(input) } - } catch (error) { - throw new Error(`Invalid JSON: ${(error as Error).message}`) - } -}) - -// Transformer: JSON Stringify -registerBlockExecutor('transformer.jsonStringify', async (context, inputs, config) => { - const input = inputs.input - const pretty = config.pretty as boolean - - return { - output: pretty - ? JSON.stringify(input, null, 2) - : JSON.stringify(input), - } -}) - -// Transformer: JavaScript Code -registerBlockExecutor('transformer.code', async (context, inputs, config) => { - const code = config.code as string - const input = inputs.input - - if (!code) { - return { output: input } - } - - try { - // Create a sandboxed function - const fn = new Function('input', 'context', ` - 'use strict'; - ${code} - `) - const result = fn(input, { timestamp: Date.now() }) - return { output: result } - } catch (error) { - throw new Error(`Code execution failed: ${(error as Error).message}`) - } -}) - -// Transformer: Template -registerBlockExecutor('transformer.template', async (context, inputs, config) => { - const template = (config.template as string) || '' - const variables = (inputs.variables as Record) || {} - - let result = template - for (const [key, value] of Object.entries(variables)) { - result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), String(value)) - } - - return { output: result } -}) - -// Transformer: Get Property -registerBlockExecutor('transformer.getProperty', async (context, inputs, config) => { - const object = inputs.object as Record - const path = (config.path as string) || '' - - if (!object || typeof object !== 'object') { - return { value: undefined } - } - - const parts = path.split('.') - let value: unknown = object - - for (const part of parts) { - if (value && typeof value === 'object' && part in (value as Record)) { - value = (value as Record)[part] - } else { - return { value: undefined } - } - } - - return { value } -}) - -// Transformer: Set Property -registerBlockExecutor('transformer.setProperty', async (context, inputs, config) => { - const object = { ...(inputs.object as Record) } || {} - const path = (config.path as string) || '' - const value = inputs.value - - if (path) { - const parts = path.split('.') - let current = object as Record - - for (let i = 0; i < parts.length - 1; i++) { - if (!(parts[i] in current)) { - current[parts[i]] = {} - } - current = current[parts[i]] as Record - } - - current[parts[parts.length - 1]] = value - } - - return { output: object } -}) - -// Transformer: Array Map -registerBlockExecutor('transformer.arrayMap', async (context, inputs, config) => { - const array = inputs.array as unknown[] - const expression = (config.expression as string) || 'item' - - if (!Array.isArray(array)) { - throw new Error('Input must be an array') - } - - const fn = new Function('item', 'index', `return ${expression}`) - return { output: array.map((item, index) => fn(item, index)) } -}) - -// Transformer: Array Filter -registerBlockExecutor('transformer.arrayFilter', async (context, inputs, config) => { - const array = inputs.array as unknown[] - const condition = (config.condition as string) || 'true' - - if (!Array.isArray(array)) { - throw new Error('Input must be an array') - } - - const fn = new Function('item', 'index', `return ${condition}`) - return { output: array.filter((item, index) => fn(item, index)) } -}) - -// AI: LLM Prompt (placeholder - integrates with existing AI utilities) -registerBlockExecutor('ai.llm', async (context, inputs, config) => { - const prompt = inputs.prompt as string - const systemPrompt = (config.systemPrompt as string) || '' - - // TODO: Integrate with existing LLM utilities - // For now, return a placeholder - console.log('LLM Prompt:', { prompt, systemPrompt }) - - return { - response: `[LLM response to: "${prompt?.substring(0, 50)}..."]`, - tokens: 0, - } -}) - -// AI: Image Generation (placeholder) -registerBlockExecutor('ai.imageGen', async (context, inputs, config) => { - const prompt = inputs.prompt as string - const size = (config.size as string) || '512x512' - - // TODO: Integrate with existing image generation - console.log('Image Gen:', { prompt, size }) - - return { - image: '[Image URL placeholder]', - prompt, - } -}) - -// AI: Text to Speech (placeholder) -registerBlockExecutor('ai.tts', async (context, inputs, config) => { - const text = inputs.text as string - const voice = (config.voice as string) || 'default' - - console.log('TTS:', { text, voice }) - - return { - audio: '[Audio URL placeholder]', - duration: 0, - } -}) - -// AI: Speech to Text (placeholder) -registerBlockExecutor('ai.stt', async (context, inputs) => { - const audio = inputs.audio as string - - console.log('STT:', { audio }) - - return { - text: '[Transcription placeholder]', - confidence: 0, - } -}) - -// Output: Display -registerBlockExecutor('output.display', async (context, inputs, config) => { - const value = inputs.value - const format = (config.format as string) || 'auto' - - let displayed: string - if (format === 'json') { - displayed = JSON.stringify(value, null, 2) - } else if (format === 'text') { - displayed = String(value) - } else { - displayed = typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value) - } - - console.log('Display:', displayed) - return { displayed } -}) - -// Output: Log -registerBlockExecutor('output.log', async (context, inputs, config) => { - const message = inputs.message - const level = (config.level as string) || 'info' - - const timestamp = new Date().toISOString() - console.log(`[${level.toUpperCase()}] ${timestamp}:`, message) - - return { logged: true, timestamp, level } -}) - -// Output: Notify -registerBlockExecutor('output.notify', async (context, inputs, config) => { - const message = inputs.message as string - const title = (config.title as string) || 'Notification' - - // Dispatch notification event - window.dispatchEvent(new CustomEvent('workflow:notify', { - detail: { title, message }, - })) - - return { notified: true } -}) - -// Output: Create Markdown -registerBlockExecutor('output.markdown', async (context, inputs, config) => { - const content = inputs.content as string - const position = (inputs.position as { x: number; y: number }) || { x: 100, y: 100 } - - // Create a markdown shape - const newShape = context.editor.createShape({ - type: 'Markdown', - x: position.x, - y: position.y, - props: { - w: 400, - h: 300, - text: content, - }, - }) - - return { shapeId: newShape?.id || null, created: true } -}) - -// ============================================================================= -// Execution Functions -// ============================================================================= - -/** - * Execute a single workflow block - */ -export async function executeBlock( - editor: Editor, - blockId: TLShapeId, - additionalInputs: Record = {} -): Promise { - const shape = editor.getShape(blockId) as IWorkflowBlock | undefined - - if (!shape || shape.type !== 'WorkflowBlock') { - return { - success: false, - error: 'Invalid block shape', - outputs: {}, - executionTime: 0, - } - } - - const { blockType, blockConfig, inputValues } = shape.props - - if (!hasBlockDefinition(blockType)) { - return { - success: false, - error: `Unknown block type: ${blockType}`, - outputs: {}, - executionTime: 0, - } - } - - // Get executor - const executor = blockExecutors.get(blockType) - if (!executor) { - return { - success: false, - error: `No executor registered for block type: ${blockType}`, - outputs: {}, - executionTime: 0, - } - } - - // Update execution state to running - updateBlockState(editor, blockId, 'running') - - const startTime = Date.now() - - try { - // Gather inputs from upstream blocks - const inputs = await gatherBlockInputs(editor, blockId) - - // Merge with additional inputs and stored input values - const mergedInputs = { - ...inputValues, - ...inputs, - ...additionalInputs, - } - - // Create execution context - const context: ExecutionContext = { - editor, - blockId, - timestamp: Date.now(), - } - - // Execute - const outputs = await executor(context, mergedInputs, blockConfig) - - // Update block with outputs - editor.updateShape({ - id: blockId, - type: 'WorkflowBlock', - props: { - outputValues: outputs, - executionState: 'success', - executionError: undefined, - }, - }) - - return { - success: true, - outputs, - executionTime: Date.now() - startTime, - } - } catch (error) { - const errorMessage = (error as Error).message - - // Update block with error - editor.updateShape({ - id: blockId, - type: 'WorkflowBlock', - props: { - executionState: 'error', - executionError: errorMessage, - }, - }) - - return { - success: false, - error: errorMessage, - outputs: {}, - executionTime: Date.now() - startTime, - } - } -} - -/** - * Gather input values from upstream connected blocks - */ -async function gatherBlockInputs( - editor: Editor, - blockId: TLShapeId -): Promise> { - const inputs: Record = {} - const bindings = getBlockInputBindings(editor, blockId) - - for (const binding of bindings) { - const sourceShape = editor.getShape(binding.fromShapeId) as IWorkflowBlock | undefined - if (sourceShape && sourceShape.type === 'WorkflowBlock') { - const outputValue = sourceShape.props.outputValues?.[binding.fromPortId] - if (outputValue !== undefined) { - inputs[binding.toPortId] = outputValue - } - } - } - - return inputs -} - -/** - * Update block execution state - */ -function updateBlockState( - editor: Editor, - blockId: TLShapeId, - state: ExecutionState, - error?: string -): void { - editor.updateShape({ - id: blockId, - type: 'WorkflowBlock', - props: { - executionState: state, - executionError: error, - }, - }) -} - -/** - * Execute an entire workflow starting from trigger blocks - */ -export async function executeWorkflow( - editor: Editor, - options: { - startBlockId?: TLShapeId - signal?: AbortSignal - onProgress?: (completed: number, total: number) => void - } = {} -): Promise<{ - success: boolean - results: Map - error?: string -}> { - const { signal, onProgress } = options - - // Validate workflow first - const { blocks, connections } = buildWorkflowGraph(editor) - const validation = validateWorkflow(blocks, connections) - - if (!validation.valid) { - return { - success: false, - results: new Map(), - error: validation.errors.map(e => e.message).join('; '), - } - } - - // Get execution order - const executionOrder = getExecutionOrder(editor) - - // If start block specified, only execute that subgraph - let blocksToExecute = executionOrder - if (options.startBlockId) { - const startIndex = executionOrder.indexOf(options.startBlockId) - if (startIndex >= 0) { - blocksToExecute = executionOrder.slice(startIndex) - } - } - - const results = new Map() - let completed = 0 - - // Reset all blocks to idle - for (const blockId of blocksToExecute) { - updateBlockState(editor, blockId, 'idle') - } - - // Execute blocks in order - for (const blockId of blocksToExecute) { - // Check for abort - if (signal?.aborted) { - return { - success: false, - results, - error: 'Execution aborted', - } - } - - const result = await executeBlock(editor, blockId) - results.set(blockId, result) - - if (!result.success) { - // Stop on first error - return { - success: false, - results, - error: `Block execution failed: ${result.error}`, - } - } - - completed++ - onProgress?.(completed, blocksToExecute.length) - } - - return { - success: true, - results, - } -} - -/** - * Reset all workflow blocks to idle state - */ -export function resetWorkflow(editor: Editor): void { - const blocks = editor.getCurrentPageShapes().filter(s => s.type === 'WorkflowBlock') - - for (const block of blocks) { - editor.updateShape({ - id: block.id, - type: 'WorkflowBlock', - props: { - executionState: 'idle', - executionError: undefined, - outputValues: {}, - }, - }) - } -} - -// ============================================================================= -// Event Listener for Manual Block Execution -// ============================================================================= - -/** - * Setup event listener for workflow:execute-block events - */ -export function setupBlockExecutionListener(editor: Editor): () => void { - const handler = async (event: CustomEvent<{ blockId: TLShapeId }>) => { - const { blockId } = event.detail - await executeBlock(editor, blockId) - } - - window.addEventListener('workflow:execute-block', handler as EventListener) - - return () => { - window.removeEventListener('workflow:execute-block', handler as EventListener) - } -} diff --git a/src/lib/workflow/portBindings.ts b/src/lib/workflow/portBindings.ts deleted file mode 100644 index a80485a..0000000 --- a/src/lib/workflow/portBindings.ts +++ /dev/null @@ -1,468 +0,0 @@ -/** - * Port Binding Utilities - * - * Handles the connection between workflow blocks via arrows. - * Stores port metadata in arrow meta and provides utilities for - * querying connections between blocks. - */ - -import { - Editor, - TLArrowBinding, - TLArrowShape, - TLShape, - TLShapeId, - Vec, -} from 'tldraw' -import { PortBinding } from './types' -import { getBlockDefinition, hasBlockDefinition } from './blockRegistry' -import { validateConnection } from './validation' - -// ============================================================================= -// Port Position Constants -// ============================================================================= - -const PORT_SIZE = 12 -const PORT_SPACING = 28 -const HEADER_HEIGHT = 36 - -// ============================================================================= -// Port Position Calculation -// ============================================================================= - -/** - * Get the position of a port in world coordinates - */ -export function getPortWorldPosition( - editor: Editor, - shapeId: TLShapeId, - portId: string, - direction: 'input' | 'output' -): Vec | null { - const shape = editor.getShape(shapeId) - if (!shape || shape.type !== 'WorkflowBlock') return null - - const props = shape.props as { w: number; blockType: string } - if (!hasBlockDefinition(props.blockType)) return null - - const definition = getBlockDefinition(props.blockType) - const ports = direction === 'input' ? definition.inputs : definition.outputs - const portIndex = ports.findIndex(p => p.id === portId) - - if (portIndex === -1) return null - - // Calculate local position - const localX = direction === 'input' ? 0 : props.w - const localY = HEADER_HEIGHT + 12 + portIndex * PORT_SPACING + PORT_SIZE / 2 - - // Transform to world coordinates - const point = editor.getShapePageTransform(shapeId)?.applyToPoint({ x: localX, y: localY }) - return point ? new Vec(point.x, point.y) : null -} - -/** - * Find the closest port to a given point on a workflow block - */ -export function findClosestPort( - editor: Editor, - shapeId: TLShapeId, - point: Vec, - direction: 'input' | 'output' -): { portId: string; distance: number } | null { - const shape = editor.getShape(shapeId) - if (!shape || shape.type !== 'WorkflowBlock') return null - - const props = shape.props as { blockType: string } - if (!hasBlockDefinition(props.blockType)) return null - - const definition = getBlockDefinition(props.blockType) - const ports = direction === 'input' ? definition.inputs : definition.outputs - - let closestPort: { portId: string; distance: number } | null = null - - for (const port of ports) { - const portPos = getPortWorldPosition(editor, shapeId, port.id, direction) - if (!portPos) continue - - const distance = Vec.Dist(point, portPos) - if (!closestPort || distance < closestPort.distance) { - closestPort = { portId: port.id, distance } - } - } - - return closestPort -} - -// ============================================================================= -// Arrow Port Metadata -// ============================================================================= - -/** - * Arrow meta type for workflow connections - */ -interface WorkflowArrowMeta { - fromPortId?: string - toPortId?: string - validated?: boolean -} - -/** - * Get port binding from an arrow shape - */ -export function getPortBinding( - editor: Editor, - arrowId: TLShapeId -): PortBinding | null { - const arrow = editor.getShape(arrowId) as TLArrowShape | undefined - if (!arrow || arrow.type !== 'arrow') return null - - const bindings = editor.getBindingsInvolvingShape(arrowId) - if (!bindings || bindings.length !== 2) return null - - // Find start and end bindings - const startBinding = bindings.find(b => b.props.terminal === 'start') - const endBinding = bindings.find(b => b.props.terminal === 'end') - - if (!startBinding || !endBinding) return null - - // Get meta from arrow - const meta = (arrow.meta || {}) as WorkflowArrowMeta - - return { - fromShapeId: startBinding.toId, - fromPortId: meta.fromPortId || 'output', - toShapeId: endBinding.toId, - toPortId: meta.toPortId || 'input', - arrowId, - } -} - -/** - * Set port binding metadata on an arrow - */ -export function setPortBinding( - editor: Editor, - arrowId: TLShapeId, - fromPortId: string, - toPortId: string -): void { - const arrow = editor.getShape(arrowId) as TLArrowShape | undefined - if (!arrow || arrow.type !== 'arrow') return - - editor.updateShape({ - id: arrowId, - type: 'arrow', - meta: { - ...arrow.meta, - fromPortId, - toPortId, - validated: true, - }, - }) -} - -/** - * Clear port binding metadata from an arrow - */ -export function clearPortBinding( - editor: Editor, - arrowId: TLShapeId -): void { - const arrow = editor.getShape(arrowId) as TLArrowShape | undefined - if (!arrow || arrow.type !== 'arrow') return - - const meta = { ...arrow.meta } as WorkflowArrowMeta - delete meta.fromPortId - delete meta.toPortId - delete meta.validated - - editor.updateShape({ - id: arrowId, - type: 'arrow', - meta, - }) -} - -// ============================================================================= -// Connection Queries -// ============================================================================= - -/** - * Get all input bindings for a workflow block - */ -export function getBlockInputBindings( - editor: Editor, - shapeId: TLShapeId -): PortBinding[] { - const bindings: PortBinding[] = [] - - // Get all arrows ending at this shape - const arrowBindings = editor.getBindingsToShape(shapeId, 'arrow') - const incomingArrows = arrowBindings - .filter(b => b.props.terminal === 'end') - .map(b => b.fromId) - - for (const arrowId of incomingArrows) { - const portBinding = getPortBinding(editor, arrowId) - if (portBinding) { - bindings.push(portBinding) - } - } - - return bindings -} - -/** - * Get all output bindings from a workflow block - */ -export function getBlockOutputBindings( - editor: Editor, - shapeId: TLShapeId -): PortBinding[] { - const bindings: PortBinding[] = [] - - // Get all arrows starting from this shape - const arrowBindings = editor.getBindingsToShape(shapeId, 'arrow') - const outgoingArrows = arrowBindings - .filter(b => b.props.terminal === 'start') - .map(b => b.fromId) - - for (const arrowId of outgoingArrows) { - const portBinding = getPortBinding(editor, arrowId) - if (portBinding) { - bindings.push(portBinding) - } - } - - return bindings -} - -/** - * Get bindings for a specific input port - */ -export function getInputPortBindings( - editor: Editor, - shapeId: TLShapeId, - portId: string -): PortBinding[] { - return getBlockInputBindings(editor, shapeId).filter(b => b.toPortId === portId) -} - -/** - * Get bindings for a specific output port - */ -export function getOutputPortBindings( - editor: Editor, - shapeId: TLShapeId, - portId: string -): PortBinding[] { - return getBlockOutputBindings(editor, shapeId).filter(b => b.fromPortId === portId) -} - -/** - * Check if a specific port is connected - */ -export function isPortConnected( - editor: Editor, - shapeId: TLShapeId, - portId: string, - direction: 'input' | 'output' -): boolean { - const bindings = direction === 'input' - ? getBlockInputBindings(editor, shapeId) - : getBlockOutputBindings(editor, shapeId) - - const portKey = direction === 'input' ? 'toPortId' : 'fromPortId' - return bindings.some(b => b[portKey] === portId) -} - -/** - * Get all connected ports for a shape - */ -export function getConnectedPorts( - editor: Editor, - shapeId: TLShapeId -): { inputs: string[]; outputs: string[] } { - const inputBindings = getBlockInputBindings(editor, shapeId) - const outputBindings = getBlockOutputBindings(editor, shapeId) - - return { - inputs: [...new Set(inputBindings.map(b => b.toPortId))], - outputs: [...new Set(outputBindings.map(b => b.fromPortId))], - } -} - -// ============================================================================= -// Workflow Graph Utilities -// ============================================================================= - -/** - * Get upstream blocks (blocks that feed into this one) - */ -export function getUpstreamBlocks( - editor: Editor, - shapeId: TLShapeId -): TLShapeId[] { - const inputBindings = getBlockInputBindings(editor, shapeId) - return [...new Set(inputBindings.map(b => b.fromShapeId))] -} - -/** - * Get downstream blocks (blocks that this one feeds into) - */ -export function getDownstreamBlocks( - editor: Editor, - shapeId: TLShapeId -): TLShapeId[] { - const outputBindings = getBlockOutputBindings(editor, shapeId) - return [...new Set(outputBindings.map(b => b.toShapeId))] -} - -/** - * Get all workflow blocks in the editor - */ -export function getAllWorkflowBlocks(editor: Editor): TLShape[] { - return editor.getCurrentPageShapes().filter(s => s.type === 'WorkflowBlock') -} - -/** - * Get all arrows connecting workflow blocks - */ -export function getWorkflowArrows(editor: Editor): TLArrowShape[] { - const workflowBlockIds = new Set( - getAllWorkflowBlocks(editor).map(s => s.id) - ) - - return (editor.getCurrentPageShapes().filter(s => s.type === 'arrow') as TLArrowShape[]) - .filter(arrow => { - const binding = getPortBinding(editor, arrow.id) - return binding && - workflowBlockIds.has(binding.fromShapeId) && - workflowBlockIds.has(binding.toShapeId) - }) -} - -/** - * Build a workflow graph from the current canvas - */ -export function buildWorkflowGraph(editor: Editor): { - blocks: Array<{ id: TLShapeId; blockType: string; config: Record }> - connections: PortBinding[] -} { - const blocks = getAllWorkflowBlocks(editor).map(shape => { - const props = shape.props as { blockType: string; blockConfig: Record } - return { - id: shape.id, - blockType: props.blockType, - config: props.blockConfig || {}, - } - }) - - const connections: PortBinding[] = [] - for (const arrow of getWorkflowArrows(editor)) { - const binding = getPortBinding(editor, arrow.id) - if (binding) { - connections.push(binding) - } - } - - return { blocks, connections } -} - -/** - * Get topologically sorted execution order - */ -export function getExecutionOrder(editor: Editor): TLShapeId[] { - const { blocks, connections } = buildWorkflowGraph(editor) - - // Build adjacency list - const inDegree = new Map() - const outEdges = new Map() - - for (const block of blocks) { - inDegree.set(block.id, 0) - outEdges.set(block.id, []) - } - - for (const conn of connections) { - inDegree.set(conn.toShapeId, (inDegree.get(conn.toShapeId) || 0) + 1) - outEdges.get(conn.fromShapeId)?.push(conn.toShapeId) - } - - // Kahn's algorithm - const queue: TLShapeId[] = [] - const result: TLShapeId[] = [] - - for (const [id, degree] of inDegree) { - if (degree === 0) queue.push(id) - } - - while (queue.length > 0) { - const node = queue.shift()! - result.push(node) - - for (const neighbor of outEdges.get(node) || []) { - const newDegree = (inDegree.get(neighbor) || 1) - 1 - inDegree.set(neighbor, newDegree) - if (newDegree === 0) queue.push(neighbor) - } - } - - return result -} - -// ============================================================================= -// Connection Validation Helpers -// ============================================================================= - -/** - * Validate if a potential connection is valid - */ -export function canCreateConnection( - editor: Editor, - fromShapeId: TLShapeId, - fromPortId: string, - toShapeId: TLShapeId, - toPortId: string -): { valid: boolean; error?: string } { - const fromShape = editor.getShape(fromShapeId) - const toShape = editor.getShape(toShapeId) - - if (!fromShape || fromShape.type !== 'WorkflowBlock') { - return { valid: false, error: 'Source is not a workflow block' } - } - - if (!toShape || toShape.type !== 'WorkflowBlock') { - return { valid: false, error: 'Target is not a workflow block' } - } - - const fromProps = fromShape.props as { blockType: string } - const toProps = toShape.props as { blockType: string } - - const result = validateConnection( - fromProps.blockType, - fromPortId, - toProps.blockType, - toPortId - ) - - if (!result.valid) { - return { valid: false, error: result.errors[0]?.message } - } - - return { valid: true } -} - -/** - * Get block type from a shape ID - */ -export function getBlockType( - editor: Editor, - shapeId: TLShapeId -): string | undefined { - const shape = editor.getShape(shapeId) - if (!shape || shape.type !== 'WorkflowBlock') return undefined - - const props = shape.props as { blockType: string } - return props.blockType -} diff --git a/src/lib/workflow/serialization.ts b/src/lib/workflow/serialization.ts deleted file mode 100644 index 84ee495..0000000 --- a/src/lib/workflow/serialization.ts +++ /dev/null @@ -1,659 +0,0 @@ -/** - * Workflow Serialization - * - * Export and import workflows as JSON for sharing, backup, - * and loading templates. Compatible with Flowy JSON format. - */ - -import { Editor, TLShapeId, createShapeId } from 'tldraw' -import { PortBinding, WorkflowBlockProps } from './types' -import { IWorkflowBlock } from '@/shapes/WorkflowBlockShapeUtil' -import { - buildWorkflowGraph, - getPortBinding, - setPortBinding, - getAllWorkflowBlocks, -} from './portBindings' -import { hasBlockDefinition, getBlockDefinition } from './blockRegistry' - -// ============================================================================= -// Serialized Types -// ============================================================================= - -/** - * Serialized block format - */ -interface SerializedBlock { - id: string - type: string - x: number - y: number - w: number - h: number - blockType: string - blockConfig: Record - inputValues?: Record - tags?: string[] -} - -/** - * Serialized connection format - */ -interface SerializedConnection { - id: string - from: { - blockId: string - portId: string - } - to: { - blockId: string - portId: string - } -} - -/** - * Full workflow export format - */ -export interface SerializedWorkflow { - version: string - name: string - description?: string - createdAt: string - blocks: SerializedBlock[] - connections: SerializedConnection[] - metadata?: Record -} - -// ============================================================================= -// Export Functions -// ============================================================================= - -/** - * Export a workflow to JSON format - */ -export function exportWorkflow( - editor: Editor, - options: { - name?: string - description?: string - includeInputValues?: boolean - blockIds?: TLShapeId[] - } = {} -): SerializedWorkflow { - const { - name = 'Untitled Workflow', - description, - includeInputValues = false, - blockIds, - } = options - - // Get blocks to export - let blocks = getAllWorkflowBlocks(editor) as IWorkflowBlock[] - - if (blockIds && blockIds.length > 0) { - const blockIdSet = new Set(blockIds) - blocks = blocks.filter(b => blockIdSet.has(b.id)) - } - - // Serialize blocks - const serializedBlocks: SerializedBlock[] = blocks.map(block => ({ - id: block.id, - type: block.type, - x: block.x, - y: block.y, - w: block.props.w, - h: block.props.h, - blockType: block.props.blockType, - blockConfig: block.props.blockConfig, - inputValues: includeInputValues ? block.props.inputValues : undefined, - tags: block.props.tags, - })) - - // Get connections between exported blocks - const blockIdSet = new Set(blocks.map(b => b.id)) - const connections: SerializedConnection[] = [] - - // Find all arrows connecting our blocks - const arrows = editor.getCurrentPageShapes().filter(s => s.type === 'arrow') - for (const arrow of arrows) { - const binding = getPortBinding(editor, arrow.id) - if ( - binding && - blockIdSet.has(binding.fromShapeId) && - blockIdSet.has(binding.toShapeId) - ) { - connections.push({ - id: arrow.id, - from: { - blockId: binding.fromShapeId, - portId: binding.fromPortId, - }, - to: { - blockId: binding.toShapeId, - portId: binding.toPortId, - }, - }) - } - } - - return { - version: '1.0.0', - name, - description, - createdAt: new Date().toISOString(), - blocks: serializedBlocks, - connections, - } -} - -/** - * Export workflow to JSON string - */ -export function exportWorkflowToJSON( - editor: Editor, - options?: Parameters[1] -): string { - return JSON.stringify(exportWorkflow(editor, options), null, 2) -} - -/** - * Download workflow as JSON file - */ -export function downloadWorkflow( - editor: Editor, - options?: Parameters[1] -): void { - const workflow = exportWorkflow(editor, options) - const json = JSON.stringify(workflow, null, 2) - const blob = new Blob([json], { type: 'application/json' }) - const url = URL.createObjectURL(blob) - - const filename = `${workflow.name.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}.json` - - const a = document.createElement('a') - a.href = url - a.download = filename - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) -} - -// ============================================================================= -// Import Functions -// ============================================================================= - -/** - * Import a workflow from JSON format - */ -export function importWorkflow( - editor: Editor, - workflow: SerializedWorkflow, - options: { - offset?: { x: number; y: number } - preserveIds?: boolean - } = {} -): { - success: boolean - blockIds: TLShapeId[] - errors: string[] -} { - const { offset = { x: 0, y: 0 }, preserveIds = false } = options - const errors: string[] = [] - const blockIdMap = new Map() - const newBlockIds: TLShapeId[] = [] - - // Calculate bounds for centering - let minX = Infinity - let minY = Infinity - - for (const block of workflow.blocks) { - minX = Math.min(minX, block.x) - minY = Math.min(minY, block.y) - } - - // Create blocks - for (const block of workflow.blocks) { - // Validate block type - if (!hasBlockDefinition(block.blockType)) { - errors.push(`Unknown block type: ${block.blockType}`) - continue - } - - const definition = getBlockDefinition(block.blockType) - - // Generate or preserve ID - const newId = preserveIds && block.id.startsWith('shape:') - ? block.id as TLShapeId - : createShapeId() - - blockIdMap.set(block.id, newId) - - // Calculate position with offset - const x = block.x - minX + offset.x - const y = block.y - minY + offset.y - - // Calculate height based on ports - const maxPorts = Math.max(definition.inputs.length, definition.outputs.length) - const height = Math.max(block.h, 36 + 24 + maxPorts * 28 + 60) - - // Create the block - try { - editor.createShape({ - id: newId, - type: 'WorkflowBlock', - x, - y, - props: { - w: block.w, - h: height, - blockType: block.blockType, - blockConfig: block.blockConfig || {}, - inputValues: block.inputValues || {}, - outputValues: {}, - executionState: 'idle', - tags: block.tags || ['workflow'], - pinnedToView: false, - }, - }) - - newBlockIds.push(newId) - } catch (error) { - errors.push(`Failed to create block: ${(error as Error).message}`) - } - } - - // Create connections (arrows) - for (const conn of workflow.connections) { - const fromId = blockIdMap.get(conn.from.blockId) - const toId = blockIdMap.get(conn.to.blockId) - - if (!fromId || !toId) { - errors.push(`Connection references missing block`) - continue - } - - const fromBlock = editor.getShape(fromId) as IWorkflowBlock | undefined - const toBlock = editor.getShape(toId) as IWorkflowBlock | undefined - - if (!fromBlock || !toBlock) continue - - try { - // Create arrow between blocks - const arrowId = createShapeId() - - // Get port positions for arrow endpoints - const fromDef = getBlockDefinition(fromBlock.props.blockType) - const toDef = getBlockDefinition(toBlock.props.blockType) - - const fromPortIndex = fromDef.outputs.findIndex(p => p.id === conn.from.portId) - const toPortIndex = toDef.inputs.findIndex(p => p.id === conn.to.portId) - - if (fromPortIndex === -1 || toPortIndex === -1) { - errors.push(`Invalid port in connection`) - continue - } - - // Calculate port positions - const fromX = fromBlock.x + fromBlock.props.w - const fromY = fromBlock.y + 36 + 12 + fromPortIndex * 28 + 6 - - const toX = toBlock.x - const toY = toBlock.y + 36 + 12 + toPortIndex * 28 + 6 - - // Create arrow with bindings - editor.createShape({ - id: arrowId, - type: 'arrow', - x: 0, - y: 0, - props: { - start: { x: fromX, y: fromY }, - end: { x: toX, y: toY }, - color: 'black', - }, - meta: { - fromPortId: conn.from.portId, - toPortId: conn.to.portId, - validated: true, - }, - }) - - // Create bindings for the arrow - editor.createBinding({ - type: 'arrow', - fromId: arrowId, - toId: fromId, - props: { - terminal: 'start', - normalizedAnchor: { x: 1, y: 0.5 }, - isPrecise: false, - isExact: false, - }, - }) - - editor.createBinding({ - type: 'arrow', - fromId: arrowId, - toId: toId, - props: { - terminal: 'end', - normalizedAnchor: { x: 0, y: 0.5 }, - isPrecise: false, - isExact: false, - }, - }) - } catch (error) { - errors.push(`Failed to create connection: ${(error as Error).message}`) - } - } - - return { - success: errors.length === 0, - blockIds: newBlockIds, - errors, - } -} - -/** - * Import workflow from JSON string - */ -export function importWorkflowFromJSON( - editor: Editor, - json: string, - options?: Parameters[2] -): ReturnType { - try { - const workflow = JSON.parse(json) as SerializedWorkflow - return importWorkflow(editor, workflow, options) - } catch (error) { - return { - success: false, - blockIds: [], - errors: [`Invalid JSON: ${(error as Error).message}`], - } - } -} - -/** - * Load workflow from file - */ -export async function loadWorkflowFromFile( - editor: Editor, - file: File, - options?: Parameters[2] -): Promise> { - return new Promise((resolve) => { - const reader = new FileReader() - - reader.onload = (e) => { - const json = e.target?.result as string - resolve(importWorkflowFromJSON(editor, json, options)) - } - - reader.onerror = () => { - resolve({ - success: false, - blockIds: [], - errors: ['Failed to read file'], - }) - } - - reader.readAsText(file) - }) -} - -// ============================================================================= -// Workflow Templates -// ============================================================================= - -/** - * Pre-built workflow templates - */ -export const WORKFLOW_TEMPLATES: Record = { - 'api-transform-display': { - version: '1.0.0', - name: 'API Transform Display', - description: 'Fetch data from an API, transform it, and display the result', - createdAt: new Date().toISOString(), - blocks: [ - { - id: 'block-1', - type: 'WorkflowBlock', - x: 100, - y: 100, - w: 220, - h: 180, - blockType: 'trigger.manual', - blockConfig: {}, - tags: ['workflow', 'trigger'], - }, - { - id: 'block-2', - type: 'WorkflowBlock', - x: 400, - y: 100, - w: 220, - h: 200, - blockType: 'action.http', - blockConfig: { url: 'https://api.example.com/data', method: 'GET' }, - tags: ['workflow', 'action'], - }, - { - id: 'block-3', - type: 'WorkflowBlock', - x: 700, - y: 100, - w: 220, - h: 180, - blockType: 'transformer.jsonParse', - blockConfig: {}, - tags: ['workflow', 'transformer'], - }, - { - id: 'block-4', - type: 'WorkflowBlock', - x: 1000, - y: 100, - w: 220, - h: 180, - blockType: 'output.display', - blockConfig: { format: 'json' }, - tags: ['workflow', 'output'], - }, - ], - connections: [ - { - id: 'conn-1', - from: { blockId: 'block-1', portId: 'timestamp' }, - to: { blockId: 'block-2', portId: 'trigger' }, - }, - { - id: 'conn-2', - from: { blockId: 'block-2', portId: 'response' }, - to: { blockId: 'block-3', portId: 'input' }, - }, - { - id: 'conn-3', - from: { blockId: 'block-3', portId: 'output' }, - to: { blockId: 'block-4', portId: 'value' }, - }, - ], - }, - - 'llm-chain': { - version: '1.0.0', - name: 'LLM Chain', - description: 'Chain multiple LLM prompts together', - createdAt: new Date().toISOString(), - blocks: [ - { - id: 'block-1', - type: 'WorkflowBlock', - x: 100, - y: 100, - w: 220, - h: 180, - blockType: 'trigger.manual', - blockConfig: {}, - tags: ['workflow', 'trigger'], - }, - { - id: 'block-2', - type: 'WorkflowBlock', - x: 400, - y: 100, - w: 220, - h: 200, - blockType: 'ai.llm', - blockConfig: { systemPrompt: 'You are a helpful assistant.' }, - inputValues: { prompt: 'Summarize the following topic:' }, - tags: ['workflow', 'ai'], - }, - { - id: 'block-3', - type: 'WorkflowBlock', - x: 700, - y: 100, - w: 220, - h: 200, - blockType: 'ai.llm', - blockConfig: { systemPrompt: 'You are a creative writer.' }, - inputValues: { prompt: 'Expand on this summary:' }, - tags: ['workflow', 'ai'], - }, - { - id: 'block-4', - type: 'WorkflowBlock', - x: 1000, - y: 100, - w: 220, - h: 180, - blockType: 'output.display', - blockConfig: { format: 'text' }, - tags: ['workflow', 'output'], - }, - ], - connections: [ - { - id: 'conn-1', - from: { blockId: 'block-1', portId: 'timestamp' }, - to: { blockId: 'block-2', portId: 'trigger' }, - }, - { - id: 'conn-2', - from: { blockId: 'block-2', portId: 'response' }, - to: { blockId: 'block-3', portId: 'context' }, - }, - { - id: 'conn-3', - from: { blockId: 'block-3', portId: 'response' }, - to: { blockId: 'block-4', portId: 'value' }, - }, - ], - }, - - 'conditional-branch': { - version: '1.0.0', - name: 'Conditional Branch', - description: 'Branch workflow based on a condition', - createdAt: new Date().toISOString(), - blocks: [ - { - id: 'block-1', - type: 'WorkflowBlock', - x: 100, - y: 200, - w: 220, - h: 180, - blockType: 'trigger.manual', - blockConfig: {}, - tags: ['workflow', 'trigger'], - }, - { - id: 'block-2', - type: 'WorkflowBlock', - x: 400, - y: 200, - w: 220, - h: 200, - blockType: 'condition.if', - blockConfig: {}, - tags: ['workflow', 'condition'], - }, - { - id: 'block-3', - type: 'WorkflowBlock', - x: 700, - y: 50, - w: 220, - h: 180, - blockType: 'output.log', - blockConfig: { level: 'info' }, - inputValues: { message: 'Condition was TRUE' }, - tags: ['workflow', 'output'], - }, - { - id: 'block-4', - type: 'WorkflowBlock', - x: 700, - y: 300, - w: 220, - h: 180, - blockType: 'output.log', - blockConfig: { level: 'warn' }, - inputValues: { message: 'Condition was FALSE' }, - tags: ['workflow', 'output'], - }, - ], - connections: [ - { - id: 'conn-1', - from: { blockId: 'block-1', portId: 'timestamp' }, - to: { blockId: 'block-2', portId: 'value' }, - }, - { - id: 'conn-2', - from: { blockId: 'block-2', portId: 'true' }, - to: { blockId: 'block-3', portId: 'message' }, - }, - { - id: 'conn-3', - from: { blockId: 'block-2', portId: 'false' }, - to: { blockId: 'block-4', portId: 'message' }, - }, - ], - }, -} - -/** - * Load a workflow template - */ -export function loadTemplate( - editor: Editor, - templateId: string, - options?: Parameters[2] -): ReturnType { - const template = WORKFLOW_TEMPLATES[templateId] - - if (!template) { - return { - success: false, - blockIds: [], - errors: [`Unknown template: ${templateId}`], - } - } - - return importWorkflow(editor, template, options) -} - -/** - * Get available template names - */ -export function getTemplateNames(): Array<{ id: string; name: string; description?: string }> { - return Object.entries(WORKFLOW_TEMPLATES).map(([id, template]) => ({ - id, - name: template.name, - description: template.description, - })) -} diff --git a/src/lib/workflow/types.ts b/src/lib/workflow/types.ts deleted file mode 100644 index acec0f8..0000000 --- a/src/lib/workflow/types.ts +++ /dev/null @@ -1,385 +0,0 @@ -/** - * Workflow Builder Type Definitions - * - * Core types for the Flowy-like workflow system including: - * - Port type system for typed connections - * - Block definitions for workflow nodes - * - Execution context and results - * - Serialization format - */ - -import { TLBaseShape, TLShapeId } from 'tldraw' - -// ============================================================================= -// Port Type System -// ============================================================================= - -/** - * Data types that can flow through ports - */ -export type PortDataType = - | 'text' // String data - | 'number' // Numeric data - | 'boolean' // True/false - | 'object' // JSON objects - | 'array' // Arrays of any type - | 'any' // Accepts all types - | 'file' // Binary/file data - | 'image' // Image data (base64 or URL) - -/** - * Base port definition shared by inputs and outputs - */ -export interface PortDefinition { - id: string - name: string - type: PortDataType - required: boolean - description?: string - defaultValue?: unknown -} - -/** - * Input port - receives data from connected output ports - */ -export interface InputPort extends PortDefinition { - direction: 'input' - accepts: PortDataType[] // Types this port can receive -} - -/** - * Output port - sends data to connected input ports - */ -export interface OutputPort extends PortDefinition { - direction: 'output' - produces: PortDataType // Type this port outputs -} - -export type Port = InputPort | OutputPort - -// ============================================================================= -// Block Categories and Definitions -// ============================================================================= - -/** - * Categories for organizing blocks in the palette - */ -export type BlockCategory = - | 'trigger' // Manual, schedule, webhook, event - | 'action' // API calls, canvas operations - | 'condition' // If/else, switch - | 'transformer' // Data manipulation - | 'output' // Display, export, notify - | 'ai' // LLM, image gen, etc. - -/** - * Category display information - */ -export interface CategoryInfo { - name: string - icon: string - color: string - description: string -} - -/** - * Complete block definition for the registry - */ -export interface BlockDefinition { - type: string // Unique identifier (e.g., 'action.http') - category: BlockCategory - name: string // Display name - description: string - icon: string // Emoji or icon identifier - color: string // Primary color for the block - inputs: InputPort[] - outputs: OutputPort[] - configSchema?: object // JSON Schema for block configuration - defaultConfig?: object // Default configuration values - executor?: string // Name of executor function -} - -// ============================================================================= -// Shape Types -// ============================================================================= - -/** - * Props stored on WorkflowBlock shapes - */ -export interface WorkflowBlockProps { - w: number - h: number - blockType: string // Reference to BlockDefinition.type - blockConfig: Record // User-configured values - inputValues: Record // Current input port values - outputValues: Record // Current output port values - executionState: ExecutionState - executionError?: string - lastExecutedAt?: number - tags: string[] - pinnedToView: boolean -} - -/** - * Execution state for visual feedback - */ -export type ExecutionState = 'idle' | 'running' | 'success' | 'error' - -/** - * The WorkflowBlock shape type for tldraw - */ -export type WorkflowBlockShape = TLBaseShape<'WorkflowBlock', WorkflowBlockProps> - -// ============================================================================= -// Port Binding (Arrow Connections) -// ============================================================================= - -/** - * Represents a connection between two ports via an arrow - */ -export interface PortBinding { - fromShapeId: TLShapeId - fromPortId: string - toShapeId: TLShapeId - toPortId: string - arrowId: TLShapeId -} - -/** - * Arrow metadata for storing port binding info - */ -export interface ArrowPortMeta { - fromPortId?: string - toPortId?: string - validated?: boolean - validationError?: string -} - -// ============================================================================= -// Execution Types -// ============================================================================= - -/** - * Context passed to block executors - */ -export interface ExecutionContext { - workflowId: string - executionId: string - mode: 'manual' | 'realtime' - startTime: number - variables: Record - abortSignal?: AbortSignal -} - -/** - * Result from executing a single block - */ -export interface BlockExecutionResult { - blockId: TLShapeId - blockType: string - status: 'success' | 'error' | 'skipped' - outputs: Record - error?: string - duration: number - startTime: number - endTime: number -} - -/** - * Result from executing an entire workflow - */ -export interface WorkflowExecutionResult { - workflowId: string - executionId: string - status: 'success' | 'partial' | 'error' | 'aborted' - results: BlockExecutionResult[] - totalDuration: number - startTime: number - endTime: number - error?: string -} - -// ============================================================================= -// Callbacks (Flowy-compatible events) -// ============================================================================= - -/** - * Event callbacks for workflow interactions - */ -export interface WorkflowCallbacks { - onBlockAdd?: (block: WorkflowBlockShape) => void - onBlockRemove?: (blockId: TLShapeId) => void - onBlockUpdate?: (block: WorkflowBlockShape) => void - onConnect?: (binding: PortBinding) => void - onDisconnect?: (binding: PortBinding) => void - onValidationError?: (binding: PortBinding, error: string) => void - onExecutionStart?: (context: ExecutionContext) => void - onBlockExecute?: (result: BlockExecutionResult) => void - onExecutionComplete?: (result: WorkflowExecutionResult) => void -} - -// ============================================================================= -// Serialization Format -// ============================================================================= - -/** - * Serialized block for export/import - */ -export interface SerializedBlock { - id: string - type: string - position: { x: number; y: number } - size: { w: number; h: number } - config: Record -} - -/** - * Serialized connection for export/import - */ -export interface SerializedConnection { - id: string - fromBlock: string - fromPort: string - toBlock: string - toPort: string -} - -/** - * Complete serialized workflow - */ -export interface SerializedWorkflow { - version: string - name: string - description?: string - blocks: SerializedBlock[] - connections: SerializedConnection[] - metadata?: { - createdAt: number - updatedAt: number - author?: string - } -} - -// ============================================================================= -// Block Executor Types -// ============================================================================= - -/** - * Function signature for block executors - */ -export type BlockExecutor = ( - inputs: Record, - config: Record, - context: ExecutionContext -) => Promise> - -/** - * Registry of block executors - */ -export type BlockExecutorRegistry = Record - -// ============================================================================= -// UI State Types -// ============================================================================= - -/** - * State for the workflow palette UI - */ -export interface WorkflowPaletteState { - isOpen: boolean - expandedCategory: BlockCategory | null - searchQuery: string - selectedBlockType: string | null -} - -/** - * State for workflow execution UI - */ -export interface WorkflowExecutionState { - isRunning: boolean - currentBlockId: TLShapeId | null - executionHistory: WorkflowExecutionResult[] - realtimeEnabled: boolean -} - -// ============================================================================= -// Utility Types -// ============================================================================= - -/** - * Check if a type is compatible with another - */ -export function isTypeCompatible( - outputType: PortDataType, - inputAccepts: PortDataType[] -): boolean { - // 'any' accepts everything - if (inputAccepts.includes('any')) return true - - // Direct match - if (inputAccepts.includes(outputType)) return true - - // 'any' output can go to anything - if (outputType === 'any') return true - - return false -} - -/** - * Get color for a port data type - */ -export function getPortTypeColor(type: PortDataType): string { - const colors: Record = { - text: '#10b981', // Green - number: '#3b82f6', // Blue - boolean: '#8b5cf6', // Purple - object: '#f59e0b', // Amber - array: '#ec4899', // Pink - any: '#6b7280', // Gray - file: '#ef4444', // Red - image: '#06b6d4', // Cyan - } - return colors[type] || colors.any -} - -/** - * Category information for UI - */ -export const CATEGORY_INFO: Record = { - trigger: { - name: 'Triggers', - icon: '⚡', - color: '#f59e0b', - description: 'Start workflows with triggers', - }, - action: { - name: 'Actions', - icon: '🔧', - color: '#3b82f6', - description: 'Perform operations and API calls', - }, - condition: { - name: 'Conditions', - icon: '❓', - color: '#8b5cf6', - description: 'Branch based on conditions', - }, - transformer: { - name: 'Transformers', - icon: '🔄', - color: '#10b981', - description: 'Transform and manipulate data', - }, - output: { - name: 'Outputs', - icon: '📤', - color: '#ef4444', - description: 'Display and export results', - }, - ai: { - name: 'AI', - icon: '🤖', - color: '#ec4899', - description: 'AI and machine learning blocks', - }, -} diff --git a/src/lib/workflow/validation.ts b/src/lib/workflow/validation.ts deleted file mode 100644 index b970961..0000000 --- a/src/lib/workflow/validation.ts +++ /dev/null @@ -1,466 +0,0 @@ -/** - * Port Validation - * - * Handles type compatibility checking between ports and validates - * workflow connections to prevent invalid data flow. - */ - -import { - PortDataType, - InputPort, - OutputPort, - BlockDefinition, - PortBinding, - isTypeCompatible, -} from './types' -import { getBlockDefinition, hasBlockDefinition } from './blockRegistry' - -// ============================================================================= -// Validation Result Types -// ============================================================================= - -export interface ValidationResult { - valid: boolean - errors: ValidationError[] - warnings: ValidationWarning[] -} - -export interface ValidationError { - type: 'type_mismatch' | 'missing_required' | 'unknown_block' | 'unknown_port' | 'cycle_detected' - message: string - blockId?: string - portId?: string - details?: Record -} - -export interface ValidationWarning { - type: 'implicit_conversion' | 'unused_output' | 'unconnected_input' - message: string - blockId?: string - portId?: string -} - -// ============================================================================= -// Port Compatibility -// ============================================================================= - -/** - * Check if an output port can connect to an input port - */ -export function canConnect( - outputPort: OutputPort, - inputPort: InputPort -): boolean { - return isTypeCompatible(outputPort.produces, inputPort.accepts) -} - -/** - * Check if a specific type can connect to an input port - */ -export function canConnectType( - outputType: PortDataType, - inputPort: InputPort -): boolean { - return isTypeCompatible(outputType, inputPort.accepts) -} - -/** - * Get all compatible ports on a target block for a given output port - */ -export function getCompatibleInputPorts( - sourceBlockType: string, - sourcePortId: string, - targetBlockType: string -): InputPort[] { - if (!hasBlockDefinition(sourceBlockType) || !hasBlockDefinition(targetBlockType)) { - return [] - } - - const sourceBlock = getBlockDefinition(sourceBlockType) - const targetBlock = getBlockDefinition(targetBlockType) - - const sourcePort = sourceBlock.outputs.find(p => p.id === sourcePortId) - if (!sourcePort) return [] - - return targetBlock.inputs.filter(inputPort => - canConnect(sourcePort, inputPort) - ) -} - -/** - * Get all compatible output ports on a source block for a given input port - */ -export function getCompatibleOutputPorts( - sourceBlockType: string, - targetBlockType: string, - targetPortId: string -): OutputPort[] { - if (!hasBlockDefinition(sourceBlockType) || !hasBlockDefinition(targetBlockType)) { - return [] - } - - const sourceBlock = getBlockDefinition(sourceBlockType) - const targetBlock = getBlockDefinition(targetBlockType) - - const targetPort = targetBlock.inputs.find(p => p.id === targetPortId) - if (!targetPort) return [] - - return sourceBlock.outputs.filter(outputPort => - canConnect(outputPort, targetPort) - ) -} - -// ============================================================================= -// Connection Validation -// ============================================================================= - -/** - * Validate a single connection between two blocks - */ -export function validateConnection( - sourceBlockType: string, - sourcePortId: string, - targetBlockType: string, - targetPortId: string -): ValidationResult { - const errors: ValidationError[] = [] - const warnings: ValidationWarning[] = [] - - // Check source block exists - if (!hasBlockDefinition(sourceBlockType)) { - errors.push({ - type: 'unknown_block', - message: `Unknown source block type: ${sourceBlockType}`, - details: { blockType: sourceBlockType }, - }) - return { valid: false, errors, warnings } - } - - // Check target block exists - if (!hasBlockDefinition(targetBlockType)) { - errors.push({ - type: 'unknown_block', - message: `Unknown target block type: ${targetBlockType}`, - details: { blockType: targetBlockType }, - }) - return { valid: false, errors, warnings } - } - - const sourceBlock = getBlockDefinition(sourceBlockType) - const targetBlock = getBlockDefinition(targetBlockType) - - // Check source port exists - const sourcePort = sourceBlock.outputs.find(p => p.id === sourcePortId) - if (!sourcePort) { - errors.push({ - type: 'unknown_port', - message: `Unknown output port "${sourcePortId}" on block "${sourceBlockType}"`, - portId: sourcePortId, - details: { blockType: sourceBlockType, availablePorts: sourceBlock.outputs.map(p => p.id) }, - }) - return { valid: false, errors, warnings } - } - - // Check target port exists - const targetPort = targetBlock.inputs.find(p => p.id === targetPortId) - if (!targetPort) { - errors.push({ - type: 'unknown_port', - message: `Unknown input port "${targetPortId}" on block "${targetBlockType}"`, - portId: targetPortId, - details: { blockType: targetBlockType, availablePorts: targetBlock.inputs.map(p => p.id) }, - }) - return { valid: false, errors, warnings } - } - - // Check type compatibility - if (!canConnect(sourcePort, targetPort)) { - errors.push({ - type: 'type_mismatch', - message: `Type mismatch: "${sourcePort.produces}" cannot connect to "${targetPort.accepts.join(' | ')}"`, - details: { - sourceType: sourcePort.produces, - targetAccepts: targetPort.accepts, - sourcePort: sourcePortId, - targetPort: targetPortId, - }, - }) - return { valid: false, errors, warnings } - } - - // Check for implicit conversions (warning, not error) - if (sourcePort.produces !== targetPort.type && targetPort.accepts.includes('any')) { - warnings.push({ - type: 'implicit_conversion', - message: `Implicit conversion from "${sourcePort.produces}" to "${targetPort.type}"`, - }) - } - - return { valid: true, errors, warnings } -} - -/** - * Validate a port binding - */ -export function validatePortBinding( - binding: PortBinding, - getBlockType: (shapeId: string) => string | undefined -): ValidationResult { - const sourceType = getBlockType(binding.fromShapeId as string) - const targetType = getBlockType(binding.toShapeId as string) - - if (!sourceType || !targetType) { - return { - valid: false, - errors: [{ - type: 'unknown_block', - message: 'Could not determine block types for binding', - blockId: !sourceType ? binding.fromShapeId as string : binding.toShapeId as string, - }], - warnings: [], - } - } - - return validateConnection( - sourceType, - binding.fromPortId, - targetType, - binding.toPortId - ) -} - -// ============================================================================= -// Block Validation -// ============================================================================= - -/** - * Validate a block's configuration - */ -export function validateBlockConfig( - blockType: string, - config: Record -): ValidationResult { - const errors: ValidationError[] = [] - const warnings: ValidationWarning[] = [] - - if (!hasBlockDefinition(blockType)) { - errors.push({ - type: 'unknown_block', - message: `Unknown block type: ${blockType}`, - }) - return { valid: false, errors, warnings } - } - - const definition = getBlockDefinition(blockType) - - // If no config schema, any config is valid - if (!definition.configSchema) { - return { valid: true, errors, warnings } - } - - // Basic schema validation (could use ajv for full JSON Schema validation) - const schema = definition.configSchema as { properties?: Record } - if (schema.properties) { - for (const [key, propSchema] of Object.entries(schema.properties)) { - const prop = propSchema as { type?: string; required?: boolean; enum?: unknown[] } - - // Check required properties - if (prop.required && !(key in config)) { - errors.push({ - type: 'missing_required', - message: `Missing required configuration: ${key}`, - details: { key }, - }) - } - - // Check enum values - if (prop.enum && key in config && !prop.enum.includes(config[key])) { - errors.push({ - type: 'type_mismatch', - message: `Invalid value for "${key}": must be one of ${prop.enum.join(', ')}`, - details: { key, value: config[key], allowed: prop.enum }, - }) - } - } - } - - return { valid: errors.length === 0, errors, warnings } -} - -/** - * Check if a block has all required inputs satisfied - */ -export function validateRequiredInputs( - blockType: string, - inputValues: Record, - connectedInputs: string[] -): ValidationResult { - const errors: ValidationError[] = [] - const warnings: ValidationWarning[] = [] - - if (!hasBlockDefinition(blockType)) { - errors.push({ - type: 'unknown_block', - message: `Unknown block type: ${blockType}`, - }) - return { valid: false, errors, warnings } - } - - const definition = getBlockDefinition(blockType) - - for (const input of definition.inputs) { - if (input.required) { - const hasValue = input.id in inputValues && inputValues[input.id] !== undefined - const hasConnection = connectedInputs.includes(input.id) - - if (!hasValue && !hasConnection) { - errors.push({ - type: 'missing_required', - message: `Required input "${input.name}" is not connected or provided`, - portId: input.id, - }) - } - } - } - - // Warn about unconnected optional inputs - for (const input of definition.inputs) { - if (!input.required) { - const hasValue = input.id in inputValues && inputValues[input.id] !== undefined - const hasConnection = connectedInputs.includes(input.id) - - if (!hasValue && !hasConnection && input.defaultValue === undefined) { - warnings.push({ - type: 'unconnected_input', - message: `Optional input "${input.name}" has no value or connection`, - portId: input.id, - }) - } - } - } - - return { valid: errors.length === 0, errors, warnings } -} - -// ============================================================================= -// Workflow Validation -// ============================================================================= - -/** - * Detect cycles in a workflow graph - */ -export function detectCycles( - connections: PortBinding[] -): { hasCycle: boolean; cycleNodes?: string[] } { - // Build adjacency list - const graph = new Map>() - - for (const conn of connections) { - const from = conn.fromShapeId as string - const to = conn.toShapeId as string - - if (!graph.has(from)) graph.set(from, new Set()) - graph.get(from)!.add(to) - } - - // DFS to detect cycles - const visited = new Set() - const recursionStack = new Set() - const cyclePath: string[] = [] - - function dfs(node: string): boolean { - visited.add(node) - recursionStack.add(node) - cyclePath.push(node) - - const neighbors = graph.get(node) || new Set() - for (const neighbor of neighbors) { - if (!visited.has(neighbor)) { - if (dfs(neighbor)) return true - } else if (recursionStack.has(neighbor)) { - cyclePath.push(neighbor) - return true - } - } - - cyclePath.pop() - recursionStack.delete(node) - return false - } - - for (const node of graph.keys()) { - if (!visited.has(node)) { - if (dfs(node)) { - // Extract just the cycle portion - const cycleStart = cyclePath.indexOf(cyclePath[cyclePath.length - 1]) - return { - hasCycle: true, - cycleNodes: cyclePath.slice(cycleStart), - } - } - } - } - - return { hasCycle: false } -} - -/** - * Validate an entire workflow - */ -export function validateWorkflow( - blocks: Array<{ id: string; blockType: string; config: Record }>, - connections: PortBinding[] -): ValidationResult { - const errors: ValidationError[] = [] - const warnings: ValidationWarning[] = [] - - // Validate each connection - for (const conn of connections) { - const sourceBlock = blocks.find(b => b.id === conn.fromShapeId) - const targetBlock = blocks.find(b => b.id === conn.toShapeId) - - if (sourceBlock && targetBlock) { - const result = validateConnection( - sourceBlock.blockType, - conn.fromPortId, - targetBlock.blockType, - conn.toPortId - ) - errors.push(...result.errors) - warnings.push(...result.warnings) - } - } - - // Check for cycles - const cycleResult = detectCycles(connections) - if (cycleResult.hasCycle) { - errors.push({ - type: 'cycle_detected', - message: `Cycle detected in workflow: ${cycleResult.cycleNodes?.join(' -> ')}`, - details: { cycleNodes: cycleResult.cycleNodes }, - }) - } - - // Check for unused outputs (optional warning) - const connectedOutputs = new Set(connections.map(c => `${c.fromShapeId}:${c.fromPortId}`)) - for (const block of blocks) { - if (!hasBlockDefinition(block.blockType)) continue - const def = getBlockDefinition(block.blockType) - - for (const output of def.outputs) { - if (!connectedOutputs.has(`${block.id}:${output.id}`)) { - // Only warn for non-terminal blocks - if (def.category !== 'output') { - warnings.push({ - type: 'unused_output', - message: `Output "${output.name}" on block "${def.name}" is not connected`, - blockId: block.id, - portId: output.id, - }) - } - } - } - } - - return { valid: errors.length === 0, errors, warnings } -} diff --git a/src/propagators/WorkflowPropagator.ts b/src/propagators/WorkflowPropagator.ts deleted file mode 100644 index cbdd2ce..0000000 --- a/src/propagators/WorkflowPropagator.ts +++ /dev/null @@ -1,404 +0,0 @@ -/** - * WorkflowPropagator - * - * A propagator that handles real-time data flow between workflow blocks. - * When a workflow block's output changes, it automatically propagates - * the data to connected downstream blocks and triggers their execution. - * - * Uses the 'flow' prefix for arrows (e.g., flow{ ... } in arrow text) - * to identify workflow connections. - */ - -import { - Editor, - TLArrowBinding, - TLArrowShape, - TLShape, - TLShapeId, -} from 'tldraw' -import { getEdge, getArrowsFromShape } from './tlgraph' -import { isShapeOfType } from './utils' -import { IWorkflowBlock } from '@/shapes/WorkflowBlockShapeUtil' -import { - getPortBinding, - getBlockOutputBindings, - getDownstreamBlocks, -} from '@/lib/workflow/portBindings' -import { executeBlock } from '@/lib/workflow/executor' -import { canConnect } from '@/lib/workflow/validation' -import { getBlockDefinition, hasBlockDefinition } from '@/lib/workflow/blockRegistry' - -// ============================================================================= -// Configuration -// ============================================================================= - -/** - * Whether to auto-execute downstream blocks when outputs change - */ -let autoExecuteEnabled = true - -/** - * Debounce time for propagation (ms) - */ -const PROPAGATION_DEBOUNCE = 100 - -/** - * Enable/disable auto-execution - */ -export function setAutoExecute(enabled: boolean): void { - autoExecuteEnabled = enabled -} - -/** - * Get auto-execution status - */ -export function isAutoExecuteEnabled(): boolean { - return autoExecuteEnabled -} - -// ============================================================================= -// Propagator State -// ============================================================================= - -interface PropagatorState { - editor: Editor | null - watchedBlocks: Set - pendingPropagations: Map - executingBlocks: Set -} - -const state: PropagatorState = { - editor: null, - watchedBlocks: new Set(), - pendingPropagations: new Map(), - executingBlocks: new Set(), -} - -// ============================================================================= -// Propagator Functions -// ============================================================================= - -/** - * Check if a shape is a workflow block - */ -function isWorkflowBlock(shape: TLShape | undefined): shape is IWorkflowBlock { - return shape?.type === 'WorkflowBlock' -} - -/** - * Check if an arrow is a workflow connection (connects two workflow blocks) - */ -function isWorkflowArrow(editor: Editor, arrowId: TLShapeId): boolean { - const arrow = editor.getShape(arrowId) as TLArrowShape | undefined - if (!arrow || arrow.type !== 'arrow') return false - - const bindings = editor.getBindingsInvolvingShape(arrowId) - if (bindings.length !== 2) return false - - const startBinding = bindings.find(b => b.props.terminal === 'start') - const endBinding = bindings.find(b => b.props.terminal === 'end') - - if (!startBinding || !endBinding) return false - - const startShape = editor.getShape(startBinding.toId) - const endShape = editor.getShape(endBinding.toId) - - return isWorkflowBlock(startShape) && isWorkflowBlock(endShape) -} - -/** - * Propagate output data from a block to its downstream connections - */ -async function propagateOutputs( - editor: Editor, - sourceBlockId: TLShapeId -): Promise { - if (!autoExecuteEnabled) return - if (state.executingBlocks.has(sourceBlockId)) return - - const sourceShape = editor.getShape(sourceBlockId) as IWorkflowBlock | undefined - if (!sourceShape || !isWorkflowBlock(sourceShape)) return - - const outputBindings = getBlockOutputBindings(editor, sourceBlockId) - const downstreamBlocks = new Set() - - // Collect downstream blocks and update their input values - for (const binding of outputBindings) { - const outputValue = sourceShape.props.outputValues?.[binding.fromPortId] - - if (outputValue !== undefined) { - // Update the target block's input value - const targetShape = editor.getShape(binding.toShapeId) as IWorkflowBlock | undefined - if (targetShape && isWorkflowBlock(targetShape)) { - editor.updateShape({ - id: binding.toShapeId, - type: 'WorkflowBlock', - props: { - inputValues: { - ...targetShape.props.inputValues, - [binding.toPortId]: outputValue, - }, - }, - }) - downstreamBlocks.add(binding.toShapeId) - } - } - } - - // Execute downstream blocks if auto-execute is enabled - for (const blockId of downstreamBlocks) { - // Skip blocks that are already executing - if (state.executingBlocks.has(blockId)) continue - - const blockShape = editor.getShape(blockId) as IWorkflowBlock | undefined - if (!blockShape) continue - - // Check if block has all required inputs satisfied - if (hasBlockDefinition(blockShape.props.blockType)) { - const definition = getBlockDefinition(blockShape.props.blockType) - const requiredInputs = definition.inputs.filter(i => i.required) - const hasAllRequired = requiredInputs.every(input => { - const inputValue = blockShape.props.inputValues?.[input.id] - return inputValue !== undefined - }) - - if (hasAllRequired) { - // Debounce execution to avoid rapid-fire updates - const existingTimeout = state.pendingPropagations.get(blockId) - if (existingTimeout) clearTimeout(existingTimeout) - - const timeout = setTimeout(async () => { - state.pendingPropagations.delete(blockId) - state.executingBlocks.add(blockId) - - try { - await executeBlock(editor, blockId) - } finally { - state.executingBlocks.delete(blockId) - } - }, PROPAGATION_DEBOUNCE) - - state.pendingPropagations.set(blockId, timeout) - } - } - } -} - -/** - * Handle workflow block changes - */ -function onBlockChange(editor: Editor, shape: TLShape): void { - if (!isWorkflowBlock(shape)) return - - // Check if output values changed - const oldShape = editor.store.query.record('shape', () => shape.id) - if (oldShape && isWorkflowBlock(oldShape as TLShape)) { - const oldOutputs = (oldShape as IWorkflowBlock).props.outputValues - const newOutputs = shape.props.outputValues - - // Only propagate if outputs actually changed - if (JSON.stringify(oldOutputs) !== JSON.stringify(newOutputs)) { - propagateOutputs(editor, shape.id) - } - } -} - -/** - * Handle arrow changes to update port bindings - */ -function onArrowChange(editor: Editor, arrow: TLArrowShape): void { - if (!isWorkflowArrow(editor, arrow.id)) return - - const edge = getEdge(arrow, editor) - if (!edge) return - - const fromShape = editor.getShape(edge.from) as IWorkflowBlock | undefined - const toShape = editor.getShape(edge.to) as IWorkflowBlock | undefined - - if (!fromShape || !toShape) return - if (!isWorkflowBlock(fromShape) || !isWorkflowBlock(toShape)) return - - // Determine port IDs based on arrow position or existing meta - const meta = (arrow.meta || {}) as { fromPortId?: string; toPortId?: string } - - // If meta already has port IDs, validate the connection - if (meta.fromPortId && meta.toPortId) { - if (!hasBlockDefinition(fromShape.props.blockType) || - !hasBlockDefinition(toShape.props.blockType)) { - return - } - - const fromDef = getBlockDefinition(fromShape.props.blockType) - const toDef = getBlockDefinition(toShape.props.blockType) - - const fromPort = fromDef.outputs.find(p => p.id === meta.fromPortId) - const toPort = toDef.inputs.find(p => p.id === meta.toPortId) - - if (fromPort && toPort && canConnect(fromPort, toPort)) { - // Valid connection - update arrow color to indicate valid - editor.updateShape({ - id: arrow.id, - type: 'arrow', - props: { color: 'black' }, - }) - } else { - // Invalid connection - editor.updateShape({ - id: arrow.id, - type: 'arrow', - props: { color: 'orange' }, - }) - } - } else { - // Auto-detect ports based on first available compatible pair - if (!hasBlockDefinition(fromShape.props.blockType) || - !hasBlockDefinition(toShape.props.blockType)) { - return - } - - const fromDef = getBlockDefinition(fromShape.props.blockType) - const toDef = getBlockDefinition(toShape.props.blockType) - - // Find first compatible port pair - for (const outputPort of fromDef.outputs) { - for (const inputPort of toDef.inputs) { - if (canConnect(outputPort, inputPort)) { - // Set port binding on arrow meta - editor.updateShape({ - id: arrow.id, - type: 'arrow', - meta: { - ...arrow.meta, - fromPortId: outputPort.id, - toPortId: inputPort.id, - validated: true, - }, - props: { color: 'black' }, - }) - return - } - } - } - - // No compatible ports found - editor.updateShape({ - id: arrow.id, - type: 'arrow', - props: { color: 'orange' }, - }) - } -} - -// ============================================================================= -// Registration -// ============================================================================= - -/** - * Register the workflow propagator with the editor - */ -export function registerWorkflowPropagator(editor: Editor): () => void { - state.editor = editor - state.watchedBlocks.clear() - state.pendingPropagations.clear() - state.executingBlocks.clear() - - // Initialize: find all existing workflow blocks - for (const shape of editor.getCurrentPageShapes()) { - if (isWorkflowBlock(shape)) { - state.watchedBlocks.add(shape.id) - } - } - - // Register change handler - const unsubscribeChange = editor.sideEffects.registerAfterChangeHandler<'shape'>( - 'shape', - (_, next) => { - // Handle workflow block changes - if (isWorkflowBlock(next)) { - state.watchedBlocks.add(next.id) - onBlockChange(editor, next) - } - - // Handle arrow changes - if (isShapeOfType(next, 'arrow')) { - onArrowChange(editor, next) - } - } - ) - - // Register create handler - const unsubscribeCreate = editor.sideEffects.registerAfterCreateHandler<'shape'>( - 'shape', - (shape) => { - if (isWorkflowBlock(shape)) { - state.watchedBlocks.add(shape.id) - } - } - ) - - // Register delete handler - const unsubscribeDelete = editor.sideEffects.registerAfterDeleteHandler<'shape'>( - 'shape', - (shape) => { - if (shape.type === 'WorkflowBlock') { - state.watchedBlocks.delete(shape.id) - state.pendingPropagations.delete(shape.id) - state.executingBlocks.delete(shape.id) - } - } - ) - - // Register binding change handler for arrows - const unsubscribeBinding = editor.sideEffects.registerAfterChangeHandler<'binding'>( - 'binding', - (_, binding) => { - if (binding.type !== 'arrow') return - const arrow = editor.getShape(binding.fromId) - if (arrow && isShapeOfType(arrow, 'arrow')) { - onArrowChange(editor, arrow) - } - } - ) - - // Return cleanup function - return () => { - unsubscribeChange() - unsubscribeCreate() - unsubscribeDelete() - unsubscribeBinding() - - // Clear pending propagations - for (const timeout of state.pendingPropagations.values()) { - clearTimeout(timeout) - } - - state.editor = null - state.watchedBlocks.clear() - state.pendingPropagations.clear() - state.executingBlocks.clear() - } -} - -/** - * Manually trigger propagation from a block - */ -export function triggerPropagation( - editor: Editor, - blockId: TLShapeId -): Promise { - return propagateOutputs(editor, blockId) -} - -/** - * Get all blocks currently being watched - */ -export function getWatchedBlocks(): TLShapeId[] { - return Array.from(state.watchedBlocks) -} - -/** - * Check if a block is currently executing - */ -export function isBlockExecuting(blockId: TLShapeId): boolean { - return state.executingBlocks.has(blockId) -} diff --git a/src/shapes/WorkflowBlockShapeUtil.tsx b/src/shapes/WorkflowBlockShapeUtil.tsx deleted file mode 100644 index e32c3a3..0000000 --- a/src/shapes/WorkflowBlockShapeUtil.tsx +++ /dev/null @@ -1,539 +0,0 @@ -/** - * WorkflowBlockShapeUtil - * - * A visual workflow block shape with typed input/output ports. - * Supports connection to other blocks via tldraw arrows for - * building automation flows, data pipelines, and AI agent chains. - */ - -import { - BaseBoxShapeUtil, - Geometry2d, - HTMLContainer, - Rectangle2d, - TLBaseShape, - Vec, -} from 'tldraw' -import React, { useState, useCallback, useMemo } from 'react' -import { StandardizedToolWrapper } from '../components/StandardizedToolWrapper' -import { usePinnedToView } from '../hooks/usePinnedToView' -import { useMaximize } from '../hooks/useMaximize' -import { - WorkflowBlockProps, - ExecutionState, - getPortTypeColor, - CATEGORY_INFO, -} from '../lib/workflow/types' -import { - getBlockDefinition, - hasBlockDefinition, -} from '../lib/workflow/blockRegistry' - -// ============================================================================= -// Shape Type Definition -// ============================================================================= - -export type IWorkflowBlock = TLBaseShape<'WorkflowBlock', WorkflowBlockProps> - -// ============================================================================= -// Constants -// ============================================================================= - -const PORT_SIZE = 12 -const PORT_SPACING = 28 -const HEADER_HEIGHT = 36 -const MIN_WIDTH = 180 -const MIN_HEIGHT = 100 -const DEFAULT_WIDTH = 220 -const DEFAULT_HEIGHT = 150 - -// ============================================================================= -// Execution State Colors -// ============================================================================= - -const EXECUTION_COLORS: Record = { - idle: { bg: 'transparent', border: 'transparent', text: '#6b7280' }, - running: { bg: '#fef3c7', border: '#f59e0b', text: '#92400e' }, - success: { bg: '#d1fae5', border: '#10b981', text: '#065f46' }, - error: { bg: '#fee2e2', border: '#ef4444', text: '#991b1b' }, -} - -// ============================================================================= -// Port Renderer Component -// ============================================================================= - -interface PortProps { - port: { id: string; name: string; type: string; required?: boolean } - direction: 'input' | 'output' - index: number - shapeWidth: number - isConnected?: boolean - onHover?: (portId: string | null) => void -} - -const Port: React.FC = ({ - port, - direction, - index, - shapeWidth, - isConnected = false, - onHover, -}) => { - const [isHovered, setIsHovered] = useState(false) - const color = getPortTypeColor(port.type as any) - - const x = direction === 'input' ? -PORT_SIZE / 2 : shapeWidth - PORT_SIZE / 2 - const y = HEADER_HEIGHT + 12 + index * PORT_SPACING - - const handleMouseEnter = () => { - setIsHovered(true) - onHover?.(port.id) - } - - const handleMouseLeave = () => { - setIsHovered(false) - onHover?.(null) - } - - return ( -
- ) -} - -// ============================================================================= -// Port Label Component -// ============================================================================= - -interface PortLabelProps { - port: { id: string; name: string; type: string } - direction: 'input' | 'output' - index: number - shapeWidth: number -} - -const PortLabel: React.FC = ({ port, direction, index, shapeWidth }) => { - const y = HEADER_HEIGHT + 12 + index * PORT_SPACING - const color = getPortTypeColor(port.type as any) - - return ( -
- {direction === 'output' && ( - - )} - {port.name} - {direction === 'input' && ( - - )} -
- ) -} - -// ============================================================================= -// Main Shape Util Class -// ============================================================================= - -export class WorkflowBlockShapeUtil extends BaseBoxShapeUtil { - static override type = 'WorkflowBlock' as const - - // Workflow blocks use indigo as base, but category determines actual color - static readonly PRIMARY_COLOR = '#6366f1' - - getDefaultProps(): IWorkflowBlock['props'] { - return { - w: DEFAULT_WIDTH, - h: DEFAULT_HEIGHT, - blockType: 'trigger.manual', - blockConfig: {}, - inputValues: {}, - outputValues: {}, - executionState: 'idle', - tags: ['workflow'], - pinnedToView: false, - } - } - - getGeometry(shape: IWorkflowBlock): Geometry2d { - return new Rectangle2d({ - width: Math.max(shape.props.w, MIN_WIDTH), - height: Math.max(shape.props.h, MIN_HEIGHT), - isFilled: true, - }) - } - - /** - * Get the position of a port in shape-local coordinates. - * Used for arrow snapping. - */ - getPortPosition(shape: IWorkflowBlock, portId: string, direction: 'input' | 'output'): Vec { - if (!hasBlockDefinition(shape.props.blockType)) { - return new Vec(0, HEADER_HEIGHT + 20) - } - - const definition = getBlockDefinition(shape.props.blockType) - const ports = direction === 'input' ? definition.inputs : definition.outputs - const portIndex = ports.findIndex(p => p.id === portId) - - if (portIndex === -1) { - return new Vec(0, HEADER_HEIGHT + 20) - } - - const x = direction === 'input' ? 0 : shape.props.w - const y = HEADER_HEIGHT + 12 + portIndex * PORT_SPACING + PORT_SIZE / 2 - - return new Vec(x, y) - } - - component(shape: IWorkflowBlock) { - const { blockType, executionState, executionError, blockConfig } = shape.props - - // Get block definition - const definition = useMemo(() => { - if (!hasBlockDefinition(blockType)) { - return null - } - return getBlockDefinition(blockType) - }, [blockType]) - - // Determine colors based on category - const categoryColor = definition - ? CATEGORY_INFO[definition.category].color - : WorkflowBlockShapeUtil.PRIMARY_COLOR - - const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) - const [hoveredPort, setHoveredPort] = useState(null) - - // Pin to view functionality - usePinnedToView(this.editor, shape.id, shape.props.pinnedToView, { position: 'current' }) - - // Maximize functionality - const { isMaximized, toggleMaximize } = useMaximize({ - editor: this.editor, - shapeId: shape.id, - currentW: shape.props.w, - currentH: shape.props.h, - shapeType: 'WorkflowBlock', - padding: 40, - }) - - // Handlers - const handleClose = useCallback(() => { - this.editor.deleteShapes([shape.id]) - }, [shape.id]) - - const handlePinToggle = useCallback(() => { - this.editor.updateShape({ - id: shape.id, - type: 'WorkflowBlock', - props: { pinnedToView: !shape.props.pinnedToView }, - }) - }, [shape.id, shape.props.pinnedToView]) - - const handleTagsChange = useCallback((newTags: string[]) => { - this.editor.updateShape({ - id: shape.id, - type: 'WorkflowBlock', - props: { tags: newTags }, - }) - }, [shape.id]) - - const handleRunBlock = useCallback(() => { - // Trigger manual execution (will be handled by executor) - this.editor.updateShape({ - id: shape.id, - type: 'WorkflowBlock', - props: { executionState: 'running' }, - }) - - // Dispatch custom event for executor to pick up - window.dispatchEvent(new CustomEvent('workflow:execute-block', { - detail: { blockId: shape.id }, - })) - }, [shape.id]) - - // If block type is unknown, show error state - if (!definition) { - return ( - -
- Unknown block type: {blockType} -
-
- ) - } - - const executionColors = EXECUTION_COLORS[executionState] - - // Calculate minimum height based on ports - const maxPorts = Math.max(definition.inputs.length, definition.outputs.length) - const calculatedHeight = Math.max( - shape.props.h, - HEADER_HEIGHT + 24 + maxPorts * PORT_SPACING + 40 - ) - - return ( - - - {definition.icon} -
- } - > - {/* Execution state indicator */} - {executionState !== 'idle' && ( -
- {executionState === 'running' && '⏳ Running'} - {executionState === 'success' && '✓ Done'} - {executionState === 'error' && '✕ Error'} -
- )} - - {/* Block description */} -
- {definition.description} -
- - {/* Ports container */} -
- {/* Input ports */} - {definition.inputs.map((port, index) => ( - - - - - ))} - - {/* Output ports */} - {definition.outputs.map((port, index) => ( - - - - - ))} -
- - {/* Error message */} - {executionError && ( -
- {executionError} -
- )} - - {/* Run button for trigger blocks */} - {definition.category === 'trigger' && ( -
- -
- )} - - - ) - } - - indicator(shape: IWorkflowBlock) { - // Calculate height same as component - const definition = hasBlockDefinition(shape.props.blockType) - ? getBlockDefinition(shape.props.blockType) - : null - - const maxPorts = definition - ? Math.max(definition.inputs.length, definition.outputs.length) - : 0 - const calculatedHeight = Math.max( - shape.props.h, - HEADER_HEIGHT + 24 + maxPorts * PORT_SPACING + 40 - ) - - return ( - - ) - } -} - -// Export the shape for registration -export const WorkflowBlockShape = WorkflowBlockShapeUtil diff --git a/src/tools/WorkflowBlockTool.ts b/src/tools/WorkflowBlockTool.ts deleted file mode 100644 index 1af80b0..0000000 --- a/src/tools/WorkflowBlockTool.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * WorkflowBlockTool - * - * A StateNode-based tool for placing workflow blocks on the canvas. - * Shows a tooltip with the block type and creates the block on click. - */ - -import { StateNode, TLEventHandlers } from 'tldraw' -import { findNonOverlappingPosition } from '@/utils/shapeCollisionUtils' -import { getBlockDefinition, hasBlockDefinition } from '@/lib/workflow/blockRegistry' -import { CATEGORY_INFO } from '@/lib/workflow/types' - -// Store the selected block type for creation -let selectedBlockType = 'trigger.manual' - -/** - * Set the block type that will be created when clicking - */ -export function setWorkflowBlockType(blockType: string): void { - selectedBlockType = blockType -} - -/** - * Get the currently selected block type - */ -export function getWorkflowBlockType(): string { - return selectedBlockType -} - -/** - * Main WorkflowBlock tool - */ -export class WorkflowBlockTool extends StateNode { - static override id = 'WorkflowBlock' - static override initial = 'idle' - static override children = () => [WorkflowBlockIdle] -} - -/** - * Idle state - shows tooltip and handles click to create block - */ -export class WorkflowBlockIdle extends StateNode { - static override id = 'idle' - - tooltipElement?: HTMLDivElement - mouseMoveHandler?: (e: MouseEvent) => void - - override onEnter = () => { - // Set cursor to cross - this.editor.setCursor({ type: 'cross', rotation: 0 }) - - // Get block info for tooltip - const blockType = getWorkflowBlockType() - const definition = hasBlockDefinition(blockType) - ? getBlockDefinition(blockType) - : null - - const blockName = definition?.name || 'Workflow Block' - const categoryInfo = definition ? CATEGORY_INFO[definition.category] : null - const icon = definition?.icon || '📦' - - // Create tooltip element - this.tooltipElement = document.createElement('div') - this.tooltipElement.style.cssText = ` - position: fixed; - background: rgba(0, 0, 0, 0.9); - color: white; - padding: 8px 14px; - border-radius: 8px; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - font-size: 13px; - white-space: nowrap; - z-index: 10000; - pointer-events: none; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); - border: 1px solid rgba(255, 255, 255, 0.1); - display: flex; - align-items: center; - gap: 8px; - ` - - // Add colored category indicator - if (categoryInfo) { - const indicator = document.createElement('span') - indicator.style.cssText = ` - width: 8px; - height: 8px; - border-radius: 50%; - background: ${categoryInfo.color}; - ` - this.tooltipElement.appendChild(indicator) - } - - // Add icon and text - const textSpan = document.createElement('span') - textSpan.textContent = `${icon} Click to place ${blockName}` - this.tooltipElement.appendChild(textSpan) - - // Add tooltip to DOM - document.body.appendChild(this.tooltipElement) - - // Update tooltip position on mouse move - this.mouseMoveHandler = (e: MouseEvent) => { - if (this.tooltipElement) { - const x = e.clientX + 15 - const y = e.clientY - 40 - - // Keep tooltip within viewport bounds - const rect = this.tooltipElement.getBoundingClientRect() - const viewportWidth = window.innerWidth - const viewportHeight = window.innerHeight - - let finalX = x - let finalY = y - - // Adjust if tooltip would go off edges - if (x + rect.width > viewportWidth) { - finalX = e.clientX - rect.width - 15 - } - if (y + rect.height > viewportHeight) { - finalY = e.clientY - rect.height - 15 - } - - finalX = Math.max(10, finalX) - finalY = Math.max(10, finalY) - - this.tooltipElement.style.left = `${finalX}px` - this.tooltipElement.style.top = `${finalY}px` - } - } - - document.addEventListener('mousemove', this.mouseMoveHandler) - } - - override onPointerDown: TLEventHandlers['onPointerDown'] = () => { - const { currentPagePoint } = this.editor.inputs - this.createWorkflowBlock(currentPagePoint.x, currentPagePoint.y) - } - - override onExit = () => { - this.cleanupTooltip() - } - - private cleanupTooltip = () => { - if (this.mouseMoveHandler) { - document.removeEventListener('mousemove', this.mouseMoveHandler) - this.mouseMoveHandler = undefined - } - - if (this.tooltipElement && this.tooltipElement.parentNode) { - this.tooltipElement.parentNode.removeChild(this.tooltipElement) - this.tooltipElement = undefined - } - } - - private createWorkflowBlock(clickX: number, clickY: number) { - try { - const blockType = getWorkflowBlockType() - const definition = hasBlockDefinition(blockType) - ? getBlockDefinition(blockType) - : null - - // Calculate size based on ports - const shapeWidth = 220 - const maxPorts = definition - ? Math.max(definition.inputs.length, definition.outputs.length) - : 2 - const shapeHeight = Math.max(150, 36 + 24 + maxPorts * 28 + 60) - - // Center the shape on click - const finalX = clickX - shapeWidth / 2 - const finalY = clickY - shapeHeight / 2 - - // Create the shape - const shape = this.editor.createShape({ - type: 'WorkflowBlock', - x: finalX, - y: finalY, - props: { - w: shapeWidth, - h: shapeHeight, - blockType: blockType, - blockConfig: definition?.defaultConfig || {}, - inputValues: {}, - outputValues: {}, - executionState: 'idle', - tags: ['workflow', definition?.category || 'block'], - pinnedToView: false, - }, - }) - - // Select the new shape and switch to select tool - if (shape) { - this.editor.setSelectedShapes([shape.id]) - } - this.editor.setCurrentTool('select') - - } catch (error) { - console.error('Error creating WorkflowBlock shape:', error) - } - } -} - -export default WorkflowBlockTool