chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import { useQuery } from "convex/react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Real-time approval watcher using Convex subscriptions
|
||||
*
|
||||
* Why Convex is perfect for human-in-the-loop approvals:
|
||||
* --------------------------------------------------------
|
||||
* 1. REAL-TIME: Automatically updates when approval status changes (no polling!)
|
||||
* 2. REACTIVE: Component re-renders when someone approves/rejects
|
||||
* 3. PERSISTENT: Survives server restarts (unlike in-memory stores)
|
||||
* 4. EFFICIENT: Only sends updates when data actually changes
|
||||
* 5. AUTOMATIC: No need to manually subscribe/unsubscribe
|
||||
*
|
||||
* Example workflow:
|
||||
* -----------------
|
||||
* 1. Workflow hits "User Approval" node → pauses execution
|
||||
* 2. Create approval record in Convex
|
||||
* 3. Frontend watches approval with this hook
|
||||
* 4. User approves via UI
|
||||
* 5. Convex instantly notifies all watchers
|
||||
* 6. Workflow automatically resumes!
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const { status, approval, isApproved, isRejected, isPending } = useApprovalWatch(approvalId);
|
||||
*
|
||||
* if (isPending) {
|
||||
* return <ApprovalButton onApprove={() => approveWorkflow()} />;
|
||||
* }
|
||||
*
|
||||
* if (isApproved) {
|
||||
* // Workflow will auto-resume!
|
||||
* return <div>Approved - workflow continuing...</div>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useApprovalWatch(approvalId: string | null | undefined) {
|
||||
const prevStatusRef = useRef<string | null>(null);
|
||||
|
||||
// Real-time subscription - updates instantly when approval changes!
|
||||
const result = useQuery(
|
||||
api.approvals.watchStatus,
|
||||
approvalId ? { approvalId } : "skip"
|
||||
);
|
||||
|
||||
const status = result?.status || "not_found";
|
||||
const approval = result?.approval;
|
||||
|
||||
const isPending = status === "pending";
|
||||
const isApproved = status === "approved";
|
||||
const isRejected = status === "rejected";
|
||||
const isResolved = isApproved || isRejected;
|
||||
|
||||
// Detect status changes for callbacks
|
||||
useEffect(() => {
|
||||
if (status && status !== prevStatusRef.current) {
|
||||
console.log(`Approval ${approvalId} status changed: ${prevStatusRef.current} → ${status}`);
|
||||
prevStatusRef.current = status;
|
||||
}
|
||||
}, [status, approvalId]);
|
||||
|
||||
return {
|
||||
status,
|
||||
approval,
|
||||
isPending,
|
||||
isApproved,
|
||||
isRejected,
|
||||
isResolved,
|
||||
isLoading: result === undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for watching multiple pending approvals for a workflow
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const { pendingApprovals, hasPending } = usePendingApprovals(workflowId);
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {hasPending && (
|
||||
* <div>
|
||||
* {pendingApprovals.map(approval => (
|
||||
* <ApprovalCard key={approval.approvalId} approval={approval} />
|
||||
* ))}
|
||||
* </div>
|
||||
* )}
|
||||
* </div>
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function usePendingApprovals(workflowId: string | null | undefined) {
|
||||
const approvals = useQuery(
|
||||
api.approvals.listPending,
|
||||
workflowId ? { workflowId: workflowId as any } : "skip"
|
||||
);
|
||||
|
||||
const pendingApprovals = approvals || [];
|
||||
const hasPending = pendingApprovals.length > 0;
|
||||
|
||||
return {
|
||||
pendingApprovals,
|
||||
hasPending,
|
||||
count: pendingApprovals.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for watching approvals for a specific execution
|
||||
*
|
||||
* Useful for showing "Waiting for approval" status in execution panel
|
||||
*/
|
||||
export function useExecutionApprovals(executionId: string | null | undefined) {
|
||||
const approvals = useQuery(
|
||||
api.approvals.getByExecution,
|
||||
executionId ? { executionId } : "skip"
|
||||
);
|
||||
|
||||
const allApprovals = approvals || [];
|
||||
const pendingApprovals = allApprovals.filter(a => a.status === "pending");
|
||||
const hasPending = pendingApprovals.length > 0;
|
||||
|
||||
return {
|
||||
approvals: allApprovals,
|
||||
pendingApprovals,
|
||||
hasPending,
|
||||
isPaused: hasPending, // Execution is paused if any approvals are pending
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
useEffect, useRef
|
||||
} from 'react';
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
timeout: 0,
|
||||
ignoreInitialCall: true
|
||||
};
|
||||
|
||||
export function useDebouncedEffect(
|
||||
callback: () => (void | (() => void)),
|
||||
config: number | {
|
||||
timeout?: number;
|
||||
ignoreInitialCall?: boolean;
|
||||
},
|
||||
deps: any[] = []
|
||||
): void {
|
||||
let currentConfig;
|
||||
|
||||
if (typeof config === 'object') {
|
||||
currentConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
...config
|
||||
};
|
||||
} else {
|
||||
currentConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
timeout: config
|
||||
};
|
||||
}
|
||||
const {
|
||||
timeout, ignoreInitialCall
|
||||
} = currentConfig;
|
||||
|
||||
const data = useRef<{ firstTime: boolean }>({ firstTime: true });
|
||||
|
||||
useEffect(() => {
|
||||
const { firstTime } = data.current;
|
||||
|
||||
if (firstTime && ignoreInitialCall) {
|
||||
data.current.firstTime = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let clearFunc: (() => void) | undefined;
|
||||
|
||||
const handler = setTimeout(() => {
|
||||
clearFunc = callback() ?? undefined;
|
||||
}, timeout);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
|
||||
if (clearFunc && typeof clearFunc === 'function') {
|
||||
clearFunc();
|
||||
}
|
||||
};
|
||||
}, [
|
||||
callback,
|
||||
ignoreInitialCall,
|
||||
timeout,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
...deps
|
||||
]);
|
||||
}
|
||||
|
||||
export default useDebouncedEffect;
|
||||
@@ -0,0 +1,342 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Workflow, WorkflowNode, WorkflowEdge, MCPServer } from '@/lib/workflow/types';
|
||||
import {
|
||||
saveWorkflow as saveWorkflowToStorage,
|
||||
getWorkflows,
|
||||
getWorkflow,
|
||||
deleteWorkflow as deleteWorkflowFromStorage,
|
||||
setCurrentWorkflow,
|
||||
getCurrentWorkflowId,
|
||||
saveMCPServer,
|
||||
getMCPServers,
|
||||
} from '@/lib/workflow/storage';
|
||||
import { cleanupInvalidEdges } from '@/lib/workflow/edge-cleanup';
|
||||
|
||||
export function useWorkflow(workflowId?: string) {
|
||||
const [workflow, setWorkflow] = useState<Workflow | null>(null);
|
||||
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [convexId, setConvexId] = useState<string | null>(null); // Track Convex ID
|
||||
const saveToConvexTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Load workflow from Redis via API
|
||||
useEffect(() => {
|
||||
const loadWorkflow = async () => {
|
||||
setLoading(true);
|
||||
|
||||
if (workflowId) {
|
||||
// Fetch workflow from API (Redis)
|
||||
try {
|
||||
const response = await fetch('/api/workflows');
|
||||
const data = await response.json();
|
||||
const workflows = data.workflows || [];
|
||||
const loaded = workflows.find((w: any) => w.id === workflowId);
|
||||
|
||||
if (loaded) {
|
||||
// Fetch full workflow details
|
||||
const fullWorkflow = await fetch(`/api/workflows/${workflowId}`).then(r => r.json());
|
||||
let workflowData = fullWorkflow.workflow || loaded;
|
||||
|
||||
// Clean up any invalid edges before setting the workflow
|
||||
const cleaned = cleanupInvalidEdges(workflowData.nodes, workflowData.edges);
|
||||
if (cleaned.removedCount > 0) {
|
||||
console.log(`🧹 Cleaned ${cleaned.removedCount} invalid edges from loaded workflow`);
|
||||
workflowData = {
|
||||
...workflowData,
|
||||
nodes: cleaned.nodes,
|
||||
edges: cleaned.edges,
|
||||
};
|
||||
}
|
||||
|
||||
setWorkflow(workflowData);
|
||||
// Store the Convex ID for future saves
|
||||
setConvexId(workflowData._convexId || workflowData._id || null);
|
||||
} else {
|
||||
createNewWorkflow();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load workflow from Convex:', error);
|
||||
createNewWorkflow();
|
||||
}
|
||||
} else {
|
||||
createNewWorkflow();
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
loadWorkflow();
|
||||
}, [workflowId]);
|
||||
|
||||
// Load all workflows from API
|
||||
const loadWorkflows = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/workflows');
|
||||
const data = await response.json();
|
||||
setWorkflows(data.workflows || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load workflows from API:', error);
|
||||
setWorkflows([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadWorkflows();
|
||||
}, [loadWorkflows]);
|
||||
|
||||
// Create new workflow
|
||||
const createNewWorkflow = useCallback(() => {
|
||||
const newWorkflow: Workflow = {
|
||||
id: `workflow_${Date.now()}`,
|
||||
name: 'New Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node_0',
|
||||
type: 'start',
|
||||
position: { x: 250, y: 100 },
|
||||
data: { label: 'Start' },
|
||||
},
|
||||
{
|
||||
id: 'node_1',
|
||||
type: 'agent',
|
||||
position: { x: 250, y: 250 },
|
||||
data: {
|
||||
label: 'Agent',
|
||||
name: 'My agent',
|
||||
instructions: 'You are a helpful assistant.',
|
||||
model: 'gpt-4.1',
|
||||
includeChatHistory: true,
|
||||
tools: [],
|
||||
outputFormat: 'Text',
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'edge_0_1',
|
||||
source: 'node_0',
|
||||
target: 'node_1',
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setWorkflow(newWorkflow);
|
||||
// No longer save to localStorage
|
||||
// Workflow will be saved via API when user makes changes
|
||||
|
||||
return newWorkflow;
|
||||
}, []);
|
||||
|
||||
// Save workflow with debounce to prevent multiple rapid saves
|
||||
// Use useCallback with minimal deps to prevent infinite loops
|
||||
const saveWorkflow = useCallback(async (updates?: Partial<Workflow>) => {
|
||||
// If no workflow exists yet, create a new one from updates
|
||||
if (!workflow) {
|
||||
if (!updates || !updates.nodes || !updates.edges) {
|
||||
console.warn('⚠️ Cannot save workflow: no workflow state and incomplete updates');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a complete workflow from updates
|
||||
const newWorkflow: Workflow = {
|
||||
id: updates.id || `workflow_${Date.now()}`,
|
||||
name: updates.name || 'New Workflow',
|
||||
description: updates.description,
|
||||
nodes: updates.nodes,
|
||||
edges: updates.edges,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setWorkflow(newWorkflow);
|
||||
|
||||
// Save immediately to Convex
|
||||
try {
|
||||
const response = await fetch('/api/workflows', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newWorkflow),
|
||||
});
|
||||
const data = await response.json();
|
||||
console.log('💾 New workflow saved to Convex:', data.success ? 'SUCCESS' : 'FAILED');
|
||||
|
||||
// Store the Convex ID from the response
|
||||
if (data.success && data.workflowId) {
|
||||
setConvexId(data.workflowId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save new workflow to Convex:', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const updated: Workflow = {
|
||||
...workflow,
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setWorkflow(updated);
|
||||
|
||||
// Clear any pending save timeout
|
||||
if (saveToConvexTimeoutRef.current) {
|
||||
clearTimeout(saveToConvexTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce the save to Convex to prevent rapid saves
|
||||
saveToConvexTimeoutRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/workflows', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updated),
|
||||
});
|
||||
const data = await response.json();
|
||||
console.log('💾 [AUTO-SAVE] Workflow synced to Convex:', data.success ? '✅ SUCCESS' : '❌ FAILED');
|
||||
|
||||
// Store the Convex ID from the response
|
||||
if (data.success && data.workflowId) {
|
||||
setConvexId(data.workflowId);
|
||||
}
|
||||
|
||||
// Don't reload workflows on every save - only when explicitly needed
|
||||
// This prevents unnecessary re-fetches and duplicate saves
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to save workflow to Convex:', error);
|
||||
}
|
||||
}, 1000); // 1000ms debounce to batch rapid saves
|
||||
}, [workflow, loadWorkflows]);
|
||||
|
||||
// Update nodes
|
||||
const updateNodes = useCallback((nodes: WorkflowNode[]) => {
|
||||
if (!workflow) {
|
||||
console.warn('⚠️ updateNodes called but no workflow exists');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📝 updateNodes called with', nodes.length, 'nodes');
|
||||
saveWorkflow({ nodes });
|
||||
}, [workflow, saveWorkflow]);
|
||||
|
||||
// Update edges
|
||||
const updateEdges = useCallback((edges: WorkflowEdge[]) => {
|
||||
if (!workflow) return;
|
||||
|
||||
saveWorkflow({ edges });
|
||||
}, [workflow, saveWorkflow]);
|
||||
|
||||
// Update node data
|
||||
const updateNodeData = useCallback((nodeId: string, data: any) => {
|
||||
if (!workflow) return;
|
||||
|
||||
const nodes = workflow.nodes.map(node =>
|
||||
node.id === nodeId
|
||||
? { ...node, data: { ...node.data, ...data } }
|
||||
: node
|
||||
);
|
||||
|
||||
updateNodes(nodes);
|
||||
}, [workflow, updateNodes]);
|
||||
|
||||
// Delete workflow
|
||||
const deleteWorkflow = useCallback((id: string) => {
|
||||
deleteWorkflowFromStorage(id);
|
||||
loadWorkflows();
|
||||
|
||||
if (workflow?.id === id) {
|
||||
createNewWorkflow();
|
||||
}
|
||||
}, [workflow, loadWorkflows, createNewWorkflow]);
|
||||
|
||||
// Save workflow immediately (non-debounced) - used before execution
|
||||
const saveWorkflowImmediate = useCallback(async (updates?: Partial<Workflow>) => {
|
||||
if (!workflow) {
|
||||
console.warn('⚠️ Cannot save workflow immediately: no workflow state');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated: Workflow = {
|
||||
...workflow,
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setWorkflow(updated);
|
||||
|
||||
// Cancel any pending debounced saves
|
||||
if (saveToConvexTimeoutRef.current) {
|
||||
clearTimeout(saveToConvexTimeoutRef.current);
|
||||
saveToConvexTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Save immediately without debounce
|
||||
try {
|
||||
const response = await fetch('/api/workflows', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updated),
|
||||
});
|
||||
const data = await response.json();
|
||||
console.log('💾 [IMMEDIATE SAVE] Workflow saved to Convex:', data.success ? '✅ SUCCESS' : '❌ FAILED');
|
||||
|
||||
if (data.success && data.workflowId) {
|
||||
setConvexId(data.workflowId);
|
||||
}
|
||||
|
||||
return data.success;
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to save workflow immediately:', error);
|
||||
return false;
|
||||
}
|
||||
}, [workflow]);
|
||||
|
||||
return {
|
||||
workflow,
|
||||
workflows,
|
||||
loading,
|
||||
convexId, // Expose Convex ID for templates and other features
|
||||
saveWorkflow,
|
||||
saveWorkflowImmediate, // Non-debounced save for before execution
|
||||
updateNodes,
|
||||
updateEdges,
|
||||
updateNodeData,
|
||||
deleteWorkflow,
|
||||
createNewWorkflow,
|
||||
loadWorkflows,
|
||||
};
|
||||
}
|
||||
|
||||
export function useMCPServers() {
|
||||
const [servers, setServers] = useState<MCPServer[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadServers();
|
||||
}, []);
|
||||
|
||||
const loadServers = () => {
|
||||
const loaded = getMCPServers();
|
||||
setServers(loaded);
|
||||
};
|
||||
|
||||
const addServer = useCallback((server: MCPServer) => {
|
||||
saveMCPServer(server);
|
||||
loadServers();
|
||||
}, []);
|
||||
|
||||
const updateServer = useCallback((id: string, updates: Partial<MCPServer>) => {
|
||||
const existing = servers.find(s => s.id === id);
|
||||
if (existing) {
|
||||
saveMCPServer({ ...existing, ...updates });
|
||||
loadServers();
|
||||
}
|
||||
}, [servers]);
|
||||
|
||||
return {
|
||||
servers,
|
||||
addServer,
|
||||
updateServer,
|
||||
loadServers,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
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 = <T>(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<string, string>;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem('firecrawl_api_keys');
|
||||
if (!raw) {
|
||||
return {} as Record<string, string>;
|
||||
}
|
||||
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<string, string>;
|
||||
}
|
||||
};
|
||||
|
||||
export function useWorkflowExecution() {
|
||||
const [execution, setExecution] = useState<WorkflowExecution | null>(null);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [currentNodeId, setCurrentNodeId] = useState<string | null>(null);
|
||||
const [nodeResults, setNodeResults] = useState<Record<string, NodeExecutionResult>>({});
|
||||
const [pendingAuth, setPendingAuth] = useState<WorkflowPendingAuth | null>(null);
|
||||
const [currentWorkflow, setCurrentWorkflow] = useState<Workflow | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const pendingResumeRef = useRef<PendingArcadeResume | null>(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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user