chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:37:47 +08:00
commit 7653f56fed
1422 changed files with 359026 additions and 0 deletions
@@ -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;
+342
View File
@@ -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,
};
}