From 9167342d98cb50064e2f5796c41762b658988c26 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 23 Dec 2025 11:20:15 -0500 Subject: [PATCH] fix: resolve TypeScript build errors for calendar and workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CalendarEventShapeUtil: Fix destructuring (w,h are in props, not shape) - CalendarPanel: Prefix unused variables with underscore - YearViewPanel: Prefix unused variables with underscore - Add missing workflow files (WorkflowPropagator, WorkflowBlockShape, etc.) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/CalendarPanel.tsx | 6 +- src/components/YearViewPanel.tsx | 4 +- 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/CalendarEventShapeUtil.tsx | 3 +- src/shapes/WorkflowBlockShapeUtil.tsx | 539 ++++++++++++ src/tools/WorkflowBlockTool.ts | 204 +++++ 14 files changed, 5830 insertions(+), 6 deletions(-) create mode 100644 src/components/workflow/WorkflowPalette.tsx create mode 100644 src/css/workflow.css create mode 100644 src/lib/workflow/blockRegistry.ts create mode 100644 src/lib/workflow/executor.ts create mode 100644 src/lib/workflow/portBindings.ts create mode 100644 src/lib/workflow/serialization.ts create mode 100644 src/lib/workflow/types.ts create mode 100644 src/lib/workflow/validation.ts create mode 100644 src/propagators/WorkflowPropagator.ts create mode 100644 src/shapes/WorkflowBlockShapeUtil.tsx create mode 100644 src/tools/WorkflowBlockTool.ts diff --git a/src/components/CalendarPanel.tsx b/src/components/CalendarPanel.tsx index 160f909..add6495 100644 --- a/src/components/CalendarPanel.tsx +++ b/src/components/CalendarPanel.tsx @@ -45,9 +45,9 @@ const isToday = (date: Date) => { } export function CalendarPanel({ - onClose, + onClose: _onClose, onEventSelect, - shapeMode = false, + shapeMode: _shapeMode = false, initialView = "month", initialDate, }: CalendarPanelProps) { @@ -255,7 +255,7 @@ export function CalendarPanel({ {date.getDate()}
- {dayEvents.slice(0, 3).map((event, i) => ( + {dayEvents.slice(0, 3).map((event) => (
= ({ - onClose, + onClose: _onClose, onMonthSelect, - shapeMode = false, + shapeMode: _shapeMode = false, initialYear, }) => { const [currentYear, setCurrentYear] = useState(initialYear || new Date().getFullYear()) diff --git a/src/components/workflow/WorkflowPalette.tsx b/src/components/workflow/WorkflowPalette.tsx new file mode 100644 index 0000000..eba50fd --- /dev/null +++ b/src/components/workflow/WorkflowPalette.tsx @@ -0,0 +1,497 @@ +/** + * 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 new file mode 100644 index 0000000..f3bd429 --- /dev/null +++ b/src/css/workflow.css @@ -0,0 +1,589 @@ +/** + * 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 new file mode 100644 index 0000000..89fd177 --- /dev/null +++ b/src/lib/workflow/blockRegistry.ts @@ -0,0 +1,881 @@ +/** + * 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 new file mode 100644 index 0000000..a9aedc4 --- /dev/null +++ b/src/lib/workflow/executor.ts @@ -0,0 +1,731 @@ +/** + * 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 new file mode 100644 index 0000000..a80485a --- /dev/null +++ b/src/lib/workflow/portBindings.ts @@ -0,0 +1,468 @@ +/** + * 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 new file mode 100644 index 0000000..84ee495 --- /dev/null +++ b/src/lib/workflow/serialization.ts @@ -0,0 +1,659 @@ +/** + * 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 new file mode 100644 index 0000000..acec0f8 --- /dev/null +++ b/src/lib/workflow/types.ts @@ -0,0 +1,385 @@ +/** + * 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 new file mode 100644 index 0000000..b970961 --- /dev/null +++ b/src/lib/workflow/validation.ts @@ -0,0 +1,466 @@ +/** + * 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 new file mode 100644 index 0000000..cbdd2ce --- /dev/null +++ b/src/propagators/WorkflowPropagator.ts @@ -0,0 +1,404 @@ +/** + * 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/CalendarEventShapeUtil.tsx b/src/shapes/CalendarEventShapeUtil.tsx index f4a0c14..f6bc4eb 100644 --- a/src/shapes/CalendarEventShapeUtil.tsx +++ b/src/shapes/CalendarEventShapeUtil.tsx @@ -93,7 +93,8 @@ export class CalendarEventShape extends BaseBoxShapeUtil { } component(shape: ICalendarEventShape) { - const { w, h, props } = shape + const { props } = shape + const { w, h } = props const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) // Detect dark mode diff --git a/src/shapes/WorkflowBlockShapeUtil.tsx b/src/shapes/WorkflowBlockShapeUtil.tsx new file mode 100644 index 0000000..e32c3a3 --- /dev/null +++ b/src/shapes/WorkflowBlockShapeUtil.tsx @@ -0,0 +1,539 @@ +/** + * 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 new file mode 100644 index 0000000..1af80b0 --- /dev/null +++ b/src/tools/WorkflowBlockTool.ts @@ -0,0 +1,204 @@ +/** + * 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