From 5fc505f1fcd7cda644a24cf05d63733a6a4e05e8 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 23 Dec 2025 15:45:27 -0500 Subject: [PATCH] feat: add Flowy-like workflow builder system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a visual workflow builder with: - WorkflowBlockShapeUtil: Visual blocks with typed input/output ports - WorkflowBlockTool: Click-to-place tool for adding blocks - Block registry with 20+ blocks (triggers, actions, conditions, transformers, AI, outputs) - Port validation and type compatibility checking - WorkflowPropagator for real-time data flow between connected blocks - Workflow executor for manual execution with topological ordering - WorkflowPalette UI sidebar with searchable block categories - JSON serialization for workflow export/import - Workflow templates (API request, LLM chain, conditional) Blocks are accessible via "Workflow Blocks" button in toolbar dropdown. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/workflow/WorkflowPalette.tsx | 273 ++++++++++ src/css/workflow.css | 357 ++++++++++++ src/lib/workflow/blockRegistry.ts | 438 +++++++++++++++ src/lib/workflow/executor.ts | 556 +++++++++++++++++++ src/lib/workflow/portBindings.ts | 464 ++++++++++++++++ src/lib/workflow/serialization.ts | 571 ++++++++++++++++++++ src/lib/workflow/types.ts | 159 ++++++ src/lib/workflow/validation.ts | 417 ++++++++++++++ src/propagators/WorkflowPropagator.ts | 326 +++++++++++ src/routes/Board.tsx | 20 +- src/shapes/WorkflowBlockShapeUtil.tsx | 513 ++++++++++++++++++ src/tools/WorkflowBlockTool.ts | 192 +++++++ src/ui/CustomToolbar.tsx | 11 +- 13 files changed, 4280 insertions(+), 17 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/workflow/WorkflowPalette.tsx b/src/components/workflow/WorkflowPalette.tsx new file mode 100644 index 0000000..b9cee73 --- /dev/null +++ b/src/components/workflow/WorkflowPalette.tsx @@ -0,0 +1,273 @@ +/** + * WorkflowPalette + * + * Sidebar palette showing available workflow blocks organized by category. + * Supports click-to-place and displays block descriptions. + */ + +import React, { useState, useCallback, useMemo } from 'react' +import { Editor } from 'tldraw' +import { + getAllBlockDefinitions, + getBlocksByCategory, +} from '@/lib/workflow/blockRegistry' +import { + BlockCategory, + BlockDefinition, + CATEGORY_INFO, +} from '@/lib/workflow/types' +import { + setWorkflowBlockType, +} from '@/tools/WorkflowBlockTool' + +// ============================================================================= +// Types +// ============================================================================= + +interface WorkflowPaletteProps { + editor: Editor + isOpen: boolean + onClose: () => void +} + +// ============================================================================= +// Category Section Component +// ============================================================================= + +interface CategorySectionProps { + category: BlockCategory + blocks: BlockDefinition[] + isExpanded: boolean + onToggle: () => void + onBlockClick: (blockType: string) => void +} + +const CategorySection: React.FC = ({ + category, + blocks, + isExpanded, + onToggle, + onBlockClick, +}) => { + const info = CATEGORY_INFO[category] + + return ( +
+ + + {isExpanded && ( +
+ {blocks.map((block) => ( + onBlockClick(block.type)} + /> + ))} +
+ )} +
+ ) +} + +// ============================================================================= +// Block Card Component +// ============================================================================= + +interface BlockCardProps { + block: BlockDefinition + categoryColor: string + onClick: () => void +} + +const BlockCard: React.FC = ({ block, categoryColor, onClick }) => { + const [isHovered, setIsHovered] = useState(false) + + return ( + + ) +} + +// ============================================================================= +// Search Bar Component +// ============================================================================= + +interface SearchBarProps { + value: string + onChange: (value: string) => void +} + +const SearchBar: React.FC = ({ value, onChange }) => { + return ( +
+ onChange(e.target.value)} + className="workflow-palette-search-input" + /> + {value && ( + + )} +
+ ) +} + +// ============================================================================= +// Main Palette Component +// ============================================================================= + +const WorkflowPalette: React.FC = ({ + editor, + isOpen, + onClose, +}) => { + const [searchQuery, setSearchQuery] = useState('') + const [expandedCategories, setExpandedCategories] = useState>( + new Set(['trigger', 'action']) + ) + + const allBlocks = useMemo(() => getAllBlockDefinitions(), []) + + const categories: BlockCategory[] = [ + 'trigger', + 'action', + 'condition', + 'transformer', + 'ai', + 'output', + ] + + const filteredBlocksByCategory = useMemo(() => { + const result: Record = { + trigger: [], + action: [], + condition: [], + transformer: [], + ai: [], + output: [], + } + + const query = searchQuery.toLowerCase() + + for (const block of allBlocks) { + const matches = + !query || + block.name.toLowerCase().includes(query) || + block.description.toLowerCase().includes(query) || + block.type.toLowerCase().includes(query) + + if (matches) { + result[block.category].push(block) + } + } + + return result + }, [allBlocks, searchQuery]) + + 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 + }) + }, []) + + const handleBlockClick = useCallback( + (blockType: string) => { + // Set the block type for the tool + setWorkflowBlockType(blockType) + + // Switch to the WorkflowBlock tool + editor.setCurrentTool('WorkflowBlock') + }, + [editor] + ) + + if (!isOpen) return null + + return ( +
+
+

Workflow Blocks

+ +
+ + + +
+ {categories.map((category) => { + const blocks = filteredBlocksByCategory[category] + if (blocks.length === 0 && searchQuery) return null + + return ( + toggleCategory(category)} + onBlockClick={handleBlockClick} + /> + ) + })} +
+ +
+
+ Click a block to place it on the canvas +
+
+
+ ) +} + +export default WorkflowPalette diff --git a/src/css/workflow.css b/src/css/workflow.css new file mode 100644 index 0000000..913a3b3 --- /dev/null +++ b/src/css/workflow.css @@ -0,0 +1,357 @@ +/** + * Workflow Palette Styles + * + * Styles for the workflow block palette sidebar component. + */ + +/* ============================================================================= + Palette Container + ============================================================================= */ + +.workflow-palette { + position: fixed; + left: 0; + top: 0; + width: 280px; + height: 100vh; + background: white; + border-right: 1px solid #e5e7eb; + display: flex; + flex-direction: column; + z-index: 1000; + box-shadow: 4px 0 16px rgba(0, 0, 0, 0.08); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; +} + +/* ============================================================================= + Header + ============================================================================= */ + +.workflow-palette-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid #e5e7eb; + background: #f9fafb; +} + +.workflow-palette-title { + margin: 0; + font-size: 14px; + font-weight: 600; + color: #111827; +} + +.workflow-palette-close { + background: none; + border: none; + font-size: 20px; + color: #6b7280; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + line-height: 1; +} + +.workflow-palette-close:hover { + background: #e5e7eb; + color: #111827; +} + +/* ============================================================================= + Search Bar + ============================================================================= */ + +.workflow-palette-search { + position: relative; + padding: 12px 16px; + border-bottom: 1px solid #e5e7eb; +} + +.workflow-palette-search-input { + width: 100%; + padding: 8px 32px 8px 12px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 13px; + outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.workflow-palette-search-input:focus { + border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.workflow-palette-search-input::placeholder { + color: #9ca3af; +} + +.workflow-palette-search-clear { + position: absolute; + right: 24px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + font-size: 16px; + color: #9ca3af; + cursor: pointer; + padding: 4px; + line-height: 1; +} + +.workflow-palette-search-clear:hover { + color: #6b7280; +} + +/* ============================================================================= + Content Area + ============================================================================= */ + +.workflow-palette-content { + flex: 1; + overflow-y: auto; + padding: 8px 0; +} + +/* ============================================================================= + Category Section + ============================================================================= */ + +.workflow-palette-category { + margin-bottom: 4px; +} + +.workflow-palette-category-header { + display: flex; + align-items: center; + width: 100%; + padding: 10px 16px; + background: none; + border: none; + border-left: 3px solid transparent; + cursor: pointer; + text-align: left; + font-size: 12px; + font-weight: 600; + color: #374151; + text-transform: uppercase; + letter-spacing: 0.05em; + transition: background-color 0.15s ease; +} + +.workflow-palette-category-header:hover { + background: #f3f4f6; +} + +.workflow-palette-category-icon { + font-size: 14px; + margin-right: 8px; +} + +.workflow-palette-category-label { + flex: 1; +} + +.workflow-palette-category-count { + font-weight: 400; + color: #9ca3af; + margin-right: 8px; +} + +.workflow-palette-chevron { + font-size: 10px; + color: #9ca3af; + transition: transform 0.15s ease; +} + +.workflow-palette-chevron.expanded { + transform: rotate(90deg); +} + +/* ============================================================================= + Block Cards + ============================================================================= */ + +.workflow-palette-blocks { + padding: 4px 0; +} + +.workflow-palette-block { + display: flex; + align-items: flex-start; + width: 100%; + padding: 10px 16px; + background: none; + border: none; + border-left: 3px solid transparent; + cursor: pointer; + text-align: left; + transition: background-color 0.15s ease, border-color 0.15s ease; +} + +.workflow-palette-block:hover { + background: #f9fafb; +} + +.workflow-palette-block-icon { + font-size: 18px; + margin-right: 10px; + flex-shrink: 0; + margin-top: 2px; +} + +.workflow-palette-block-content { + flex: 1; + min-width: 0; +} + +.workflow-palette-block-name { + display: block; + font-size: 13px; + font-weight: 500; + color: #111827; + margin-bottom: 2px; +} + +.workflow-palette-block-description { + display: block; + font-size: 11px; + color: #6b7280; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workflow-palette-block-ports { + display: flex; + flex-direction: column; + align-items: flex-end; + margin-left: 8px; + flex-shrink: 0; +} + +.workflow-palette-port-count { + font-size: 10px; + color: #9ca3af; + font-family: monospace; +} + +/* ============================================================================= + Footer + ============================================================================= */ + +.workflow-palette-footer { + padding: 12px 16px; + border-top: 1px solid #e5e7eb; + background: #f9fafb; +} + +.workflow-palette-hint { + font-size: 11px; + color: #6b7280; + text-align: center; +} + +/* ============================================================================= + Scrollbar Styling + ============================================================================= */ + +.workflow-palette-content::-webkit-scrollbar { + width: 6px; +} + +.workflow-palette-content::-webkit-scrollbar-track { + background: transparent; +} + +.workflow-palette-content::-webkit-scrollbar-thumb { + background: #d1d5db; + border-radius: 3px; +} + +.workflow-palette-content::-webkit-scrollbar-thumb:hover { + background: #9ca3af; +} + +/* ============================================================================= + Dark Mode Support (optional) + ============================================================================= */ + +@media (prefers-color-scheme: dark) { + .workflow-palette { + background: #1f2937; + border-right-color: #374151; + } + + .workflow-palette-header { + background: #111827; + border-bottom-color: #374151; + } + + .workflow-palette-title { + color: #f9fafb; + } + + .workflow-palette-close { + color: #9ca3af; + } + + .workflow-palette-close:hover { + background: #374151; + color: #f9fafb; + } + + .workflow-palette-search { + border-bottom-color: #374151; + } + + .workflow-palette-search-input { + background: #111827; + border-color: #4b5563; + color: #f9fafb; + } + + .workflow-palette-search-input:focus { + border-color: #6366f1; + } + + .workflow-palette-category-header { + color: #d1d5db; + } + + .workflow-palette-category-header:hover { + background: #374151; + } + + .workflow-palette-block:hover { + background: #374151; + } + + .workflow-palette-block-name { + color: #f9fafb; + } + + .workflow-palette-block-description { + color: #9ca3af; + } + + .workflow-palette-footer { + background: #111827; + border-top-color: #374151; + } +} + +/* ============================================================================= + Responsive Adjustments + ============================================================================= */ + +@media (max-width: 640px) { + .workflow-palette { + width: 100%; + max-width: 320px; + } +} diff --git a/src/lib/workflow/blockRegistry.ts b/src/lib/workflow/blockRegistry.ts new file mode 100644 index 0000000..4c8683d --- /dev/null +++ b/src/lib/workflow/blockRegistry.ts @@ -0,0 +1,438 @@ +/** + * Block Registry + * + * Defines all available workflow blocks with their ports and configuration. + */ + +import { BlockDefinition, BlockCategory } from './types' + +// ============================================================================= +// Block Registry +// ============================================================================= + +const blockRegistry = new Map() + +export function registerBlock(definition: BlockDefinition): void { + blockRegistry.set(definition.type, definition) +} + +export function getBlockDefinition(type: string): BlockDefinition { + const def = blockRegistry.get(type) + if (!def) { + throw new Error(`Unknown block type: ${type}`) + } + return def +} + +export function hasBlockDefinition(type: string): boolean { + return blockRegistry.has(type) +} + +export function getAllBlockDefinitions(): BlockDefinition[] { + return Array.from(blockRegistry.values()) +} + +export function getBlocksByCategory(category: BlockCategory): BlockDefinition[] { + return getAllBlockDefinitions().filter(b => b.category === category) +} + +// ============================================================================= +// Trigger Blocks +// ============================================================================= + +registerBlock({ + type: 'trigger.manual', + name: 'Manual Trigger', + description: 'Start workflow manually with a button click', + icon: '▢️', + category: 'trigger', + inputs: [], + outputs: [ + { id: 'timestamp', name: 'Timestamp', type: 'number', produces: 'number' }, + ], +}) + +registerBlock({ + type: 'trigger.schedule', + name: 'Schedule Trigger', + description: 'Start workflow on a schedule', + icon: '⏰', + category: 'trigger', + inputs: [], + outputs: [ + { id: 'timestamp', name: 'Timestamp', type: 'number', produces: 'number' }, + ], + defaultConfig: { interval: 'daily', time: '09:00' }, +}) + +registerBlock({ + type: 'trigger.webhook', + name: 'Webhook Trigger', + description: 'Start workflow from HTTP request', + icon: '🌐', + category: 'trigger', + inputs: [], + outputs: [ + { id: 'body', name: 'Body', type: 'object', produces: 'object' }, + { id: 'headers', name: 'Headers', type: 'object', produces: 'object' }, + ], +}) + +// ============================================================================= +// Action Blocks +// ============================================================================= + +registerBlock({ + type: 'action.http', + name: 'HTTP Request', + description: 'Make an HTTP request to an API', + icon: 'πŸ”—', + category: 'action', + inputs: [ + { id: 'url', name: 'URL', type: 'text', accepts: ['text'], required: true }, + { id: 'body', name: 'Body', type: 'any', accepts: ['text', 'object', 'any'] }, + { id: 'trigger', name: 'Trigger', type: 'any', accepts: ['any'] }, + ], + outputs: [ + { id: 'response', name: 'Response', type: 'any', produces: 'any' }, + { id: 'status', name: 'Status', type: 'number', produces: 'number' }, + ], + defaultConfig: { method: 'GET' }, +}) + +registerBlock({ + type: 'action.createShape', + name: 'Create Shape', + description: 'Create a new shape on the canvas', + icon: 'πŸ“¦', + category: 'action', + inputs: [ + { id: 'content', name: 'Content', type: 'text', accepts: ['text', 'any'] }, + { id: 'position', name: 'Position', type: 'object', accepts: ['object'] }, + ], + outputs: [ + { id: 'shapeId', name: 'Shape ID', type: 'text', produces: 'text' }, + ], + defaultConfig: { shapeType: 'text' }, +}) + +registerBlock({ + type: 'action.delay', + name: 'Delay', + description: 'Wait for a specified duration', + icon: '⏳', + category: 'action', + inputs: [ + { id: 'input', name: 'Input', type: 'any', accepts: ['any'] }, + ], + outputs: [ + { id: 'passthrough', name: 'Output', type: 'any', produces: 'any' }, + ], + defaultConfig: { duration: 1000 }, +}) + +// ============================================================================= +// Condition Blocks +// ============================================================================= + +registerBlock({ + type: 'condition.if', + name: 'If / Else', + description: 'Branch based on a condition', + icon: 'πŸ”€', + category: 'condition', + inputs: [ + { id: 'condition', name: 'Condition', type: 'boolean', accepts: ['boolean', 'any'], required: true }, + { id: 'value', name: 'Value', type: 'any', accepts: ['any'] }, + ], + outputs: [ + { id: 'true', name: 'True', type: 'any', produces: 'any' }, + { id: 'false', name: 'False', type: 'any', produces: 'any' }, + ], +}) + +registerBlock({ + type: 'condition.switch', + name: 'Switch', + description: 'Route based on value matching', + icon: 'πŸ”ƒ', + category: 'condition', + inputs: [ + { id: 'value', name: 'Value', type: 'any', accepts: ['any'], required: true }, + ], + outputs: [ + { id: 'match', name: 'Match', type: 'any', produces: 'any' }, + { id: 'default', name: 'Default', type: 'any', produces: 'any' }, + ], + defaultConfig: { cases: {} }, +}) + +registerBlock({ + type: 'condition.compare', + name: 'Compare', + description: 'Compare two values', + icon: 'βš–οΈ', + category: 'condition', + inputs: [ + { id: 'a', name: 'A', type: 'any', accepts: ['any'], required: true }, + { id: 'b', name: 'B', type: 'any', accepts: ['any'], required: true }, + ], + outputs: [ + { id: 'result', name: 'Result', type: 'boolean', produces: 'boolean' }, + ], + defaultConfig: { operator: 'equals' }, +}) + +// ============================================================================= +// Transformer Blocks +// ============================================================================= + +registerBlock({ + type: 'transformer.jsonParse', + name: 'JSON Parse', + description: 'Parse JSON text into object', + icon: 'πŸ“‹', + category: 'transformer', + inputs: [ + { id: 'input', name: 'Input', type: 'text', accepts: ['text'], required: true }, + ], + outputs: [ + { id: 'output', name: 'Output', type: 'object', produces: 'object' }, + ], +}) + +registerBlock({ + type: 'transformer.jsonStringify', + name: 'JSON Stringify', + description: 'Convert object to JSON text', + icon: 'πŸ“', + category: 'transformer', + inputs: [ + { id: 'input', name: 'Input', type: 'any', accepts: ['any'], required: true }, + ], + outputs: [ + { id: 'output', name: 'Output', type: 'text', produces: 'text' }, + ], + defaultConfig: { pretty: false }, +}) + +registerBlock({ + type: 'transformer.code', + name: 'JavaScript Code', + description: 'Run custom JavaScript code', + icon: 'πŸ’»', + category: 'transformer', + inputs: [ + { id: 'input', name: 'Input', type: 'any', accepts: ['any'] }, + ], + outputs: [ + { id: 'output', name: 'Output', type: 'any', produces: 'any' }, + ], + defaultConfig: { code: 'return input' }, +}) + +registerBlock({ + type: 'transformer.template', + name: 'Template', + description: 'Fill template with variables', + icon: 'πŸ“„', + category: 'transformer', + inputs: [ + { id: 'variables', name: 'Variables', type: 'object', accepts: ['object'], required: true }, + ], + outputs: [ + { id: 'output', name: 'Output', type: 'text', produces: 'text' }, + ], + defaultConfig: { template: 'Hello {{name}}!' }, +}) + +registerBlock({ + type: 'transformer.getProperty', + name: 'Get Property', + description: 'Get a property from an object', + icon: 'πŸ”', + category: 'transformer', + inputs: [ + { id: 'object', name: 'Object', type: 'object', accepts: ['object'], required: true }, + ], + outputs: [ + { id: 'value', name: 'Value', type: 'any', produces: 'any' }, + ], + defaultConfig: { path: '' }, +}) + +registerBlock({ + type: 'transformer.setProperty', + name: 'Set Property', + description: 'Set a property on an object', + icon: '✏️', + category: 'transformer', + inputs: [ + { id: 'object', name: 'Object', type: 'object', accepts: ['object'], required: true }, + { id: 'value', name: 'Value', type: 'any', accepts: ['any'], required: true }, + ], + outputs: [ + { id: 'output', name: 'Output', type: 'object', produces: 'object' }, + ], + defaultConfig: { path: '' }, +}) + +registerBlock({ + type: 'transformer.arrayMap', + name: 'Array Map', + description: 'Transform each array element', + icon: 'πŸ—ΊοΈ', + category: 'transformer', + inputs: [ + { id: 'array', name: 'Array', type: 'array', accepts: ['array'], required: true }, + ], + outputs: [ + { id: 'output', name: 'Output', type: 'array', produces: 'array' }, + ], + defaultConfig: { expression: 'item' }, +}) + +registerBlock({ + type: 'transformer.arrayFilter', + name: 'Array Filter', + description: 'Filter array elements', + icon: 'πŸ”Ž', + category: 'transformer', + inputs: [ + { id: 'array', name: 'Array', type: 'array', accepts: ['array'], required: true }, + ], + outputs: [ + { id: 'output', name: 'Output', type: 'array', produces: 'array' }, + ], + defaultConfig: { condition: 'true' }, +}) + +// ============================================================================= +// AI Blocks +// ============================================================================= + +registerBlock({ + type: 'ai.llm', + name: 'LLM Prompt', + description: 'Send prompt to language model', + icon: 'πŸ€–', + category: 'ai', + inputs: [ + { id: 'prompt', name: 'Prompt', type: 'text', accepts: ['text'], required: true }, + { id: 'context', name: 'Context', type: 'text', accepts: ['text', 'any'] }, + { id: 'trigger', name: 'Trigger', type: 'any', accepts: ['any'] }, + ], + outputs: [ + { id: 'response', name: 'Response', type: 'text', produces: 'text' }, + { id: 'tokens', name: 'Tokens', type: 'number', produces: 'number' }, + ], + defaultConfig: { systemPrompt: '', model: 'default' }, +}) + +registerBlock({ + type: 'ai.imageGen', + name: 'Image Generation', + description: 'Generate image from prompt', + icon: '🎨', + category: 'ai', + inputs: [ + { id: 'prompt', name: 'Prompt', type: 'text', accepts: ['text'], required: true }, + ], + outputs: [ + { id: 'image', name: 'Image', type: 'image', produces: 'image' }, + ], + defaultConfig: { size: '512x512' }, +}) + +registerBlock({ + type: 'ai.tts', + name: 'Text to Speech', + description: 'Convert text to audio', + icon: 'πŸ”Š', + category: 'ai', + inputs: [ + { id: 'text', name: 'Text', type: 'text', accepts: ['text'], required: true }, + ], + outputs: [ + { id: 'audio', name: 'Audio', type: 'file', produces: 'file' }, + ], + defaultConfig: { voice: 'default' }, +}) + +registerBlock({ + type: 'ai.stt', + name: 'Speech to Text', + description: 'Convert audio to text', + icon: '🎀', + category: 'ai', + inputs: [ + { id: 'audio', name: 'Audio', type: 'file', accepts: ['file'], required: true }, + ], + outputs: [ + { id: 'text', name: 'Text', type: 'text', produces: 'text' }, + ], +}) + +// ============================================================================= +// Output Blocks +// ============================================================================= + +registerBlock({ + type: 'output.display', + name: 'Display', + description: 'Display value on canvas', + icon: 'πŸ“Ί', + category: 'output', + inputs: [ + { id: 'value', name: 'Value', type: 'any', accepts: ['any'], required: true }, + ], + outputs: [ + { id: 'displayed', name: 'Displayed', type: 'text', produces: 'text' }, + ], + defaultConfig: { format: 'auto' }, +}) + +registerBlock({ + type: 'output.log', + name: 'Log', + description: 'Log message to console', + icon: 'πŸ“‹', + category: 'output', + inputs: [ + { id: 'message', name: 'Message', type: 'any', accepts: ['any'], required: true }, + ], + outputs: [ + { id: 'logged', name: 'Logged', type: 'boolean', produces: 'boolean' }, + ], + defaultConfig: { level: 'info' }, +}) + +registerBlock({ + type: 'output.notify', + name: 'Notify', + description: 'Show notification', + icon: 'πŸ””', + category: 'output', + inputs: [ + { id: 'message', name: 'Message', type: 'text', accepts: ['text'], required: true }, + ], + outputs: [ + { id: 'notified', name: 'Notified', type: 'boolean', produces: 'boolean' }, + ], + defaultConfig: { title: 'Notification' }, +}) + +registerBlock({ + type: 'output.markdown', + name: 'Create Markdown', + description: 'Create markdown shape on canvas', + icon: 'πŸ“', + category: 'output', + inputs: [ + { id: 'content', name: 'Content', type: 'text', accepts: ['text'], required: true }, + { id: 'position', name: 'Position', type: 'object', accepts: ['object'] }, + ], + outputs: [ + { id: 'shapeId', name: 'Shape ID', type: 'text', produces: 'text' }, + ], +}) diff --git a/src/lib/workflow/executor.ts b/src/lib/workflow/executor.ts new file mode 100644 index 0000000..ef1b045 --- /dev/null +++ b/src/lib/workflow/executor.ts @@ -0,0 +1,556 @@ +/** + * Workflow Executor + * + * Handles manual execution of workflow blocks and complete workflows. + * Supports topological execution order, error handling, and state updates. + */ + +import { Editor, TLShapeId } from 'tldraw' +import { + ExecutionContext, + BlockExecutionResult, + ExecutionState, +} from './types' +import { getBlockDefinition, hasBlockDefinition } from './blockRegistry' +import { IWorkflowBlock } from '@/shapes/WorkflowBlockShapeUtil' +import { + getExecutionOrder, + getBlockInputBindings, + getBlockOutputBindings, +} from './portBindings' + +// ============================================================================= +// Block Executors Registry +// ============================================================================= + +type BlockExecutor = ( + context: ExecutionContext, + inputs: Record, + config: Record +) => Promise> + +const blockExecutors = new Map() + +/** + * Register a custom executor for a block type + */ +export function registerBlockExecutor( + blockType: string, + executor: BlockExecutor +): void { + blockExecutors.set(blockType, executor) +} + +// ============================================================================= +// Built-in Block Executors +// ============================================================================= + +// Trigger: Manual +registerBlockExecutor('trigger.manual', async () => { + return { timestamp: Date.now() } +}) + +// Trigger: Schedule +registerBlockExecutor('trigger.schedule', async () => { + return { timestamp: Date.now() } +}) + +// Trigger: Webhook +registerBlockExecutor('trigger.webhook', async (_ctx, inputs) => { + return { + body: inputs.body || {}, + headers: inputs.headers || {}, + } +}) + +// Action: HTTP Request +registerBlockExecutor('action.http', async (_ctx, inputs, config) => { + const url = inputs.url as string + const method = (config.method as string) || 'GET' + const body = inputs.body + + try { + const response = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + }, + body: method !== 'GET' && body ? JSON.stringify(body) : undefined, + }) + + const data = await response.json().catch(() => response.text()) + return { + response: data, + status: response.status, + } + } catch (error) { + throw new Error(`HTTP request failed: ${error}`) + } +}) + +// Action: Delay +registerBlockExecutor('action.delay', async (_ctx, inputs, config) => { + const duration = (config.duration as number) || 1000 + await new Promise(resolve => setTimeout(resolve, duration)) + return { passthrough: inputs.input } +}) + +// Condition: If/Else +registerBlockExecutor('condition.if', async (_ctx, inputs) => { + const condition = Boolean(inputs.condition) + const value = inputs.value + + if (condition) { + return { true: value, false: undefined } + } else { + return { true: undefined, false: value } + } +}) + +// Condition: Compare +registerBlockExecutor('condition.compare', async (_ctx, inputs, config) => { + const a = inputs.a + const b = inputs.b + const operator = (config.operator as string) || 'equals' + + let result = false + switch (operator) { + case 'equals': + result = a === b + break + case 'not_equals': + result = a !== b + break + case 'greater': + result = (a as number) > (b as number) + break + case 'less': + result = (a as number) < (b as number) + break + case 'greater_equal': + result = (a as number) >= (b as number) + break + case 'less_equal': + result = (a as number) <= (b as number) + break + case 'contains': + result = String(a).includes(String(b)) + break + } + + return { result } +}) + +// Transformer: JSON Parse +registerBlockExecutor('transformer.jsonParse', async (_ctx, inputs) => { + const input = inputs.input as string + try { + return { output: JSON.parse(input) } + } catch (error) { + throw new Error(`JSON parse error: ${error}`) + } +}) + +// Transformer: JSON Stringify +registerBlockExecutor('transformer.jsonStringify', async (_ctx, inputs, config) => { + const pretty = config.pretty as boolean + const output = pretty + ? JSON.stringify(inputs.input, null, 2) + : JSON.stringify(inputs.input) + return { output } +}) + +// Transformer: JavaScript Code +registerBlockExecutor('transformer.code', async (_ctx, inputs, config) => { + const code = config.code as string + const input = inputs.input + + try { + // Create a sandboxed function + const fn = new Function('input', `return (${code})`) + const output = fn(input) + return { output } + } catch (error) { + throw new Error(`Code execution error: ${error}`) + } +}) + +// Transformer: Template +registerBlockExecutor('transformer.template', async (_ctx, inputs, config) => { + const template = config.template as string + const variables = inputs.variables as Record + + let output = template + for (const [key, value] of Object.entries(variables || {})) { + output = output.replace(new RegExp(`{{${key}}}`, 'g'), String(value)) + } + + return { output } +}) + +// Transformer: Get Property +registerBlockExecutor('transformer.getProperty', async (_ctx, inputs, config) => { + const obj = inputs.object as Record + const path = (config.path as string) || '' + + const parts = path.split('.') + let value: unknown = obj + + for (const part of parts) { + if (value == null || typeof value !== 'object') { + value = undefined + break + } + value = (value as Record)[part] + } + + return { value } +}) + +// Transformer: Set Property +registerBlockExecutor('transformer.setProperty', async (_ctx, inputs, config) => { + const obj = { ...(inputs.object as Record) } + const path = (config.path as string) || '' + const value = inputs.value + + const parts = path.split('.') + let current: Record = obj + + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i] + if (!(part in current) || typeof current[part] !== 'object') { + current[part] = {} + } + current = current[part] as Record + } + + current[parts[parts.length - 1]] = value + return { output: obj } +}) + +// Transformer: Array Map +registerBlockExecutor('transformer.arrayMap', async (_ctx, inputs, config) => { + const array = inputs.array as unknown[] + const expression = config.expression as string + + const fn = new Function('item', 'index', `return ${expression}`) + const output = array.map((item, index) => fn(item, index)) + + return { output } +}) + +// Transformer: Array Filter +registerBlockExecutor('transformer.arrayFilter', async (_ctx, inputs, config) => { + const array = inputs.array as unknown[] + const condition = config.condition as string + + const fn = new Function('item', 'index', `return ${condition}`) + const output = array.filter((item, index) => fn(item, index)) + + return { output } +}) + +// AI: LLM Prompt (placeholder - integrate with actual LLM service) +registerBlockExecutor('ai.llm', async (_ctx, inputs, config) => { + const prompt = inputs.prompt as string + const context = inputs.context as string + const systemPrompt = config.systemPrompt as string + + // Placeholder - would integrate with actual LLM API + console.log('[AI LLM] Prompt:', prompt) + console.log('[AI LLM] Context:', context) + console.log('[AI LLM] System:', systemPrompt) + + return { + response: `[LLM Response placeholder for: ${prompt}]`, + tokens: 0, + } +}) + +// AI: Image Generation (placeholder) +registerBlockExecutor('ai.imageGen', async (_ctx, inputs, config) => { + const prompt = inputs.prompt as string + const size = config.size as string + + console.log('[AI Image] Prompt:', prompt, 'Size:', size) + + return { + image: `[Generated image placeholder for: ${prompt}]`, + } +}) + +// Output: Display +registerBlockExecutor('output.display', async (_ctx, inputs, config) => { + const value = inputs.value + const format = config.format as string + + let displayValue: string + if (format === 'json' || format === 'auto') { + displayValue = typeof value === 'string' ? value : JSON.stringify(value, null, 2) + } else { + displayValue = String(value) + } + + console.log('[Display]:', displayValue) + return { displayed: displayValue } +}) + +// Output: Log +registerBlockExecutor('output.log', async (_ctx, inputs, config) => { + const message = inputs.message + const level = (config.level as string) || 'info' + + switch (level) { + case 'error': + console.error('[Workflow Log]:', message) + break + case 'warn': + console.warn('[Workflow Log]:', message) + break + default: + console.log('[Workflow Log]:', message) + } + + return { logged: true } +}) + +// Output: Notify +registerBlockExecutor('output.notify', async (_ctx, inputs, config) => { + const message = inputs.message as string + const title = (config.title as string) || 'Notification' + + // Dispatch custom event for UI to handle + window.dispatchEvent( + new CustomEvent('workflow:notification', { + detail: { title, message }, + }) + ) + + return { notified: true } +}) + +// ============================================================================= +// Block Execution +// ============================================================================= + +/** + * Execute a single workflow block + */ +export async function executeBlock( + editor: Editor, + blockId: TLShapeId, + inputs: Record = {} +): Promise { + const startTime = Date.now() + + const shape = editor.getShape(blockId) as IWorkflowBlock | undefined + if (!shape || shape.type !== 'WorkflowBlock') { + return { + success: false, + outputs: {}, + error: 'Block not found', + executionTime: 0, + } + } + + const { blockType, blockConfig } = shape.props + + if (!hasBlockDefinition(blockType)) { + return { + success: false, + outputs: {}, + error: `Unknown block type: ${blockType}`, + executionTime: Date.now() - startTime, + } + } + + // Set running state + updateBlockState(editor, blockId, 'running') + + try { + const executor = blockExecutors.get(blockType) + if (!executor) { + throw new Error(`No executor registered for block type: ${blockType}`) + } + + const context: ExecutionContext = { + editor, + blockId, + timestamp: Date.now(), + } + + const outputs = await executor(context, inputs, blockConfig) + + // Update block with outputs and success state + 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 instanceof Error ? error.message : String(error) + + // Update block with error state + editor.updateShape({ + id: blockId, + type: 'WorkflowBlock', + props: { + executionState: 'error', + executionError: errorMessage, + }, + }) + + return { + success: false, + outputs: {}, + error: errorMessage, + executionTime: Date.now() - startTime, + } + } +} + +/** + * Update a block's execution state + */ +function updateBlockState( + editor: Editor, + blockId: TLShapeId, + state: ExecutionState, + error?: string +): void { + editor.updateShape({ + id: blockId, + type: 'WorkflowBlock', + props: { + executionState: state, + executionError: error, + }, + }) +} + +// ============================================================================= +// Workflow Execution +// ============================================================================= + +/** + * Execute a complete workflow starting from trigger blocks + */ +export async function executeWorkflow( + editor: Editor, + startBlockId?: TLShapeId +): Promise> { + const results = new Map() + const outputValues = new Map>() + + // Get execution order + const executionOrder = getExecutionOrder(editor, startBlockId) + + if (executionOrder.length === 0) { + console.warn('No blocks to execute in workflow') + return results + } + + console.log(`[Workflow] Executing ${executionOrder.length} blocks`) + + for (const blockId of executionOrder) { + // Gather inputs from upstream blocks + const inputs = gatherBlockInputs(editor, blockId, outputValues) + + // Execute the block + const result = await executeBlock(editor, blockId, inputs) + results.set(blockId, result) + + if (result.success) { + outputValues.set(blockId, result.outputs) + } else { + console.error(`[Workflow] Block ${blockId} failed:`, result.error) + // Optionally stop on first error + // break + } + } + + console.log('[Workflow] Execution complete') + return results +} + +/** + * Gather input values for a block from its upstream connections + */ +function gatherBlockInputs( + editor: Editor, + blockId: TLShapeId, + outputValues: Map> +): Record { + const inputs: Record = {} + const bindings = getBlockInputBindings(editor, blockId) + + for (const binding of bindings) { + const sourceOutputs = outputValues.get(binding.fromShapeId) + if (sourceOutputs && binding.fromPortId in sourceOutputs) { + inputs[binding.toPortId] = sourceOutputs[binding.fromPortId] + } + } + + // Also include any static input values from the block itself + const shape = editor.getShape(blockId) as IWorkflowBlock | undefined + if (shape && shape.type === 'WorkflowBlock') { + for (const [key, value] of Object.entries(shape.props.inputValues)) { + if (!(key in inputs)) { + inputs[key] = value + } + } + } + + return inputs +} + +// ============================================================================= +// Execution Event Listener Setup +// ============================================================================= + +/** + * Set up listener for block execution events + */ +export function setupBlockExecutionListener(editor: Editor): () => void { + const handler = (event: CustomEvent<{ blockId: TLShapeId }>) => { + const { blockId } = event.detail + executeWorkflow(editor, blockId) + } + + window.addEventListener('workflow:execute-block', handler as EventListener) + + return () => { + window.removeEventListener('workflow:execute-block', handler as EventListener) + } +} + +/** + * Reset all blocks to idle state + */ +export function resetWorkflowState(editor: Editor): void { + const blocks = editor + .getCurrentPageShapes() + .filter((s): s is IWorkflowBlock => s.type === 'WorkflowBlock') + + for (const block of blocks) { + editor.updateShape({ + id: block.id, + type: 'WorkflowBlock', + props: { + executionState: 'idle', + executionError: undefined, + outputValues: {}, + }, + }) + } +} diff --git a/src/lib/workflow/portBindings.ts b/src/lib/workflow/portBindings.ts new file mode 100644 index 0000000..dc8d4e6 --- /dev/null +++ b/src/lib/workflow/portBindings.ts @@ -0,0 +1,464 @@ +/** + * Port Binding Utilities + * + * Manages port-to-port connections between workflow blocks via tldraw arrows. + * Stores binding info in arrow metadata and provides utilities for querying. + */ + +import { Editor, TLShapeId, TLArrowShape, TLShape, TLArrowShapeArrowheadStyle, JsonObject } from 'tldraw' +import { PortBinding } from './types' +import { IWorkflowBlock } from '@/shapes/WorkflowBlockShapeUtil' + +// ============================================================================= +// Arrow Metadata Types +// ============================================================================= + +export interface WorkflowArrowMeta extends JsonObject { + isWorkflowBinding?: boolean + fromPortId?: string + toPortId?: string + validated?: boolean + dataType?: string +} + +// Type guard for arrow binding terminal +interface ArrowBindingTerminal { + type: 'binding' + boundShapeId: TLShapeId + normalizedAnchor: { x: number; y: number } + isExact: boolean + isPrecise: boolean +} + +function isArrowBinding(terminal: unknown): terminal is ArrowBindingTerminal { + return ( + terminal !== null && + typeof terminal === 'object' && + 'type' in terminal && + (terminal as { type: string }).type === 'binding' && + 'boundShapeId' in terminal + ) +} + +// ============================================================================= +// Binding Extraction +// ============================================================================= + +/** + * Extract port binding info 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 meta = arrow.meta as WorkflowArrowMeta + if (!meta?.isWorkflowBinding) return null + + const startBinding = arrow.props.start + const endBinding = arrow.props.end + + if (!isArrowBinding(startBinding) || !isArrowBinding(endBinding)) { + return null + } + + return { + fromShapeId: startBinding.boundShapeId, + fromPortId: meta.fromPortId || 'output', + toShapeId: endBinding.boundShapeId, + toPortId: meta.toPortId || 'input', + arrowId: arrowId, + } +} + +/** + * Get all port bindings in the editor + */ +export function getAllBindings(editor: Editor): PortBinding[] { + const arrows = editor.getCurrentPageShapes().filter( + (s): s is TLArrowShape => s.type === 'arrow' + ) + + const bindings: PortBinding[] = [] + for (const arrow of arrows) { + const binding = getPortBinding(editor, arrow.id) + if (binding) { + bindings.push(binding) + } + } + + return bindings +} + +/** + * Get all arrows connected to workflow blocks (even without metadata) + */ +export function getWorkflowArrows(editor: Editor): TLArrowShape[] { + const arrows = editor.getCurrentPageShapes().filter( + (s): s is TLArrowShape => s.type === 'arrow' + ) + + return arrows.filter(arrow => { + const start = arrow.props.start + const end = arrow.props.end + + if (!isArrowBinding(start) || !isArrowBinding(end)) { + return false + } + + const startShape = editor.getShape(start.boundShapeId) + const endShape = editor.getShape(end.boundShapeId) + + return ( + startShape?.type === 'WorkflowBlock' || + endShape?.type === 'WorkflowBlock' + ) + }) +} + +// ============================================================================= +// Block-specific Queries +// ============================================================================= + +/** + * Get all input bindings for a workflow block + */ +export function getBlockInputBindings( + editor: Editor, + blockId: TLShapeId +): PortBinding[] { + return getAllBindings(editor).filter(b => b.toShapeId === blockId) +} + +/** + * Get all output bindings for a workflow block + */ +export function getBlockOutputBindings( + editor: Editor, + blockId: TLShapeId +): PortBinding[] { + return getAllBindings(editor).filter(b => b.fromShapeId === blockId) +} + +/** + * Get all connected block IDs for a given block + */ +export function getConnectedBlocks( + editor: Editor, + blockId: TLShapeId +): { upstream: TLShapeId[]; downstream: TLShapeId[] } { + const bindings = getAllBindings(editor) + + const upstream = bindings + .filter(b => b.toShapeId === blockId) + .map(b => b.fromShapeId) + + const downstream = bindings + .filter(b => b.fromShapeId === blockId) + .map(b => b.toShapeId) + + return { upstream, downstream } +} + +/** + * Get the binding for a specific input port + */ +export function getInputPortBinding( + editor: Editor, + blockId: TLShapeId, + portId: string +): PortBinding | null { + const bindings = getBlockInputBindings(editor, blockId) + return bindings.find(b => b.toPortId === portId) || null +} + +/** + * Get all bindings for a specific output port + */ +export function getOutputPortBindings( + editor: Editor, + blockId: TLShapeId, + portId: string +): PortBinding[] { + return getBlockOutputBindings(editor, blockId).filter( + b => b.fromPortId === portId + ) +} + +// ============================================================================= +// Binding Creation & Updates +// ============================================================================= + +/** + * Mark an arrow as a workflow binding with port metadata + */ +export function setArrowBinding( + editor: Editor, + arrowId: TLShapeId, + fromPortId: string, + toPortId: string, + dataType?: string +): void { + editor.updateShape({ + id: arrowId, + type: 'arrow', + meta: { + isWorkflowBinding: true, + fromPortId, + toPortId, + validated: true, + dataType, + } as WorkflowArrowMeta, + }) +} + +/** + * Clear workflow binding metadata from an arrow + */ +export function clearArrowBinding(editor: Editor, arrowId: TLShapeId): void { + editor.updateShape({ + id: arrowId, + type: 'arrow', + meta: { + isWorkflowBinding: false, + fromPortId: undefined, + toPortId: undefined, + validated: false, + dataType: undefined, + } as WorkflowArrowMeta, + }) +} + +/** + * Remove a binding (delete the arrow) + */ +export function removeBinding(editor: Editor, binding: PortBinding): void { + editor.deleteShape(binding.arrowId) +} + +// ============================================================================= +// Port Position Helpers +// ============================================================================= + +/** + * Calculate the world position of a port on a workflow block + */ +export function getPortWorldPosition( + editor: Editor, + blockId: TLShapeId, + portId: string, + direction: 'input' | 'output' +): { x: number; y: number } | null { + const shape = editor.getShape(blockId) as IWorkflowBlock | undefined + if (!shape || shape.type !== 'WorkflowBlock') return null + + // Get the shape's transform + const point = editor.getShapePageTransform(blockId)?.point() + if (!point) return null + + // Import dynamically to avoid circular deps + const { getBlockDefinition, hasBlockDefinition } = require('./blockRegistry') + + if (!hasBlockDefinition(shape.props.blockType)) { + return null + } + + const definition = getBlockDefinition(shape.props.blockType) + const ports = direction === 'input' ? definition.inputs : definition.outputs + const portIndex = ports.findIndex((p: { id: string }) => p.id === portId) + + if (portIndex === -1) return null + + const PORT_SIZE = 12 + const PORT_SPACING = 28 + const HEADER_HEIGHT = 36 + + const x = direction === 'input' ? 0 : shape.props.w + const y = HEADER_HEIGHT + 12 + portIndex * PORT_SPACING + PORT_SIZE / 2 + + return { + x: point.x + x, + y: point.y + y, + } +} + +// ============================================================================= +// Connection Validation Helpers +// ============================================================================= + +/** + * Check if a connection already exists between two ports + */ +export function connectionExists( + editor: Editor, + fromBlockId: TLShapeId, + fromPortId: string, + toBlockId: TLShapeId, + toPortId: string +): boolean { + const bindings = getAllBindings(editor) + return bindings.some( + b => + b.fromShapeId === fromBlockId && + b.fromPortId === fromPortId && + b.toShapeId === toBlockId && + b.toPortId === toPortId + ) +} + +/** + * Check if an input port already has a connection + */ +export function inputPortHasConnection( + editor: Editor, + blockId: TLShapeId, + portId: string +): boolean { + return getInputPortBinding(editor, blockId, portId) !== null +} + +/** + * Get the list of connected input port IDs for a block + */ +export function getConnectedInputPorts( + editor: Editor, + blockId: TLShapeId +): string[] { + return getBlockInputBindings(editor, blockId).map(b => b.toPortId) +} + +/** + * Get the list of connected output port IDs for a block + */ +export function getConnectedOutputPorts( + editor: Editor, + blockId: TLShapeId +): string[] { + const bindings = getBlockOutputBindings(editor, blockId) + return [...new Set(bindings.map(b => b.fromPortId))] +} + +// ============================================================================= +// Graph Traversal +// ============================================================================= + +/** + * Get all workflow blocks in topological order (for execution) + */ +export function getExecutionOrder( + editor: Editor, + startBlockId?: TLShapeId +): TLShapeId[] { + const bindings = getAllBindings(editor) + const blocks = editor + .getCurrentPageShapes() + .filter((s): s is IWorkflowBlock => s.type === 'WorkflowBlock') + .map(s => s.id) + + // Build adjacency list + const graph = new Map>() + const inDegree = new Map() + + for (const blockId of blocks) { + graph.set(blockId, new Set()) + inDegree.set(blockId, 0) + } + + for (const binding of bindings) { + if (blocks.includes(binding.fromShapeId) && blocks.includes(binding.toShapeId)) { + graph.get(binding.fromShapeId)?.add(binding.toShapeId) + inDegree.set( + binding.toShapeId, + (inDegree.get(binding.toShapeId) || 0) + 1 + ) + } + } + + // Kahn's algorithm for topological sort + const queue: TLShapeId[] = [] + const result: TLShapeId[] = [] + + // Start from specified block or all roots + if (startBlockId && blocks.includes(startBlockId)) { + queue.push(startBlockId) + } else { + for (const [blockId, degree] of inDegree) { + if (degree === 0) { + queue.push(blockId) + } + } + } + + while (queue.length > 0) { + const current = queue.shift()! + result.push(current) + + for (const neighbor of graph.get(current) || []) { + const newDegree = (inDegree.get(neighbor) || 1) - 1 + inDegree.set(neighbor, newDegree) + if (newDegree === 0) { + queue.push(neighbor) + } + } + } + + return result +} + +/** + * Get all blocks downstream from a given block + */ +export function getDownstreamBlocks( + editor: Editor, + blockId: TLShapeId +): TLShapeId[] { + const visited = new Set() + const result: TLShapeId[] = [] + + function dfs(current: TLShapeId) { + if (visited.has(current)) return + visited.add(current) + + const downstream = getBlockOutputBindings(editor, current).map( + b => b.toShapeId + ) + + for (const next of downstream) { + result.push(next) + dfs(next) + } + } + + dfs(blockId) + return result +} + +/** + * Get all blocks upstream from a given block + */ +export function getUpstreamBlocks( + editor: Editor, + blockId: TLShapeId +): TLShapeId[] { + const visited = new Set() + const result: TLShapeId[] = [] + + function dfs(current: TLShapeId) { + if (visited.has(current)) return + visited.add(current) + + const upstream = getBlockInputBindings(editor, current).map( + b => b.fromShapeId + ) + + for (const prev of upstream) { + result.push(prev) + dfs(prev) + } + } + + dfs(blockId) + return result +} diff --git a/src/lib/workflow/serialization.ts b/src/lib/workflow/serialization.ts new file mode 100644 index 0000000..4f20c2d --- /dev/null +++ b/src/lib/workflow/serialization.ts @@ -0,0 +1,571 @@ +/** + * Workflow Serialization + * + * Export and import workflows as JSON for sharing and backup. + * Compatible with a simplified Flowy-like format. + */ + +import { Editor, TLShapeId, createShapeId } from 'tldraw' +import { IWorkflowBlock } from '@/shapes/WorkflowBlockShapeUtil' +import { WorkflowBlockProps, PortBinding } from './types' +import { getAllBindings, setArrowBinding } from './portBindings' +import { hasBlockDefinition } from './blockRegistry' + +// ============================================================================= +// Serialization Types +// ============================================================================= + +export interface SerializedBlock { + id: string + type: string + blockType: string + x: number + y: number + w: number + h: number + config: Record + inputValues: Record + tags: string[] +} + +export interface SerializedConnection { + id: string + fromBlock: string + fromPort: string + toBlock: string + toPort: string +} + +export interface SerializedWorkflow { + version: string + name: string + description?: string + createdAt: string + blocks: SerializedBlock[] + connections: SerializedConnection[] + metadata?: Record +} + +// ============================================================================= +// Export Functions +// ============================================================================= + +/** + * Export workflow blocks and connections from the editor + */ +export function exportWorkflow( + editor: Editor, + options: { + name?: string + description?: string + selectedOnly?: boolean + } = {} +): SerializedWorkflow { + const { name = 'Untitled Workflow', description, selectedOnly = false } = options + + // Get all workflow blocks + let blocks = editor + .getCurrentPageShapes() + .filter((s): s is IWorkflowBlock => s.type === 'WorkflowBlock') + + if (selectedOnly) { + const selectedIds = new Set(editor.getSelectedShapeIds()) + blocks = blocks.filter((b) => selectedIds.has(b.id)) + } + + const blockIds = new Set(blocks.map((b) => b.id)) + + // Get all bindings between these blocks + const allBindings = getAllBindings(editor) + const relevantBindings = allBindings.filter( + (b) => blockIds.has(b.fromShapeId) && blockIds.has(b.toShapeId) + ) + + // Serialize blocks + const serializedBlocks: SerializedBlock[] = blocks.map((block) => ({ + id: block.id, + type: 'WorkflowBlock', + blockType: block.props.blockType, + x: block.x, + y: block.y, + w: block.props.w, + h: block.props.h, + config: block.props.blockConfig, + inputValues: block.props.inputValues, + tags: block.props.tags, + })) + + // Serialize connections + const serializedConnections: SerializedConnection[] = relevantBindings.map( + (binding) => ({ + id: binding.arrowId, + fromBlock: binding.fromShapeId, + fromPort: binding.fromPortId, + toBlock: binding.toShapeId, + toPort: binding.toPortId, + }) + ) + + return { + version: '1.0.0', + name, + description, + createdAt: new Date().toISOString(), + blocks: serializedBlocks, + connections: serializedConnections, + } +} + +/** + * Export workflow as a JSON string + */ +export function exportWorkflowToJSON( + editor: Editor, + options: { + name?: string + description?: string + selectedOnly?: boolean + pretty?: boolean + } = {} +): string { + const { pretty = true, ...workflowOptions } = options + const workflow = exportWorkflow(editor, workflowOptions) + return pretty ? JSON.stringify(workflow, null, 2) : JSON.stringify(workflow) +} + +/** + * Download workflow as a JSON file + */ +export function downloadWorkflow( + editor: Editor, + options: { + name?: string + description?: string + selectedOnly?: boolean + } = {} +): void { + const json = exportWorkflowToJSON(editor, { ...options, pretty: true }) + const filename = `${options.name || 'workflow'}-${Date.now()}.json` + + const blob = new Blob([json], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + + const link = document.createElement('a') + link.href = url + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} + +// ============================================================================= +// Import Functions +// ============================================================================= + +/** + * Import a workflow from serialized data + */ +export function importWorkflow( + editor: Editor, + workflow: SerializedWorkflow, + options: { + offset?: { x: number; y: number } + generateNewIds?: boolean + } = {} +): { blockIds: TLShapeId[]; arrowIds: TLShapeId[] } { + const { offset = { x: 0, y: 0 }, generateNewIds = true } = options + + // Map old IDs to new IDs + const idMap = new Map() + + // Validate blocks + const validBlocks = workflow.blocks.filter((block) => { + if (!hasBlockDefinition(block.blockType)) { + console.warn(`Unknown block type: ${block.blockType}, skipping`) + return false + } + return true + }) + + // Create new IDs if needed + for (const block of validBlocks) { + const newId = generateNewIds + ? createShapeId() + : (block.id as TLShapeId) + idMap.set(block.id, newId) + } + + // Calculate bounding box for centering + let minX = Infinity + let minY = Infinity + for (const block of validBlocks) { + minX = Math.min(minX, block.x) + minY = Math.min(minY, block.y) + } + + // Create blocks + const blockIds: TLShapeId[] = [] + for (const block of validBlocks) { + const newId = idMap.get(block.id)! + blockIds.push(newId) + + editor.createShape({ + id: newId, + type: 'WorkflowBlock', + x: block.x - minX + offset.x, + y: block.y - minY + offset.y, + props: { + w: block.w, + h: block.h, + blockType: block.blockType, + blockConfig: block.config, + inputValues: block.inputValues, + outputValues: {}, + executionState: 'idle', + tags: block.tags || ['workflow'], + pinnedToView: false, + }, + }) + } + + // Create connections (arrows) + const arrowIds: TLShapeId[] = [] + for (const conn of workflow.connections) { + const fromId = idMap.get(conn.fromBlock) + const toId = idMap.get(conn.toBlock) + + if (!fromId || !toId) { + console.warn(`Skipping connection: missing block reference`) + continue + } + + const arrowId = generateNewIds + ? createShapeId() + : (conn.id as TLShapeId) + arrowIds.push(arrowId) + + // Create arrow between blocks + editor.createShape({ + id: arrowId, + type: 'arrow', + props: { + start: { + type: 'binding', + boundShapeId: fromId, + normalizedAnchor: { x: 1, y: 0.5 }, + isExact: false, + isPrecise: false, + }, + end: { + type: 'binding', + boundShapeId: toId, + normalizedAnchor: { x: 0, y: 0.5 }, + isExact: false, + isPrecise: false, + }, + text: `flow{ ${conn.fromPort} -> ${conn.toPort} }`, + }, + }) + + // Set arrow binding metadata + setArrowBinding(editor, arrowId, conn.fromPort, conn.toPort) + } + + // Select imported shapes + editor.setSelectedShapes([...blockIds, ...arrowIds]) + + return { blockIds, arrowIds } +} + +/** + * Import workflow from JSON string + */ +export function importWorkflowFromJSON( + editor: Editor, + json: string, + options: { + offset?: { x: number; y: number } + generateNewIds?: boolean + } = {} +): { blockIds: TLShapeId[]; arrowIds: TLShapeId[] } | null { + try { + const workflow = JSON.parse(json) as SerializedWorkflow + return importWorkflow(editor, workflow, options) + } catch (error) { + console.error('Failed to parse workflow JSON:', error) + return null + } +} + +/** + * Import workflow from File object + */ +export async function importWorkflowFromFile( + editor: Editor, + file: File, + options: { + offset?: { x: number; y: number } + generateNewIds?: boolean + } = {} +): Promise<{ blockIds: TLShapeId[]; arrowIds: TLShapeId[] } | null> { + try { + const text = await file.text() + return importWorkflowFromJSON(editor, text, options) + } catch (error) { + console.error('Failed to read workflow file:', error) + return null + } +} + +// ============================================================================= +// Template Functions +// ============================================================================= + +/** + * Create a basic API workflow template + */ +export function getApiWorkflowTemplate(): SerializedWorkflow { + return { + version: '1.0.0', + name: 'API Request Template', + description: 'Fetch data from an API and display the result', + createdAt: new Date().toISOString(), + blocks: [ + { + id: 'trigger-1', + type: 'WorkflowBlock', + blockType: 'trigger.manual', + x: 100, + y: 100, + w: 220, + h: 150, + config: {}, + inputValues: {}, + tags: ['workflow', 'trigger'], + }, + { + id: 'http-1', + type: 'WorkflowBlock', + blockType: 'action.http', + x: 400, + y: 100, + w: 220, + h: 180, + config: { method: 'GET' }, + inputValues: { url: 'https://api.example.com/data' }, + tags: ['workflow', 'action'], + }, + { + id: 'display-1', + type: 'WorkflowBlock', + blockType: 'output.display', + x: 700, + y: 100, + w: 220, + h: 150, + config: { format: 'json' }, + inputValues: {}, + tags: ['workflow', 'output'], + }, + ], + connections: [ + { + id: 'conn-1', + fromBlock: 'trigger-1', + fromPort: 'timestamp', + toBlock: 'http-1', + toPort: 'trigger', + }, + { + id: 'conn-2', + fromBlock: 'http-1', + fromPort: 'response', + toBlock: 'display-1', + toPort: 'value', + }, + ], + } +} + +/** + * Create an LLM chain workflow template + */ +export function getLLMChainTemplate(): SerializedWorkflow { + return { + version: '1.0.0', + name: 'LLM Chain Template', + description: 'Chain multiple LLM prompts together', + createdAt: new Date().toISOString(), + blocks: [ + { + id: 'trigger-1', + type: 'WorkflowBlock', + blockType: 'trigger.manual', + x: 100, + y: 100, + w: 220, + h: 150, + config: {}, + inputValues: {}, + tags: ['workflow', 'trigger'], + }, + { + id: 'llm-1', + type: 'WorkflowBlock', + blockType: 'ai.llm', + x: 400, + y: 100, + w: 220, + h: 200, + config: { systemPrompt: 'You are a helpful assistant.' }, + inputValues: { prompt: 'Summarize the following topic:' }, + tags: ['workflow', 'ai'], + }, + { + id: 'llm-2', + type: 'WorkflowBlock', + blockType: 'ai.llm', + x: 700, + y: 100, + w: 220, + h: 200, + config: { systemPrompt: 'You expand on summaries with examples.' }, + inputValues: {}, + tags: ['workflow', 'ai'], + }, + { + id: 'display-1', + type: 'WorkflowBlock', + blockType: 'output.display', + x: 1000, + y: 100, + w: 220, + h: 150, + config: { format: 'auto' }, + inputValues: {}, + tags: ['workflow', 'output'], + }, + ], + connections: [ + { + id: 'conn-1', + fromBlock: 'trigger-1', + fromPort: 'timestamp', + toBlock: 'llm-1', + toPort: 'trigger', + }, + { + id: 'conn-2', + fromBlock: 'llm-1', + fromPort: 'response', + toBlock: 'llm-2', + toPort: 'prompt', + }, + { + id: 'conn-3', + fromBlock: 'llm-2', + fromPort: 'response', + toBlock: 'display-1', + toPort: 'value', + }, + ], + } +} + +/** + * Create a conditional branch workflow template + */ +export function getConditionalTemplate(): SerializedWorkflow { + return { + version: '1.0.0', + name: 'Conditional Branch Template', + description: 'Route data based on conditions', + createdAt: new Date().toISOString(), + blocks: [ + { + id: 'trigger-1', + type: 'WorkflowBlock', + blockType: 'trigger.manual', + x: 100, + y: 200, + w: 220, + h: 150, + config: {}, + inputValues: {}, + tags: ['workflow', 'trigger'], + }, + { + id: 'compare-1', + type: 'WorkflowBlock', + blockType: 'condition.compare', + x: 400, + y: 200, + w: 220, + h: 180, + config: { operator: 'greater' }, + inputValues: { a: 10, b: 5 }, + tags: ['workflow', 'condition'], + }, + { + id: 'if-1', + type: 'WorkflowBlock', + blockType: 'condition.if', + x: 700, + y: 200, + w: 220, + h: 180, + config: {}, + inputValues: { value: 'Result data' }, + tags: ['workflow', 'condition'], + }, + { + id: 'display-true', + type: 'WorkflowBlock', + blockType: 'output.display', + x: 1000, + y: 100, + w: 220, + h: 150, + config: { format: 'auto' }, + inputValues: {}, + tags: ['workflow', 'output'], + }, + { + id: 'display-false', + type: 'WorkflowBlock', + blockType: 'output.display', + x: 1000, + y: 300, + w: 220, + h: 150, + config: { format: 'auto' }, + inputValues: {}, + tags: ['workflow', 'output'], + }, + ], + connections: [ + { + id: 'conn-1', + fromBlock: 'compare-1', + fromPort: 'result', + toBlock: 'if-1', + toPort: 'condition', + }, + { + id: 'conn-2', + fromBlock: 'if-1', + fromPort: 'true', + toBlock: 'display-true', + toPort: 'value', + }, + { + id: 'conn-3', + fromBlock: 'if-1', + fromPort: 'false', + toBlock: 'display-false', + toPort: 'value', + }, + ], + } +} diff --git a/src/lib/workflow/types.ts b/src/lib/workflow/types.ts new file mode 100644 index 0000000..6d214d0 --- /dev/null +++ b/src/lib/workflow/types.ts @@ -0,0 +1,159 @@ +/** + * Workflow Type Definitions + * + * Core types for the Flowy-like workflow builder system. + * Supports typed ports, block definitions, and execution context. + */ + +import { TLShapeId } from 'tldraw' + +// ============================================================================= +// Port Data Types +// ============================================================================= + +export type PortDataType = + | 'text' + | 'number' + | 'boolean' + | 'object' + | 'array' + | 'any' + | 'file' + | 'image' + +/** + * Check if a source type is compatible with target accepted types + */ +export function isTypeCompatible( + sourceType: PortDataType, + targetAccepts: PortDataType[] +): boolean { + if (targetAccepts.includes('any')) return true + if (sourceType === 'any') return true + return targetAccepts.includes(sourceType) +} + +/** + * Get display color for port type + */ +export function getPortTypeColor(type: PortDataType): string { + const colors: Record = { + text: '#3b82f6', // blue + number: '#10b981', // green + boolean: '#f59e0b', // amber + object: '#8b5cf6', // purple + array: '#06b6d4', // cyan + any: '#6b7280', // gray + file: '#ec4899', // pink + image: '#f97316', // orange + } + return colors[type] || colors.any +} + +// ============================================================================= +// Port Definitions +// ============================================================================= + +export interface InputPort { + id: string + name: string + type: PortDataType + accepts: PortDataType[] + required?: boolean + defaultValue?: unknown + description?: string +} + +export interface OutputPort { + id: string + name: string + type: PortDataType + produces: PortDataType + description?: string +} + +// ============================================================================= +// Block Categories +// ============================================================================= + +export type BlockCategory = + | 'trigger' + | 'action' + | 'condition' + | 'transformer' + | 'ai' + | 'output' + +export const CATEGORY_INFO: Record = { + trigger: { label: 'Triggers', color: '#ef4444', icon: '⚑' }, + action: { label: 'Actions', color: '#3b82f6', icon: 'πŸ”§' }, + condition: { label: 'Conditions', color: '#f59e0b', icon: 'πŸ”€' }, + transformer: { label: 'Transformers', color: '#10b981', icon: 'πŸ”„' }, + ai: { label: 'AI', color: '#8b5cf6', icon: 'πŸ€–' }, + output: { label: 'Outputs', color: '#06b6d4', icon: 'πŸ“€' }, +} + +// ============================================================================= +// Block Definition +// ============================================================================= + +export interface BlockDefinition { + type: string + name: string + description: string + icon: string + category: BlockCategory + inputs: InputPort[] + outputs: OutputPort[] + defaultConfig?: Record + configSchema?: Record +} + +// ============================================================================= +// Workflow Block Props +// ============================================================================= + +export interface WorkflowBlockProps { + w: number + h: number + blockType: string + blockConfig: Record + inputValues: Record + outputValues: Record + executionState: ExecutionState + executionError?: string + tags: string[] + pinnedToView: boolean +} + +export type ExecutionState = 'idle' | 'running' | 'success' | 'error' + +// ============================================================================= +// Port Binding (Arrow Metadata) +// ============================================================================= + +export interface PortBinding { + fromShapeId: TLShapeId + fromPortId: string + toShapeId: TLShapeId + toPortId: string + arrowId: TLShapeId +} + +// ============================================================================= +// Execution Context +// ============================================================================= + +export interface ExecutionContext { + editor: unknown // Editor type + blockId: TLShapeId + timestamp: number + abortSignal?: AbortSignal +} + +export interface BlockExecutionResult { + success: boolean + outputs: Record + error?: string + executionTime: number +} diff --git a/src/lib/workflow/validation.ts b/src/lib/workflow/validation.ts new file mode 100644 index 0000000..70243c4 --- /dev/null +++ b/src/lib/workflow/validation.ts @@ -0,0 +1,417 @@ +/** + * Port Validation + * + * Handles type compatibility checking between ports and validates + * workflow connections to prevent invalid data flow. + */ + +import { + PortDataType, + InputPort, + OutputPort, + 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 +// ============================================================================= + +export function canConnect( + outputPort: OutputPort, + inputPort: InputPort +): boolean { + return isTypeCompatible(outputPort.produces, inputPort.accepts) +} + +export function canConnectType( + outputType: PortDataType, + inputPort: InputPort +): boolean { + return isTypeCompatible(outputType, inputPort.accepts) +} + +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) + ) +} + +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 +// ============================================================================= + +export function validateConnection( + sourceBlockType: string, + sourcePortId: string, + targetBlockType: string, + targetPortId: string +): ValidationResult { + const errors: ValidationError[] = [] + const warnings: ValidationWarning[] = [] + + if (!hasBlockDefinition(sourceBlockType)) { + errors.push({ + type: 'unknown_block', + message: `Unknown source block type: ${sourceBlockType}`, + details: { blockType: sourceBlockType }, + }) + return { valid: false, errors, warnings } + } + + 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) + + 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 } + } + + 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 } + } + + 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 } + } + + 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 } +} + +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 +// ============================================================================= + +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 (!definition.configSchema) { + return { valid: true, errors, warnings } + } + + 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[] } + + if (prop.required && !(key in config)) { + errors.push({ + type: 'missing_required', + message: `Missing required configuration: ${key}`, + details: { key }, + }) + } + + 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 } +} + +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, + }) + } + } + } + + 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 +// ============================================================================= + +export function detectCycles( + connections: PortBinding[] +): { hasCycle: boolean; cycleNodes?: string[] } { + 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) + } + + 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)) { + const cycleStart = cyclePath.indexOf(cyclePath[cyclePath.length - 1]) + return { + hasCycle: true, + cycleNodes: cyclePath.slice(cycleStart), + } + } + } + } + + return { hasCycle: false } +} + +export function validateWorkflow( + blocks: Array<{ id: string; blockType: string; config: Record }>, + connections: PortBinding[] +): ValidationResult { + const errors: ValidationError[] = [] + const warnings: ValidationWarning[] = [] + + 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) + } + } + + const cycleResult = detectCycles(connections) + if (cycleResult.hasCycle) { + errors.push({ + type: 'cycle_detected', + message: `Cycle detected in workflow: ${cycleResult.cycleNodes?.join(' -> ')}`, + details: { cycleNodes: cycleResult.cycleNodes }, + }) + } + + 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}`)) { + 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..2f12ab9 --- /dev/null +++ b/src/propagators/WorkflowPropagator.ts @@ -0,0 +1,326 @@ +/** + * WorkflowPropagator + * + * Real-time data propagation for workflow blocks. + * Automatically executes downstream blocks when source block outputs change. + * Uses 'flow' prefix syntax: flow{ ... } in arrow text. + */ + +import { Editor, TLArrowShape, TLShape, TLShapeId } from 'tldraw' +import { getEdge } from '@/propagators/tlgraph' +import { isShapeOfType } from '@/propagators/utils' +import { IWorkflowBlock } from '@/shapes/WorkflowBlockShapeUtil' +import { getBlockDefinition, hasBlockDefinition } from '@/lib/workflow/blockRegistry' +import { executeBlock } from '@/lib/workflow/executor' +import { + setArrowBinding, + getBlockInputBindings, + getConnectedInputPorts, +} from '@/lib/workflow/portBindings' +import { validateRequiredInputs } from '@/lib/workflow/validation' + +// ============================================================================= +// Propagator Registration +// ============================================================================= + +/** + * Register the workflow propagator with the editor + */ +export function registerWorkflowPropagator(editor: Editor): () => void { + const propagator = new WorkflowPropagator(editor) + const cleanup: (() => void)[] = [] + + // Initial scan for existing workflow arrows + for (const shape of editor.getCurrentPageShapes()) { + if (isShapeOfType(shape, 'arrow')) { + propagator.onArrowChange(shape) + } + } + + // Register shape change handler + const shapeHandler = editor.sideEffects.registerAfterChangeHandler<'shape'>( + 'shape', + (prev, next) => { + if (isShapeOfType(next, 'arrow')) { + propagator.onArrowChange(next) + } else if (isWorkflowBlock(next)) { + propagator.onBlockChange(prev as IWorkflowBlock, next as IWorkflowBlock) + } + } + ) + cleanup.push(shapeHandler) + + // Register binding create/delete handlers + const bindingCreateHandler = editor.sideEffects.registerAfterCreateHandler<'binding'>( + 'binding', + (binding) => { + if (binding.type !== 'arrow') return + const arrow = editor.getShape(binding.fromId) + if (arrow && isShapeOfType(arrow, 'arrow')) { + propagator.onArrowChange(arrow) + } + } + ) + cleanup.push(bindingCreateHandler) + + const bindingDeleteHandler = editor.sideEffects.registerAfterDeleteHandler<'binding'>( + 'binding', + (binding) => { + if (binding.type !== 'arrow') return + propagator.removeArrow(binding.fromId) + } + ) + cleanup.push(bindingDeleteHandler) + + return () => { + cleanup.forEach((fn) => fn()) + } +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +function isWorkflowBlock(shape: TLShape): shape is IWorkflowBlock { + return shape.type === 'WorkflowBlock' +} + +function isWorkflowArrow(arrow: TLArrowShape): boolean { + // Check if arrow text starts with 'flow{' or just connects two workflow blocks + const text = arrow.props.text.trim() + return text.startsWith('flow{') || text.startsWith('flow (') +} + +function parseFlowSyntax(text: string): { fromPort?: string; toPort?: string } | null { + // Parse flow{ fromPort -> toPort } syntax + const match = text.match(/^flow\s*\{\s*(\w+)?\s*(?:->|β†’)?\s*(\w+)?\s*\}$/i) + if (!match) { + // Try simple format: flow{} + if (/^flow\s*\{\s*\}$/.test(text)) { + return { fromPort: undefined, toPort: undefined } + } + return null + } + + return { + fromPort: match[1] || undefined, + toPort: match[2] || undefined, + } +} + +// ============================================================================= +// WorkflowPropagator Class +// ============================================================================= + +class WorkflowPropagator { + private editor: Editor + private workflowArrows: Map = new Map() + private pendingExecutions: Set = new Set() + private executionDebounce: Map = new Map() + + constructor(editor: Editor) { + this.editor = editor + } + + /** + * Called when an arrow changes - check if it's a workflow connection + */ + onArrowChange(arrow: TLArrowShape): void { + const edge = getEdge(arrow, this.editor) + if (!edge) { + this.removeArrow(arrow.id) + return + } + + const fromShape = this.editor.getShape(edge.from) + const toShape = this.editor.getShape(edge.to) + + // Only track arrows between workflow blocks + if (!fromShape || !toShape) { + this.removeArrow(arrow.id) + return + } + + if (!isWorkflowBlock(fromShape) || !isWorkflowBlock(toShape)) { + this.removeArrow(arrow.id) + return + } + + // Parse flow syntax if present, or use default ports + const text = arrow.props.text.trim() + const parsed = parseFlowSyntax(text) + + // Determine port IDs + let fromPortId = 'output' + let toPortId = 'input' + + if (parsed) { + if (parsed.fromPort) fromPortId = parsed.fromPort + if (parsed.toPort) toPortId = parsed.toPort + } else if (!text || !text.startsWith('flow')) { + // For arrows without explicit flow syntax between workflow blocks, + // try to infer the first available ports + const fromDef = hasBlockDefinition(fromShape.props.blockType) + ? getBlockDefinition(fromShape.props.blockType) + : null + const toDef = hasBlockDefinition(toShape.props.blockType) + ? getBlockDefinition(toShape.props.blockType) + : null + + if (fromDef?.outputs.length) { + fromPortId = fromDef.outputs[0].id + } + if (toDef?.inputs.length) { + toPortId = toDef.inputs[0].id + } + } + + // Update arrow metadata + setArrowBinding(this.editor, arrow.id, fromPortId, toPortId) + + // Track this arrow + this.workflowArrows.set(arrow.id, { + fromBlock: edge.from, + toBlock: edge.to, + }) + } + + /** + * Called when a workflow block changes + */ + onBlockChange(prev: IWorkflowBlock, next: IWorkflowBlock): void { + // Check if outputs changed + const prevOutputs = prev.props.outputValues + const nextOutputs = next.props.outputValues + + const outputsChanged = + JSON.stringify(prevOutputs) !== JSON.stringify(nextOutputs) + + // Check if execution state changed to success + const justSucceeded = + prev.props.executionState !== 'success' && + next.props.executionState === 'success' + + if (outputsChanged || justSucceeded) { + this.propagateFromBlock(next.id) + } + } + + /** + * Remove tracking for an arrow + */ + removeArrow(arrowId: TLShapeId): void { + this.workflowArrows.delete(arrowId) + } + + /** + * Propagate data from a block to all downstream connections + */ + private propagateFromBlock(blockId: TLShapeId): void { + const block = this.editor.getShape(blockId) as IWorkflowBlock | undefined + if (!block || block.type !== 'WorkflowBlock') return + + // Find all arrows originating from this block + const downstreamBlocks = new Set() + + for (const [, connection] of this.workflowArrows) { + if (connection.fromBlock === blockId) { + downstreamBlocks.add(connection.toBlock) + } + } + + // Schedule execution for each downstream block + for (const targetBlockId of downstreamBlocks) { + this.scheduleExecution(targetBlockId) + } + } + + /** + * Schedule block execution with debouncing + */ + private scheduleExecution(blockId: TLShapeId): void { + // Cancel any pending execution for this block + const existing = this.executionDebounce.get(blockId) + if (existing) { + clearTimeout(existing) + } + + // Schedule new execution + const timeout = setTimeout(() => { + this.executeBlockIfReady(blockId) + this.executionDebounce.delete(blockId) + }, 50) // 50ms debounce + + this.executionDebounce.set(blockId, timeout) + } + + /** + * Execute a block if all required inputs are satisfied + */ + private async executeBlockIfReady(blockId: TLShapeId): Promise { + if (this.pendingExecutions.has(blockId)) return + this.pendingExecutions.add(blockId) + + try { + const block = this.editor.getShape(blockId) as IWorkflowBlock | undefined + if (!block || block.type !== 'WorkflowBlock') return + + const { blockType, inputValues } = block.props + + if (!hasBlockDefinition(blockType)) return + + // Check if all required inputs are satisfied + const connectedInputs = getConnectedInputPorts(this.editor, blockId) + const validation = validateRequiredInputs( + blockType, + inputValues, + connectedInputs + ) + + // Gather input values from upstream blocks + const inputs = this.gatherInputs(blockId) + + // If validation passes (or has only warnings), execute + if (validation.valid || validation.errors.length === 0) { + await executeBlock(this.editor, blockId, inputs) + } + } finally { + this.pendingExecutions.delete(blockId) + } + } + + /** + * Gather input values from upstream connected blocks + */ + private gatherInputs(blockId: TLShapeId): Record { + const inputs: Record = {} + const bindings = getBlockInputBindings(this.editor, blockId) + + for (const binding of bindings) { + const sourceBlock = this.editor.getShape( + binding.fromShapeId + ) as IWorkflowBlock | undefined + + if (sourceBlock && sourceBlock.type === 'WorkflowBlock') { + const sourceOutputs = sourceBlock.props.outputValues + if (binding.fromPortId in sourceOutputs) { + inputs[binding.toPortId] = sourceOutputs[binding.fromPortId] + } + } + } + + // Include any static input values + const block = this.editor.getShape(blockId) as IWorkflowBlock | undefined + if (block && block.type === 'WorkflowBlock') { + for (const [key, value] of Object.entries(block.props.inputValues)) { + if (!(key in inputs)) { + inputs[key] = value + } + } + } + + return inputs + } +} + +export default WorkflowPropagator diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index cadd36a..f6c2638 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -65,16 +65,15 @@ import { GoogleItemTool } from "@/tools/GoogleItemTool" import { MapShape } from "@/shapes/MapShapeUtil" import { MapTool } from "@/tools/MapTool" // Workflow Builder - Flowy-like workflow blocks -// TODO: Fix TypeScript errors in workflow files before re-enabling -// import { WorkflowBlockShape } from "@/shapes/WorkflowBlockShapeUtil" -// import { WorkflowBlockTool } from "@/tools/WorkflowBlockTool" +import { WorkflowBlockShape } from "@/shapes/WorkflowBlockShapeUtil" +import { WorkflowBlockTool } from "@/tools/WorkflowBlockTool" // Calendar - Unified calendar with view switching (browser, widget, year) import { CalendarShape } from "@/shapes/CalendarShapeUtil" import { CalendarTool } from "@/tools/CalendarTool" import { CalendarEventShape } from "@/shapes/CalendarEventShapeUtil" -// TODO: Fix TypeScript errors in workflow files before re-enabling -// import { registerWorkflowPropagator } from "@/propagators/WorkflowPropagator" -// import { setupBlockExecutionListener } from "@/lib/workflow/executor" +// Workflow propagator for real-time data flow +import { registerWorkflowPropagator } from "@/propagators/WorkflowPropagator" +import { setupBlockExecutionListener } from "@/lib/workflow/executor" import { lockElement, unlockElement, @@ -176,7 +175,7 @@ const customShapeUtils = [ PrivateWorkspaceShape, // Private zone for Google Export data sovereignty GoogleItemShape, // Individual items from Google Export with privacy badges MapShape, // Open Mapping - OSM map shape - // WorkflowBlockShape, // Workflow Builder - Flowy-like blocks (disabled - TypeScript errors) + WorkflowBlockShape, // Workflow Builder - Flowy-like blocks CalendarShape, // Calendar - Unified with view switching (browser/widget/year) CalendarEventShape, // Calendar - Individual event cards ] @@ -201,7 +200,7 @@ const customTools = [ PrivateWorkspaceTool, GoogleItemTool, MapTool, // Open Mapping - OSM map tool - // WorkflowBlockTool, // Workflow Builder - click-to-place (disabled - TypeScript errors) + WorkflowBlockTool, // Workflow Builder - click-to-place CalendarTool, // Calendar - Unified with view switching ] @@ -1376,9 +1375,8 @@ export function Board() { ]) // Register workflow propagator for real-time data flow - // TODO: Fix TypeScript errors in workflow files before re-enabling - // const cleanupWorkflowPropagator = registerWorkflowPropagator(editor) - // const cleanupBlockExecution = setupBlockExecutionListener(editor) + const cleanupWorkflowPropagator = registerWorkflowPropagator(editor) + const cleanupBlockExecution = setupBlockExecutionListener(editor) // Clean up corrupted shapes that cause "No nearest point found" errors // This typically happens with draw/line shapes that have no points diff --git a/src/shapes/WorkflowBlockShapeUtil.tsx b/src/shapes/WorkflowBlockShapeUtil.tsx new file mode 100644 index 0000000..988f419 --- /dev/null +++ b/src/shapes/WorkflowBlockShapeUtil.tsx @@ -0,0 +1,513 @@ +/** + * WorkflowBlockShapeUtil + * + * A visual workflow block shape with typed input/output ports. + * Supports connection to other blocks via tldraw arrows. + */ + +import { + BaseBoxShapeUtil, + Geometry2d, + HTMLContainer, + Rectangle2d, + TLBaseShape, + Vec, + Editor, + TLShapeId, +} 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 +} + +const PortLabel: React.FC = ({ port, direction, index }) => { + 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 + + 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, + }) + } + + 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 } = shape.props + + const definition = useMemo(() => { + if (!hasBlockDefinition(blockType)) { + return null + } + return getBlockDefinition(blockType) + }, [blockType]) + + const categoryColor = definition + ? CATEGORY_INFO[definition.category].color + : WorkflowBlockShapeUtil.PRIMARY_COLOR + + const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) + const [hoveredPort, setHoveredPort] = useState(null) + + usePinnedToView(this.editor, shape.id, shape.props.pinnedToView, { position: 'current' }) + + const { isMaximized, toggleMaximize } = useMaximize({ + editor: this.editor, + shapeId: shape.id, + currentW: shape.props.w, + currentH: shape.props.h, + shapeType: 'WorkflowBlock', + padding: 40, + }) + + 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(() => { + this.editor.updateShape({ + id: shape.id, + type: 'WorkflowBlock', + props: { executionState: 'running' }, + }) + + window.dispatchEvent(new CustomEvent('workflow:execute-block', { + detail: { blockId: shape.id }, + })) + }, [shape.id]) + + if (!definition) { + return ( + +
+ Unknown block type: {blockType} +
+
+ ) + } + + const executionColors = EXECUTION_COLORS[executionState] + 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} +
+ } + > + {executionState !== 'idle' && ( +
+ {executionState === 'running' && '... Running'} + {executionState === 'success' && 'Done'} + {executionState === 'error' && 'Error'} +
+ )} + +
+ {definition.description} +
+ +
+ {definition.inputs.map((port, index) => ( + + + + + ))} + + {definition.outputs.map((port, index) => ( + + + + + ))} +
+ + {executionError && ( +
+ {executionError} +
+ )} + + {definition.category === 'trigger' && ( +
+ +
+ )} + + + ) + } + + indicator(shape: IWorkflowBlock) { + 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 const WorkflowBlockShape = WorkflowBlockShapeUtil diff --git a/src/tools/WorkflowBlockTool.ts b/src/tools/WorkflowBlockTool.ts new file mode 100644 index 0000000..eb4fbe2 --- /dev/null +++ b/src/tools/WorkflowBlockTool.ts @@ -0,0 +1,192 @@ +/** + * 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, createShapeId } from 'tldraw' +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 = () => { + this.editor.setCursor({ type: 'cross', rotation: 0 }) + + 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 || '?' + + 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; + ` + + if (categoryInfo) { + const indicator = document.createElement('span') + indicator.style.cssText = ` + width: 8px; + height: 8px; + border-radius: 50%; + background: ${categoryInfo.color}; + ` + this.tooltipElement.appendChild(indicator) + } + + const textSpan = document.createElement('span') + textSpan.textContent = `${icon} Click to place ${blockName}` + this.tooltipElement.appendChild(textSpan) + + document.body.appendChild(this.tooltipElement) + + this.mouseMoveHandler = (e: MouseEvent) => { + if (this.tooltipElement) { + const x = e.clientX + 15 + const y = e.clientY - 40 + + const rect = this.tooltipElement.getBoundingClientRect() + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight + + let finalX = x + let finalY = y + + 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 + + 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) + + const finalX = clickX - shapeWidth / 2 + const finalY = clickY - shapeHeight / 2 + + // Create a unique ID for the shape + const shapeId = createShapeId() + + this.editor.createShape({ + id: shapeId, + 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, + }, + }) + + this.editor.setSelectedShapes([shapeId]) + this.editor.setCurrentTool('select') + + } catch (error) { + console.error('Error creating WorkflowBlock shape:', error) + } + } +} + +export default WorkflowBlockTool diff --git a/src/ui/CustomToolbar.tsx b/src/ui/CustomToolbar.tsx index b5db1b3..51bf27e 100644 --- a/src/ui/CustomToolbar.tsx +++ b/src/ui/CustomToolbar.tsx @@ -14,8 +14,8 @@ import { createShapeId } from "tldraw" import type { ObsidianObsNote } from "../lib/obsidianImporter" import { HolonData } from "../lib/HoloSphereService" import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel" -// TODO: Fix TypeScript errors in workflow files before re-enabling -// import { WorkflowPalette } from "../components/workflow/WorkflowPalette" +// Workflow Builder palette +import WorkflowPalette from "../components/workflow/WorkflowPalette" import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey" import { getMyConnections, updateEdgeMetadata, createConnection, removeConnection, updateTrustLevel } from "../lib/networking/connectionService" import { TRUST_LEVEL_COLORS, type TrustLevel, type UserConnectionWithProfile, type EdgeMetadata } from "../lib/networking/types" @@ -793,14 +793,13 @@ export function CustomToolbar() { isSelected={tools["calendar"].id === editor.getCurrentToolId()} /> )} - {/* Workflow Builder - Toggle Palette (disabled - TypeScript errors) + {/* Workflow Builder - Toggle Palette */} setShowWorkflowPalette(!showWorkflowPalette)} /> - */} {/* Refresh All ObsNotes Button */} {(() => { const allShapes = editor.getCurrentPageShapes() @@ -828,12 +827,12 @@ export function CustomToolbar() { /> )} - {/* Workflow Builder Palette (disabled - TypeScript errors) + {/* Workflow Builder Palette */} setShowWorkflowPalette(false)} /> - */} ) }