import { useState, useCallback, useRef, useEffect } from 'react'; import { Workflow, WorkflowExecution, NodeExecutionResult, WorkflowPendingAuth } from '@/lib/workflow/types'; import { toast } from 'sonner'; interface PendingArcadeResume { nodeId: string; workflow: Workflow; state: any; execution: WorkflowExecution; pendingAuth: WorkflowPendingAuth; pendingInput?: any; } const clone = (value: T): T => { if (typeof structuredClone === 'function') { try { return structuredClone(value); } catch (error) { // Fallback to JSON cloning } } return JSON.parse(JSON.stringify(value)) as T; }; const loadStoredApiKeys = () => { if (typeof window === 'undefined') { return {} as Record; } try { const raw = localStorage.getItem('firecrawl_api_keys'); if (!raw) { return {} as Record; } const parsed = JSON.parse(raw); return typeof parsed === 'object' && parsed !== null ? parsed : {}; } catch (error) { console.warn('Failed to load stored API keys:', error); return {} as Record; } }; export function useWorkflowExecution() { const [execution, setExecution] = useState(null); const [isRunning, setIsRunning] = useState(false); const [currentNodeId, setCurrentNodeId] = useState(null); const [nodeResults, setNodeResults] = useState>({}); const [pendingAuth, setPendingAuth] = useState(null); const [currentWorkflow, setCurrentWorkflow] = useState(null); const abortControllerRef = useRef(null); const pendingResumeRef = useRef(null); // Cleanup on unmount to prevent memory leaks useEffect(() => { return () => { // Only abort if there's an active workflow running if (abortControllerRef.current && isRunning) { console.log('๐Ÿงน Cleanup: Aborting active workflow on unmount'); abortControllerRef.current.abort(); abortControllerRef.current = null; } // Clear pending resume data pendingResumeRef.current = null; }; }, [isRunning]); const runWorkflow = useCallback(async (workflow: Workflow, input?: string) => { if (!workflow) { console.error('No workflow to execute'); return; } setIsRunning(true); setNodeResults({}); setCurrentNodeId(null); setPendingAuth(null); setCurrentWorkflow(workflow); pendingResumeRef.current = null; // Create abort controller abortControllerRef.current = new AbortController(); try { // Fetch API keys from server const configResponse = await fetch('/api/config'); const apiConfig = await configResponse.json(); // Execute workflow via LangGraph streaming API // Parse input if it's a JSON string, otherwise send as-is let parsedInput: any; try { parsedInput = typeof input === 'string' && input.trim().startsWith('{') ? JSON.parse(input) : { input }; } catch (e) { // If parsing fails, wrap in input object parsedInput = { input }; } const response = await fetch(`/api/workflows/${workflow.id}/execute-stream`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(parsedInput), signal: abortControllerRef.current.signal, }); if (!response.ok) { throw new Error('Workflow execution failed'); } // Handle SSE stream const reader = response.body?.getReader(); const decoder = new TextDecoder(); if (!reader) { throw new Error('No response body'); } let buffer = ''; let executionId = ''; let currentEvent = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (line.trim() === '') { // Empty line marks end of SSE message currentEvent = ''; continue; } if (line.startsWith('event: ')) { currentEvent = line.slice(7).trim(); continue; } if (line.startsWith('data: ')) { try { const data = JSON.parse(line.slice(6)); console.log(`๐Ÿ“จ SSE Event: ${currentEvent}`, data); // Handle error events if (currentEvent === 'error' && data.error) { console.error('โŒ Workflow error:', data.error); // Show error toast to user toast.error('Workflow Error', { description: data.error, duration: 10000, // Show for 10 seconds }); // Set failed execution state setExecution({ id: executionId || `exec_${Date.now()}`, workflowId: workflow.id, status: 'failed', error: data.error, nodeResults: nodeResults, startedAt: data.timestamp || new Date().toISOString(), completedAt: data.timestamp || new Date().toISOString(), }); // Stop execution setIsRunning(false); setCurrentNodeId(null); break; // Exit the SSE loop } // Set current node immediately when node starts (before it completes) if (currentEvent === 'node_started' && data.nodeId) { setCurrentNodeId(data.nodeId); } // Update node results from stream if (data.nodeResults) { setNodeResults(prev => { const updated = { ...prev, ...data.nodeResults }; console.log('๐Ÿ“Š Updated nodeResults:', Object.keys(updated)); return updated; }); } // Update current node from state updates if (data.currentNodeId) { setCurrentNodeId(data.currentNodeId); } // Store execution ID if (data.executionId) { executionId = data.executionId; } // Check for pending auth if (data.pendingAuth) { setPendingAuth(data.pendingAuth); // Set execution status to waiting-auth const waitingExecution: WorkflowExecution = { id: executionId || data.executionId || `exec_${Date.now()}`, workflowId: workflow.id, status: 'waiting-auth', nodeResults: data.nodeResults || {}, startedAt: data.timestamp || new Date().toISOString(), }; setExecution(waitingExecution); break; } // Check for workflow completion if (currentEvent === 'workflow_completed' || data.status === 'completed' || data.status === 'waiting-auth') { const execution: WorkflowExecution = { id: executionId || data.executionId || `exec_${Date.now()}`, workflowId: workflow.id, status: data.status || 'completed', nodeResults: data.results || data.nodeResults || {}, startedAt: data.timestamp || new Date().toISOString(), completedAt: data.timestamp || new Date().toISOString(), }; setExecution(execution); } } catch (e) { console.error('Failed to parse SSE data:', e, 'Line:', line); } } } } console.log('โœ… Workflow complete'); } catch (error) { // Don't treat abort as an error - it's intentional user action if (error instanceof Error && error.name === 'AbortError') { console.log('โน๏ธ Workflow stopped by user'); setPendingAuth(null); pendingResumeRef.current = null; return; } console.error('โŒ Workflow execution failed:', error); setPendingAuth(null); pendingResumeRef.current = null; // Set error state setExecution({ id: `exec_${Date.now()}`, workflowId: workflow.id, status: 'failed', error: error instanceof Error ? error.message : 'Unknown error', nodeResults: {}, startedAt: new Date().toISOString(), completedAt: new Date().toISOString(), }); } finally { console.log('๐Ÿ Setting isRunning = false'); setIsRunning(false); setCurrentNodeId(null); } }, []); const stopWorkflow = useCallback(() => { if (abortControllerRef.current) { abortControllerRef.current.abort(); abortControllerRef.current = null; } setIsRunning(false); setCurrentNodeId(null); }, []); const resumeWorkflow = useCallback(async () => { if (!currentWorkflow) { console.error('โŒ No workflow to resume'); return; } if (!pendingAuth) { console.error('โŒ No pending authorization to resume from'); return; } const threadId = pendingAuth.threadId; const executionId = pendingAuth.executionId; if (!threadId) { console.error('โŒ No threadId in pendingAuth'); return; } console.log('โณ Resuming workflow from approval...'); setIsRunning(true); try { // Call resume API endpoint const response = await fetch(`/api/workflows/${currentWorkflow.id}/resume`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ threadId, executionId, resumeValue: { approved: true, status: 'approved' }, }), signal: abortControllerRef.current?.signal, }); if (!response.ok) { throw new Error(`Resume failed: ${response.statusText}`); } if (!response.body) { throw new Error('No response body from resume endpoint'); } // Clear pending auth since we're resuming setPendingAuth(null); // Process SSE stream const reader = response.body.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value); const lines = chunk.split('\n'); for (const line of lines) { if (line.startsWith('data: ')) { try { const data = JSON.parse(line.slice(6)); // Update current node if (data.currentNodeId) { setCurrentNodeId(data.currentNodeId); } // Update node results if (data.nodeResults) { setNodeResults(prevResults => ({ ...prevResults, ...data.nodeResults, })); } // Check for pending auth again (multiple approvals) if (data.pendingAuth) { setPendingAuth(data.pendingAuth); const waitingExecution = { id: executionId || `exec_${Date.now()}`, workflowId: currentWorkflow.id, status: 'waiting-auth' as const, nodeResults: data.nodeResults || {}, startedAt: new Date().toISOString(), }; setExecution(waitingExecution); break; } // Check for completion if (data.status === 'completed') { const completedExecution = { id: executionId || `exec_${Date.now()}`, workflowId: currentWorkflow.id, status: 'completed' as const, nodeResults: data.results || data.nodeResults || {}, startedAt: new Date().toISOString(), completedAt: new Date().toISOString(), }; setExecution(completedExecution); } } catch (e) { console.error('Failed to parse SSE data:', e); } } } } console.log('โœ… Workflow resumed and completed'); } catch (error) { // Don't treat abort as an error - it's intentional user action if (error instanceof Error && error.name === 'AbortError') { console.log('โน๏ธ Workflow stopped by user'); return; } console.error('โŒ Workflow resume failed:', error); setExecution({ id: executionId || `exec_${Date.now()}`, workflowId: currentWorkflow.id, status: 'failed', nodeResults: {}, startedAt: new Date().toISOString(), completedAt: new Date().toISOString(), }); } finally { setIsRunning(false); setCurrentNodeId(null); } }, [currentWorkflow, pendingAuth]); const clearExecution = useCallback(() => { setExecution(null); setNodeResults({}); setCurrentNodeId(null); setPendingAuth(null); setCurrentWorkflow(null); pendingResumeRef.current = null; }, []); return { execution, isRunning, currentNodeId, nodeResults, pendingAuth, runWorkflow, stopWorkflow, resumeWorkflow, clearExecution, }; }