diff --git a/apps/sim/app/academy/components/sandbox-canvas-provider.tsx b/apps/sim/app/academy/components/sandbox-canvas-provider.tsx index 62edc38bc76..3bd682f43ef 100644 --- a/apps/sim/app/academy/components/sandbox-canvas-provider.tsx +++ b/apps/sim/app/academy/components/sandbox-canvas-provider.tsx @@ -13,10 +13,12 @@ import type { import { validateExercise } from '@/lib/academy/validation' import { cn } from '@/lib/core/utils/cn' import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { SandboxWorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import Workflow from '@/app/workspace/[workspaceId]/w/[workflowId]/workflow' import { getBlock } from '@/blocks/registry' +import { workflowKeys } from '@/hooks/queries/workflows' import { SandboxBlockConstraintsContext } from '@/hooks/use-sandbox-block-constraints' import { useExecutionStore } from '@/stores/execution/store' import { useTerminalConsoleStore } from '@/stores/terminal/console/store' @@ -218,8 +220,13 @@ export function SandboxCanvasProvider({ useWorkflowStore.getState().replaceWorkflowState(workflowState) useSubBlockStore.getState().initializeFromWorkflow(workflowId, workflowState.blocks) - useWorkflowRegistry.setState((state) => ({ - workflows: { ...state.workflows, [workflowId]: syntheticMetadata }, + + const qc = getQueryClient() + const cacheKey = workflowKeys.list(SANDBOX_WORKSPACE_ID, 'active') + const cached = qc.getQueryData(cacheKey) ?? [] + qc.setQueryData(cacheKey, [...cached.filter((w) => w.id !== workflowId), syntheticMetadata]) + + useWorkflowRegistry.setState({ activeWorkflowId: workflowId, hydration: { phase: 'ready', @@ -228,7 +235,7 @@ export function SandboxCanvasProvider({ requestId: null, error: null, }, - })) + }) logger.info('Sandbox stores hydrated', { workflowId }) setIsReady(true) @@ -262,17 +269,21 @@ export function SandboxCanvasProvider({ unsubWorkflow() unsubSubBlock() unsubExecution() - useWorkflowRegistry.setState((state) => { - const { [workflowId]: _removed, ...rest } = state.workflows - return { - workflows: rest, - activeWorkflowId: state.activeWorkflowId === workflowId ? null : state.activeWorkflowId, - hydration: - state.hydration.workflowId === workflowId - ? { phase: 'idle', workspaceId: null, workflowId: null, requestId: null, error: null } - : state.hydration, - } - }) + const cleanupQc = getQueryClient() + const cleanupKey = workflowKeys.list(SANDBOX_WORKSPACE_ID, 'active') + const cleanupCached = cleanupQc.getQueryData(cleanupKey) ?? [] + cleanupQc.setQueryData( + cleanupKey, + cleanupCached.filter((w) => w.id !== workflowId) + ) + + useWorkflowRegistry.setState((state) => ({ + activeWorkflowId: state.activeWorkflowId === workflowId ? null : state.activeWorkflowId, + hydration: + state.hydration.workflowId === workflowId + ? { phase: 'idle', workspaceId: null, workflowId: null, requestId: null, error: null } + : state.hydration, + })) useWorkflowStore.setState({ blocks: {}, edges: [], loops: {}, parallels: {} }) useSubBlockStore.setState((state) => { const { [workflowId]: _removed, ...rest } = state.workflowValues diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx index bd0cf8cc792..821d6c47242 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -47,7 +47,7 @@ export function useAvailableResources( workspaceId: string, existingKeys: Set ): AvailableItemsByType[] { - const { data: workflows = [] } = useWorkflows(workspaceId, { syncRegistry: false }) + const { data: workflows = [] } = useWorkflows(workspaceId) const { data: tables = [] } = useTablesList(workspaceId) const { data: files = [] } = useWorkspaceFiles(workspaceId) const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 6e5913caa1e..3ec33a67366 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -37,6 +37,7 @@ import { import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/components' import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks' import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' +import { useWorkflows } from '@/hooks/queries/workflows' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useExecutionStore } from '@/stores/execution/store' @@ -375,15 +376,16 @@ interface EmbeddedWorkflowProps { } function EmbeddedWorkflow({ workspaceId, workflowId }: EmbeddedWorkflowProps) { - const workflowExists = useWorkflowRegistry((state) => Boolean(state.workflows[workflowId])) - const isMetadataLoaded = useWorkflowRegistry( - (state) => state.hydration.phase !== 'idle' && state.hydration.phase !== 'metadata-loading' + const { data: workflowList, isPending: isWorkflowsPending } = useWorkflows(workspaceId) + const workflowExists = useMemo( + () => (workflowList ?? []).some((w) => w.id === workflowId), + [workflowList, workflowId] ) const hasLoadError = useWorkflowRegistry( (state) => state.hydration.phase === 'error' && state.hydration.workflowId === workflowId ) - if (!isMetadataLoaded) return LOADING_SKELETON + if (isWorkflowsPending) return LOADING_SKELETON if (!workflowExists || hasLoadError) { return ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index 4b545dc298b..5c8bd184cf5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -1,7 +1,8 @@ 'use client' -import type { ElementType, ReactNode } from 'react' +import { type ElementType, type ReactNode, useMemo } from 'react' import type { QueryClient } from '@tanstack/react-query' +import { useParams } from 'next/navigation' import { Database, File as FileIcon, @@ -17,9 +18,9 @@ import type { } from '@/app/workspace/[workspaceId]/home/types' import { knowledgeKeys } from '@/hooks/queries/kb/knowledge' import { tableKeys } from '@/hooks/queries/tables' -import { workflowKeys } from '@/hooks/queries/workflows' +import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' +import { useWorkflows } from '@/hooks/queries/workflows' import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' interface DropdownItemRenderProps { item: { id: string; name: string; [key: string]: unknown } @@ -34,7 +35,12 @@ export interface ResourceTypeConfig { } function WorkflowTabSquare({ workflowId, className }: { workflowId: string; className?: string }) { - const color = useWorkflowRegistry((state) => state.workflows[workflowId]?.color ?? '#888') + const { workspaceId } = useParams<{ workspaceId: string }>() + const { data: workflowList } = useWorkflows(workspaceId) + const color = useMemo(() => { + const wf = (workflowList ?? []).find((w) => w.id === workflowId) + return wf?.color ?? '#888' + }, [workflowList, workflowId]) return (
{ - qc.invalidateQueries({ queryKey: workflowKeys.lists() }) + workflow: (qc, wId) => { + void invalidateWorkflowLists(qc, wId) }, knowledgebase: (qc, _wId, id) => { qc.invalidateQueries({ queryKey: knowledgeKeys.lists() }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx index 20e132089c9..c630d50b5e9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx @@ -53,7 +53,7 @@ const PREVIEW_MODE_LABELS: Record = { * tabs always reflect the latest name even after a rename. */ function useResourceNameLookup(workspaceId: string): Map { - const { data: workflows = [] } = useWorkflows(workspaceId, { syncRegistry: false }) + const { data: workflows = [] } = useWorkflows(workspaceId) const { data: tables = [] } = useTablesList(workspaceId) const { data: files = [] } = useWorkspaceFiles(workspaceId) const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index 2735afc993e..c44471f019a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -45,8 +45,8 @@ import { computeMentionHighlightRanges, extractContextTokens, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' +import { useWorkflowMap } from '@/hooks/queries/workflows' import type { ChatContext } from '@/stores/panel' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types' @@ -122,6 +122,7 @@ export function UserInput({ onContextAdd, }: UserInputProps) { const { workspaceId } = useParams<{ workspaceId: string }>() + const { data: workflowsById = {} } = useWorkflowMap(workspaceId) const { data: session } = useSession() const [value, setValue] = useState(defaultValue) const overlayRef = useRef(null) @@ -617,7 +618,6 @@ export function UserInput({ const elements: React.ReactNode[] = [] let lastIndex = 0 - for (let i = 0; i < ranges.length; i++) { const range = ranges[i] @@ -639,7 +639,7 @@ export function UserInput({ case 'workflow': case 'current_workflow': { const wfId = (matchingCtx as { workflowId: string }).workflowId - const wfColor = useWorkflowRegistry.getState().workflows[wfId]?.color ?? '#888' + const wfColor = workflowsById[wfId]?.color ?? '#888' mentionIconNode = (
0 ? elements : {'\u00A0'} - }, [value, contextManagement.selectedContexts]) + }, [value, contextManagement.selectedContexts, workflowsById]) return (
{ - if (context.kind === 'workflow' || context.kind === 'current_workflow') { - return state.workflows[context.workflowId || '']?.color ?? null - } - return null - }) + const { workspaceId } = useParams<{ workspaceId: string }>() + const { data: workflowList } = useWorkflows(workspaceId) + const workflowColor = useMemo(() => { + if (context.kind !== 'workflow' && context.kind !== 'current_workflow') return null + return (workflowList ?? []).find((w) => w.id === context.workflowId)?.color ?? null + }, [workflowList, context.kind, context.workflowId]) let icon: React.ReactNode = null const iconClasses = 'h-[12px] w-[12px] flex-shrink-0 text-[var(--text-icon)]' diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 7b5c1cda635..5fa965d9abe 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -21,7 +21,23 @@ import { import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resource-types' import { isWorkflowToolName } from '@/lib/copilot/workflow-tools' import { getNextWorkflowColor } from '@/lib/workflows/colors' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' +import type { + ChatMessage, + ChatMessageAttachment, + ContentBlock, + ContentBlockType, + FileAttachmentForApi, + GenericResourceData, + GenericResourceEntry, + MothershipResource, + MothershipResourceType, + QueuedMessage, + SSEPayload, + SSEPayloadData, + ToolCallStatus, +} from '@/app/workspace/[workspaceId]/home/types' import { deploymentKeys } from '@/hooks/queries/deployments' import { fetchChatHistory, @@ -34,29 +50,17 @@ import { taskKeys, useChatHistory, } from '@/hooks/queries/tasks' +import { getFolderMap } from '@/hooks/queries/utils/folder-cache' +import { invalidateWorkflowSelectors } from '@/hooks/queries/utils/invalidate-workflow-lists' import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' +import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache' import { workflowKeys } from '@/hooks/queries/workflows' import { useExecutionStream } from '@/hooks/use-execution-stream' import { useExecutionStore } from '@/stores/execution/store' -import { useFolderStore } from '@/stores/folders/store' import type { ChatContext } from '@/stores/panel' import { consolePersistence, useTerminalConsoleStore } from '@/stores/terminal' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import type { - ChatMessage, - ChatMessageAttachment, - ContentBlock, - ContentBlockType, - FileAttachmentForApi, - GenericResourceData, - GenericResourceEntry, - MothershipResource, - MothershipResourceType, - QueuedMessage, - SSEPayload, - SSEPayloadData, - ToolCallStatus, -} from '../types' +import type { WorkflowMetadata } from '@/stores/workflows/registry/types' export interface UseChatReturn { messages: ChatMessage[] @@ -301,31 +305,37 @@ function getPayloadData(payload: SSEPayload): SSEPayloadData | undefined { return typeof payload.data === 'object' ? payload.data : undefined } -/** Adds a workflow to the registry with a top-insertion sort order if it doesn't already exist. */ +/** Adds a workflow to the React Query cache with a top-insertion sort order if it doesn't already exist. */ function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId: string): boolean { - const registry = useWorkflowRegistry.getState() - if (registry.workflows[resourceId]) return false + const workflows = getWorkflows(workspaceId) + if (workflows.some((w) => w.id === resourceId)) return false const sortOrder = getTopInsertionSortOrder( - registry.workflows, - useFolderStore.getState().folders, + Object.fromEntries(workflows.map((w) => [w.id, w])), + getFolderMap(workspaceId), workspaceId, null ) - useWorkflowRegistry.setState((state) => ({ - workflows: { - ...state.workflows, - [resourceId]: { - id: resourceId, - name: title, - lastModified: new Date(), - createdAt: new Date(), - color: getNextWorkflowColor(), - workspaceId, - folderId: null, - sortOrder, - }, - }, - })) + const newMetadata: WorkflowMetadata = { + id: resourceId, + name: title, + lastModified: new Date(), + createdAt: new Date(), + color: getNextWorkflowColor(), + workspaceId, + folderId: null, + sortOrder, + } + const queryClient = getQueryClient() + const key = workflowKeys.list(workspaceId, 'active') + queryClient.setQueryData(key, (current) => { + const next = current ?? workflows + if (next.some((workflow) => workflow.id === resourceId)) { + return next + } + + return [...next, newMetadata] + }) + void invalidateWorkflowSelectors(queryClient, workspaceId) return true } @@ -1253,7 +1263,7 @@ export function useChat( ? ((args as Record).workflowId as string) : useWorkflowRegistry.getState().activeWorkflowId if (targetWorkflowId) { - const meta = useWorkflowRegistry.getState().workflows[targetWorkflowId] + const meta = getWorkflowById(workspaceId, targetWorkflowId) const wasAdded = addResource({ type: 'workflow', id: targetWorkflowId, diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index ad7b57e437f..075d1f2d39a 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -5,6 +5,7 @@ import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/ import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader' import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader' import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { WorkspaceScopeSync } from '@/app/workspace/[workspaceId]/providers/workspace-scope-sync' import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { @@ -16,6 +17,7 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
+
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx index 70915c2b8f3..ee52e0fa0bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx @@ -1,10 +1,11 @@ import { memo } from 'react' +import { useParams } from 'next/navigation' import { cn } from '@/lib/core/utils/cn' import { DELETED_WORKFLOW_COLOR, DELETED_WORKFLOW_LABEL, } from '@/app/workspace/[workspaceId]/logs/utils' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useWorkflowMap } from '@/hooks/queries/workflows' import { StatusBar, type StatusBarSegment } from '..' export interface WorkflowExecutionItem { @@ -36,7 +37,8 @@ function WorkflowsListInner({ searchQuery: string segmentDurationMs: number }) { - const workflows = useWorkflowRegistry((s) => s.workflows) + const { workspaceId } = useParams<{ workspaceId: string }>() + const { data: workflows = {} } = useWorkflowMap(workspaceId) return (
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx index e19df1fc194..f0ff6ca54b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx @@ -2,12 +2,13 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Loader2 } from 'lucide-react' +import { useParams } from 'next/navigation' import { useShallow } from 'zustand/react/shallow' import { Skeleton } from '@/components/emcn' import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils' import type { DashboardStatsResponse, WorkflowStats } from '@/hooks/queries/logs' +import { useWorkflows } from '@/hooks/queries/workflows' import { useFilterStore } from '@/stores/logs/filters/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { LineChart, WorkflowsList } from './components' interface WorkflowExecution { @@ -156,7 +157,8 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) { })) ) - const allWorkflows = useWorkflowRegistry((state) => state.workflows) + const { workspaceId } = useParams<{ workspaceId: string }>() + const { data: allWorkflowList = [], isPending: isWorkflowsPending } = useWorkflows(workspaceId) const expandedWorkflowId = workflowIds.length === 1 ? workflowIds[0] : null @@ -459,7 +461,7 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) { ) } - if (Object.keys(allWorkflows).length === 0) { + if (!isWorkflowsPending && allWorkflowList.length === 0) { return (
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/workflow-selector/workflow-selector.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/workflow-selector/workflow-selector.tsx index 02c3173bd83..f0172a1ee17 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/workflow-selector/workflow-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/workflow-selector/workflow-selector.tsx @@ -25,9 +25,7 @@ export function WorkflowSelector({ onChange, error, }: WorkflowSelectorProps) { - const { data: workflows = [], isPending: isLoading } = useWorkflows(workspaceId, { - syncRegistry: false, - }) + const { data: workflows = [], isPending: isLoading } = useWorkflows(workspaceId) const options: ComboboxOption[] = useMemo(() => { return workflows.map((w) => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx index cb8b795276b..1ebc721e795 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' import { Search, X } from 'lucide-react' +import { useParams } from 'next/navigation' import { Badge } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { getTriggerOptions } from '@/lib/logs/get-trigger-options' @@ -14,8 +15,8 @@ import { type WorkflowData, } from '@/lib/logs/search-suggestions' import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-search-state' -import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useFolderMap } from '@/hooks/queries/folders' +import { useWorkflows } from '@/hooks/queries/workflows' function truncateFilterValue(field: string, value: string): string { if ((field === 'executionId' || field === 'workflowId') && value.length > 12) { @@ -42,16 +43,17 @@ export function AutocompleteSearch({ className, onOpenChange, }: AutocompleteSearchProps) { - const workflows = useWorkflowRegistry((state) => state.workflows) - const folders = useFolderStore((state) => state.folders) + const { workspaceId } = useParams<{ workspaceId: string }>() + const { data: workflowList = [] } = useWorkflows(workspaceId) + const { data: folders = {} } = useFolderMap(workspaceId) const workflowsData = useMemo(() => { - return Object.values(workflows).map((w) => ({ + return workflowList.map((w) => ({ id: w.id, name: w.name, description: w.description, })) - }, [workflows]) + }, [workflowList]) const foldersData = useMemo(() => { return Object.values(folders).map((f) => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx index 542a64ca0ed..04926c08665 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx @@ -20,10 +20,10 @@ import { hasActiveFilters } from '@/lib/logs/filters' import { getTriggerOptions } from '@/lib/logs/get-trigger-options' import { type LogStatus, STATUS_CONFIG } from '@/app/workspace/[workspaceId]/logs/utils' import { getBlock } from '@/blocks/registry' -import { useFolderStore } from '@/stores/folders/store' +import { useFolderMap } from '@/hooks/queries/folders' +import { useWorkflows } from '@/hooks/queries/workflows' import { useFilterStore } from '@/stores/logs/filters/store' import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { AutocompleteSearch } from './components/search' const TIME_RANGE_OPTIONS: ComboboxOption[] = [ @@ -218,17 +218,17 @@ export const LogsToolbar = memo(function LogsToolbar({ const [datePickerOpen, setDatePickerOpen] = useState(false) const [previousTimeRange, setPreviousTimeRange] = useState(timeRange) - const folders = useFolderStore((state) => state.folders) + const { data: folders = {} } = useFolderMap(workspaceId) - const allWorkflows = useWorkflowRegistry((state) => state.workflows) + const { data: allWorkflowList = [] } = useWorkflows(workspaceId) const workflows = useMemo(() => { - return Object.values(allWorkflows).map((w) => ({ + return allWorkflowList.map((w) => ({ id: w.id, name: w.name, color: w.color, })) - }, [allWorkflows]) + }, [allWorkflowList]) const folderList = useMemo(() => { return Object.values(folders).filter((f) => f.workspaceId === workspaceId) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 098f23158a8..49521f6ff87 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -50,19 +50,18 @@ import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-sea import type { Suggestion } from '@/app/workspace/[workspaceId]/logs/types' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { getBlock } from '@/blocks/registry' -import { useFolders } from '@/hooks/queries/folders' +import { useFolderMap, useFolders } from '@/hooks/queries/folders' import { prefetchLogDetail, useDashboardStats, useLogDetail, useLogsList, } from '@/hooks/queries/logs' +import { useWorkflowMap, useWorkflows } from '@/hooks/queries/workflows' import { useDebounce } from '@/hooks/use-debounce' -import { useFolderStore } from '@/stores/folders/store' import { useFilterStore } from '@/stores/logs/filters/store' import type { WorkflowLog } from '@/stores/logs/filters/types' import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { Dashboard, ExecutionSnapshot, @@ -783,8 +782,8 @@ export default function Logs() { ] ) - const allWorkflows = useWorkflowRegistry((state) => state.workflows) - const folders = useFolderStore((state) => state.folders) + const { data: allWorkflows = {} } = useWorkflowMap(workspaceId) + const { data: folders = {} } = useFolderMap(workspaceId) const filterTags = useMemo(() => { const tags: FilterTag[] = [] @@ -1243,12 +1242,12 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr const [datePickerOpen, setDatePickerOpen] = useState(false) const [previousTimeRange, setPreviousTimeRange] = useState(timeRange) - const folders = useFolderStore((state) => state.folders) - const allWorkflows = useWorkflowRegistry((state) => state.workflows) + const { data: folders = {} } = useFolderMap(workspaceId) + const { data: allWorkflowList = [] } = useWorkflows(workspaceId) const workflows = useMemo( - () => Object.values(allWorkflows).map((w) => ({ id: w.id, name: w.name, color: w.color })), - [allWorkflows] + () => allWorkflowList.map((w) => ({ id: w.id, name: w.name, color: w.color })), + [allWorkflowList] ) const folderList = useMemo( diff --git a/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx b/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx new file mode 100644 index 00000000000..6ffcb96facc --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx @@ -0,0 +1,24 @@ +'use client' + +import { useEffect } from 'react' +import { useParams } from 'next/navigation' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +/** + * Keeps workflow registry workspace scope synchronized with the current route. + */ +export function WorkspaceScopeSync() { + const { workspaceId } = useParams<{ workspaceId: string }>() + const hydrationWorkspaceId = useWorkflowRegistry((state) => state.hydration.workspaceId) + const switchToWorkspace = useWorkflowRegistry((state) => state.switchToWorkspace) + + useEffect(() => { + if (!workspaceId || hydrationWorkspaceId === workspaceId) { + return + } + + switchToWorkspace(workspaceId) + }, [hydrationWorkspaceId, switchToWorkspace, workspaceId]) + + return null +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index 8fdb3500bcc..c9b3f37a48c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -119,7 +119,7 @@ export function RecentlyDeleted() { const [restoringIds, setRestoringIds] = useState>(new Set()) const [restoredItems, setRestoredItems] = useState>(new Map()) - const workflowsQuery = useWorkflows(workspaceId, { syncRegistry: false, scope: 'archived' }) + const workflowsQuery = useWorkflows(workspaceId, { scope: 'archived' }) const tablesQuery = useTablesList(workspaceId, 'archived') const knowledgeQuery = useKnowledgeBasesQuery(workspaceId, { scope: 'archived' }) const filesQuery = useWorkspaceFiles(workspaceId, 'archived') @@ -245,7 +245,10 @@ export function RecentlyDeleted() { switch (resource.type) { case 'workflow': - restoreWorkflow.mutate(resource.id, { onSettled, onSuccess }) + restoreWorkflow.mutate( + { workflowId: resource.id, workspaceId: resource.workspaceId }, + { onSettled, onSuccess } + ) break case 'table': restoreTable.mutate(resource.id, { onSettled, onSuccess }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts index 72fc8e3027b..cdced3555eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts @@ -70,7 +70,6 @@ export const FOLDER_CONFIGS: Record = { title: 'All workflows', dataKey: 'workflows', loadingKey: 'isLoadingWorkflows', - // No ensureLoadedKey - workflows auto-load from registry store getLabel: (item) => item.name || 'Untitled Workflow', getId: (item) => item.id, emptyMessage: 'No workflows', diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts index 15f5007b6bb..687deb61e86 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react' import { createLogger } from '@sim/logger' import { useShallow } from 'zustand/react/shallow' +import { useWorkflows } from '@/hooks/queries/workflows' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -151,14 +152,11 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn { useShallow(useCallback((state) => Object.keys(state.blocks), [])) ) - const registryWorkflows = useWorkflowRegistry(useShallow((state) => state.workflows)) + const { data: registryWorkflowList = [] } = useWorkflows(workspaceId) const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase) - const isLoadingWorkflows = - hydrationPhase === 'idle' || - hydrationPhase === 'metadata-loading' || - hydrationPhase === 'state-loading' + const isLoadingWorkflows = hydrationPhase === 'idle' || hydrationPhase === 'state-loading' - const workflows: WorkflowItem[] = Object.values(registryWorkflows) + const workflows: WorkflowItem[] = registryWorkflowList .filter((w) => w.workspaceId === workspaceId) .sort((a, b) => { const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0 diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx index c701e0bbb6e..4fca3c633b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx @@ -1,6 +1,7 @@ 'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useParams } from 'next/navigation' import { Badge, Button, @@ -19,6 +20,7 @@ import { normalizeInputFormatValue } from '@/lib/workflows/input-format' import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' import type { InputFormatField } from '@/lib/workflows/types' import { useDeploymentInfo, useUpdatePublicApi } from '@/hooks/queries/deployments' +import { useUpdateWorkflow, useWorkflowMap } from '@/hooks/queries/workflows' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' @@ -33,16 +35,16 @@ interface ApiInfoModalProps { } export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalProps) { + const { workspaceId } = useParams<{ workspaceId: string }>() const blocks = useWorkflowStore((state) => state.blocks) const setValue = useSubBlockStore((state) => state.setValue) const subBlockValues = useSubBlockStore((state) => workflowId ? (state.workflowValues[workflowId] ?? {}) : {} ) - const workflowMetadata = useWorkflowRegistry((state) => - workflowId ? state.workflows[workflowId] : undefined - ) - const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow) + const { data: workflows = {} } = useWorkflowMap(workspaceId) + const workflowMetadata = workflowId ? workflows[workflowId] : undefined + const updateWorkflowMutation = useUpdateWorkflow() const { data: deploymentData } = useDeploymentInfo(workflowId, { enabled: open }) const updatePublicApiMutation = useUpdatePublicApi() @@ -175,7 +177,11 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro } if (description.trim() !== (workflowMetadata?.description || '')) { - updateWorkflow(workflowId, { description: description.trim() || 'New workflow' }) + await updateWorkflowMutation.mutateAsync({ + workspaceId, + workflowId, + metadata: { description: description.trim() || 'New workflow' }, + }) } if (starterBlockId) { @@ -195,16 +201,15 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro } }, [ workflowId, + workspaceId, description, workflowMetadata, - updateWorkflow, starterBlockId, inputFormat, paramDescriptions, setValue, onOpenChange, accessMode, - updatePublicApiMutation, ]) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index ed55ce78817..6e228f22c39 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' +import { useParams } from 'next/navigation' import { Badge, Button, @@ -35,6 +36,7 @@ import { } from '@/hooks/queries/deployments' // import { useTemplateByWorkflow } from '@/hooks/queries/templates' import { useWorkflowMcpServers } from '@/hooks/queries/workflow-mcp-servers' +import { useWorkflowMap } from '@/hooks/queries/workflows' import { useWorkspaceSettings } from '@/hooks/queries/workspace' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -85,14 +87,15 @@ export function DeployModal({ isLoadingDeployedState, }: DeployModalProps) { const queryClient = useQueryClient() + const params = useParams() + const workspaceId = params?.workspaceId as string const { navigateToSettings } = useSettingsNavigation() const deploymentStatus = useWorkflowRegistry((state) => state.getWorkflowDeploymentStatus(workflowId) ) const isDeployed = deploymentStatus?.isDeployed ?? isDeployedProp - const workflowMetadata = useWorkflowRegistry((state) => - workflowId ? state.workflows[workflowId] : undefined - ) + const { data: workflowMap = {} } = useWorkflowMap(workspaceId) + const workflowMetadata = workflowId ? workflowMap[workflowId] : undefined const workflowWorkspaceId = workflowMetadata?.workspaceId ?? null const [activeTab, setActiveTab] = useState('general') const [chatSubmitting, setChatSubmitting] = useState(false) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx index 3285337ab96..78081e6eeac 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx @@ -22,10 +22,7 @@ interface DeployProps { export function Deploy({ activeWorkflowId, userPermissions, className }: DeployProps) { const [isModalOpen, setIsModalOpen] = useState(false) const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase) - const isRegistryLoading = - hydrationPhase === 'idle' || - hydrationPhase === 'metadata-loading' || - hydrationPhase === 'state-loading' + const isRegistryLoading = hydrationPhase === 'idle' || hydrationPhase === 'state-loading' const { hasBlocks } = useCurrentWorkflow() const deploymentStatus = useWorkflowRegistry((state) => diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx index d7dce93c7f9..af69db47468 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx @@ -18,6 +18,7 @@ import { ConnectCredentialModal } from '@/app/workspace/[workspaceId]/w/[workflo import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal' import { useWorkspaceCredential } from '@/hooks/queries/credentials' import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials' +import { useWorkflowMap } from '@/hooks/queries/workflows' import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -77,9 +78,10 @@ export function ToolCredentialSelector({ const [showOAuthModal, setShowOAuthModal] = useState(false) const [editingInputValue, setEditingInputValue] = useState('') const [isEditing, setIsEditing] = useState(false) - const { activeWorkflowId, workflows } = useWorkflowRegistry() + const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) + const { data: workflowMap = {} } = useWorkflowMap(workspaceId) const effectiveWorkflowId = - activeWorkflowId && workflows[activeWorkflowId] ? activeWorkflowId : undefined + activeWorkflowId && workflowMap[activeWorkflowId] ? activeWorkflowId : undefined const selectedId = value || '' const effectiveLabel = label || `Select ${getProviderName(provider)} account` diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 0adb1a286bf..7c77db6e61c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -500,7 +500,7 @@ export const ToolInput = memo(function ToolInput({ const availableEnvVars = useAvailableEnvVarKeys(workspaceId) const mcpDataLoading = mcpLoading || mcpServersLoading - const { data: workflowsList = [] } = useWorkflows(workspaceId, { syncRegistry: false }) + const { data: workflowsList = [] } = useWorkflows(workspaceId) const availableWorkflows = useMemo( () => workflowsList.filter((w) => w.id !== workflowId), [workflowsList, workflowId] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-selector/workflow-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-selector/workflow-selector-input.tsx index 4b5abc8afd0..4d1266b273f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-selector/workflow-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-selector/workflow-selector-input.tsx @@ -1,6 +1,7 @@ 'use client' import { useMemo } from 'react' +import { useParams } from 'next/navigation' import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import type { SubBlockConfig } from '@/blocks/types' @@ -22,13 +23,15 @@ export function WorkflowSelectorInput({ isPreview = false, previewValue, }: WorkflowSelectorInputProps) { + const { workspaceId } = useParams<{ workspaceId: string }>() const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) const context: SelectorContext = useMemo( () => ({ + workspaceId, excludeWorkflowId: activeWorkflowId ?? undefined, }), - [activeWorkflowId] + [activeWorkflowId, workspaceId] ) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 05eb824623c..0f349d1b85f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -56,6 +56,7 @@ import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId] import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks' +import { useDuplicateWorkflowMutation, useWorkflowMap } from '@/hooks/queries/workflows' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -126,18 +127,15 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel const userPermissions = useUserPermissionsContext() const { config: permissionConfig } = usePermissionConfig() const { isImporting, handleFileChange } = useImportWorkflow({ workspaceId }) - const { workflows, activeWorkflowId, duplicateWorkflow, hydration } = useWorkflowRegistry( + const duplicateWorkflowMutation = useDuplicateWorkflowMutation() + const { data: workflows = {} } = useWorkflowMap(workspaceId) + const { activeWorkflowId, hydration } = useWorkflowRegistry( useShallow((state) => ({ - workflows: state.workflows, activeWorkflowId: state.activeWorkflowId, - duplicateWorkflow: state.duplicateWorkflow, hydration: state.hydration, })) ) - const isRegistryLoading = - hydration.phase === 'idle' || - hydration.phase === 'metadata-loading' || - hydration.phase === 'state-loading' + const isRegistryLoading = hydration.phase === 'idle' || hydration.phase === 'state-loading' const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null) // Check for locked blocks (disables auto-layout) @@ -478,7 +476,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel setIsExporting(true) try { - const workflow = getWorkflowWithValues(activeWorkflowId) + const workflow = getWorkflowWithValues(activeWorkflowId, workspaceId) if (!workflow || !workflow.state) { throw new Error('No workflow state found') @@ -519,11 +517,21 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel return } + const sourceWorkflow = workflows[activeWorkflowId] + if (!sourceWorkflow) return + setIsDuplicating(true) try { - const newWorkflow = await duplicateWorkflow(activeWorkflowId) - if (newWorkflow) { - router.push(`/workspace/${workspaceId}/w/${newWorkflow}`) + const result = await duplicateWorkflowMutation.mutateAsync({ + workspaceId, + sourceId: activeWorkflowId, + name: `${sourceWorkflow.name} (Copy)`, + description: sourceWorkflow.description, + color: sourceWorkflow.color ?? '', + folderId: sourceWorkflow.folderId, + }) + if (result?.id) { + router.push(`/workspace/${workspaceId}/w/${result.id}`) } } catch (error) { logger.error('Error duplicating workflow:', error) @@ -531,14 +539,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel setIsDuplicating(false) setIsMenuOpen(false) } - }, [ - activeWorkflowId, - userPermissions.canEdit, - isDuplicating, - duplicateWorkflow, - router, - workspaceId, - ]) + }, [activeWorkflowId, userPermissions.canEdit, isDuplicating, workflows, router, workspaceId]) /** * Toggles the locked state of all blocks in the workflow diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 8f7b8b80d0b..70ddefc95e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -46,9 +46,9 @@ import { useCredentialName } from '@/hooks/queries/oauth/oauth-credentials' import { useReactivateSchedule, useScheduleInfo } from '@/hooks/queries/schedules' import { useSkills } from '@/hooks/queries/skills' import { useTablesList } from '@/hooks/queries/tables' +import { useWorkflowMap } from '@/hooks/queries/workflows' import { useSelectorDisplayName } from '@/hooks/use-selector-display-name' import { useVariablesStore } from '@/stores/panel' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { wouldCreateCycle } from '@/stores/workflows/workflow/utils' @@ -600,11 +600,11 @@ const SubBlockRow = memo(function SubBlockRow({ ) const knowledgeBaseDisplayName = kbForDisplayName?.name ?? null - const workflowMap = useWorkflowRegistry((state) => state.workflows) - const workflowSelectionName = - subBlock?.id === 'workflowId' && typeof rawValue === 'string' - ? (workflowMap[rawValue]?.name ?? null) - : null + const { data: workflowMapForLookup = {} } = useWorkflowMap(workspaceId) + const workflowSelectionName = useMemo(() => { + if (subBlock?.id !== 'workflowId' || typeof rawValue !== 'string') return null + return workflowMapForLookup[rawValue]?.name ?? null + }, [workflowMapForLookup, subBlock?.id, rawValue]) const { data: mcpServers = [] } = useMcpServers(workspaceId || '') const mcpServerDisplayName = useMemo(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 08bb15ab3b9..2e8e4b8ce4a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' +import { useParams } from 'next/navigation' import { v4 as uuidv4 } from 'uuid' import { useShallow } from 'zustand/react/shallow' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' @@ -30,6 +31,7 @@ import type { BlockLog, BlockState, ExecutionResult, StreamingExecution } from ' import { hasExecutionResult } from '@/executor/utils/errors' import { coerceValue } from '@/executor/utils/start-block' import { subscriptionKeys } from '@/hooks/queries/subscription' +import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' import { useExecutionStream } from '@/hooks/use-execution-stream' import { WorkflowValidationError } from '@/serializer' import { useCurrentWorkflowExecution, useExecutionStore } from '@/stores/execution' @@ -102,11 +104,11 @@ function normalizeErrorMessage(error: unknown): string { } export function useWorkflowExecution() { + const { workspaceId: routeWorkspaceId } = useParams<{ workspaceId: string }>() + const hydrationWorkspaceId = useWorkflowRegistry((s) => s.hydration.workspaceId) const queryClient = useQueryClient() const currentWorkflow = useCurrentWorkflow() - const { activeWorkflowId, workflows } = useWorkflowRegistry( - useShallow((s) => ({ activeWorkflowId: s.activeWorkflowId, workflows: s.workflows })) - ) + const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) const { toggleConsole, addConsole, updateConsole, cancelRunningEntries, clearExecutionEntries } = useTerminalConsoleStore( useShallow((s) => ({ @@ -382,13 +384,15 @@ export function useWorkflowExecution() { // Sandbox exercises have no real workflow — signal the SandboxCanvasProvider // to run mock execution by setting isExecuting, then bail out immediately. - if (workflows[activeWorkflowId]?.isSandbox) { + const scopedWorkspaceId = routeWorkspaceId ?? hydrationWorkspaceId ?? undefined + const cachedWorkflows = scopedWorkspaceId ? getWorkflows(scopedWorkspaceId) : [] + const activeWorkflow = cachedWorkflows.find((w) => w.id === activeWorkflowId) + if (activeWorkflow?.isSandbox) { setIsExecuting(activeWorkflowId, true) return } - // Get workspaceId from workflow metadata - const workspaceId = workflows[activeWorkflowId]?.workspaceId + const workspaceId = scopedWorkspaceId ?? activeWorkflow?.workspaceId if (!workspaceId) { logger.error('Cannot execute workflow without workspaceId') @@ -748,7 +752,6 @@ export function useWorkflowExecution() { setExecutor, setPendingBlocks, setActiveBlocks, - workflows, queryClient, ] ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index fd69e48bbc8..7f9479b64d8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -72,6 +72,7 @@ import { getBlock } from '@/blocks' import { isAnnotationOnlyBlock } from '@/executor/constants' import { useWorkspaceEnvironment } from '@/hooks/queries/environment' import { useAutoConnect, useSnapToGridSize } from '@/hooks/queries/general-settings' +import { useWorkflowMap } from '@/hooks/queries/workflows' import { useCanvasViewport } from '@/hooks/use-canvas-viewport' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useOAuthReturnForWorkflow } from '@/hooks/use-oauth-return' @@ -278,7 +279,12 @@ const WorkflowContent = React.memo( useOAuthReturnForWorkflow(workflowIdParam) const { - workflows, + data: workflows = {}, + isLoading: isWorkflowMapLoading, + isPlaceholderData: isWorkflowMapPlaceholderData, + } = useWorkflowMap(workspaceId) + + const { activeWorkflowId, hydration, setActiveWorkflow, @@ -291,7 +297,6 @@ const WorkflowContent = React.memo( clearPendingSelection, } = useWorkflowRegistry( useShallow((state) => ({ - workflows: state.workflows, activeWorkflowId: state.activeWorkflowId, hydration: state.hydration, setActiveWorkflow: state.setActiveWorkflow, @@ -356,12 +361,14 @@ const WorkflowContent = React.memo( const isWorkflowReady = useMemo( () => + !isWorkflowMapPlaceholderData && hydration.phase === 'ready' && hydration.workflowId === workflowIdParam && activeWorkflowId === workflowIdParam && Boolean(workflows[workflowIdParam]) && lastSaved !== undefined, [ + isWorkflowMapPlaceholderData, hydration.phase, hydration.workflowId, workflowIdParam, @@ -2192,23 +2199,22 @@ const WorkflowContent = React.memo( ) const loadingWorkflowRef = useRef(null) - const currentWorkflowExists = Boolean(workflows[workflowIdParam]) + const currentWorkflowExists = + !isWorkflowMapPlaceholderData && Boolean(workflows[workflowIdParam]) useEffect(() => { // In sandbox mode the stores are pre-hydrated externally; skip the API load. if (sandbox) return const currentId = workflowIdParam - const currentWorkspaceHydration = hydration.workspaceId - - const isRegistryReady = hydration.phase !== 'metadata-loading' && hydration.phase !== 'idle' - - // Wait for registry to be ready to prevent race conditions + // Wait for workflow data to be available before attempting to load if ( + isWorkflowMapLoading || + isWorkflowMapPlaceholderData || !currentId || !currentWorkflowExists || - !isRegistryReady || - (currentWorkspaceHydration && currentWorkspaceHydration !== workspaceId) + !hydration.workspaceId || + hydration.workspaceId !== workspaceId ) { return } @@ -2257,6 +2263,8 @@ const WorkflowContent = React.memo( } }, [ workflowIdParam, + isWorkflowMapLoading, + isWorkflowMapPlaceholderData, currentWorkflowExists, activeWorkflowId, setActiveWorkflow, @@ -2274,8 +2282,12 @@ const WorkflowContent = React.memo( useEffect(() => { if (embedded || sandbox) return - // Wait for metadata to finish loading before making navigation decisions - if (hydration.phase === 'metadata-loading' || hydration.phase === 'idle') { + if ( + isWorkflowMapLoading || + isWorkflowMapPlaceholderData || + !hydration.workspaceId || + hydration.workspaceId !== workspaceId + ) { return } @@ -2318,9 +2330,12 @@ const WorkflowContent = React.memo( }, [ embedded, workflowIdParam, + isWorkflowMapLoading, + isWorkflowMapPlaceholderData, currentWorkflowExists, workflowCount, hydration.phase, + hydration.workspaceId, workspaceId, router, workflows, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx index f6183bbd661..f2c78c680c3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx @@ -14,7 +14,7 @@ import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/co import { getBlock } from '@/blocks' import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types' import { useVariablesStore } from '@/stores/panel/variables/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import type { WorkflowMetadata } from '@/stores/workflows/registry/types' /** Execution status for blocks in preview mode */ type ExecutionStatus = 'success' | 'error' | 'not-executed' @@ -48,6 +48,8 @@ const ERROR_HANDLE_STYLE: CSSProperties = { interface WorkflowPreviewBlockData { type: string name: string + workflowMap?: Record + workflowLabelsReady?: boolean isTrigger?: boolean horizontalHandles?: boolean enabled?: boolean @@ -77,6 +79,8 @@ interface SubBlockRowProps { value?: string subBlock?: SubBlockConfig rawValue?: unknown + workflowMap: Record + workflowLabelsReady: boolean } /** @@ -107,12 +111,14 @@ function resolveDropdownLabel( */ function resolveWorkflowName( subBlock: SubBlockConfig | undefined, - rawValue: unknown + rawValue: unknown, + workflowMap: Record, + workflowLabelsReady: boolean ): string | null { if (subBlock?.type !== 'workflow-selector') return null if (!rawValue || typeof rawValue !== 'string') return null + if (!workflowLabelsReady) return null - const workflowMap = useWorkflowRegistry.getState().workflows return workflowMap[rawValue]?.name ?? DELETED_WORKFLOW_LABEL } @@ -228,6 +234,8 @@ const SubBlockRow = memo(function SubBlockRow({ value, subBlock, rawValue, + workflowMap, + workflowLabelsReady, }: SubBlockRowProps) { const isPasswordField = subBlock?.password === true const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null @@ -235,7 +243,7 @@ const SubBlockRow = memo(function SubBlockRow({ const dropdownLabel = resolveDropdownLabel(subBlock, rawValue) const variablesDisplay = resolveVariablesDisplay(subBlock, rawValue) const toolsDisplay = resolveToolsDisplay(subBlock, rawValue) - const workflowName = resolveWorkflowName(subBlock, rawValue) + const workflowName = resolveWorkflowName(subBlock, rawValue, workflowMap, workflowLabelsReady) const isSelectorType = subBlock?.type && SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlock.type) @@ -272,6 +280,8 @@ function WorkflowPreviewBlockInner({ data }: NodeProps const { type, name, + workflowMap = {}, + workflowLabelsReady = false, isTrigger = false, horizontalHandles = false, enabled = true, @@ -492,6 +502,8 @@ function WorkflowPreviewBlockInner({ data }: NodeProps key={cond.id} title={cond.title} value={lightweight ? undefined : getDisplayValue(cond.value)} + workflowMap={workflowMap} + workflowLabelsReady={workflowLabelsReady} /> )) ) : type === 'router_v2' ? ( @@ -500,12 +512,16 @@ function WorkflowPreviewBlockInner({ data }: NodeProps key='context' title='Context' value={lightweight ? undefined : getDisplayValue(rawValues.context)} + workflowMap={workflowMap} + workflowLabelsReady={workflowLabelsReady} /> {routerRows.map((route, index) => ( ))} @@ -519,12 +535,20 @@ function WorkflowPreviewBlockInner({ data }: NodeProps value={lightweight ? undefined : getDisplayValue(rawValue)} subBlock={lightweight ? undefined : subBlock} rawValue={rawValue} + workflowMap={workflowMap} + workflowLabelsReady={workflowLabelsReady} /> ) }) )} {/* Error row for non-trigger blocks */} - {shouldShowDefaultHandles && } + {shouldShowDefaultHandles && ( + + )}
)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx index c1487a4b72b..dfde43d4792 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx @@ -1,6 +1,7 @@ 'use client' import { useEffect, useMemo, useRef } from 'react' +import { useParams } from 'next/navigation' import ReactFlow, { ConnectionLineType, type Edge, @@ -19,6 +20,7 @@ import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/compo import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' import { PreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block' import { PreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow' +import { useWorkflowMap } from '@/hooks/queries/workflows' import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('PreviewWorkflow') @@ -130,6 +132,7 @@ function calculateAbsolutePosition( interface PreviewWorkflowProps { workflowState: WorkflowState + workspaceId?: string className?: string height?: string | number width?: string | number @@ -213,6 +216,7 @@ function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeP /** Readonly workflow visualization with execution status highlighting. */ export function PreviewWorkflow({ workflowState, + workspaceId: propWorkspaceId, className, height = '100%', width = '100%', @@ -228,6 +232,14 @@ export function PreviewWorkflow({ selectedBlockId, lightweight = false, }: PreviewWorkflowProps) { + const params = useParams<{ workspaceId: string }>() + const workspaceId = propWorkspaceId ?? params.workspaceId + const { + data: workflowMap = {}, + isLoading: isWorkflowMapLoading, + isPlaceholderData: isWorkflowMapPlaceholderData, + } = useWorkflowMap(workspaceId) + const workflowLabelsReady = !isWorkflowMapLoading && !isWorkflowMapPlaceholderData const containerRef = useRef(null) const nodeTypes = previewNodeTypes const isValidWorkflowState = workflowState?.blocks && workflowState.edges @@ -424,6 +436,8 @@ export function PreviewWorkflow({ data: { type: block.type, name: block.name, + workflowMap, + workflowLabelsReady, isTrigger: block.triggerMode === true, horizontalHandles: block.horizontalHandles ?? false, enabled: block.enabled ?? true, @@ -445,6 +459,8 @@ export function PreviewWorkflow({ executedBlocks, selectedBlockId, getSubflowExecutionStatus, + workflowMap, + workflowLabelsReady, lightweight, ]) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx index 1b5680c16e7..295f8d88033 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx @@ -27,10 +27,11 @@ import { useExportSelection, } from '@/app/workspace/[workspaceId]/w/hooks' import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders' +import { getFolderMap } from '@/hooks/queries/utils/folder-cache' +import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' import { useCreateWorkflow } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' import type { FolderTreeNode } from '@/stores/folders/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils' const logger = createLogger('FolderItem') @@ -245,16 +246,16 @@ export function FolderItem({ const workflowIds = Array.from(finalWorkflowSelection) const isMixed = folderIds.length > 0 && workflowIds.length > 0 - const { folders } = useFolderStore.getState() - const { workflows } = useWorkflowRegistry.getState() + const folderMap = getFolderMap(workspaceId) + const workflows = getWorkflows(workspaceId) const names: string[] = [] for (const id of folderIds) { - const f = folders[id] + const f = folderMap[id] if (f) names.push(f.name) } for (const id of workflowIds) { - const w = workflows[id] + const w = workflows.find((wf) => wf.id === id) if (w) names.push(w.name) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx index 37f276ff686..2075bc26781 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx @@ -25,6 +25,9 @@ import { useExportSelection, useExportWorkflow, } from '@/app/workspace/[workspaceId]/w/hooks' +import { getFolderMap } from '@/hooks/queries/utils/folder-cache' +import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' +import { useUpdateWorkflow } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -60,7 +63,7 @@ export function WorkflowItem({ const params = useParams() const workspaceId = params.workspaceId as string const selectedWorkflows = useFolderStore((state) => state.selectedWorkflows) - const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow) + const updateWorkflowMutation = useUpdateWorkflow() const userPermissions = useUserPermissionsContext() const isSelected = selectedWorkflows.has(workflow.id) @@ -166,9 +169,9 @@ export function WorkflowItem({ const handleColorChange = useCallback( (color: string) => { - updateWorkflow(workflow.id, { color }) + updateWorkflowMutation.mutate({ workspaceId, workflowId: workflow.id, metadata: { color } }) }, - [workflow.id, updateWorkflow] + [workflow.id, workspaceId] ) const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) @@ -227,16 +230,16 @@ export function WorkflowItem({ const folderIds = Array.from(finalFolderSelection) const isMixed = workflowIds.length > 0 && folderIds.length > 0 - const { workflows } = useWorkflowRegistry.getState() - const { folders } = useFolderStore.getState() + const workflows = getWorkflows(workspaceId) + const folderMap = getFolderMap(workspaceId) const names: string[] = [] for (const id of workflowIds) { - const w = workflows[id] + const w = workflows.find((wf) => wf.id === id) if (w) names.push(w.name) } for (const id of folderIds) { - const f = folders[id] + const f = folderMap[id] if (f) names.push(f.name) } @@ -301,7 +304,11 @@ export function WorkflowItem({ } = useItemRename({ initialName: workflow.name, onSave: async (newName) => { - await updateWorkflow(workflow.id, { name: newName }) + await updateWorkflowMutation.mutateAsync({ + workspaceId, + workflowId: workflow.id, + metadata: { name: newName }, + }) }, itemType: 'workflow', itemId: workflow.id, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx index 6b4d5c180b4..49df9dc57a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx @@ -3,6 +3,7 @@ import { memo, useCallback, useEffect, useMemo } from 'react' import clsx from 'clsx' import { useShallow } from 'zustand/react/shallow' +import { buildFolderTree, getFolderPath } from '@/lib/folders/tree' import { EmptyAreaContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/empty-area-context-menu' import { FolderItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item' import { WorkflowItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item' @@ -18,7 +19,7 @@ import { compareByOrder, groupWorkflowsByFolder, } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils' -import { useFolders } from '@/hooks/queries/folders' +import { useFolderMap, useFolders } from '@/hooks/queries/folders' import { useFolderStore } from '@/stores/folders/store' import type { FolderTreeNode } from '@/stores/folders/types' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -78,12 +79,10 @@ export const WorkflowList = memo(function WorkflowList({ disableCreate = false, }: WorkflowListProps) { const { isLoading: foldersLoading } = useFolders(workspaceId) - const folders = useFolderStore((state) => state.folders) - const { getFolderTree, expandedFolders, getFolderPath, setExpanded } = useFolderStore( + const { data: folderMap = {} } = useFolderMap(workspaceId) + const { expandedFolders, setExpanded } = useFolderStore( useShallow((s) => ({ - getFolderTree: s.getFolderTree, expandedFolders: s.expandedFolders, - getFolderPath: s.getFolderPath, setExpanded: s.setExpanded, })) ) @@ -120,8 +119,8 @@ export const WorkflowList = memo(function WorkflowList({ }, [scrollContainerRef, setScrollContainer]) const folderTree = useMemo( - () => (workspaceId ? getFolderTree(workspaceId) : []), - [workspaceId, folders, getFolderTree] + () => (workspaceId ? buildFolderTree(folderMap, workspaceId) : []), + [workspaceId, folderMap] ) const activeWorkflowFolderId = useMemo(() => { @@ -354,7 +353,7 @@ export const WorkflowList = memo(function WorkflowList({ if (!workflowId || isLoading || foldersLoading) return if (activeWorkflowFolderId) { - const folderPath = getFolderPath(activeWorkflowFolderId) + const folderPath = getFolderPath(folderMap, activeWorkflowFolderId) folderPath.forEach((folder) => setExpanded(folder.id, true)) } @@ -362,7 +361,7 @@ export const WorkflowList = memo(function WorkflowList({ if (!selectedWorkflows.has(workflowId)) { selectOnly(workflowId) } - }, [workflowId, activeWorkflowFolderId, isLoading, foldersLoading, getFolderPath, setExpanded]) + }, [workflowId, activeWorkflowFolderId, isLoading, foldersLoading, folderMap, setExpanded]) const renderWorkflowItem = useCallback( (workflow: WorkflowMetadata, level: number, folderId: string | null = null) => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts index 4e86d19749e..63f1d0d25f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts @@ -1,10 +1,12 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useParams } from 'next/navigation' +import { getFolderPath } from '@/lib/folders/tree' import { useReorderFolders } from '@/hooks/queries/folders' +import { getFolderMap } from '@/hooks/queries/utils/folder-cache' +import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' import { useReorderWorkflows } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('WorkflowList:DragDrop') @@ -233,8 +235,8 @@ export function useDragDrop(options: UseDragDropOptions = {}) { const cached = siblingsCacheRef.current.get(cacheKey) if (cached) return cached - const currentFolders = useFolderStore.getState().folders - const currentWorkflows = useWorkflowRegistry.getState().workflows + const currentFolders = workspaceId ? getFolderMap(workspaceId) : {} + const currentWorkflows = workspaceId ? getWorkflows(workspaceId) : [] const siblings = [ ...Object.values(currentFolders) .filter((f) => f.parentId === folderId) @@ -244,7 +246,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) { sortOrder: f.sortOrder, createdAt: f.createdAt, })), - ...Object.values(currentWorkflows) + ...currentWorkflows .filter((w) => w.folderId === folderId) .map((w) => ({ type: 'workflow' as const, @@ -294,10 +296,11 @@ export function useDragDrop(options: UseDragDropOptions = {}) { (folderId: string, destinationFolderId: string | null): boolean => { if (folderId === destinationFolderId) return false if (!destinationFolderId) return true - const targetPath = useFolderStore.getState().getFolderPath(destinationFolderId) + if (!workspaceId) return false + const targetPath = getFolderPath(getFolderMap(workspaceId), destinationFolderId) return !targetPath.some((f) => f.id === folderId) }, - [] + [workspaceId] ) const collectMovingItems = useCallback( @@ -306,14 +309,14 @@ export function useDragDrop(options: UseDragDropOptions = {}) { folderIds: string[], destinationFolderId: string | null ): { fromDestination: SiblingItem[]; fromOther: SiblingItem[] } => { - const { folders } = useFolderStore.getState() - const { workflows } = useWorkflowRegistry.getState() + const folders = workspaceId ? getFolderMap(workspaceId) : {} + const workflows = workspaceId ? getWorkflows(workspaceId) : [] const fromDestination: SiblingItem[] = [] const fromOther: SiblingItem[] = [] for (const id of workflowIds) { - const workflow = workflows[id] + const workflow = workflows.find((w) => w.id === id) if (!workflow) continue const item: SiblingItem = { type: 'workflow', diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts index e304f7d5975..054151a5a5b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts @@ -1,11 +1,9 @@ import { useCallback, useMemo } from 'react' import { createLogger } from '@sim/logger' import { useRouter } from 'next/navigation' -import { useShallow } from 'zustand/react/shallow' import { getNextWorkflowColor } from '@/lib/workflows/colors' -import { useCreateWorkflow, useWorkflows } from '@/hooks/queries/workflows' +import { useCreateWorkflow, useWorkflowMap } from '@/hooks/queries/workflows' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils' const logger = createLogger('useWorkflowOperations') @@ -16,17 +14,14 @@ interface UseWorkflowOperationsProps { export function useWorkflowOperations({ workspaceId }: UseWorkflowOperationsProps) { const router = useRouter() - const workflows = useWorkflowRegistry(useShallow((state) => state.workflows)) - const workflowsQuery = useWorkflows(workspaceId) + const { data: workflows = {}, isLoading: workflowsLoading } = useWorkflowMap(workspaceId) const createWorkflowMutation = useCreateWorkflow() const regularWorkflows = useMemo( () => Object.values(workflows) .filter((workflow) => workflow.workspaceId === workspaceId) - .sort((a, b) => { - return b.createdAt.getTime() - a.createdAt.getTime() - }), + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()), [workflows, workspaceId] ) @@ -59,7 +54,7 @@ export function useWorkflowOperations({ workspaceId }: UseWorkflowOperationsProp return { workflows, regularWorkflows, - workflowsLoading: workflowsQuery.isLoading, + workflowsLoading, isCreatingWorkflow: createWorkflowMutation.isPending, handleCreateWorkflow, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts index 7004584a5e9..47da0573a4f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts @@ -110,7 +110,7 @@ export function useWorkspaceManagement({ } try { - await switchToWorkspace(workspace.id) + switchToWorkspace(workspace.id) routerRef.current?.push(`/workspace/${workspace.id}/home`) logger.info(`Switched to workspace: ${workspace.name} (${workspace.id})`) } catch (error) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 7c89b20e191..f54a0fe8e37 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -38,6 +38,7 @@ import { import { useSession } from '@/lib/auth/auth-client' import { cn } from '@/lib/core/utils/cn' import { isMacPlatform } from '@/lib/core/utils/platform' +import { buildFolderTree } from '@/lib/folders/tree' import { START_NAV_TOUR_EVENT, START_WORKFLOW_TOUR_EVENT, @@ -77,7 +78,7 @@ import { useImportWorkspace, } from '@/app/workspace/[workspaceId]/w/hooks' import { getBrandConfig } from '@/ee/whitelabeling' -import { useFolders } from '@/hooks/queries/folders' +import { useFolderMap, useFolders } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' import { useTablesList } from '@/hooks/queries/tables' import { @@ -88,6 +89,7 @@ import { useRenameTask, useTasks, } from '@/hooks/queries/tasks' +import { useUpdateWorkflow } from '@/hooks/queries/workflows' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -96,7 +98,6 @@ import { SIDEBAR_WIDTH } from '@/stores/constants' import { useFolderStore } from '@/stores/folders/store' import { useSearchModalStore } from '@/stores/modals/search/store' import { useSidebarStore } from '@/stores/sidebar/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('Sidebar') @@ -436,13 +437,12 @@ export const Sidebar = memo(function Sidebar() { }) useFolders(workspaceId) - const folders = useFolderStore((s) => s.folders) - const getFolderTree = useFolderStore((s) => s.getFolderTree) - const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow) + const { data: folderMap = {} } = useFolderMap(workspaceId) + const updateWorkflowMutation = useUpdateWorkflow() const folderTree = useMemo( - () => (isCollapsed && workspaceId ? getFolderTree(workspaceId) : []), - [isCollapsed, workspaceId, folders, getFolderTree] + () => (isCollapsed && workspaceId ? buildFolderTree(folderMap, workspaceId) : []), + [isCollapsed, workspaceId, folderMap] ) const workflowsByFolder = useMemo( @@ -814,7 +814,11 @@ export const Sidebar = memo(function Sidebar() { const workflowFlyoutRename = useFlyoutInlineRename({ itemType: 'workflow', onSave: async (workflowIdToRename, name) => { - await updateWorkflow(workflowIdToRename, { name }) + await updateWorkflowMutation.mutateAsync({ + workspaceId, + workflowId: workflowIdToRename, + metadata: { name }, + }) }, }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-can-delete.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-can-delete.ts index e109ca816cb..22c96afbb85 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-can-delete.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-can-delete.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo } from 'react' -import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useFolderMap } from '@/hooks/queries/folders' +import { useWorkflows } from '@/hooks/queries/workflows' interface UseCanDeleteProps { /** @@ -36,17 +36,15 @@ interface UseCanDeleteReturn { * @returns Functions to check deletion eligibility */ export function useCanDelete({ workspaceId }: UseCanDeleteProps): UseCanDeleteReturn { - const workflows = useWorkflowRegistry((s) => s.workflows) - const folders = useFolderStore((s) => s.folders) + const { data: workflowList = [] } = useWorkflows(workspaceId) + const { data: folders = {} } = useFolderMap(workspaceId) /** * Pre-computed data structures for efficient lookups */ const { totalWorkflows, workflowIdSet, workflowsByFolderId, childFoldersByParentId } = useMemo(() => { - const workspaceWorkflows = Object.values(workflows).filter( - (w) => w.workspaceId === workspaceId - ) + const workspaceWorkflows = workflowList.filter((w) => w.workspaceId === workspaceId) const idSet = new Set(workspaceWorkflows.map((w) => w.id)) @@ -72,7 +70,7 @@ export function useCanDelete({ workspaceId }: UseCanDeleteProps): UseCanDeleteRe workflowsByFolderId: byFolderId, childFoldersByParentId: childrenByParent, } - }, [workflows, folders, workspaceId]) + }, [workflowList, folders, workspaceId]) /** * Count workflows in a folder and all its subfolders recursively. diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-selection.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-selection.ts index b37cf32c322..bb22fb2fa01 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-selection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-selection.ts @@ -2,8 +2,8 @@ import { useCallback, useState } from 'react' import { createLogger } from '@sim/logger' import { useRouter } from 'next/navigation' import { useDeleteFolderMutation } from '@/hooks/queries/folders' +import { useDeleteWorkflowMutation, useWorkflows } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('useDeleteSelection') @@ -46,8 +46,8 @@ export function useDeleteSelection({ onSuccess, }: UseDeleteSelectionProps) { const router = useRouter() - const workflows = useWorkflowRegistry((s) => s.workflows) - const removeWorkflow = useWorkflowRegistry((s) => s.removeWorkflow) + const { data: workflowList = [] } = useWorkflows(workspaceId) + const deleteWorkflowMutation = useDeleteWorkflowMutation() const deleteFolderMutation = useDeleteFolderMutation() const [isDeleting, setIsDeleting] = useState(false) @@ -72,7 +72,7 @@ export function useDeleteSelection({ ? workflowIds.some((id) => isActiveWorkflow(id)) : false - const sidebarWorkflows = Object.values(workflows).filter((w) => w.workspaceId === workspaceId) + const sidebarWorkflows = workflowList.filter((w) => w.workspaceId === workspaceId) const workflowsInFolders = sidebarWorkflows .filter((w) => w.folderId && folderIds.includes(w.folderId)) @@ -128,7 +128,11 @@ export function useDeleteSelection({ } const standaloneWorkflowIds = workflowIds.filter((id) => !workflowsInFolders.includes(id)) - await Promise.all(standaloneWorkflowIds.map((id) => removeWorkflow(id))) + await Promise.all( + standaloneWorkflowIds.map((id) => + deleteWorkflowMutation.mutateAsync({ workspaceId, workflowId: id }) + ) + ) const { clearSelection, clearFolderSelection } = useFolderStore.getState() clearSelection() @@ -151,12 +155,10 @@ export function useDeleteSelection({ workflowIds, folderIds, isDeleting, - workflows, + workflowList, workspaceId, isActiveWorkflow, router, - removeWorkflow, - deleteFolderMutation, onSuccess, ]) diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts index 37a56d24c0d..0e9c5d82cc5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts @@ -1,10 +1,8 @@ import { useCallback, useState } from 'react' import { createLogger } from '@sim/logger' -import { useQueryClient } from '@tanstack/react-query' import { useRouter } from 'next/navigation' -import { workflowKeys } from '@/hooks/queries/workflows' +import { useDeleteWorkflowMutation, useWorkflows } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('useDeleteWorkflow') @@ -41,9 +39,8 @@ export function useDeleteWorkflow({ onSuccess, }: UseDeleteWorkflowProps) { const router = useRouter() - const queryClient = useQueryClient() - const workflows = useWorkflowRegistry((s) => s.workflows) - const removeWorkflow = useWorkflowRegistry((s) => s.removeWorkflow) + const { data: workflowList = [] } = useWorkflows(workspaceId) + const deleteWorkflowMutation = useDeleteWorkflowMutation() const [isDeleting, setIsDeleting] = useState(false) /** @@ -65,7 +62,7 @@ export function useDeleteWorkflow({ const isActiveWorkflowBeingDeleted = typeof isActive === 'function' ? isActive(workflowIdsToDelete) : isActive - const sidebarWorkflows = Object.values(workflows).filter((w) => w.workspaceId === workspaceId) + const sidebarWorkflows = workflowList.filter((w) => w.workspaceId === workspaceId) let activeWorkflowId: string | null = null if (isActiveWorkflowBeingDeleted && typeof isActive === 'function') { @@ -105,8 +102,11 @@ export function useDeleteWorkflow({ } } - await Promise.all(workflowIdsToDelete.map((id) => removeWorkflow(id))) - await queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }) + await Promise.all( + workflowIdsToDelete.map((id) => + deleteWorkflowMutation.mutateAsync({ workspaceId, workflowId: id }) + ) + ) const { clearSelection } = useFolderStore.getState() clearSelection() @@ -122,13 +122,12 @@ export function useDeleteWorkflow({ }, [ workflowIds, isDeleting, - workflows, + workflowList, workspaceId, isActive, router, - removeWorkflow, + deleteWorkflowMutation, onSuccess, - queryClient, ]) return { diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-folder.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-folder.ts index 43b7399e4db..56851aed4f0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-folder.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-folder.ts @@ -1,6 +1,8 @@ import { useCallback, useState } from 'react' import { createLogger } from '@sim/logger' +import { getChildFolders, getFolderById } from '@/lib/folders/tree' import { useDuplicateFolderMutation } from '@/hooks/queries/folders' +import { getFolderMap } from '@/hooks/queries/utils/folder-cache' import { useFolderStore } from '@/stores/folders/store' const logger = createLogger('useDuplicateFolder') @@ -54,10 +56,10 @@ export function useDuplicateFolder({ workspaceId, folderIds, onSuccess }: UseDup const folderIdsToDuplicate = Array.isArray(folderIds) ? folderIds : [folderIds] const duplicatedIds: string[] = [] - const folderStore = useFolderStore.getState() + const folderMap = getFolderMap(workspaceId) for (const folderId of folderIdsToDuplicate) { - const folder = folderStore.getFolderById(folderId) + const folder = getFolderById(folderMap, folderId) if (!folder) { logger.warn('Attempted to duplicate folder that no longer exists', { folderId }) @@ -65,7 +67,7 @@ export function useDuplicateFolder({ workspaceId, folderIds, onSuccess }: UseDup } const siblingNames = new Set( - folderStore.getChildFolders(folder.parentId).map((sibling) => sibling.name) + getChildFolders(folderMap, folder.parentId).map((sibling) => sibling.name) ) siblingNames.add(folder.name) diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-selection.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-selection.ts index 48a48146e07..4ee23292533 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-selection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-selection.ts @@ -1,11 +1,13 @@ import { useCallback, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useRouter } from 'next/navigation' +import { getChildFolders, getFolderById } from '@/lib/folders/tree' import { getNextWorkflowColor } from '@/lib/workflows/colors' import { useDuplicateFolderMutation } from '@/hooks/queries/folders' +import { getFolderMap } from '@/hooks/queries/utils/folder-cache' +import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' import { useDuplicateWorkflowMutation } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('useDuplicateSelection') @@ -62,21 +64,21 @@ export function useDuplicateSelection({ workspaceId, onSuccess }: UseDuplicateSe setIsDuplicating(true) try { - const { workflows } = useWorkflowRegistry.getState() - const folderStore = useFolderStore.getState() + const workflowMap = new Map(getWorkflows(workspaceIdRef.current).map((w) => [w.id, w])) + const folderMap = getFolderMap(workspaceIdRef.current) const duplicatedWorkflowIds: string[] = [] const duplicatedFolderIds: string[] = [] for (const folderId of folderIds) { - const folder = folderStore.getFolderById(folderId) + const folder = getFolderById(folderMap, folderId) if (!folder) { logger.warn(`Folder ${folderId} not found, skipping`) continue } const siblingNames = new Set( - folderStore.getChildFolders(folder.parentId).map((sibling) => sibling.name) + getChildFolders(folderMap, folder.parentId).map((sibling) => sibling.name) ) siblingNames.add(folder.name) @@ -97,7 +99,7 @@ export function useDuplicateSelection({ workspaceId, onSuccess }: UseDuplicateSe } for (const workflowId of workflowIds) { - const workflow = workflows[workflowId] + const workflow = workflowMap.get(workflowId) if (!workflow) { logger.warn(`Workflow ${workflowId} not found, skipping`) continue diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workflow.ts index e1a14b49bfc..bffe42d6266 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workflow.ts @@ -2,9 +2,9 @@ import { useCallback, useRef } from 'react' import { createLogger } from '@sim/logger' import { useRouter } from 'next/navigation' import { getNextWorkflowColor } from '@/lib/workflows/colors' +import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' import { useDuplicateWorkflowMutation } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('useDuplicateWorkflow') @@ -61,10 +61,10 @@ export function useDuplicateWorkflow({ workspaceId, onSuccess }: UseDuplicateWor const duplicatedIds: string[] = [] try { - const { workflows } = useWorkflowRegistry.getState() + const workflowMap = new Map(getWorkflows(workspaceIdRef.current).map((w) => [w.id, w])) for (const sourceId of workflowIdsToDuplicate) { - const sourceWorkflow = workflows[sourceId] + const sourceWorkflow = workflowMap.get(sourceId) if (!sourceWorkflow) { logger.warn(`Workflow ${sourceId} not found, skipping`) continue diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts index e7a646b2f68..ff85f17e938 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts @@ -1,5 +1,7 @@ import { useCallback, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' +import { useParams } from 'next/navigation' +import { getFolderById } from '@/lib/folders/tree' import { downloadFile, exportFolderToZip, @@ -8,9 +10,10 @@ import { sanitizePathSegment, type WorkflowExportData, } from '@/lib/workflows/operations/import-export' +import { useFolderMap } from '@/hooks/queries/folders' +import { useWorkflowMap } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' import type { WorkflowFolder } from '@/stores/folders/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' const logger = createLogger('useExportFolder') @@ -89,8 +92,9 @@ function collectSubfolders( * Hook for managing folder export to ZIP. */ export function useExportFolder({ folderId, onSuccess }: UseExportFolderProps) { - const workflows = useWorkflowRegistry((s) => s.workflows) - const folders = useFolderStore((s) => s.folders) + const { workspaceId } = useParams<{ workspaceId: string }>() + const { data: workflows = {} } = useWorkflowMap(workspaceId) + const { data: folders = {} } = useFolderMap(workspaceId) const [isExporting, setIsExporting] = useState(false) const hasWorkflows = useMemo(() => { @@ -105,22 +109,21 @@ export function useExportFolder({ folderId, onSuccess }: UseExportFolderProps) { setIsExporting(true) try { - const folderStore = useFolderStore.getState() - const folder = folderStore.getFolderById(folderId) + const folder = getFolderById(folders, folderId) if (!folder) { logger.warn('Folder not found for export', { folderId }) return } - const workflowsToExport = collectWorkflowsInFolder(folderId, workflows, folderStore.folders) + const workflowsToExport = collectWorkflowsInFolder(folderId, workflows, folders) if (workflowsToExport.length === 0) { logger.warn('No workflows found in folder to export', { folderId, folderName: folder.name }) return } - const subfolders = collectSubfolders(folderId, folderStore.folders) + const subfolders = collectSubfolders(folderId, folders) logger.info('Starting folder export', { folderId, diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-selection.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-selection.ts index 92502caf55f..deed7a4e1dc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-selection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-selection.ts @@ -1,5 +1,6 @@ import { useCallback, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { useParams } from 'next/navigation' import { downloadFile, exportWorkflowsToZip, @@ -7,9 +8,10 @@ import { fetchWorkflowForExport, type WorkflowExportData, } from '@/lib/workflows/operations/import-export' +import { getFolderMap } from '@/hooks/queries/utils/folder-cache' +import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' import { useFolderStore } from '@/stores/folders/store' import type { WorkflowFolder } from '@/stores/folders/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' const logger = createLogger('useExportSelection') @@ -88,10 +90,15 @@ function collectSubfoldersForMultipleFolders( */ export function useExportSelection({ onSuccess }: UseExportSelectionProps = {}) { const [isExporting, setIsExporting] = useState(false) + const params = useParams() + const workspaceId = params.workspaceId as string | undefined const onSuccessRef = useRef(onSuccess) onSuccessRef.current = onSuccess + const workspaceIdRef = useRef(workspaceId) + workspaceIdRef.current = workspaceId + /** * Export all selected workflows and folders to a ZIP file. * - Collects workflows from selected folders recursively @@ -113,25 +120,29 @@ export function useExportSelection({ onSuccess }: UseExportSelectionProps = {}) setIsExporting(true) try { - const { workflows } = useWorkflowRegistry.getState() - const { folders } = useFolderStore.getState() + if (!workspaceIdRef.current) return + const workflowsArray = getWorkflows(workspaceIdRef.current) + const workflows = Object.fromEntries(workflowsArray.map((w) => [w.id, w])) + const folderMap = getFolderMap(workspaceIdRef.current) const workflowsFromFolders: CollectedWorkflow[] = [] for (const folderId of folderIds) { - const collected = collectWorkflowsInFolder(folderId, workflows, folders) + const collected = collectWorkflowsInFolder(folderId, workflows, folderMap) workflowsFromFolders.push(...collected) } - const subfolders = collectSubfoldersForMultipleFolders(folderIds, folders) - - const selectedFoldersData: FolderExportData[] = folderIds.map((folderId) => { - const folder = folders[folderId] - return { - id: folder.id, - name: folder.name, - parentId: null, - } - }) + const subfolders = collectSubfoldersForMultipleFolders(folderIds, folderMap) + + const selectedFoldersData: FolderExportData[] = folderIds + .filter((folderId) => folderMap[folderId]) + .map((folderId) => { + const folder = folderMap[folderId] + return { + id: folder.id, + name: folder.name, + parentId: null, + } + }) const allFolders = [...selectedFoldersData, ...subfolders] const workflowIdsFromFolders = workflowsFromFolders.map((w) => w.id) diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts index afa812ed549..91faca98627 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts @@ -1,5 +1,6 @@ import { useCallback, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { useParams } from 'next/navigation' import { downloadFile, exportWorkflowsToZip, @@ -7,8 +8,8 @@ import { fetchWorkflowForExport, sanitizePathSegment, } from '@/lib/workflows/operations/import-export' +import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('useExportWorkflow') @@ -24,10 +25,15 @@ interface UseExportWorkflowProps { */ export function useExportWorkflow({ onSuccess }: UseExportWorkflowProps = {}) { const [isExporting, setIsExporting] = useState(false) + const params = useParams() + const workspaceId = params.workspaceId as string | undefined const onSuccessRef = useRef(onSuccess) onSuccessRef.current = onSuccess + const workspaceIdRef = useRef(workspaceId) + workspaceIdRef.current = workspaceId + /** * Export the workflow(s) to JSON or ZIP * - Single workflow: exports as JSON file @@ -52,11 +58,12 @@ export function useExportWorkflow({ onSuccess }: UseExportWorkflowProps = {}) { count: workflowIdsToExport.length, }) - const { workflows } = useWorkflowRegistry.getState() + if (!workspaceIdRef.current) return + const workflowMap = new Map(getWorkflows(workspaceIdRef.current).map((w) => [w.id, w])) const exportedWorkflows = [] for (const workflowId of workflowIdsToExport) { - const workflowMeta = workflows[workflowId] + const workflowMeta = workflowMap.get(workflowId) if (!workflowMeta) { logger.warn(`Workflow ${workflowId} not found in registry`) continue diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts index c7f461b71f6..75ed0a4577f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts @@ -8,8 +8,10 @@ import { persistImportedWorkflow, sanitizePathSegment, } from '@/lib/workflows/operations/import-export' -import { folderKeys, useCreateFolder } from '@/hooks/queries/folders' -import { useCreateWorkflow, workflowKeys } from '@/hooks/queries/workflows' +import { useCreateFolder } from '@/hooks/queries/folders' +import { folderKeys } from '@/hooks/queries/utils/folder-keys' +import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' +import { useCreateWorkflow } from '@/hooks/queries/workflows' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' const logger = createLogger('useImportWorkflow') @@ -196,7 +198,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) { } } - await queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }) + await invalidateWorkflowLists(queryClient, workspaceId) await queryClient.invalidateQueries({ queryKey: folderKeys.list(workspaceId) }) logger.info(`Import complete. Imported ${importedWorkflowIds.length} workflow(s)`) diff --git a/apps/sim/app/workspace/[workspaceId]/w/page.tsx b/apps/sim/app/workspace/[workspaceId]/w/page.tsx index e19bfd387e4..1276a785276 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/page.tsx @@ -12,47 +12,41 @@ const logger = createLogger('WorkflowsPage') export default function WorkflowsPage() { const router = useRouter() - const workflows = useWorkflowRegistry((s) => s.workflows) const setActiveWorkflow = useWorkflowRegistry((s) => s.setActiveWorkflow) const params = useParams() const workspaceId = params.workspaceId as string const [isMounted, setIsMounted] = useState(false) - // Fetch workflows using React Query - const { isLoading, isError } = useWorkflows(workspaceId) + const { data: workflows = [], isLoading, isError, isPlaceholderData } = useWorkflows(workspaceId) - // Track when component is mounted to avoid hydration issues useEffect(() => { setIsMounted(true) }, []) - // Handle redirection once workflows are loaded and component is mounted useEffect(() => { - // Wait for component to be mounted to avoid hydration mismatches if (!isMounted) return - - // Only proceed if workflows are done loading - if (isLoading) return + if (isLoading || isPlaceholderData) return if (isError) { logger.error('Failed to load workflows for workspace') return } - const workflowIds = Object.keys(workflows) - - // Validate that workflows belong to the current workspace - const workspaceWorkflows = workflowIds.filter((id) => { - const workflow = workflows[id] - return workflow.workspaceId === workspaceId - }) + const workspaceWorkflows = workflows.filter((w) => w.workspaceId === workspaceId) - // If we have valid workspace workflows, redirect to the first one if (workspaceWorkflows.length > 0) { - const firstWorkflowId = workspaceWorkflows[0] - router.replace(`/workspace/${workspaceId}/w/${firstWorkflowId}`) + router.replace(`/workspace/${workspaceId}/w/${workspaceWorkflows[0].id}`) } - }, [isMounted, isLoading, workflows, workspaceId, router, setActiveWorkflow, isError]) + }, [ + isMounted, + isLoading, + isPlaceholderData, + workflows, + workspaceId, + router, + setActiveWorkflow, + isError, + ]) // Always show loading state until redirect happens // There should always be a default workflow, so we never show "no workflows found" diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts index a7a0829c882..b44ef4658c3 100644 --- a/apps/sim/blocks/blocks/agent.ts +++ b/apps/sim/blocks/blocks/agent.ts @@ -7,20 +7,30 @@ import { getApiKeyCondition, getModelOptions, RESPONSE_FORMAT_WAND_CONFIG } from import { getBaseModelProviders, getMaxTemperature, + getModelsWithDeepResearch, + getModelsWithoutMemory, + getModelsWithReasoningEffort, + getModelsWithThinking, + getModelsWithVerbosity, + getProviderModels, getReasoningEffortValuesForModel, getThinkingLevelsForModel, getVerbosityValuesForModel, - MODELS_WITH_DEEP_RESEARCH, - MODELS_WITH_REASONING_EFFORT, - MODELS_WITH_THINKING, - MODELS_WITH_VERBOSITY, - MODELS_WITHOUT_MEMORY, - providers, supportsTemperature, -} from '@/providers/utils' +} from '@/providers/models' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' import type { ToolResponse } from '@/tools/types' const logger = createLogger('AgentBlock') +const VERTEX_MODELS = getProviderModels('vertex') +const BEDROCK_MODELS = getProviderModels('bedrock') +const AZURE_MODELS = [...getProviderModels('azure-openai'), ...getProviderModels('azure-anthropic')] +const MODELS_WITH_REASONING_EFFORT = getModelsWithReasoningEffort() +const MODELS_WITH_VERBOSITY = getModelsWithVerbosity() +const MODELS_WITH_THINKING = getModelsWithThinking() +const MODELS_WITH_DEEP_RESEARCH = getModelsWithDeepResearch() +const MODELS_WITHOUT_MEMORY = getModelsWithoutMemory() interface AgentResponse extends ToolResponse { output: { @@ -136,7 +146,7 @@ Return ONLY the JSON array.`, required: true, condition: { field: 'model', - value: providers.vertex.models, + value: VERTEX_MODELS, }, }, { @@ -149,7 +159,7 @@ Return ONLY the JSON array.`, required: true, condition: { field: 'model', - value: providers.vertex.models, + value: VERTEX_MODELS, }, }, { @@ -165,9 +175,6 @@ Return ONLY the JSON array.`, ], dependsOn: ['model'], fetchOptions: async (blockId: string) => { - const { useSubBlockStore } = await import('@/stores/workflows/subblock/store') - const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') - const autoOption = { label: 'auto', id: 'auto' } const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId @@ -224,9 +231,6 @@ Return ONLY the JSON array.`, ], dependsOn: ['model'], fetchOptions: async (blockId: string) => { - const { useSubBlockStore } = await import('@/stores/workflows/subblock/store') - const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') - const autoOption = { label: 'auto', id: 'auto' } const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId @@ -285,9 +289,6 @@ Return ONLY the JSON array.`, ], dependsOn: ['model'], fetchOptions: async (blockId: string) => { - const { useSubBlockStore } = await import('@/stores/workflows/subblock/store') - const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') - const noneOption = { label: 'none', id: 'none' } const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId @@ -326,7 +327,7 @@ Return ONLY the JSON array.`, connectionDroppable: false, condition: { field: 'model', - value: [...providers['azure-openai'].models, ...providers['azure-anthropic'].models], + value: AZURE_MODELS, }, }, { @@ -337,7 +338,7 @@ Return ONLY the JSON array.`, connectionDroppable: false, condition: { field: 'model', - value: [...providers['azure-openai'].models, ...providers['azure-anthropic'].models], + value: AZURE_MODELS, }, }, { @@ -349,7 +350,7 @@ Return ONLY the JSON array.`, required: true, condition: { field: 'model', - value: providers.vertex.models, + value: VERTEX_MODELS, }, }, { @@ -361,7 +362,7 @@ Return ONLY the JSON array.`, required: true, condition: { field: 'model', - value: providers.vertex.models, + value: VERTEX_MODELS, }, }, { @@ -374,7 +375,7 @@ Return ONLY the JSON array.`, required: true, condition: { field: 'model', - value: providers.bedrock.models, + value: BEDROCK_MODELS, }, }, { @@ -387,7 +388,7 @@ Return ONLY the JSON array.`, required: true, condition: { field: 'model', - value: providers.bedrock.models, + value: BEDROCK_MODELS, }, }, { @@ -398,7 +399,7 @@ Return ONLY the JSON array.`, connectionDroppable: false, condition: { field: 'model', - value: providers.bedrock.models, + value: BEDROCK_MODELS, }, }, { diff --git a/apps/sim/blocks/blocks/evaluator.ts b/apps/sim/blocks/blocks/evaluator.ts index 4edb032ba9f..d3a78fa9574 100644 --- a/apps/sim/blocks/blocks/evaluator.ts +++ b/apps/sim/blocks/blocks/evaluator.ts @@ -6,8 +6,8 @@ import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS, } from '@/blocks/utils' +import { getBaseModelProviders } from '@/providers/models' import type { ProviderId } from '@/providers/types' -import { getBaseModelProviders } from '@/providers/utils' import type { ToolResponse } from '@/tools/types' const logger = createLogger('EvaluatorBlock') diff --git a/apps/sim/blocks/blocks/router.ts b/apps/sim/blocks/blocks/router.ts index 4fae2383ff4..c82c502c1c0 100644 --- a/apps/sim/blocks/blocks/router.ts +++ b/apps/sim/blocks/blocks/router.ts @@ -5,8 +5,8 @@ import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS, } from '@/blocks/utils' +import { getBaseModelProviders } from '@/providers/models' import type { ProviderId } from '@/providers/types' -import { getBaseModelProviders } from '@/providers/utils' import type { ToolResponse } from '@/tools/types' interface RouterResponse extends ToolResponse { diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index 373538b50e3..d187b2e751c 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -4,10 +4,14 @@ import { getHostedModels, getProviderFromModel, getProviderIcon, - providers, -} from '@/providers/utils' + getProviderModels, +} from '@/providers/models' import { useProvidersStore } from '@/stores/providers/store' +const VERTEX_MODELS = getProviderModels('vertex') +const BEDROCK_MODELS = getProviderModels('bedrock') +const AZURE_MODELS = [...getProviderModels('azure-openai'), ...getProviderModels('azure-anthropic')] + /** * Returns model options for combobox subblocks, combining all provider sources. */ @@ -152,7 +156,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { required: true, condition: { field: 'model', - value: providers.vertex.models, + value: VERTEX_MODELS, }, }, { @@ -174,7 +178,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { connectionDroppable: false, condition: { field: 'model', - value: [...providers['azure-openai'].models, ...providers['azure-anthropic'].models], + value: AZURE_MODELS, }, }, { @@ -185,7 +189,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { connectionDroppable: false, condition: { field: 'model', - value: [...providers['azure-openai'].models, ...providers['azure-anthropic'].models], + value: AZURE_MODELS, }, }, { @@ -197,7 +201,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { required: true, condition: { field: 'model', - value: providers.vertex.models, + value: VERTEX_MODELS, }, }, { @@ -209,7 +213,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { required: true, condition: { field: 'model', - value: providers.vertex.models, + value: VERTEX_MODELS, }, }, { @@ -222,7 +226,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { required: true, condition: { field: 'model', - value: providers.bedrock.models, + value: BEDROCK_MODELS, }, }, { @@ -235,7 +239,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { required: true, condition: { field: 'model', - value: providers.bedrock.models, + value: BEDROCK_MODELS, }, }, { @@ -246,7 +250,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { connectionDroppable: false, condition: { field: 'model', - value: providers.bedrock.models, + value: BEDROCK_MODELS, }, }, ] diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 492d07c9b00..b277de87649 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -34,7 +34,9 @@ import { resolveVertexCredential } from '@/executor/utils/vertex-credential' import { executeProviderRequest } from '@/providers' import { getProviderFromModel, transformBlockTool } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' -import { getTool, getToolAsync } from '@/tools/utils' +import { filterSchemaForLLM } from '@/tools/params' +import { getTool } from '@/tools/utils' +import { getToolAsync } from '@/tools/utils.server' const logger = createLogger('AgentBlockHandler') @@ -140,8 +142,13 @@ export class AgentBlockHandler implements BlockHandler { const serverIds = [...new Set(mcpTools.map((t) => t.params?.serverId).filter(Boolean))] if (serverIds.length === 0) return tools + if (!ctx.workspaceId) { + logger.warn('Skipping MCP availability filtering without workspace scope') + return tools + } + const availableServerIds = new Set() - if (ctx.workspaceId && serverIds.length > 0) { + if (serverIds.length > 0) { try { const servers = await db .select({ id: mcpServers.id, connectionStatus: mcpServers.connectionStatus }) @@ -245,8 +252,6 @@ export class AgentBlockHandler implements BlockHandler { return null } - const { filterSchemaForLLM } = await import('@/tools/params') - const filteredSchema = filterSchemaForLLM(schema.function.parameters, userProvidedParams) const toolId = `${AGENT.CUSTOM_TOOL_PREFIX}${title}` @@ -272,22 +277,6 @@ export class AgentBlockHandler implements BlockHandler { ctx: ExecutionContext, customToolId: string ): Promise<{ schema: any; title: string } | null> { - if (typeof window !== 'undefined') { - try { - const { getCustomTool } = await import('@/hooks/queries/custom-tools') - const tool = getCustomTool(customToolId, ctx.workspaceId) - if (tool) { - return { - schema: tool.schema, - title: tool.title, - } - } - logger.warn(`Custom tool not found in cache: ${customToolId}`) - } catch (error) { - logger.error('Error accessing custom tools cache:', { error }) - } - } - try { const headers = await buildAuthHeaders(ctx.userId) const params: Record = {} @@ -572,7 +561,12 @@ export class AgentBlockHandler implements BlockHandler { const transformedTool = await transformBlockTool(tool, { selectedOperation: tool.operation, getAllBlocks, - getToolAsync: (toolId: string) => getToolAsync(toolId, ctx.workflowId), + getToolAsync: (toolId: string) => + getToolAsync(toolId, { + workflowId: ctx.workflowId, + userId: ctx.userId, + workspaceId: ctx.workspaceId, + }), getTool, canonicalModes, }) diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index 50db926be7d..4e92948ee17 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -21,7 +21,6 @@ import { parseJSON } from '@/executor/utils/json' import { lazyCleanupInputMapping } from '@/executor/utils/lazy-cleanup' import { Serializer } from '@/serializer' import type { SerializedBlock } from '@/serializer/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('WorkflowBlockHandler') @@ -74,10 +73,7 @@ export class WorkflowBlockHandler implements BlockHandler { throw new Error('No workflow selected for execution') } - // Initialize with registry name, will be updated with loaded workflow name - const { workflows } = useWorkflowRegistry.getState() - const workflowMetadata = workflows[workflowId] - let childWorkflowName = workflowMetadata?.name || workflowId + let childWorkflowName = workflowId // Unique ID per invocation — used to correlate child block events with this specific // workflow block execution, preventing cross-iteration child mixing in loop contexts. @@ -111,8 +107,7 @@ export class WorkflowBlockHandler implements BlockHandler { throw new Error(`Child workflow ${workflowId} not found`) } - // Update with loaded workflow name (more reliable than registry) - childWorkflowName = workflowMetadata?.name || childWorkflow.name || 'Unknown Workflow' + childWorkflowName = childWorkflow.name || 'Unknown Workflow' logger.info( `Executing child workflow: ${childWorkflowName} (${workflowId}), call chain depth ${ctx.callChain?.length || 0}` diff --git a/apps/sim/hooks/queries/custom-tools.ts b/apps/sim/hooks/queries/custom-tools.ts index adb8af7183b..78af01fd0ee 100644 --- a/apps/sim/hooks/queries/custom-tools.ts +++ b/apps/sim/hooks/queries/custom-tools.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { getQueryClient } from '@/app/_shell/providers/query-provider' +import { customToolsKeys } from '@/hooks/queries/utils/custom-tool-keys' const logger = createLogger('CustomToolsQueries') const API_ENDPOINT = '/api/tools/custom' @@ -29,16 +29,6 @@ export interface CustomToolDefinition { updatedAt?: string } -/** - * Query key factories for custom tools queries - */ -export const customToolsKeys = { - all: ['customTools'] as const, - lists: () => [...customToolsKeys.all, 'list'] as const, - list: (workspaceId: string) => [...customToolsKeys.lists(), workspaceId] as const, - detail: (toolId: string) => [...customToolsKeys.all, 'detail', toolId] as const, -} - export type CustomTool = CustomToolDefinition type ApiCustomTool = Partial & { @@ -87,41 +77,6 @@ function normalizeCustomTool(tool: ApiCustomTool, workspaceId: string): CustomTo } } -/** - * Extract workspaceId from the current URL path - * Expected format: /workspace/{workspaceId}/... - */ -function getWorkspaceIdFromUrl(): string | null { - if (typeof window === 'undefined') return null - const match = window.location.pathname.match(/^\/workspace\/([^/]+)/) - return match?.[1] ?? null -} - -/** - * Get all custom tools from the query cache (for non-React code) - * If workspaceId is not provided, extracts it from the current URL - */ -export function getCustomTools(workspaceId?: string): CustomToolDefinition[] { - if (typeof window === 'undefined') return [] - const wsId = workspaceId ?? getWorkspaceIdFromUrl() - if (!wsId) return [] - const queryClient = getQueryClient() - return queryClient.getQueryData(customToolsKeys.list(wsId)) ?? [] -} - -/** - * Get a specific custom tool from the query cache by ID or title (for non-React code) - * Custom tools are referenced by title in the system (custom_${title}), so title lookup is required. - * If workspaceId is not provided, extracts it from the current URL - */ -export function getCustomTool( - identifier: string, - workspaceId?: string -): CustomToolDefinition | undefined { - const tools = getCustomTools(workspaceId) - return tools.find((tool) => tool.id === identifier || tool.title === identifier) -} - /** * Fetch custom tools for a workspace */ diff --git a/apps/sim/hooks/queries/deployments.test.ts b/apps/sim/hooks/queries/deployments.test.ts new file mode 100644 index 00000000000..97628bf202b --- /dev/null +++ b/apps/sim/hooks/queries/deployments.test.ts @@ -0,0 +1,51 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { invalidateDeploymentQueries } from '@/hooks/queries/deployments' +import { fetchDeploymentVersionState } from '@/hooks/queries/utils/fetch-deployment-version-state' + +describe('deployment query helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('invalidates the deployment info, state, and versions queries', async () => { + const queryClient = { + invalidateQueries: vi.fn().mockResolvedValue(undefined), + } + + await invalidateDeploymentQueries(queryClient as any, 'wf-1') + + expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(1, { + queryKey: ['deployments', 'info', 'wf-1'], + }) + expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(2, { + queryKey: ['deployments', 'deployedState', 'wf-1'], + }) + expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(3, { + queryKey: ['deployments', 'versions', 'wf-1'], + }) + }) + + it('fetches deployment version state through the shared helper', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + deployedState: { blocks: {}, edges: [], loops: {}, parallels: {}, lastSaved: 1 }, + }), + }) as typeof fetch + + await expect(fetchDeploymentVersionState('wf-1', 3)).resolves.toEqual({ + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + lastSaved: 1, + }) + + expect(global.fetch).toHaveBeenCalledWith('/api/workflows/wf-1/deployments/3', { + signal: undefined, + }) + }) +}) diff --git a/apps/sim/hooks/queries/deployments.ts b/apps/sim/hooks/queries/deployments.ts index 0896e4d599b..62f5e970d3b 100644 --- a/apps/sim/hooks/queries/deployments.ts +++ b/apps/sim/hooks/queries/deployments.ts @@ -3,9 +3,10 @@ import { createLogger } from '@sim/logger' import type { QueryClient } from '@tanstack/react-query' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils' +import { fetchDeploymentVersionState } from '@/hooks/queries/utils/fetch-deployment-version-state' +import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' -import { fetchDeploymentVersionState, workflowKeys } from './workflows' const logger = createLogger('DeploymentQueries') diff --git a/apps/sim/hooks/queries/folders.test.ts b/apps/sim/hooks/queries/folders.test.ts index 9af6eaa5bc0..355b3d899e3 100644 --- a/apps/sim/hooks/queries/folders.test.ts +++ b/apps/sim/hooks/queries/folders.test.ts @@ -1,33 +1,32 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockLogger, queryClient, useFolderStoreMock, useWorkflowRegistryMock } = vi.hoisted(() => ({ +const { mockLogger, mockGetFolderMap, mockGetWorkflows, queryClient } = vi.hoisted(() => ({ mockLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }, + mockGetFolderMap: vi.fn(() => ({})), + mockGetWorkflows: vi.fn(() => []), queryClient: { cancelQueries: vi.fn().mockResolvedValue(undefined), invalidateQueries: vi.fn().mockResolvedValue(undefined), + getQueryData: vi.fn(), + setQueryData: vi.fn(), }, - useFolderStoreMock: Object.assign(vi.fn(), { - getState: vi.fn(), - setState: vi.fn(), - }), - useWorkflowRegistryMock: Object.assign(vi.fn(), { - getState: vi.fn(), - setState: vi.fn(), - }), })) -let folderState: { - folders: Record -} +let folderMapState: Record +let folderListState: any[] -let workflowRegistryState: { - workflows: Record -} +let workflowList: Array<{ + id: string + name: string + workspaceId: string + folderId: string + sortOrder: number +}> vi.mock('@sim/logger', () => ({ createLogger: vi.fn(() => mockLogger), @@ -40,15 +39,15 @@ vi.mock('@tanstack/react-query', () => ({ useMutation: vi.fn((options) => options), })) -vi.mock('@/stores/folders/store', () => ({ - useFolderStore: useFolderStoreMock, +vi.mock('@/hooks/queries/utils/workflow-cache', () => ({ + getWorkflows: mockGetWorkflows, })) -vi.mock('@/stores/workflows/registry/store', () => ({ - useWorkflowRegistry: useWorkflowRegistryMock, +vi.mock('@/hooks/queries/utils/folder-cache', () => ({ + getFolderMap: mockGetFolderMap, })) -vi.mock('@/hooks/queries/workflows', () => ({ +vi.mock('@/hooks/queries/utils/workflow-keys', () => ({ workflowKeys: { list: (workspaceId: string | undefined) => ['workflows', 'list', workspaceId ?? ''], }, @@ -57,7 +56,7 @@ vi.mock('@/hooks/queries/workflows', () => ({ import { useCreateFolder, useDuplicateFolderMutation } from '@/hooks/queries/folders' function getOptimisticFolderByName(name: string) { - return Object.values(folderState.folders).find((folder: any) => folder.name === name) as + return Object.values(folderMapState).find((folder: any) => folder.name === name) as | { sortOrder: number } | undefined } @@ -65,67 +64,60 @@ function getOptimisticFolderByName(name: string) { describe('folder optimistic top insertion ordering', () => { beforeEach(() => { vi.clearAllMocks() - useFolderStoreMock.getState.mockImplementation(() => folderState) - useFolderStoreMock.setState.mockImplementation((updater: any) => { - if (typeof updater === 'function') { - const next = updater(folderState) - if (next) { - folderState = { ...folderState, ...next } - } - return - } - - folderState = { ...folderState, ...updater } + queryClient.getQueryData.mockImplementation(() => folderListState) + queryClient.setQueryData.mockImplementation((_key: unknown, updater: any) => { + folderListState = typeof updater === 'function' ? updater(folderListState) : updater + folderMapState = Object.fromEntries( + (folderListState ?? []).map((folder: any) => [folder.id, folder]) + ) }) - useWorkflowRegistryMock.getState.mockImplementation(() => workflowRegistryState) - - folderState = { - folders: { - 'folder-parent-match': { - id: 'folder-parent-match', - name: 'Existing sibling folder', - userId: 'user-1', - workspaceId: 'ws-1', - parentId: 'parent-1', - color: '#808080', - isExpanded: false, - sortOrder: 5, - createdAt: new Date(), - updatedAt: new Date(), - }, - 'folder-other-parent': { - id: 'folder-other-parent', - name: 'Other parent folder', - userId: 'user-1', - workspaceId: 'ws-1', - parentId: 'parent-2', - color: '#808080', - isExpanded: false, - sortOrder: -100, - createdAt: new Date(), - updatedAt: new Date(), - }, + mockGetFolderMap.mockImplementation(() => folderMapState) + mockGetWorkflows.mockImplementation(() => workflowList) + + folderListState = [ + { + id: 'folder-parent-match', + name: 'Existing sibling folder', + userId: 'user-1', + workspaceId: 'ws-1', + parentId: 'parent-1', + color: '#808080', + isExpanded: false, + sortOrder: 5, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'folder-other-parent', + name: 'Other parent folder', + userId: 'user-1', + workspaceId: 'ws-1', + parentId: 'parent-2', + color: '#808080', + isExpanded: false, + sortOrder: -100, + createdAt: new Date(), + updatedAt: new Date(), + }, + ] + folderMapState = Object.fromEntries(folderListState.map((folder) => [folder.id, folder])) + + workflowList = [ + { + id: 'workflow-parent-match', + name: 'Existing sibling workflow', + workspaceId: 'ws-1', + folderId: 'parent-1', + sortOrder: 2, }, - } - - workflowRegistryState = { - workflows: { - 'workflow-parent-match': { - id: 'workflow-parent-match', - name: 'Existing sibling workflow', - workspaceId: 'ws-1', - folderId: 'parent-1', - sortOrder: 2, - }, - 'workflow-other-parent': { - id: 'workflow-other-parent', - name: 'Other parent workflow', - workspaceId: 'ws-1', - folderId: 'parent-2', - sortOrder: -50, - }, + { + id: 'workflow-other-parent', + name: 'Other parent workflow', + workspaceId: 'ws-1', + folderId: 'parent-2', + sortOrder: -50, }, - } + ] }) it('creates folders at top of mixed non-root siblings', async () => { diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts index 5b22872feec..4ee686ee1ea 100644 --- a/apps/sim/hooks/queries/folders.ts +++ b/apps/sim/hooks/queries/folders.ts @@ -1,24 +1,18 @@ -import { useEffect } from 'react' import { createLogger } from '@sim/logger' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { getFolderMap } from '@/hooks/queries/utils/folder-cache' +import { folderKeys } from '@/hooks/queries/utils/folder-keys' +import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' import { createOptimisticMutationHandlers, generateTempId, } from '@/hooks/queries/utils/optimistic-mutation' import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' -import { workflowKeys } from '@/hooks/queries/workflows' -import { useFolderStore } from '@/stores/folders/store' +import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' import type { WorkflowFolder } from '@/stores/folders/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('FolderQueries') -export const folderKeys = { - all: ['folders'] as const, - lists: () => [...folderKeys.all, 'list'] as const, - list: (workspaceId: string | undefined) => [...folderKeys.lists(), workspaceId ?? ''] as const, -} - function mapFolder(folder: any): WorkflowFolder { return { id: folder.id, @@ -46,23 +40,24 @@ async function fetchFolders(workspaceId: string, signal?: AbortSignal): Promise< } export function useFolders(workspaceId?: string) { - const setFolders = useFolderStore((state) => state.setFolders) - - const query = useQuery({ + return useQuery({ queryKey: folderKeys.list(workspaceId), queryFn: ({ signal }) => fetchFolders(workspaceId as string, signal), enabled: Boolean(workspaceId), placeholderData: keepPreviousData, staleTime: 60 * 1000, }) +} - useEffect(() => { - if (query.data) { - setFolders(query.data) - } - }, [query.data, setFolders]) - - return query +export function useFolderMap(workspaceId?: string) { + return useQuery({ + queryKey: folderKeys.list(workspaceId), + queryFn: ({ signal }) => fetchFolders(workspaceId as string, signal), + enabled: Boolean(workspaceId), + placeholderData: keepPreviousData, + staleTime: 60 * 1000, + select: (folders) => Object.fromEntries(folders.map((folder) => [folder.id, folder])), + }) } interface CreateFolderVariables { @@ -110,54 +105,25 @@ function createFolderMutationHandlers(queryClient, { name, getQueryKey: (variables) => folderKeys.list(variables.workspaceId), - getSnapshot: () => ({ ...useFolderStore.getState().folders }), + getSnapshot: (variables) => ({ ...getFolderMap(variables.workspaceId) }), generateTempId: customGenerateTempId ?? (() => generateTempId('temp-folder')), createOptimisticItem: (variables, tempId) => { - const previousFolders = useFolderStore.getState().folders + const previousFolders = getFolderMap(variables.workspaceId) return createOptimisticFolder(variables, tempId, previousFolders) }, applyOptimisticUpdate: (tempId, item) => { - useFolderStore.setState((state) => ({ - folders: { ...state.folders, [tempId]: item }, - })) + queryClient.setQueryData(folderKeys.list(item.workspaceId), (old) => [ + ...(old ?? []), + item, + ]) }, replaceOptimisticEntry: (tempId, data) => { - useFolderStore.setState((state) => { - const { [tempId]: _, ...remainingFolders } = state.folders - - const update: Record = { - folders: { - ...remainingFolders, - [data.id]: data, - }, - } - - if (tempId !== data.id) { - const expandedFolders = new Set(state.expandedFolders) - const selectedFolders = new Set(state.selectedFolders) - - if (expandedFolders.has(tempId)) { - expandedFolders.delete(tempId) - expandedFolders.add(data.id) - } - if (selectedFolders.has(tempId)) { - selectedFolders.delete(tempId) - selectedFolders.add(data.id) - } - - update.expandedFolders = expandedFolders - update.selectedFolders = selectedFolders - - if (state.lastSelectedFolderId === tempId) { - update.lastSelectedFolderId = data.id - } - } - - return update - }) + queryClient.setQueryData(folderKeys.list(data.workspaceId), (old) => + (old ?? []).map((folder) => (folder.id === tempId ? data : folder)) + ) }, - rollback: (snapshot) => { - useFolderStore.setState({ folders: snapshot }) + rollback: (snapshot, variables) => { + queryClient.setQueryData(folderKeys.list(variables.workspaceId), Object.values(snapshot)) }, }) } @@ -169,7 +135,9 @@ export function useCreateFolder() { queryClient, 'CreateFolder', (variables, tempId, previousFolders) => { - const currentWorkflows = useWorkflowRegistry.getState().workflows + const currentWorkflows = Object.fromEntries( + getWorkflows(variables.workspaceId).map((w) => [w.id, w]) + ) return { id: tempId, @@ -233,7 +201,7 @@ export function useUpdateFolder() { const { folder } = await response.json() return mapFolder(folder) }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) }) }, }) @@ -253,9 +221,9 @@ export function useDeleteFolderMutation() { return response.json() }, - onSuccess: async (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) }) - queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }) + return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived']) }, }) } @@ -267,7 +235,9 @@ export function useDuplicateFolderMutation() { queryClient, 'DuplicateFolder', (variables, tempId, previousFolders) => { - const currentWorkflows = useWorkflowRegistry.getState().workflows + const currentWorkflows = Object.fromEntries( + getWorkflows(variables.workspaceId).map((w) => [w.id, w]) + ) const sourceFolder = previousFolders[variables.id] const targetParentId = variables.parentId ?? sourceFolder?.parentId ?? null @@ -324,7 +294,7 @@ export function useDuplicateFolderMutation() { ...handlers, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) }) - queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }) + return invalidateWorkflowLists(queryClient, variables.workspaceId) }, }) } @@ -357,28 +327,28 @@ export function useReorderFolders() { onMutate: async (variables) => { await queryClient.cancelQueries({ queryKey: folderKeys.list(variables.workspaceId) }) - const snapshot = { ...useFolderStore.getState().folders } - - useFolderStore.setState((state) => { - const updated = { ...state.folders } - for (const update of variables.updates) { - if (updated[update.id]) { - updated[update.id] = { - ...updated[update.id], - sortOrder: update.sortOrder, - parentId: - update.parentId !== undefined ? update.parentId : updated[update.id].parentId, - } + const snapshot = queryClient.getQueryData( + folderKeys.list(variables.workspaceId) + ) + + const updatesById = new Map(variables.updates.map((update) => [update.id, update])) + queryClient.setQueryData(folderKeys.list(variables.workspaceId), (old) => + (old ?? []).map((folder) => { + const update = updatesById.get(folder.id) + if (!update) return folder + return { + ...folder, + sortOrder: update.sortOrder, + parentId: update.parentId !== undefined ? update.parentId : folder.parentId, } - } - return { folders: updated } - }) + }) + ) return { snapshot } }, - onError: (_error, _variables, context) => { + onError: (_error, variables, context) => { if (context?.snapshot) { - useFolderStore.setState({ folders: context.snapshot }) + queryClient.setQueryData(folderKeys.list(variables.workspaceId), context.snapshot) } }, onSettled: (_data, _error, variables) => { diff --git a/apps/sim/hooks/queries/general-settings.ts b/apps/sim/hooks/queries/general-settings.ts index 1d0644499af..0fb05164dd1 100644 --- a/apps/sim/hooks/queries/general-settings.ts +++ b/apps/sim/hooks/queries/general-settings.ts @@ -83,8 +83,8 @@ export function useGeneralSettings() { export function prefetchGeneralSettings(queryClient: QueryClient) { queryClient.prefetchQuery({ queryKey: generalSettingsKeys.settings(), - queryFn: async () => { - const settings = await fetchGeneralSettings() + queryFn: async ({ signal }) => { + const settings = await fetchGeneralSettings(signal) syncThemeToNextThemes(settings.theme) return settings }, diff --git a/apps/sim/hooks/queries/logs.ts b/apps/sim/hooks/queries/logs.ts index 3bed38cdf19..0e684c3dc85 100644 --- a/apps/sim/hooks/queries/logs.ts +++ b/apps/sim/hooks/queries/logs.ts @@ -176,7 +176,7 @@ export function useLogDetail(logId: string | undefined, options?: UseLogDetailOp export function prefetchLogDetail(queryClient: QueryClient, logId: string) { queryClient.prefetchQuery({ queryKey: logKeys.detail(logId), - queryFn: () => fetchLogDetail(logId), + queryFn: ({ signal }) => fetchLogDetail(logId, signal), staleTime: 30 * 1000, }) } diff --git a/apps/sim/hooks/queries/subscription.ts b/apps/sim/hooks/queries/subscription.ts index c1149bf6d8e..c30fbff6c3b 100644 --- a/apps/sim/hooks/queries/subscription.ts +++ b/apps/sim/hooks/queries/subscription.ts @@ -142,7 +142,7 @@ export function useSubscriptionData(options: UseSubscriptionDataOptions = {}) { export function prefetchSubscriptionData(queryClient: QueryClient) { queryClient.prefetchQuery({ queryKey: subscriptionKeys.user(false), - queryFn: () => fetchSubscriptionData(false), + queryFn: ({ signal }) => fetchSubscriptionData(false, signal), staleTime: 30 * 1000, }) } diff --git a/apps/sim/hooks/queries/utils/custom-tool-cache.test.ts b/apps/sim/hooks/queries/utils/custom-tool-cache.test.ts new file mode 100644 index 00000000000..c4b7fd54021 --- /dev/null +++ b/apps/sim/hooks/queries/utils/custom-tool-cache.test.ts @@ -0,0 +1,39 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { getQueryDataMock } = vi.hoisted(() => ({ + getQueryDataMock: vi.fn(), +})) + +vi.mock('@/app/_shell/providers/get-query-client', () => ({ + getQueryClient: vi.fn(() => ({ + getQueryData: getQueryDataMock, + })), +})) + +import { getCustomTool, getCustomTools } from '@/hooks/queries/utils/custom-tool-cache' + +describe('custom tool cache helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('reads workspace-scoped custom tools from the cache', () => { + const tools = [{ id: 'tool-1', title: 'Weather', schema: {}, code: '', workspaceId: 'ws-1' }] + getQueryDataMock.mockReturnValue(tools) + + expect(getCustomTools('ws-1')).toBe(tools) + expect(getQueryDataMock).toHaveBeenCalledWith(['customTools', 'list', 'ws-1']) + }) + + it('resolves custom tools by id or title', () => { + getQueryDataMock.mockReturnValue([ + { id: 'tool-1', title: 'Weather', schema: {}, code: '', workspaceId: 'ws-1' }, + ]) + + expect(getCustomTool('tool-1', 'ws-1')?.title).toBe('Weather') + expect(getCustomTool('Weather', 'ws-1')?.id).toBe('tool-1') + }) +}) diff --git a/apps/sim/hooks/queries/utils/custom-tool-cache.ts b/apps/sim/hooks/queries/utils/custom-tool-cache.ts new file mode 100644 index 00000000000..407911ddb37 --- /dev/null +++ b/apps/sim/hooks/queries/utils/custom-tool-cache.ts @@ -0,0 +1,23 @@ +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import type { CustomToolDefinition } from '@/hooks/queries/custom-tools' +import { customToolsKeys } from '@/hooks/queries/utils/custom-tool-keys' + +/** + * Reads custom tools for a workspace directly from the React Query cache. + */ +export function getCustomTools(workspaceId: string): CustomToolDefinition[] { + return ( + getQueryClient().getQueryData(customToolsKeys.list(workspaceId)) ?? [] + ) +} + +/** + * Resolves a custom tool from the cache by id or title. + */ +export function getCustomTool( + identifier: string, + workspaceId: string +): CustomToolDefinition | undefined { + const tools = getCustomTools(workspaceId) + return tools.find((tool) => tool.id === identifier || tool.title === identifier) +} diff --git a/apps/sim/hooks/queries/utils/custom-tool-keys.ts b/apps/sim/hooks/queries/utils/custom-tool-keys.ts new file mode 100644 index 00000000000..1bb74bd8da5 --- /dev/null +++ b/apps/sim/hooks/queries/utils/custom-tool-keys.ts @@ -0,0 +1,6 @@ +export const customToolsKeys = { + all: ['customTools'] as const, + lists: () => [...customToolsKeys.all, 'list'] as const, + list: (workspaceId: string) => [...customToolsKeys.lists(), workspaceId] as const, + detail: (toolId: string) => [...customToolsKeys.all, 'detail', toolId] as const, +} diff --git a/apps/sim/hooks/queries/utils/fetch-deployment-version-state.ts b/apps/sim/hooks/queries/utils/fetch-deployment-version-state.ts new file mode 100644 index 00000000000..ded2a7a10cf --- /dev/null +++ b/apps/sim/hooks/queries/utils/fetch-deployment-version-state.ts @@ -0,0 +1,27 @@ +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +interface DeploymentVersionStateResponse { + deployedState: WorkflowState +} + +/** + * Fetches the deployed state for a specific deployment version. + */ +export async function fetchDeploymentVersionState( + workflowId: string, + version: number, + signal?: AbortSignal +): Promise { + const response = await fetch(`/api/workflows/${workflowId}/deployments/${version}`, { signal }) + + if (!response.ok) { + throw new Error(`Failed to fetch deployment version: ${response.statusText}`) + } + + const data: DeploymentVersionStateResponse = await response.json() + if (!data.deployedState) { + throw new Error('No deployed state returned') + } + + return data.deployedState +} diff --git a/apps/sim/hooks/queries/utils/folder-cache.ts b/apps/sim/hooks/queries/utils/folder-cache.ts new file mode 100644 index 00000000000..158f558bb60 --- /dev/null +++ b/apps/sim/hooks/queries/utils/folder-cache.ts @@ -0,0 +1,15 @@ +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import { folderKeys } from '@/hooks/queries/utils/folder-keys' +import type { WorkflowFolder } from '@/stores/folders/types' + +const EMPTY_FOLDERS: WorkflowFolder[] = [] + +export function getFolders(workspaceId: string): WorkflowFolder[] { + return ( + getQueryClient().getQueryData(folderKeys.list(workspaceId)) ?? EMPTY_FOLDERS + ) +} + +export function getFolderMap(workspaceId: string): Record { + return Object.fromEntries(getFolders(workspaceId).map((folder) => [folder.id, folder])) +} diff --git a/apps/sim/hooks/queries/utils/folder-keys.ts b/apps/sim/hooks/queries/utils/folder-keys.ts new file mode 100644 index 00000000000..517d5cd9b94 --- /dev/null +++ b/apps/sim/hooks/queries/utils/folder-keys.ts @@ -0,0 +1,5 @@ +export const folderKeys = { + all: ['folders'] as const, + lists: () => [...folderKeys.all, 'list'] as const, + list: (workspaceId: string | undefined) => [...folderKeys.lists(), workspaceId ?? ''] as const, +} diff --git a/apps/sim/hooks/queries/utils/invalidate-workflow-lists.test.ts b/apps/sim/hooks/queries/utils/invalidate-workflow-lists.test.ts new file mode 100644 index 00000000000..6c5963840ae --- /dev/null +++ b/apps/sim/hooks/queries/utils/invalidate-workflow-lists.test.ts @@ -0,0 +1,25 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' + +describe('invalidateWorkflowLists', () => { + it('invalidates scoped workflow lists and workflow selector caches', async () => { + const queryClient = { + invalidateQueries: vi.fn().mockResolvedValue(undefined), + } + + await invalidateWorkflowLists(queryClient as any, 'ws-1', ['active', 'archived']) + + expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(1, { + queryKey: ['workflows', 'list', 'ws-1', 'active'], + }) + expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(2, { + queryKey: ['workflows', 'list', 'ws-1', 'archived'], + }) + expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(3, { + queryKey: ['selectors', 'sim.workflows', 'ws-1'], + }) + }) +}) diff --git a/apps/sim/hooks/queries/utils/invalidate-workflow-lists.ts b/apps/sim/hooks/queries/utils/invalidate-workflow-lists.ts new file mode 100644 index 00000000000..f04e19aa9b8 --- /dev/null +++ b/apps/sim/hooks/queries/utils/invalidate-workflow-lists.ts @@ -0,0 +1,25 @@ +import type { QueryClient } from '@tanstack/react-query' +import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys' +import { selectorKeys } from '@/hooks/selectors/query-keys' + +export async function invalidateWorkflowSelectors(queryClient: QueryClient, workspaceId: string) { + await queryClient.invalidateQueries({ queryKey: selectorKeys.simWorkflowsPrefix(workspaceId) }) +} + +/** + * Invalidates workflow list consumers for a single workspace. + */ +export async function invalidateWorkflowLists( + queryClient: QueryClient, + workspaceId: string, + scopes: WorkflowQueryScope[] = ['active'] +) { + const uniqueScopes = [...new Set(scopes)] + + await Promise.all([ + ...uniqueScopes.map((scope) => + queryClient.invalidateQueries({ queryKey: workflowKeys.list(workspaceId, scope) }) + ), + invalidateWorkflowSelectors(queryClient, workspaceId), + ]) +} diff --git a/apps/sim/hooks/queries/utils/optimistic-mutation.ts b/apps/sim/hooks/queries/utils/optimistic-mutation.ts index 47482e7ffa9..27f45ff26b6 100644 --- a/apps/sim/hooks/queries/utils/optimistic-mutation.ts +++ b/apps/sim/hooks/queries/utils/optimistic-mutation.ts @@ -6,12 +6,12 @@ const logger = createLogger('OptimisticMutation') export interface OptimisticMutationConfig { name: string getQueryKey: (variables: TVariables) => readonly unknown[] - getSnapshot: () => Record + getSnapshot: (variables: TVariables) => Record generateTempId: (variables: TVariables) => string createOptimisticItem: (variables: TVariables, tempId: string) => TItem applyOptimisticUpdate: (tempId: string, item: TItem) => void replaceOptimisticEntry: (tempId: string, data: TData) => void - rollback: (snapshot: Record) => void + rollback: (snapshot: Record, variables: TVariables) => void onSuccessExtra?: (data: TData, variables: TVariables) => void } @@ -40,7 +40,7 @@ export function createOptimisticMutationHandlers( onMutate: async (variables: TVariables): Promise> => { const queryKey = getQueryKey(variables) await queryClient.cancelQueries({ queryKey }) - const previousState = getSnapshot() + const previousState = getSnapshot(variables) const tempId = generateTempId(variables) const optimisticItem = createOptimisticItem(variables, tempId) applyOptimisticUpdate(tempId, optimisticItem) @@ -61,7 +61,7 @@ export function createOptimisticMutationHandlers( ) => { logger.error(`[${name}] Failed:`, error) if (context?.previousState) { - rollback(context.previousState) + rollback(context.previousState, _variables) logger.info(`[${name}] Rolled back to previous state`) } }, diff --git a/apps/sim/hooks/queries/utils/workflow-cache.test.ts b/apps/sim/hooks/queries/utils/workflow-cache.test.ts new file mode 100644 index 00000000000..a3ccf8da645 --- /dev/null +++ b/apps/sim/hooks/queries/utils/workflow-cache.test.ts @@ -0,0 +1,46 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { getQueryDataMock } = vi.hoisted(() => ({ + getQueryDataMock: vi.fn(), +})) + +vi.mock('@/app/_shell/providers/get-query-client', () => ({ + getQueryClient: vi.fn(() => ({ + getQueryData: getQueryDataMock, + })), +})) + +import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache' + +describe('getWorkflows', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('reads the active workflow list from the cache', () => { + const workflows = [{ id: 'wf-1', name: 'Workflow 1' }] + getQueryDataMock.mockReturnValue(workflows) + + expect(getWorkflows('ws-1')).toBe(workflows) + expect(getQueryDataMock).toHaveBeenCalledWith(['workflows', 'list', 'ws-1', 'active']) + }) + + it('supports alternate workflow scopes', () => { + getQueryDataMock.mockReturnValue([]) + + getWorkflows('ws-2', 'archived') + + expect(getQueryDataMock).toHaveBeenCalledWith(['workflows', 'list', 'ws-2', 'archived']) + }) + + it('reads a single workflow by id from the cache', () => { + const workflows = [{ id: 'wf-1', name: 'Workflow 1' }] + getQueryDataMock.mockReturnValue(workflows) + + expect(getWorkflowById('ws-1', 'wf-1')).toEqual(workflows[0]) + expect(getWorkflowById('ws-1', 'missing')).toBeUndefined() + }) +}) diff --git a/apps/sim/hooks/queries/utils/workflow-cache.ts b/apps/sim/hooks/queries/utils/workflow-cache.ts new file mode 100644 index 00000000000..c810a236f87 --- /dev/null +++ b/apps/sim/hooks/queries/utils/workflow-cache.ts @@ -0,0 +1,29 @@ +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys' +import type { WorkflowMetadata } from '@/stores/workflows/registry/types' + +const EMPTY_WORKFLOWS: WorkflowMetadata[] = [] + +/** + * Reads workflow metadata for a workspace directly from the React Query cache. + */ +export function getWorkflows( + workspaceId: string, + scope: WorkflowQueryScope = 'active' +): WorkflowMetadata[] { + return ( + getQueryClient().getQueryData(workflowKeys.list(workspaceId, scope)) ?? + EMPTY_WORKFLOWS + ) +} + +/** + * Reads a single workflow by id from the React Query cache. + */ +export function getWorkflowById( + workspaceId: string, + workflowId: string, + scope: WorkflowQueryScope = 'active' +): WorkflowMetadata | undefined { + return getWorkflows(workspaceId, scope).find((workflow) => workflow.id === workflowId) +} diff --git a/apps/sim/hooks/queries/utils/workflow-keys.ts b/apps/sim/hooks/queries/utils/workflow-keys.ts new file mode 100644 index 00000000000..8512e02a42b --- /dev/null +++ b/apps/sim/hooks/queries/utils/workflow-keys.ts @@ -0,0 +1,13 @@ +export type WorkflowQueryScope = 'active' | 'archived' | 'all' + +export const workflowKeys = { + all: ['workflows'] as const, + lists: () => [...workflowKeys.all, 'list'] as const, + list: (workspaceId: string | undefined, scope: WorkflowQueryScope = 'active') => + [...workflowKeys.lists(), workspaceId ?? '', scope] as const, + deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const, + deploymentVersion: (workflowId: string | undefined, version: number | undefined) => + [...workflowKeys.deploymentVersions(), workflowId ?? '', version ?? 0] as const, + state: (workflowId: string | undefined) => + [...workflowKeys.all, 'state', workflowId ?? ''] as const, +} diff --git a/apps/sim/hooks/queries/utils/workflow-list-query.ts b/apps/sim/hooks/queries/utils/workflow-list-query.ts new file mode 100644 index 00000000000..f88627a33cb --- /dev/null +++ b/apps/sim/hooks/queries/utils/workflow-list-query.ts @@ -0,0 +1,61 @@ +import type { QueryFunctionContext } from '@tanstack/react-query' +import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys' +import type { WorkflowMetadata } from '@/stores/workflows/registry/types' + +interface WorkflowApiRow { + id: string + name: string + description?: string | null + color: string + workspaceId: string + folderId?: string | null + sortOrder?: number | null + createdAt: string + updatedAt?: string | null + archivedAt?: string | null +} + +export const WORKFLOW_LIST_STALE_TIME = 60 * 1000 + +export function mapWorkflow(workflow: WorkflowApiRow): WorkflowMetadata { + return { + id: workflow.id, + name: workflow.name, + description: workflow.description ?? undefined, + color: workflow.color, + workspaceId: workflow.workspaceId, + folderId: workflow.folderId ?? undefined, + sortOrder: workflow.sortOrder ?? 0, + createdAt: new Date(workflow.createdAt), + lastModified: new Date(workflow.updatedAt || workflow.createdAt), + archivedAt: workflow.archivedAt ? new Date(workflow.archivedAt) : null, + } +} + +export async function fetchWorkflows( + workspaceId: string, + scope: WorkflowQueryScope = 'active', + signal?: AbortSignal +): Promise { + const response = await fetch(`/api/workflows?workspaceId=${workspaceId}&scope=${scope}`, { + signal, + }) + + if (!response.ok) { + throw new Error('Failed to fetch workflows') + } + + const { data }: { data: WorkflowApiRow[] } = await response.json() + return data.map(mapWorkflow) +} + +export function getWorkflowListQueryOptions( + workspaceId: string, + scope: WorkflowQueryScope = 'active' +) { + return { + queryKey: workflowKeys.list(workspaceId, scope), + queryFn: ({ signal }: QueryFunctionContext) => fetchWorkflows(workspaceId, scope, signal), + staleTime: WORKFLOW_LIST_STALE_TIME, + } +} diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index c2ae3a40363..f850eae3f40 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -1,14 +1,29 @@ -import { useEffect } from 'react' +/** + * React Query hooks for managing workflow metadata and mutations. + */ + import { createLogger } from '@sim/logger' -import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + keepPreviousData, + skipToken, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' import { getNextWorkflowColor } from '@/lib/workflows/colors' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { deploymentKeys } from '@/hooks/queries/deployments' -import { - createOptimisticMutationHandlers, - generateTempId, -} from '@/hooks/queries/utils/optimistic-mutation' +import { fetchDeploymentVersionState } from '@/hooks/queries/utils/fetch-deployment-version-state' +import { getFolderMap } from '@/hooks/queries/utils/folder-cache' +import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' +import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' +import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys' +import { + getWorkflowListQueryOptions, + mapWorkflow, + WORKFLOW_LIST_STALE_TIME, +} from '@/hooks/queries/utils/workflow-list-query' import { useFolderStore } from '@/stores/folders/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -18,24 +33,8 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowQueries') -type WorkflowQueryScope = 'active' | 'archived' | 'all' - -export const workflowKeys = { - all: ['workflows'] as const, - lists: () => [...workflowKeys.all, 'list'] as const, - list: (workspaceId: string | undefined, scope: WorkflowQueryScope = 'active') => - [...workflowKeys.lists(), workspaceId ?? '', scope] as const, - deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const, - deploymentVersion: (workflowId: string | undefined, version: number | undefined) => - [...workflowKeys.deploymentVersions(), workflowId ?? '', version ?? 0] as const, - state: (workflowId: string | undefined) => - [...workflowKeys.all, 'state', workflowId ?? ''] as const, -} +export { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys' -/** - * Fetches workflow state from the API. - * Used as the base query for both state preview and input fields extraction. - */ async function fetchWorkflowState( workflowId: string, signal?: AbortSignal @@ -47,113 +46,44 @@ async function fetchWorkflowState( } /** - * Hook to fetch workflow state. + * Fetches the full workflow state for a single workflow. * Used by workflow blocks to show a preview of the child workflow * and as a base query for input fields extraction. - * - * @param workflowId - The workflow ID to fetch state for - * @returns Query result with workflow state */ export function useWorkflowState(workflowId: string | undefined) { return useQuery({ queryKey: workflowKeys.state(workflowId), - queryFn: ({ signal }) => fetchWorkflowState(workflowId!, signal), - enabled: Boolean(workflowId), - staleTime: 30 * 1000, // 30 seconds - placeholderData: keepPreviousData, + queryFn: workflowId ? ({ signal }) => fetchWorkflowState(workflowId, signal) : skipToken, + staleTime: 30 * 1000, }) } -function mapWorkflow(workflow: any): WorkflowMetadata { - return { - id: workflow.id, - name: workflow.name, - description: workflow.description, - color: workflow.color, - workspaceId: workflow.workspaceId, - folderId: workflow.folderId, - sortOrder: workflow.sortOrder ?? 0, - createdAt: new Date(workflow.createdAt), - lastModified: new Date(workflow.updatedAt || workflow.createdAt), - archivedAt: workflow.archivedAt ? new Date(workflow.archivedAt) : null, - } -} +export function useWorkflows(workspaceId?: string, options?: { scope?: WorkflowQueryScope }) { + const { scope = 'active' } = options || {} -async function fetchWorkflows( - workspaceId: string, - scope: WorkflowQueryScope = 'active', - signal?: AbortSignal -): Promise { - const response = await fetch(`/api/workflows?workspaceId=${workspaceId}&scope=${scope}`, { - signal, + return useQuery({ + queryKey: workflowKeys.list(workspaceId, scope), + queryFn: workspaceId ? getWorkflowListQueryOptions(workspaceId, scope).queryFn : skipToken, + placeholderData: keepPreviousData, + staleTime: WORKFLOW_LIST_STALE_TIME, }) - - if (!response.ok) { - throw new Error('Failed to fetch workflows') - } - - const { data }: { data: any[] } = await response.json() - return data.map(mapWorkflow) } -export function useWorkflows( - workspaceId?: string, - options?: { syncRegistry?: boolean; scope?: WorkflowQueryScope } -) { - const { syncRegistry = true, scope = 'active' } = options || {} - const beginMetadataLoad = useWorkflowRegistry((state) => state.beginMetadataLoad) - const completeMetadataLoad = useWorkflowRegistry((state) => state.completeMetadataLoad) - const failMetadataLoad = useWorkflowRegistry((state) => state.failMetadataLoad) +/** + * Returns workflows as a `Record` keyed by ID. + * Uses the `select` option so the transformation runs inside React Query + * with structural sharing — components only re-render when the record changes. + */ +export function useWorkflowMap(workspaceId?: string, options?: { scope?: WorkflowQueryScope }) { + const { scope = 'active' } = options || {} - const query = useQuery({ + return useQuery({ queryKey: workflowKeys.list(workspaceId, scope), - queryFn: ({ signal }) => fetchWorkflows(workspaceId as string, scope, signal), - enabled: Boolean(workspaceId), + queryFn: workspaceId ? getWorkflowListQueryOptions(workspaceId, scope).queryFn : skipToken, placeholderData: keepPreviousData, - staleTime: 60 * 1000, + staleTime: WORKFLOW_LIST_STALE_TIME, + select: (data) => Object.fromEntries(data.map((w) => [w.id, w])), }) - - useEffect(() => { - if ( - syncRegistry && - scope === 'active' && - workspaceId && - (query.status === 'pending' || query.isPlaceholderData) - ) { - beginMetadataLoad(workspaceId) - } - }, [syncRegistry, scope, workspaceId, query.status, query.isPlaceholderData, beginMetadataLoad]) - - useEffect(() => { - if ( - syncRegistry && - scope === 'active' && - workspaceId && - query.status === 'success' && - query.data && - !query.isPlaceholderData - ) { - completeMetadataLoad(workspaceId, query.data) - } - }, [ - syncRegistry, - scope, - workspaceId, - query.status, - query.data, - query.isPlaceholderData, - completeMetadataLoad, - ]) - - useEffect(() => { - if (syncRegistry && scope === 'active' && workspaceId && query.status === 'error') { - const message = - query.error instanceof Error ? query.error.message : 'Failed to fetch workflows' - failMetadataLoad(workspaceId, message) - } - }, [syncRegistry, scope, workspaceId, query.status, query.error, failMetadataLoad]) - - return query } interface CreateWorkflowVariables { @@ -177,128 +107,9 @@ interface CreateWorkflowResult { sortOrder: number } -interface DuplicateWorkflowVariables { - workspaceId: string - sourceId: string - name: string - description?: string - color: string - folderId?: string | null - newId?: string -} - -interface DuplicateWorkflowResult { - id: string - name: string - description?: string - color: string - workspaceId: string - folderId?: string | null - sortOrder: number - blocksCount: number - edgesCount: number - subflowsCount: number -} - -/** - * Creates optimistic mutation handlers for workflow operations - */ -function createWorkflowMutationHandlers( - queryClient: ReturnType, - name: string, - createOptimisticWorkflow: (variables: TVariables, tempId: string) => WorkflowMetadata, - customGenerateTempId?: (variables: TVariables) => string -) { - return createOptimisticMutationHandlers< - CreateWorkflowResult | DuplicateWorkflowResult, - TVariables, - WorkflowMetadata - >(queryClient, { - name, - getQueryKey: (variables) => workflowKeys.list(variables.workspaceId, 'active'), - getSnapshot: () => ({ ...useWorkflowRegistry.getState().workflows }), - generateTempId: customGenerateTempId ?? (() => generateTempId('temp-workflow')), - createOptimisticItem: createOptimisticWorkflow, - applyOptimisticUpdate: (tempId, item) => { - useWorkflowRegistry.setState((state) => ({ - workflows: { ...state.workflows, [tempId]: item }, - })) - }, - replaceOptimisticEntry: (tempId, data) => { - useWorkflowRegistry.setState((state) => { - const { [tempId]: _, ...remainingWorkflows } = state.workflows - return { - workflows: { - ...remainingWorkflows, - [data.id]: { - id: data.id, - name: data.name, - lastModified: new Date(), - createdAt: new Date(), - description: data.description, - color: data.color, - workspaceId: data.workspaceId, - folderId: data.folderId, - sortOrder: 'sortOrder' in data ? data.sortOrder : 0, - }, - }, - error: null, - } - }) - - if (tempId !== data.id) { - useFolderStore.setState((state) => { - const selectedWorkflows = new Set(state.selectedWorkflows) - if (selectedWorkflows.has(tempId)) { - selectedWorkflows.delete(tempId) - selectedWorkflows.add(data.id) - } - return { selectedWorkflows } - }) - } - }, - rollback: (snapshot) => { - useWorkflowRegistry.setState({ workflows: snapshot }) - }, - }) -} - export function useCreateWorkflow() { const queryClient = useQueryClient() - const handlers = createWorkflowMutationHandlers( - queryClient, - 'CreateWorkflow', - (variables, tempId) => { - let sortOrder: number - if (variables.sortOrder !== undefined) { - sortOrder = variables.sortOrder - } else { - const currentWorkflows = useWorkflowRegistry.getState().workflows - const currentFolders = useFolderStore.getState().folders - sortOrder = getTopInsertionSortOrder( - currentWorkflows, - currentFolders, - variables.workspaceId, - variables.folderId - ) - } - - return { - id: tempId, - name: variables.name || generateCreativeWorkflowName(), - lastModified: new Date(), - createdAt: new Date(), - description: variables.description || 'New workflow', - color: variables.color || getNextWorkflowColor(), - workspaceId: variables.workspaceId, - folderId: variables.folderId || null, - sortOrder, - } - }, - (variables) => variables.id ?? crypto.randomUUID() - ) - return useMutation({ mutationFn: async (variables: CreateWorkflowVariables): Promise => { const { workspaceId, name, description, color, folderId, sortOrder, id, deduplicate } = @@ -343,9 +154,7 @@ export function useCreateWorkflow() { if (!stateResponse.ok) { const text = await stateResponse.text() - logger.error('Failed to persist default Start block:', text) - } else { - logger.info('Successfully persisted default Start block') + logger.error('Failed to persist default workflow state:', text) } return { @@ -358,9 +167,85 @@ export function useCreateWorkflow() { sortOrder: createdWorkflow.sortOrder ?? 0, } }, - ...handlers, + onMutate: async (variables) => { + await queryClient.cancelQueries({ + queryKey: workflowKeys.list(variables.workspaceId, 'active'), + }) + + const snapshot = queryClient.getQueryData( + workflowKeys.list(variables.workspaceId, 'active') + ) + + const tempId = variables.id ?? crypto.randomUUID() + let sortOrder: number + if (variables.sortOrder !== undefined) { + sortOrder = variables.sortOrder + } else { + const currentWorkflows = Object.fromEntries( + getWorkflows(variables.workspaceId).map((w) => [w.id, w]) + ) + sortOrder = getTopInsertionSortOrder( + currentWorkflows, + getFolderMap(variables.workspaceId), + variables.workspaceId, + variables.folderId + ) + } + + const optimistic: WorkflowMetadata = { + id: tempId, + name: variables.name || generateCreativeWorkflowName(), + lastModified: new Date(), + createdAt: new Date(), + description: variables.description || 'New workflow', + color: variables.color || getNextWorkflowColor(), + workspaceId: variables.workspaceId, + folderId: variables.folderId || null, + sortOrder, + } + + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + (old) => [...(old ?? []), optimistic] + ) + logger.info(`[CreateWorkflow] Added optimistic entry: ${tempId}`) + + return { snapshot, tempId } + }, onSuccess: (data, variables, context) => { - handlers.onSuccess(data, variables, context) + if (!context) return + const { tempId } = context + + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + (old) => + (old ?? []).map((w) => + w.id === tempId + ? { + id: data.id, + name: data.name, + lastModified: new Date(), + createdAt: new Date(), + description: data.description, + color: data.color, + workspaceId: data.workspaceId, + folderId: data.folderId, + sortOrder: data.sortOrder, + } + : w + ) + ) + + if (tempId !== data.id) { + useFolderStore.setState((state) => { + const selectedWorkflows = new Set(state.selectedWorkflows) + if (selectedWorkflows.has(tempId)) { + selectedWorkflows.delete(tempId) + selectedWorkflows.add(data.id) + } + return { selectedWorkflows } + }) + } const { subBlockValues } = buildDefaultWorkflowArtifacts() useSubBlockStore.setState((state) => ({ @@ -369,40 +254,49 @@ export function useCreateWorkflow() { [data.id]: subBlockValues, }, })) + + logger.info(`[CreateWorkflow] Success, replaced temp entry ${tempId}`) + }, + onError: (_error, variables, context) => { + if (context?.snapshot) { + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + context.snapshot + ) + logger.info('[CreateWorkflow] Rolled back to previous state') + } + }, + onSettled: (_data, _error, variables) => { + return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived']) }, }) } -export function useDuplicateWorkflowMutation() { - const queryClient = useQueryClient() +interface DuplicateWorkflowVariables { + workspaceId: string + sourceId: string + name: string + description?: string + color: string + folderId?: string | null + newId?: string +} - const handlers = createWorkflowMutationHandlers( - queryClient, - 'DuplicateWorkflow', - (variables, tempId) => { - const currentWorkflows = useWorkflowRegistry.getState().workflows - const currentFolders = useFolderStore.getState().folders - const targetFolderId = variables.folderId ?? null +interface DuplicateWorkflowResult { + id: string + name: string + description?: string + color: string + workspaceId: string + folderId?: string | null + sortOrder: number + blocksCount: number + edgesCount: number + subflowsCount: number +} - return { - id: tempId, - name: variables.name, - lastModified: new Date(), - createdAt: new Date(), - description: variables.description, - color: variables.color, - workspaceId: variables.workspaceId, - folderId: targetFolderId, - sortOrder: getTopInsertionSortOrder( - currentWorkflows, - currentFolders, - variables.workspaceId, - targetFolderId - ), - } - }, - (variables) => variables.newId ?? crypto.randomUUID() - ) +export function useDuplicateWorkflowMutation() { + const queryClient = useQueryClient() return useMutation({ mutationFn: async (variables: DuplicateWorkflowVariables): Promise => { @@ -449,11 +343,81 @@ export function useDuplicateWorkflowMutation() { subflowsCount: duplicatedWorkflow.subflowsCount || 0, } }, - ...handlers, + onMutate: async (variables) => { + await queryClient.cancelQueries({ + queryKey: workflowKeys.list(variables.workspaceId, 'active'), + }) + + const snapshot = queryClient.getQueryData( + workflowKeys.list(variables.workspaceId, 'active') + ) + const tempId = variables.newId ?? crypto.randomUUID() + + const currentWorkflows = Object.fromEntries( + getWorkflows(variables.workspaceId).map((w) => [w.id, w]) + ) + const targetFolderId = variables.folderId ?? null + + const optimistic: WorkflowMetadata = { + id: tempId, + name: variables.name, + lastModified: new Date(), + createdAt: new Date(), + description: variables.description, + color: variables.color, + workspaceId: variables.workspaceId, + folderId: targetFolderId, + sortOrder: getTopInsertionSortOrder( + currentWorkflows, + getFolderMap(variables.workspaceId), + variables.workspaceId, + targetFolderId + ), + } + + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + (old) => [...(old ?? []), optimistic] + ) + logger.info(`[DuplicateWorkflow] Added optimistic entry: ${tempId}`) + + return { snapshot, tempId } + }, onSuccess: (data, variables, context) => { - handlers.onSuccess(data, variables, context) + if (!context) return + const { tempId } = context + + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + (old) => + (old ?? []).map((w) => + w.id === tempId + ? { + id: data.id, + name: data.name, + lastModified: new Date(), + createdAt: new Date(), + description: data.description, + color: data.color, + workspaceId: data.workspaceId, + folderId: data.folderId, + sortOrder: data.sortOrder, + } + : w + ) + ) + + if (tempId !== data.id) { + useFolderStore.setState((state) => { + const selectedWorkflows = new Set(state.selectedWorkflows) + if (selectedWorkflows.has(tempId)) { + selectedWorkflows.delete(tempId) + selectedWorkflows.add(data.id) + } + return { selectedWorkflows } + }) + } - // Copy subblock values from source if it's the active workflow const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId if (variables.sourceId === activeWorkflowId) { const sourceSubblockValues = @@ -465,48 +429,143 @@ export function useDuplicateWorkflowMutation() { }, })) } + + logger.info(`[DuplicateWorkflow] Success, replaced temp entry ${tempId}`) + }, + onError: (_error, variables, context) => { + if (context?.snapshot) { + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + context.snapshot + ) + logger.info('[DuplicateWorkflow] Rolled back to previous state') + } + }, + onSettled: (_data, _error, variables) => { + return invalidateWorkflowLists(queryClient, variables.workspaceId) }, }) } -interface DeploymentVersionStateResponse { - deployedState: WorkflowState +interface UpdateWorkflowVariables { + workspaceId: string + workflowId: string + metadata: Partial } -/** - * Fetches the deployed state for a specific deployment version. - * Exported for reuse in other query hooks. - */ -export async function fetchDeploymentVersionState( - workflowId: string, - version: number, - signal?: AbortSignal -): Promise { - const response = await fetch(`/api/workflows/${workflowId}/deployments/${version}`, { signal }) +export function useUpdateWorkflow() { + const queryClient = useQueryClient() - if (!response.ok) { - throw new Error(`Failed to fetch deployment version: ${response.statusText}`) - } + return useMutation({ + mutationFn: async (variables: UpdateWorkflowVariables) => { + const response = await fetch(`/api/workflows/${variables.workflowId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(variables.metadata), + }) - const data: DeploymentVersionStateResponse = await response.json() - if (!data.deployedState) { - throw new Error('No deployed state returned') - } + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Failed to update workflow') + } + + const { workflow: updatedWorkflow } = await response.json() + return mapWorkflow(updatedWorkflow) + }, + onMutate: async (variables) => { + await queryClient.cancelQueries({ + queryKey: workflowKeys.list(variables.workspaceId, 'active'), + }) - return data.deployedState + const snapshot = queryClient.getQueryData( + workflowKeys.list(variables.workspaceId, 'active') + ) + + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + (old) => + (old ?? []).map((w) => + w.id === variables.workflowId + ? { ...w, ...variables.metadata, lastModified: new Date() } + : w + ) + ) + + return { snapshot } + }, + onError: (_error, variables, context) => { + if (context?.snapshot) { + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + context.snapshot + ) + } + }, + onSettled: (_data, _error, variables) => { + return invalidateWorkflowLists(queryClient, variables.workspaceId) + }, + }) +} + +interface DeleteWorkflowVariables { + workspaceId: string + workflowId: string +} + +export function useDeleteWorkflowMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (variables: DeleteWorkflowVariables) => { + const response = await fetch(`/api/workflows/${variables.workflowId}`, { + method: 'DELETE', + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })) + throw new Error(error.error || 'Failed to delete workflow') + } + + logger.info(`Successfully deleted workflow ${variables.workflowId} from database`) + }, + onMutate: async (variables) => { + await queryClient.cancelQueries({ + queryKey: workflowKeys.list(variables.workspaceId, 'active'), + }) + + const snapshot = queryClient.getQueryData( + workflowKeys.list(variables.workspaceId, 'active') + ) + + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + (old) => (old ?? []).filter((w) => w.id !== variables.workflowId) + ) + + return { snapshot } + }, + onError: (_error, variables, context) => { + if (context?.snapshot) { + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + context.snapshot + ) + } + }, + onSettled: (_data, _error, variables) => { + return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived']) + }, + }) } -/** - * Hook for fetching the workflow state of a specific deployment version. - * Used in the deploy modal to preview historical versions. - */ export function useDeploymentVersionState(workflowId: string | null, version: number | null) { return useQuery({ queryKey: workflowKeys.deploymentVersion(workflowId ?? undefined, version ?? undefined), - queryFn: ({ signal }) => - fetchDeploymentVersionState(workflowId as string, version as number, signal), - enabled: Boolean(workflowId) && version !== null, - staleTime: 5 * 60 * 1000, // 5 minutes - deployment versions don't change + queryFn: + workflowId && version !== null + ? ({ signal }) => fetchDeploymentVersionState(workflowId, version, signal) + : skipToken, + staleTime: 5 * 60 * 1000, }) } @@ -515,9 +574,6 @@ interface RevertToVersionVariables { version: number } -/** - * Mutation hook for reverting (loading) a deployment version into the current workflow. - */ export function useRevertToVersion() { const queryClient = useQueryClient() @@ -531,7 +587,7 @@ export function useRevertToVersion() { throw new Error('Failed to load deployment') } }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: workflowKeys.state(variables.workflowId), }) @@ -574,41 +630,45 @@ export function useReorderWorkflows() { } }, onMutate: async (variables) => { - await queryClient.cancelQueries({ queryKey: workflowKeys.lists() }) - - const snapshot = { ...useWorkflowRegistry.getState().workflows } + await queryClient.cancelQueries({ + queryKey: workflowKeys.list(variables.workspaceId, 'active'), + }) - useWorkflowRegistry.setState((state) => { - const updated = { ...state.workflows } - for (const update of variables.updates) { - if (updated[update.id]) { - updated[update.id] = { - ...updated[update.id], + const snapshot = queryClient.getQueryData( + workflowKeys.list(variables.workspaceId, 'active') + ) + + const updateMap = new Map(variables.updates.map((u) => [u.id, u])) + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + (old) => + (old ?? []).map((w) => { + const update = updateMap.get(w.id) + if (!update) return w + return { + ...w, sortOrder: update.sortOrder, - folderId: - update.folderId !== undefined ? update.folderId : updated[update.id].folderId, + folderId: update.folderId !== undefined ? update.folderId : w.folderId, } - } - } - return { workflows: updated } - }) + }) + ) return { snapshot } }, - onError: (_error, _variables, context) => { + onError: (_error, variables, context) => { if (context?.snapshot) { - useWorkflowRegistry.setState({ workflows: context.snapshot }) + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + context.snapshot + ) } }, onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }) + return invalidateWorkflowLists(queryClient, variables.workspaceId) }, }) } -/** - * Import workflow mutation (superuser debug) - */ interface ImportWorkflowParams { workflowId: string targetWorkspaceId: string @@ -641,8 +701,8 @@ export function useImportWorkflow() { return data }, - onSuccess: (_data, variables) => { - queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }) + onSettled: (_data, _error, variables) => { + return invalidateWorkflowLists(queryClient, variables.targetWorkspaceId) }, }) } @@ -651,7 +711,7 @@ export function useRestoreWorkflow() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async (workflowId: string) => { + mutationFn: async ({ workflowId }: { workflowId: string; workspaceId: string }) => { const res = await fetch(`/api/workflows/${workflowId}/restore`, { method: 'POST' }) if (!res.ok) { const data = await res.json().catch(() => ({})) @@ -659,8 +719,8 @@ export function useRestoreWorkflow() { } return res.json() }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }) + onSettled: (_data, _error, variables) => { + return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived']) }, }) } diff --git a/apps/sim/hooks/selectors/query-keys.ts b/apps/sim/hooks/selectors/query-keys.ts new file mode 100644 index 00000000000..c5fa1afe97a --- /dev/null +++ b/apps/sim/hooks/selectors/query-keys.ts @@ -0,0 +1,7 @@ +export const selectorKeys = { + all: ['selectors'] as const, + simWorkflowsPrefix: (workspaceId: string) => + [...selectorKeys.all, 'sim.workflows', workspaceId] as const, + simWorkflows: (workspaceId: string, excludeWorkflowId?: string) => + [...selectorKeys.simWorkflowsPrefix(workspaceId), excludeWorkflowId ?? 'none'] as const, +} diff --git a/apps/sim/hooks/selectors/registry.test.ts b/apps/sim/hooks/selectors/registry.test.ts new file mode 100644 index 00000000000..fe3d596fac0 --- /dev/null +++ b/apps/sim/hooks/selectors/registry.test.ts @@ -0,0 +1,84 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockEnsureQueryData, mockGetWorkflows } = vi.hoisted(() => ({ + mockEnsureQueryData: vi.fn().mockResolvedValue(undefined), + mockGetWorkflows: vi.fn(), +})) + +vi.mock('@/app/_shell/providers/get-query-client', () => ({ + getQueryClient: vi.fn(() => ({ + ensureQueryData: mockEnsureQueryData, + })), +})) + +vi.mock('@/hooks/queries/utils/workflow-cache', () => ({ + getWorkflows: mockGetWorkflows, + getWorkflowById: vi.fn((workspaceId: string, workflowId: string) => + mockGetWorkflows(workspaceId).find((workflow: { id: string }) => workflow.id === workflowId) + ), +})) + +vi.mock('@/hooks/queries/utils/workflow-list-query', () => ({ + getWorkflowListQueryOptions: vi.fn((workspaceId: string) => ({ + queryKey: ['workflows', 'list', workspaceId, 'active'], + })), +})) + +import { getSelectorDefinition } from '@/hooks/selectors/registry' + +describe('sim.workflows selector', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetWorkflows.mockReturnValue([ + { id: 'wf-1', name: 'Alpha Workflow' }, + { id: 'wf-2', name: 'Bravo Workflow' }, + ]) + }) + + it('requires an explicit workspaceId in selector context', () => { + const definition = getSelectorDefinition('sim.workflows') + + expect(definition.enabled?.({ key: 'sim.workflows', context: {} })).toBe(false) + expect(definition.staleTime).toBe(60_000) + expect( + definition.getQueryKey({ + key: 'sim.workflows', + context: { workspaceId: 'ws-1', excludeWorkflowId: 'wf-2' }, + }) + ).toEqual(['selectors', 'sim.workflows', 'ws-1', 'wf-2']) + }) + + it('reads workflow options from the scoped workflow cache', async () => { + const definition = getSelectorDefinition('sim.workflows') + + const options = await definition.fetchList({ + key: 'sim.workflows', + context: { workspaceId: 'ws-1', excludeWorkflowId: 'wf-2' }, + }) + + expect(mockEnsureQueryData).toHaveBeenCalledWith({ + queryKey: ['workflows', 'list', 'ws-1', 'active'], + }) + expect(mockGetWorkflows).toHaveBeenCalledWith('ws-1') + expect(options).toEqual([{ id: 'wf-1', label: 'Alpha Workflow' }]) + }) + + it('resolves workflow labels by id using the same workspace scope', async () => { + const definition = getSelectorDefinition('sim.workflows') + + const option = await definition.fetchById?.({ + key: 'sim.workflows', + context: { workspaceId: 'ws-1' }, + detailId: 'wf-2', + }) + + expect(mockEnsureQueryData).toHaveBeenCalledWith({ + queryKey: ['workflows', 'list', 'ws-1', 'active'], + }) + expect(mockGetWorkflows).toHaveBeenCalledWith('ws-1') + expect(option).toEqual({ id: 'wf-2', label: 'Bravo Workflow' }) + }) +}) diff --git a/apps/sim/hooks/selectors/registry.ts b/apps/sim/hooks/selectors/registry.ts index fd050f97a6a..be48437b7d7 100644 --- a/apps/sim/hooks/selectors/registry.ts +++ b/apps/sim/hooks/selectors/registry.ts @@ -1,4 +1,8 @@ +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache' +import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query' import { fetchJson, fetchOAuthToken } from '@/hooks/selectors/helpers' +import { selectorKeys } from '@/hooks/selectors/query-keys' import type { SelectorContext, SelectorDefinition, @@ -6,7 +10,6 @@ import type { SelectorOption, SelectorQueryArgs, } from '@/hooks/selectors/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const SELECTOR_STALE = 60 * 1000 @@ -1685,27 +1688,28 @@ const registry: Record = { }, 'sim.workflows': { key: 'sim.workflows', - staleTime: 0, // Always fetch fresh from store - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'sim.workflows', - context.excludeWorkflowId ?? 'none', - ], - enabled: () => true, + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => + context.workspaceId + ? selectorKeys.simWorkflows(context.workspaceId, context.excludeWorkflowId) + : [...selectorKeys.all, 'sim.workflows', 'none', context.excludeWorkflowId ?? 'none'], + enabled: ({ context }) => Boolean(context.workspaceId), fetchList: async ({ context }: SelectorQueryArgs): Promise => { - const { workflows } = useWorkflowRegistry.getState() - return Object.entries(workflows) - .filter(([id]) => id !== context.excludeWorkflowId) - .map(([id, workflow]) => ({ - id, - label: workflow.name || `Workflow ${id.slice(0, 8)}`, + if (!context.workspaceId) return [] + await getQueryClient().ensureQueryData(getWorkflowListQueryOptions(context.workspaceId)) + const workflows = getWorkflows(context.workspaceId) + return workflows + .filter((w) => w.id !== context.excludeWorkflowId) + .map((w) => ({ + id: w.id, + label: w.name || `Workflow ${w.id.slice(0, 8)}`, })) .sort((a, b) => a.label.localeCompare(b.label)) }, - fetchById: async ({ detailId }: SelectorQueryArgs): Promise => { - if (!detailId) return null - const { workflows } = useWorkflowRegistry.getState() - const workflow = workflows[detailId] + fetchById: async ({ context, detailId }: SelectorQueryArgs): Promise => { + if (!detailId || !context.workspaceId) return null + await getQueryClient().ensureQueryData(getWorkflowListQueryOptions(context.workspaceId)) + const workflow = getWorkflowById(context.workspaceId, detailId) if (!workflow) return null return { id: detailId, diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts index 87e1572ef57..2324106a802 100644 --- a/apps/sim/hooks/selectors/types.ts +++ b/apps/sim/hooks/selectors/types.ts @@ -84,6 +84,7 @@ export interface SelectorQueryArgs { context: SelectorContext search?: string detailId?: string + signal?: AbortSignal } export interface SelectorDefinition { diff --git a/apps/sim/hooks/selectors/use-selector-query.ts b/apps/sim/hooks/selectors/use-selector-query.ts index 6486e769773..0323c7f5e7d 100644 --- a/apps/sim/hooks/selectors/use-selector-query.ts +++ b/apps/sim/hooks/selectors/use-selector-query.ts @@ -21,7 +21,7 @@ export function useSelectorOptions(key: SelectorKey, args: SelectorHookArgs) { const isEnabled = args.enabled ?? (definition.enabled ? definition.enabled(queryArgs) : true) return useQuery({ queryKey: definition.getQueryKey(queryArgs), - queryFn: () => definition.fetchList(queryArgs), + queryFn: ({ signal }) => definition.fetchList({ ...queryArgs, signal }), enabled: isEnabled, staleTime: definition.staleTime ?? 30_000, }) @@ -60,7 +60,7 @@ export function useSelectorOptionDetail( const query = useQuery({ queryKey: [...definition.getQueryKey(queryArgs), 'detail', resolvedDetailId ?? 'none'], - queryFn: () => definition.fetchById!(queryArgs), + queryFn: ({ signal }) => definition.fetchById!({ ...queryArgs, signal }), enabled, staleTime: definition.staleTime ?? 300_000, }) diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index f103aa31436..f52b8aae18c 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -30,8 +30,8 @@ interface AllowedIntegrationsResponse { function useAllowedIntegrationsFromEnv() { return useQuery({ queryKey: ['allowedIntegrations', 'env'], - queryFn: async () => { - const response = await fetch('/api/settings/allowed-integrations') + queryFn: async ({ signal }) => { + const response = await fetch('/api/settings/allowed-integrations', { signal }) if (!response.ok) return { allowedIntegrations: null } return response.json() }, diff --git a/apps/sim/lib/copilot/tools/client/tool-display-registry.ts b/apps/sim/lib/copilot/tools/client/tool-display-registry.ts index 617626700d7..9a9cb88ca8a 100644 --- a/apps/sim/lib/copilot/tools/client/tool-display-registry.ts +++ b/apps/sim/lib/copilot/tools/client/tool-display-registry.ts @@ -40,7 +40,8 @@ import { XCircle, Zap, } from 'lucide-react' -import { getCustomTool } from '@/hooks/queries/custom-tools' +import { getCustomTool } from '@/hooks/queries/utils/custom-tool-cache' +import { getWorkflowById } from '@/hooks/queries/utils/workflow-cache' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -137,6 +138,15 @@ function formatDuration(seconds: number): string { return `${hours}h` } +function getScopedWorkspaceId(params: Record): string | undefined { + const paramWorkspaceId = params?.workspaceId + if (typeof paramWorkspaceId === 'string' && paramWorkspaceId.length > 0) { + return paramWorkspaceId + } + + return useWorkflowRegistry.getState().hydration.workspaceId ?? undefined +} + function toUiConfig(metadata?: ToolMetadata): ToolUIConfig | undefined { const legacy = metadata?.uiConfig const subagent = legacy?.subagent @@ -1036,13 +1046,14 @@ const META_manage_custom_tool: ToolMetadata = { }, getDynamicText: (params, state) => { const operation = params?.operation as 'add' | 'edit' | 'delete' | 'list' | undefined + const workspaceId = getScopedWorkspaceId(params) if (!operation) return undefined let toolName = params?.schema?.function?.name - if (!toolName && params?.toolId) { + if (!toolName && params?.toolId && workspaceId) { try { - const tool = getCustomTool(params.toolId) + const tool = getCustomTool(params.toolId, workspaceId) toolName = tool?.schema?.function?.name } catch { // Ignore errors accessing cache @@ -1629,8 +1640,9 @@ const META_run_workflow: ToolMetadata = { }, getDynamicText: (params, state) => { const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId - if (workflowId) { - const workflowName = useWorkflowRegistry.getState().workflows[workflowId]?.name + const workspaceId = getScopedWorkspaceId(params) + if (workflowId && workspaceId) { + const workflowName = getWorkflowById(workspaceId, workflowId)?.name if (workflowName) { switch (state) { case ClientToolCallState.success: diff --git a/apps/sim/lib/core/utils/optimistic-update.ts b/apps/sim/lib/core/utils/optimistic-update.ts deleted file mode 100644 index 4759255e4db..00000000000 --- a/apps/sim/lib/core/utils/optimistic-update.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { createLogger } from '@sim/logger' - -const logger = createLogger('OptimisticUpdate') - -/** - * Options for performing an optimistic update with automatic rollback on error - */ -export interface OptimisticUpdateOptions { - /** - * Function that returns the current state value (for rollback purposes) - */ - getCurrentState: () => T - /** - * Function that performs the optimistic update to the UI state - */ - optimisticUpdate: () => void - /** - * Async function that performs the actual API call - */ - apiCall: () => Promise - /** - * Function that rolls back the state to the original value - * @param originalValue - The value returned by getCurrentState before the update - */ - rollback: (originalValue: T) => void - /** - * Optional error message to log if the operation fails - */ - errorMessage?: string - /** - * Optional callback to execute on error (e.g., show toast notification) - */ - onError?: (error: Error, originalValue: T) => void - /** - * Optional callback that always runs regardless of success or error (e.g., to clear loading states) - */ - onComplete?: () => void -} - -/** - * Performs an optimistic update with automatic rollback on error. - * This utility standardizes the pattern of: - * 1. Save current state - * 2. Update UI optimistically - * 3. Make API call - * 4. Rollback on error - * - * @example - * ```typescript - * await withOptimisticUpdate({ - * getCurrentState: () => get().folders[id], - * optimisticUpdate: () => set(state => ({ - * folders: { ...state.folders, [id]: { ...folder, name: newName } } - * })), - * apiCall: async () => { - * await fetch(`/api/folders/${id}`, { - * method: 'PUT', - * body: JSON.stringify({ name: newName }) - * }) - * }, - * rollback: (originalFolder) => set(state => ({ - * folders: { ...state.folders, [id]: originalFolder } - * })), - * errorMessage: 'Failed to rename folder', - * onError: (error) => toast.error('Could not rename folder') - * }) - * ``` - */ -export async function withOptimisticUpdate(options: OptimisticUpdateOptions): Promise { - const { - getCurrentState, - optimisticUpdate, - apiCall, - rollback, - errorMessage, - onError, - onComplete, - } = options - - const originalValue = getCurrentState() - - optimisticUpdate() - - try { - await apiCall() - } catch (error) { - rollback(originalValue) - - if (errorMessage) { - logger.error(errorMessage, { error }) - } - - if (onError && error instanceof Error) { - onError(error, originalValue) - } - - throw error - } finally { - if (onComplete) { - onComplete() - } - } -} diff --git a/apps/sim/lib/folders/tree.ts b/apps/sim/lib/folders/tree.ts new file mode 100644 index 00000000000..41196a7e35b --- /dev/null +++ b/apps/sim/lib/folders/tree.ts @@ -0,0 +1,59 @@ +import type { FolderTreeNode, WorkflowFolder } from '@/stores/folders/types' + +export function buildFolderMap(folders: WorkflowFolder[]): Record { + return Object.fromEntries(folders.map((folder) => [folder.id, folder])) +} + +export function buildFolderTree( + folders: Record, + workspaceId: string +): FolderTreeNode[] { + const workspaceFolders = Object.values(folders).filter( + (folder) => folder.workspaceId === workspaceId + ) + + const buildTree = (parentId: string | null, level = 0): FolderTreeNode[] => { + return workspaceFolders + .filter((folder) => folder.parentId === parentId) + .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)) + .map((folder) => ({ + ...folder, + children: buildTree(folder.id, level + 1), + level, + })) + } + + return buildTree(null) +} + +export function getFolderById( + folders: Record, + folderId: string +): WorkflowFolder | undefined { + return folders[folderId] +} + +export function getChildFolders( + folders: Record, + parentId: string | null +): WorkflowFolder[] { + return Object.values(folders) + .filter((folder) => folder.parentId === parentId) + .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)) +} + +export function getFolderPath( + folders: Record, + folderId: string +): WorkflowFolder[] { + const path: WorkflowFolder[] = [] + let currentId: string | null = folderId + + while (currentId && folders[currentId]) { + const folder: WorkflowFolder = folders[currentId] + path.unshift(folder) + currentId = folder.parentId + } + + return path +} diff --git a/apps/sim/lib/workflows/comparison/resolve-values.ts b/apps/sim/lib/workflows/comparison/resolve-values.ts index 6858535a6ae..2fe7f24a34d 100644 --- a/apps/sim/lib/workflows/comparison/resolve-values.ts +++ b/apps/sim/lib/workflows/comparison/resolve-values.ts @@ -34,6 +34,8 @@ interface ResolutionContext { subBlockId: string /** The workflow ID for API calls */ workflowId: string + /** The workspace scope for selector-based lookups */ + workspaceId?: string /** The current workflow state for extracting additional context */ currentState: WorkflowState /** The block ID being resolved */ @@ -64,13 +66,15 @@ async function resolveCredential(credentialId: string, workflowId: string): Prom } } -async function resolveWorkflow(workflowId: string): Promise { +async function resolveWorkflow(workflowId: string, workspaceId?: string): Promise { + if (!workspaceId) return null + try { const definition = getSelectorDefinition('sim.workflows') if (definition.fetchById) { const result = await definition.fetchById({ key: 'sim.workflows', - context: {}, + context: { workspaceId }, detailId: workflowId, }) return result?.label ?? null @@ -141,11 +145,12 @@ export function formatValueForDisplay(value: unknown): string { function extractSelectorContext( blockId: string, currentState: WorkflowState, - workflowId: string + workflowId: string, + workspaceId?: string ): SelectorContext { const block = currentState.blocks?.[blockId] - if (!block?.subBlocks) return { workflowId } - return buildSelectorContextFromBlock(block.type, block.subBlocks, { workflowId }) + if (!block?.subBlocks) return { workflowId, workspaceId } + return buildSelectorContextFromBlock(block.type, block.subBlocks, { workflowId, workspaceId }) } /** @@ -177,8 +182,13 @@ export async function resolveValueForDisplay( const semanticFallback = getSemanticFallback(subBlockConfig) const selectorCtx = context.blockId - ? extractSelectorContext(context.blockId, context.currentState, context.workflowId) - : { workflowId: context.workflowId } + ? extractSelectorContext( + context.blockId, + context.currentState, + context.workflowId, + context.workspaceId + ) + : { workflowId: context.workflowId, workspaceId: context.workspaceId } // Credential fields (oauth-input or credential subBlockId) const isCredentialField = @@ -194,7 +204,7 @@ export async function resolveValueForDisplay( // Workflow selector if (subBlockConfig?.type === 'workflow-selector' && isUuid(value)) { - const label = await resolveWorkflow(value) + const label = await resolveWorkflow(value, selectorCtx.workspaceId) if (label) { return { original: value, displayLabel: label, resolved: true } } diff --git a/apps/sim/lib/workflows/subblocks/context.test.ts b/apps/sim/lib/workflows/subblocks/context.test.ts index c30f1f1b0af..29cad46d941 100644 --- a/apps/sim/lib/workflows/subblocks/context.test.ts +++ b/apps/sim/lib/workflows/subblocks/context.test.ts @@ -66,6 +66,16 @@ describe('buildSelectorContextFromBlock', () => { expect(ctx.workflowId).toBe('wf-123') }) + it('should pass through workspaceId from opts', () => { + const ctx = buildSelectorContextFromBlock( + 'knowledge', + { operation: { id: 'operation', type: 'dropdown', value: 'search' } }, + { workspaceId: 'ws-123' } + ) + + expect(ctx.workspaceId).toBe('ws-123') + }) + it('should ignore subblock keys not in SELECTOR_CONTEXT_FIELDS', () => { const ctx = buildSelectorContextFromBlock('knowledge', { operation: { id: 'operation', type: 'dropdown', value: 'search' }, diff --git a/apps/sim/lib/workflows/subblocks/context.ts b/apps/sim/lib/workflows/subblocks/context.ts index 9b43bc892f3..affd888e2aa 100644 --- a/apps/sim/lib/workflows/subblocks/context.ts +++ b/apps/sim/lib/workflows/subblocks/context.ts @@ -34,10 +34,11 @@ export const SELECTOR_CONTEXT_FIELDS = new Set([ export function buildSelectorContextFromBlock( blockType: string, subBlocks: Record, - opts?: { workflowId?: string } + opts?: { workflowId?: string; workspaceId?: string } ): SelectorContext { const context: SelectorContext = {} if (opts?.workflowId) context.workflowId = opts.workflowId + if (opts?.workspaceId) context.workspaceId = opts.workspaceId const blockConfig = getBlock(blockType) if (!blockConfig) return context diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 37f973198ce..e195e873de4 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -24,7 +24,7 @@ import { VllmIcon, xAIIcon, } from '@/components/icons' -import type { ModelPricing } from '@/providers/types' +import type { ModelPricing, ProviderId } from '@/providers/types' export interface ModelCapabilities { temperature?: { @@ -2284,6 +2284,45 @@ export function getProviderModels(providerId: string): string[] { return PROVIDER_DEFINITIONS[providerId]?.models.map((m) => m.id) || [] } +export function getBaseModelProviders(): Record { + return Object.entries(PROVIDER_DEFINITIONS) + .filter(([providerId]) => !['ollama', 'vllm', 'openrouter'].includes(providerId)) + .reduce( + (map, [providerId, provider]) => { + provider.models.forEach((model) => { + map[model.id.toLowerCase()] = providerId as ProviderId + }) + return map + }, + {} as Record + ) +} + +export function getProviderFromModel(model: string): ProviderId { + const normalizedModel = model.toLowerCase() + + for (const [providerId, provider] of Object.entries(PROVIDER_DEFINITIONS)) { + if ( + provider.models.some((providerModel) => providerModel.id.toLowerCase() === normalizedModel) + ) { + return providerId as ProviderId + } + } + + for (const [providerId, provider] of Object.entries(PROVIDER_DEFINITIONS)) { + if (provider.modelPatterns?.some((pattern) => pattern.test(normalizedModel))) { + return providerId as ProviderId + } + } + + return 'ollama' +} + +export function getProviderIcon(model: string): React.ComponentType<{ className?: string }> | null { + const providerId = getProviderFromModel(model) + return PROVIDER_DEFINITIONS[providerId]?.icon || null +} + export function getProviderDefaultModel(providerId: string): string { return PROVIDER_DEFINITIONS[providerId]?.defaultModel || '' } diff --git a/apps/sim/stores/folders/store.ts b/apps/sim/stores/folders/store.ts index c21f0cc03b7..88a5211e6bf 100644 --- a/apps/sim/stores/folders/store.ts +++ b/apps/sim/stores/folders/store.ts @@ -1,12 +1,10 @@ import { createLogger } from '@sim/logger' import { create } from 'zustand' import { devtools } from 'zustand/middleware' -import type { FolderTreeNode, WorkflowFolder } from './types' const logger = createLogger('FoldersStore') interface FolderState { - folders: Record expandedFolders: Set selectedWorkflows: Set selectedFolders: Set @@ -14,7 +12,6 @@ interface FolderState { selectedTasks: Set lastSelectedTaskId: string | null - setFolders: (folders: WorkflowFolder[]) => void toggleExpanded: (folderId: string) => void setExpanded: (folderId: string, expanded: boolean) => void @@ -48,18 +45,11 @@ interface FolderState { hasAnySelection: () => boolean isMixedSelection: () => boolean clearAllSelection: () => void - - // Computed values - getFolderTree: (workspaceId: string) => FolderTreeNode[] - getFolderById: (id: string) => WorkflowFolder | undefined - getChildFolders: (parentId: string | null) => WorkflowFolder[] - getFolderPath: (folderId: string) => WorkflowFolder[] } export const useFolderStore = create()( devtools( (set, get) => ({ - folders: {}, expandedFolders: new Set(), selectedWorkflows: new Set(), selectedFolders: new Set(), @@ -67,17 +57,6 @@ export const useFolderStore = create()( selectedTasks: new Set(), lastSelectedTaskId: null, - setFolders: (folders) => - set(() => ({ - folders: folders.reduce( - (acc, folder) => { - acc[folder.id] = folder - return acc - }, - {} as Record - ), - })), - toggleExpanded: (folderId) => set((state) => { const newExpanded = new Set(state.expandedFolders) @@ -312,50 +291,6 @@ export const useFolderStore = create()( selectedTasks: new Set(), lastSelectedTaskId: null, }), - - getFolderTree: (workspaceId) => { - const folders = Object.values(get().folders).filter((f) => f.workspaceId === workspaceId) - - const buildTree = (parentId: string | null, level = 0): FolderTreeNode[] => { - return folders - .filter((folder) => folder.parentId === parentId) - .sort( - (a: WorkflowFolder, b: WorkflowFolder) => - a.sortOrder - b.sortOrder || a.name.localeCompare(b.name) - ) - .map((folder) => ({ - ...folder, - children: buildTree(folder.id, level + 1), - level, - })) - } - - return buildTree(null) - }, - - getFolderById: (id) => get().folders[id], - - getChildFolders: (parentId) => - Object.values(get().folders) - .filter((folder) => folder.parentId === parentId) - .sort( - (a: WorkflowFolder, b: WorkflowFolder) => - a.sortOrder - b.sortOrder || a.name.localeCompare(b.name) - ), - - getFolderPath: (folderId) => { - const folders = get().folders - const path: WorkflowFolder[] = [] - let currentId: string | null = folderId - - while (currentId && folders[currentId]) { - const folder: WorkflowFolder = folders[currentId] - path.unshift(folder) - currentId = folder.parentId - } - - return path - }, }), { name: 'folder-store' } ) diff --git a/apps/sim/stores/index.ts b/apps/sim/stores/index.ts index 4ae3c335f07..d1bbbc9b227 100644 --- a/apps/sim/stores/index.ts +++ b/apps/sim/stores/index.ts @@ -201,7 +201,6 @@ export { export const resetAllStores = () => { // Reset all stores to initial state useWorkflowRegistry.setState({ - workflows: {}, activeWorkflowId: null, error: null, deploymentStatuses: {}, diff --git a/apps/sim/stores/panel/variables/store.ts b/apps/sim/stores/panel/variables/store.ts index 70ebc2b7191..e9a7db871c5 100644 --- a/apps/sim/stores/panel/variables/store.ts +++ b/apps/sim/stores/panel/variables/store.ts @@ -5,7 +5,6 @@ import { devtools } from 'zustand/middleware' import { normalizeName } from '@/executor/constants' import { useOperationQueueStore } from '@/stores/operation-queue/store' import type { Variable, VariablesStore } from '@/stores/panel/variables/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' const logger = createLogger('VariablesStore') @@ -175,10 +174,10 @@ export const useVariablesStore = create()( update.name = undefined } else if (newName !== oldVariableName) { const subBlockStore = useSubBlockStore.getState() - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + const targetWorkflowId = oldVariable.workflowId - if (activeWorkflowId) { - const workflowValues = subBlockStore.workflowValues[activeWorkflowId] || {} + if (targetWorkflowId) { + const workflowValues = subBlockStore.workflowValues[targetWorkflowId] || {} const updatedWorkflowValues = { ...workflowValues } const changedSubBlocks: Array<{ blockId: string; subBlockId: string; value: any }> = [] @@ -227,7 +226,7 @@ export const useVariablesStore = create()( useSubBlockStore.setState({ workflowValues: { ...subBlockStore.workflowValues, - [activeWorkflowId]: updatedWorkflowValues, + [targetWorkflowId]: updatedWorkflowValues, }, }) @@ -242,7 +241,7 @@ export const useVariablesStore = create()( target: 'subblock', payload: { blockId, subblockId: subBlockId, value }, }, - workflowId: activeWorkflowId, + workflowId: targetWorkflowId, userId: 'system', }) } diff --git a/apps/sim/stores/workflows/index.ts b/apps/sim/stores/workflows/index.ts index c3cc04ec6f8..e2fdf9a7c4a 100644 --- a/apps/sim/stores/workflows/index.ts +++ b/apps/sim/stores/workflows/index.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { mergeSubblockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -10,14 +11,15 @@ const logger = createLogger('Workflows') * Get a workflow with its state merged in by ID * Note: Since localStorage has been removed, this only works for the active workflow * @param workflowId ID of the workflow to retrieve + * @param workspaceId Workspace containing the workflow metadata * @returns The workflow with merged state values or null if not found/not active */ -export function getWorkflowWithValues(workflowId: string) { - const { workflows } = useWorkflowRegistry.getState() +export function getWorkflowWithValues(workflowId: string, workspaceId: string) { + const workflows = getWorkflows(workspaceId) const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - const currentState = useWorkflowStore.getState() - if (!workflows[workflowId]) { + const metadata = workflows.find((w) => w.id === workflowId) + if (!metadata) { logger.warn(`Workflow ${workflowId} not found`) return null } @@ -28,8 +30,6 @@ export function getWorkflowWithValues(workflowId: string) { return null } - const metadata = workflows[workflowId] - // Get deployment status from registry const deploymentStatus = useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId) @@ -77,17 +77,33 @@ export function getBlockWithValues(blockId: string): BlockState | null { /** * Get all workflows with their values merged * Note: Since localStorage has been removed, this only includes the active workflow state + * @param workspaceId Workspace containing the workflow metadata * @returns An object containing workflows, with state only for the active workflow */ -export function getAllWorkflowsWithValues() { - const { workflows } = useWorkflowRegistry.getState() - const result: Record = {} +export function getAllWorkflowsWithValues(workspaceId: string) { + const workflows = getWorkflows(workspaceId) + const result: Record< + string, + { + id: string + name: string + description?: string + color: string + folderId?: string | null + workspaceId?: string + apiKey?: string + state: WorkflowState & { isDeployed: boolean; deployedAt?: Date } + } + > = {} const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId const currentState = useWorkflowStore.getState() // Only sync the active workflow to ensure we always send valid state data - if (activeWorkflowId && workflows[activeWorkflowId]) { - const metadata = workflows[activeWorkflowId] + const activeMetadata = activeWorkflowId + ? workflows.find((w) => w.id === activeWorkflowId) + : undefined + if (activeWorkflowId && activeMetadata) { + const metadata = activeMetadata // Get deployment status from registry const deploymentStatus = useWorkflowRegistry diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index dca49b8ddae..4d3fece524a 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -1,21 +1,20 @@ import { createLogger } from '@sim/logger' import { create } from 'zustand' import { devtools } from 'zustand/middleware' -import { withOptimisticUpdate } from '@/lib/core/utils/optimistic-update' import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants' -import { getNextWorkflowColor } from '@/lib/workflows/colors' -import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' import { useVariablesStore } from '@/stores/panel/variables/store' +import type { Variable } from '@/stores/panel/variables/types' import type { DeploymentStatus, HydrationState, - WorkflowMetadata, WorkflowRegistry, } from '@/stores/workflows/registry/types' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { getUniqueBlockName, regenerateBlockIds } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' -import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types' +import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowRegistry') const initialHydration: HydrationState = { @@ -28,14 +27,9 @@ const initialHydration: HydrationState = { const createRequestId = () => `${Date.now()}-${Math.random().toString(16).slice(2)}` -// Track workspace transitions to prevent race conditions -let isWorkspaceTransitioning = false -const TRANSITION_TIMEOUT = 5000 // 5 seconds maximum for workspace transitions - -// Resets workflow and subblock stores to prevent data leakage between workspaces function resetWorkflowStores() { - // Reset the workflow store to prevent data leakage between workspaces useWorkflowStore.setState({ + currentWorkflowId: null, blocks: {}, edges: [], loops: {}, @@ -44,33 +38,14 @@ function resetWorkflowStores() { lastSaved: Date.now(), }) - // Reset the subblock store useSubBlockStore.setState({ workflowValues: {}, }) } -/** - * Handles workspace transition state tracking - * @param isTransitioning Whether workspace is currently transitioning - */ -function setWorkspaceTransitioning(isTransitioning: boolean): void { - isWorkspaceTransitioning = isTransitioning - - if (isTransitioning) { - setTimeout(() => { - if (isWorkspaceTransitioning) { - logger.warn('Forcing workspace transition to complete due to timeout') - isWorkspaceTransitioning = false - } - }, TRANSITION_TIMEOUT) - } -} - export const useWorkflowRegistry = create()( devtools( (set, get) => ({ - workflows: {}, activeWorkflowId: null, error: null, deploymentStatuses: {}, @@ -78,108 +53,26 @@ export const useWorkflowRegistry = create()( clipboard: null, pendingSelection: null, - beginMetadataLoad: (workspaceId: string) => { - set((state) => ({ + switchToWorkspace: (workspaceId: string) => { + logger.info(`Switching to workspace: ${workspaceId}`) + + resetWorkflowStores() + void invalidateWorkflowLists(getQueryClient(), workspaceId) + + set({ + activeWorkflowId: null, + deploymentStatuses: {}, error: null, hydration: { - phase: 'metadata-loading', + phase: 'idle', workspaceId, workflowId: null, requestId: null, error: null, }, - })) - }, - - completeMetadataLoad: (workspaceId: string, workflows: WorkflowMetadata[]) => { - const mapped = workflows.reduce>((acc, workflow) => { - acc[workflow.id] = workflow - return acc - }, {}) - - set((state) => { - const shouldPreserveHydration = - state.hydration.phase === 'state-loading' || - (state.hydration.phase === 'ready' && - state.hydration.workflowId && - mapped[state.hydration.workflowId]) - - return { - workflows: mapped, - error: null, - hydration: shouldPreserveHydration - ? state.hydration - : { - phase: 'metadata-ready', - workspaceId, - workflowId: null, - requestId: null, - error: null, - }, - } }) }, - failMetadataLoad: (workspaceId: string | null, errorMessage: string) => { - set((state) => ({ - error: errorMessage, - hydration: { - phase: 'error', - workspaceId: workspaceId ?? state.hydration.workspaceId, - workflowId: state.hydration.workflowId, - requestId: null, - error: errorMessage, - }, - })) - }, - - switchToWorkspace: async (workspaceId: string) => { - if (isWorkspaceTransitioning) { - logger.warn( - `Ignoring workspace switch to ${workspaceId} - transition already in progress` - ) - return - } - - setWorkspaceTransitioning(true) - - try { - logger.info(`Switching to workspace: ${workspaceId}`) - - resetWorkflowStores() - - set({ - activeWorkflowId: null, - workflows: {}, - deploymentStatuses: {}, - error: null, - hydration: { - phase: 'metadata-loading', - workspaceId, - workflowId: null, - requestId: null, - error: null, - }, - }) - - logger.info(`Successfully switched to workspace: ${workspaceId}`) - } catch (error) { - logger.error(`Error switching to workspace ${workspaceId}:`, { error }) - set({ - error: `Failed to switch workspace: ${error instanceof Error ? error.message : 'Unknown error'}`, - hydration: { - phase: 'error', - workspaceId, - workflowId: null, - requestId: null, - error: error instanceof Error ? error.message : 'Unknown error', - }, - }) - } finally { - setWorkspaceTransitioning(false) - } - }, - getWorkflowDeploymentStatus: (workflowId: string | null): DeploymentStatus | null => { if (!workflowId) { workflowId = get().activeWorkflowId @@ -215,8 +108,7 @@ export const useWorkflowRegistry = create()( apiKey, needsRedeployment: isDeployed ? false - : ((state.deploymentStatuses?.[workflowId as string] as any)?.needsRedeployment ?? - false), + : (state.deploymentStatuses?.[workflowId as string]?.needsRedeployment ?? false), }, }, })) @@ -250,10 +142,9 @@ export const useWorkflowRegistry = create()( }, loadWorkflowState: async (workflowId: string) => { - const { workflows } = get() - - if (!workflows[workflowId]) { - const message = `Workflow not found: ${workflowId}` + const workspaceId = get().hydration.workspaceId + if (!workspaceId) { + const message = `Cannot load workflow ${workflowId} without a workspace scope` logger.error(message) set({ error: message }) throw new Error(message) @@ -265,7 +156,7 @@ export const useWorkflowRegistry = create()( error: null, hydration: { phase: 'state-loading', - workspaceId: state.hydration.workspaceId, + workspaceId: workspaceId ?? state.hydration.workspaceId, workflowId, requestId, error: null, @@ -279,24 +170,41 @@ export const useWorkflowRegistry = create()( } const workflowData = (await response.json()).data - let workflowState: any + const nextDeploymentStatuses = + workflowData?.isDeployed || workflowData?.deployedAt + ? { + ...get().deploymentStatuses, + [workflowId]: { + isDeployed: workflowData.isDeployed || false, + deployedAt: workflowData.deployedAt + ? new Date(workflowData.deployedAt) + : undefined, + apiKey: workflowData.apiKey || undefined, + needsRedeployment: false, + }, + } + : get().deploymentStatuses + + let workflowState: WorkflowState if (workflowData?.state) { workflowState = { + currentWorkflowId: workflowId, blocks: workflowData.state.blocks || {}, edges: workflowData.state.edges || [], loops: workflowData.state.loops || {}, parallels: workflowData.state.parallels || {}, lastSaved: Date.now(), - deploymentStatuses: {}, + deploymentStatuses: nextDeploymentStatuses, } } else { workflowState = { + currentWorkflowId: workflowId, blocks: {}, edges: [], loops: {}, parallels: {}, - deploymentStatuses: {}, + deploymentStatuses: nextDeploymentStatuses, lastSaved: Date.now(), } @@ -305,21 +213,6 @@ export const useWorkflowRegistry = create()( ) } - const nextDeploymentStatuses = - workflowData?.isDeployed || workflowData?.deployedAt - ? { - ...get().deploymentStatuses, - [workflowId]: { - isDeployed: workflowData.isDeployed || false, - deployedAt: workflowData.deployedAt - ? new Date(workflowData.deployedAt) - : undefined, - apiKey: workflowData.apiKey || undefined, - needsRedeployment: false, - }, - } - : get().deploymentStatuses - const currentHydration = get().hydration if ( currentHydration.requestId !== requestId || @@ -338,7 +231,9 @@ export const useWorkflowRegistry = create()( if (workflowData?.variables && typeof workflowData.variables === 'object') { useVariablesStore.setState((state) => { const withoutWorkflow = Object.fromEntries( - Object.entries(state.variables).filter(([, v]: any) => v.workflowId !== workflowId) + Object.entries(state.variables).filter( + (entry): entry is [string, Variable] => entry[1].workflowId !== workflowId + ) ) return { variables: { ...withoutWorkflow, ...workflowData.variables }, @@ -392,10 +287,6 @@ export const useWorkflowRegistry = create()( const workflowStoreState = useWorkflowStore.getState() const hasWorkflowData = Object.keys(workflowStoreState.blocks).length > 0 - // Skip loading only if: - // - Same workflow is already active - // - Workflow data exists - // - Hydration is complete (phase is 'ready') const isFullyHydrated = activeWorkflowId === id && hasWorkflowData && @@ -410,320 +301,16 @@ export const useWorkflowRegistry = create()( await get().loadWorkflowState(id) }, - /** - * Duplicates an existing workflow - */ - duplicateWorkflow: async (sourceId: string) => { - const { workflows } = get() - const sourceWorkflow = workflows[sourceId] - - if (!sourceWorkflow) { - set({ error: `Workflow ${sourceId} not found` }) - return null - } - - // Get the workspace ID from the source workflow (required) - const workspaceId = sourceWorkflow.workspaceId - - // Call the server to duplicate the workflow - server generates all IDs - let duplicatedWorkflow - try { - const response = await fetch(`/api/workflows/${sourceId}/duplicate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: `${sourceWorkflow.name} (Copy)`, - description: sourceWorkflow.description, - color: sourceWorkflow.color, - workspaceId: workspaceId, - folderId: sourceWorkflow.folderId, - }), - }) - - if (!response.ok) { - throw new Error(`Failed to duplicate workflow: ${response.statusText}`) - } - - duplicatedWorkflow = await response.json() - logger.info( - `Successfully duplicated workflow ${sourceId} to ${duplicatedWorkflow.id} with ${duplicatedWorkflow.blocksCount} blocks, ${duplicatedWorkflow.edgesCount} edges, ${duplicatedWorkflow.subflowsCount} subflows` - ) - } catch (error) { - logger.error(`Failed to duplicate workflow ${sourceId}:`, error) - set({ - error: `Failed to duplicate workflow: ${error instanceof Error ? error.message : 'Unknown error'}`, - }) - return null - } - - const id = duplicatedWorkflow.id - - const newWorkflow: WorkflowMetadata = { - id, - name: `${sourceWorkflow.name} (Copy)`, - lastModified: new Date(), - createdAt: new Date(), - description: sourceWorkflow.description, - color: getNextWorkflowColor(), - workspaceId, - folderId: sourceWorkflow.folderId, - sortOrder: duplicatedWorkflow.sortOrder ?? 0, - } - - // Get the current workflow state to copy from - const currentWorkflowState = useWorkflowStore.getState() - - // If we're duplicating the active workflow, use current state - // Otherwise, we need to fetch it from DB or use empty state - let sourceState: any - - if (sourceId === get().activeWorkflowId) { - // Source is the active workflow, copy current state - sourceState = { - blocks: currentWorkflowState.blocks || {}, - edges: currentWorkflowState.edges || [], - loops: currentWorkflowState.loops || {}, - parallels: currentWorkflowState.parallels || {}, - } - } else { - const { workflowState } = buildDefaultWorkflowArtifacts() - sourceState = { - blocks: workflowState.blocks, - edges: workflowState.edges, - loops: workflowState.loops, - parallels: workflowState.parallels, - } - } - - // Create the new workflow state with copied content - const newState = { - blocks: sourceState.blocks, - edges: sourceState.edges, - loops: sourceState.loops, - parallels: sourceState.parallels, - workspaceId, - deploymentStatuses: {}, - lastSaved: Date.now(), - } - - // Add workflow to registry - set((state) => ({ - workflows: { - ...state.workflows, - [id]: newWorkflow, - }, - error: null, - })) - - // Copy subblock values if duplicating active workflow - if (sourceId === get().activeWorkflowId) { - const sourceSubblockValues = useSubBlockStore.getState().workflowValues[sourceId] || {} - useSubBlockStore.setState((state) => ({ - workflowValues: { - ...state.workflowValues, - [id]: sourceSubblockValues, - }, - })) - } else { - // Initialize subblock values for starter block - const subblockValues: Record> = {} - Object.entries(newState.blocks).forEach(([blockId, block]) => { - const blockState = block as any - subblockValues[blockId] = {} - Object.entries(blockState.subBlocks || {}).forEach(([subblockId, subblock]) => { - subblockValues[blockId][subblockId] = (subblock as any).value - }) - }) - - useSubBlockStore.setState((state) => ({ - workflowValues: { - ...state.workflowValues, - [id]: subblockValues, - }, - })) - } - - try { - await useVariablesStore.getState().loadForWorkflow(id) - } catch (error) { - logger.warn(`Error hydrating variables for duplicated workflow ${id}:`, error) - } - - logger.info( - `Duplicated workflow ${sourceId} to ${id} in workspace ${workspaceId || 'none'}` - ) - - return id - }, - - removeWorkflow: async (id: string) => { - const { workflows, activeWorkflowId } = get() - const workflowToDelete = workflows[id] - - if (!workflowToDelete) { - logger.warn(`Attempted to delete non-existent workflow: ${id}`) - return - } - - const isDeletingActiveWorkflow = activeWorkflowId === id - - await withOptimisticUpdate({ - getCurrentState: () => ({ - workflows: { ...get().workflows }, - activeWorkflowId: get().activeWorkflowId, - subBlockValues: { ...useSubBlockStore.getState().workflowValues }, - workflowStoreState: isDeletingActiveWorkflow - ? { - blocks: { ...useWorkflowStore.getState().blocks }, - edges: [...useWorkflowStore.getState().edges], - loops: { ...useWorkflowStore.getState().loops }, - parallels: { ...useWorkflowStore.getState().parallels }, - lastSaved: useWorkflowStore.getState().lastSaved, - } - : null, - }), - optimisticUpdate: () => { - const newWorkflows = { ...get().workflows } - delete newWorkflows[id] - - const currentSubBlockValues = useSubBlockStore.getState().workflowValues - const newWorkflowValues = { ...currentSubBlockValues } - delete newWorkflowValues[id] - useSubBlockStore.setState({ workflowValues: newWorkflowValues }) - - let newActiveWorkflowId = get().activeWorkflowId - if (isDeletingActiveWorkflow) { - newActiveWorkflowId = null - - useWorkflowStore.setState({ - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - lastSaved: Date.now(), - }) - - logger.info( - `Cleared active workflow ${id} - user will need to manually select another workflow` - ) - } - - set({ - workflows: newWorkflows, - activeWorkflowId: newActiveWorkflowId, - error: null, - }) - - logger.info(`Removed workflow ${id} from local state (optimistic)`) - }, - apiCall: async () => { - const response = await fetch(`/api/workflows/${id}`, { - method: 'DELETE', - }) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })) - throw new Error(error.error || 'Failed to delete workflow') - } - - logger.info(`Successfully deleted workflow ${id} from database`) - }, - rollback: (originalState) => { - set({ - workflows: originalState.workflows, - activeWorkflowId: originalState.activeWorkflowId, - }) - - useSubBlockStore.setState({ workflowValues: originalState.subBlockValues }) - - if (originalState.workflowStoreState) { - useWorkflowStore.getState().replaceWorkflowState(originalState.workflowStoreState) - logger.info(`Restored workflow store state for workflow ${id}`) - } - - logger.info(`Rolled back deletion of workflow ${id}`) - }, - errorMessage: `Failed to delete workflow ${id}`, - }) - }, - - updateWorkflow: async (id: string, metadata: Partial) => { - const { workflows } = get() - const workflow = workflows[id] - if (!workflow) { - logger.warn(`Cannot update workflow ${id}: not found in registry`) - return - } - - await withOptimisticUpdate({ - getCurrentState: () => workflow, - optimisticUpdate: () => { - set((state) => ({ - workflows: { - ...state.workflows, - [id]: { - ...workflow, - ...metadata, - lastModified: new Date(), - createdAt: workflow.createdAt, // Preserve creation date - }, - }, - error: null, - })) - }, - apiCall: async () => { - const response = await fetch(`/api/workflows/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(metadata), - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Failed to update workflow') - } - - const { workflow: updatedWorkflow } = await response.json() - logger.info(`Successfully updated workflow ${id} metadata`, metadata) - - set((state) => ({ - workflows: { - ...state.workflows, - [id]: { - ...state.workflows[id], - name: updatedWorkflow.name, - description: updatedWorkflow.description, - color: updatedWorkflow.color, - folderId: updatedWorkflow.folderId, - lastModified: new Date(updatedWorkflow.updatedAt), - createdAt: updatedWorkflow.createdAt - ? new Date(updatedWorkflow.createdAt) - : state.workflows[id].createdAt, - }, - }, - })) - }, - rollback: (originalWorkflow) => { - set((state) => ({ - workflows: { - ...state.workflows, - [id]: originalWorkflow, // Revert to original state - }, - error: `Failed to update workflow: ${metadata.name ? 'name' : 'metadata'}`, - })) - }, - errorMessage: `Failed to update workflow ${id} metadata`, - }) - }, - logout: () => { logger.info('Logging out - clearing all workflow data') resetWorkflowStores() + // Clear the React Query cache to remove all server state + getQueryClient().clear() + set({ activeWorkflowId: null, - workflows: {}, deploymentStatuses: {}, error: null, hydration: initialHydration, @@ -744,7 +331,6 @@ export const useWorkflowRegistry = create()( const copiedSubBlockValues: Record> = {} const blockIdSet = new Set(blockIds) - // Auto-include nested nodes from selected subflows blockIds.forEach((blockId) => { const loop = workflowStore.loops[blockId] if (loop?.nodes) loop.nodes.forEach((n) => blockIdSet.add(n)) diff --git a/apps/sim/stores/workflows/registry/types.ts b/apps/sim/stores/workflows/registry/types.ts index 1b22fe87d04..375ee0df239 100644 --- a/apps/sim/stores/workflows/registry/types.ts +++ b/apps/sim/stores/workflows/registry/types.ts @@ -32,13 +32,7 @@ export interface WorkflowMetadata { isSandbox?: boolean } -export type HydrationPhase = - | 'idle' - | 'metadata-loading' - | 'metadata-ready' - | 'state-loading' - | 'ready' - | 'error' +export type HydrationPhase = 'idle' | 'state-loading' | 'ready' | 'error' export interface HydrationState { phase: HydrationPhase @@ -49,7 +43,6 @@ export interface HydrationState { } export interface WorkflowRegistryState { - workflows: Record activeWorkflowId: string | null error: string | null deploymentStatuses: Record @@ -59,15 +52,9 @@ export interface WorkflowRegistryState { } export interface WorkflowRegistryActions { - beginMetadataLoad: (workspaceId: string) => void - completeMetadataLoad: (workspaceId: string, workflows: WorkflowMetadata[]) => void - failMetadataLoad: (workspaceId: string | null, error: string) => void setActiveWorkflow: (id: string) => Promise loadWorkflowState: (workflowId: string) => Promise - switchToWorkspace: (id: string) => Promise - removeWorkflow: (id: string) => Promise - updateWorkflow: (id: string, metadata: Partial) => Promise - duplicateWorkflow: (sourceId: string) => Promise + switchToWorkspace: (id: string) => void getWorkflowDeploymentStatus: (workflowId: string | null) => DeploymentStatus | null setDeploymentStatus: ( workflowId: string | null, diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index 10a580c36f6..0ddbf8d420b 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -9,7 +9,6 @@ import { } from '@/lib/workflows/dynamic-handle-topology' import type { SubBlockConfig } from '@/blocks/types' import { normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { filterNewEdges, @@ -102,6 +101,7 @@ function resolveInitialSubblockValue(config: SubBlockConfig): unknown { } const initialState = { + currentWorkflowId: null, blocks: {}, edges: [], loops: {}, @@ -120,6 +120,10 @@ export const useWorkflowStore = create()( set({ needsRedeployment }) }, + setCurrentWorkflowId: (currentWorkflowId) => { + set({ currentWorkflowId }) + }, + updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => { set((state) => { const block = state.blocks[id] @@ -289,7 +293,7 @@ export const useWorkflowStore = create()( }) if (subBlockValues && Object.keys(subBlockValues).length > 0) { - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + const activeWorkflowId = get().currentWorkflowId if (activeWorkflowId) { const subBlockStore = useSubBlockStore.getState() const updatedWorkflowValues = { @@ -343,7 +347,7 @@ export const useWorkflowStore = create()( delete newBlocks[blockId] }) - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + const activeWorkflowId = get().currentWorkflowId if (activeWorkflowId) { const subBlockStore = useSubBlockStore.getState() if (subBlockStore.workflowValues[activeWorkflowId]) { @@ -485,6 +489,7 @@ export const useWorkflowStore = create()( clear: () => { const newState = { + currentWorkflowId: get().currentWorkflowId, blocks: {}, edges: [], loops: {}, @@ -502,6 +507,7 @@ export const useWorkflowStore = create()( getWorkflowState: (): WorkflowState => { const state = get() return { + currentWorkflowId: state.currentWorkflowId, blocks: state.blocks, edges: state.edges, loops: state.loops, @@ -539,6 +545,10 @@ export const useWorkflowStore = create()( return { ...state, + currentWorkflowId: + nextState.currentWorkflowId !== undefined + ? nextState.currentWorkflowId + : state.currentWorkflowId, blocks: nextBlocks, edges: nextEdges, loops: nextLoops, @@ -613,7 +623,7 @@ export const useWorkflowStore = create()( const newName = getUniqueBlockName(block.name, get().blocks) - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + const activeWorkflowId = get().currentWorkflowId const mergedBlock = mergeSubblockState(get().blocks, activeWorkflowId || undefined, id)[id] const newSubBlocks = Object.entries(mergedBlock.subBlocks).reduce( @@ -739,7 +749,7 @@ export const useWorkflowStore = create()( // Update references in subblock store const subBlockStore = useSubBlockStore.getState() - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + const activeWorkflowId = get().currentWorkflowId const changedSubblocks: Array<{ blockId: string; subBlockId: string; newValue: any }> = [] if (activeWorkflowId) { @@ -1105,16 +1115,14 @@ export const useWorkflowStore = create()( }, revertToDeployedState: async (deployedState: WorkflowState) => { - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + const activeWorkflowId = get().currentWorkflowId if (!activeWorkflowId) { logger.error('Cannot revert: no active workflow ID') return } - const deploymentStatus = useWorkflowRegistry - .getState() - .getWorkflowDeploymentStatus(activeWorkflowId) + const deploymentStatus = get().deploymentStatuses?.[activeWorkflowId] get().replaceWorkflowState({ ...deployedState, diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts index edbc606188e..21f22ff5478 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -160,6 +160,7 @@ export interface DragStartPosition { } export interface WorkflowState { + currentWorkflowId?: string | null blocks: Record edges: Edge[] lastSaved?: number @@ -239,6 +240,7 @@ export interface WorkflowActions { ) => void setBlockLocked: (id: string, locked: boolean) => void batchToggleLocked: (ids: string[]) => void + setCurrentWorkflowId: (workflowId: string | null) => void } export type WorkflowStore = WorkflowState & WorkflowActions diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index c5beca880b7..a55aefa1ee2 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -16,16 +16,19 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // Hoisted mock state - these are available to vi.mock factories -const { mockIsHosted, mockEnv, mockGetBYOKKey, mockRateLimiterFns } = vi.hoisted(() => ({ - mockIsHosted: { value: false }, - mockEnv: { NEXT_PUBLIC_APP_URL: 'http://localhost:3000' } as Record, - mockGetBYOKKey: vi.fn(), - mockRateLimiterFns: { - acquireKey: vi.fn(), - preConsumeCapacity: vi.fn(), - consumeCapacity: vi.fn(), - }, -})) +const { mockIsHosted, mockEnv, mockGetBYOKKey, mockGetToolAsync, mockRateLimiterFns } = vi.hoisted( + () => ({ + mockIsHosted: { value: false }, + mockEnv: { NEXT_PUBLIC_APP_URL: 'http://localhost:3000' } as Record, + mockGetBYOKKey: vi.fn(), + mockGetToolAsync: vi.fn(), + mockRateLimiterFns: { + acquireKey: vi.fn(), + preConsumeCapacity: vi.fn(), + consumeCapacity: vi.fn(), + }, + }) +) // Mock feature flags vi.mock('@/lib/core/config/feature-flags', () => ({ @@ -176,27 +179,12 @@ vi.mock('@/tools/registry', () => { params: {}, request: { url: '/api/tools/serper/search', method: 'GET' }, }, - 'custom_custom-tool-123': { - id: 'custom_custom-tool-123', - name: 'Custom Weather Tool', - description: 'Get weather information', - version: '1.0.0', - params: { - location: { type: 'string', required: true, description: 'City name' }, - unit: { type: 'string', required: false, description: 'Unit (metric/imperial)' }, - }, - request: { - url: '/api/function/execute', - method: 'POST', - headers: () => ({ 'Content-Type': 'application/json' }), - }, - }, } return { tools: mockTools } }) // Mock custom tools - define mock data inside factory function -vi.mock('@/hooks/queries/custom-tools', () => { +vi.mock('@/hooks/queries/utils/custom-tool-cache', () => { const mockCustomTool = { id: 'custom-tool-123', title: 'Custom Weather Tool', @@ -226,9 +214,19 @@ vi.mock('@/hooks/queries/custom-tools', () => { } }) -import { executeTool } from '@/tools' +vi.mock('@/tools/utils.server', async (importOriginal) => { + const actual = await importOriginal() + mockGetToolAsync.mockImplementation(actual.getToolAsync) + return { + ...actual, + getToolAsync: mockGetToolAsync, + } +}) + +import { executeTool, postProcessToolOutput } from '@/tools' import { tools } from '@/tools/registry' import { getTool } from '@/tools/utils' +import { getToolAsync } from '@/tools/utils.server' /** * Sets up global fetch mock with Next.js preconnect support. @@ -304,18 +302,45 @@ describe('Tools Registry', () => { }) describe('Custom Tools', () => { - it('should get custom tool by ID', () => { - const customTool = getTool('custom_custom-tool-123') - expect(customTool).toBeDefined() - expect(customTool?.name).toBe('Custom Weather Tool') - expect(customTool?.description).toBe('Get weather information') - expect(customTool?.params.location).toBeDefined() - expect(customTool?.params.location.required).toBe(true) + it('does not resolve custom tools through the synchronous client helper', () => { + expect(getTool('custom_remote-tool-123', 'workspace-1')).toBeUndefined() }) - it('should handle non-existent custom tool', () => { - const nonExistentTool = getTool('custom_non-existent') - expect(nonExistentTool).toBeUndefined() + it('resolves custom tools through the async helper', async () => { + setupFetchMock({ + json: { + data: [ + { + id: 'remote-tool-123', + title: 'Custom Weather Tool', + schema: { + function: { + name: 'weather_tool', + description: 'Get weather information', + parameters: { + type: 'object', + properties: { + location: { type: 'string', description: 'City name' }, + }, + required: ['location'], + }, + }, + }, + }, + ], + }, + status: 200, + headers: { 'content-type': 'application/json' }, + }) + + const customTool = await getToolAsync('custom_remote-tool-123', { + workflowId: 'workflow-1', + userId: 'user-1', + workspaceId: 'workspace-1', + }) + + expect(customTool?.name).toBe('Custom Weather Tool') + expect(customTool?.params.location.required).toBe(true) }) }) @@ -1962,44 +1987,17 @@ describe('stripInternalFields Safety', () => { }) it('should preserve __-prefixed fields in custom tool output', async () => { - const mockTool = { - id: 'custom_test-preserve-dunder', - name: 'Custom Preserve Dunder', - description: 'A custom tool whose output has __ fields', - version: '1.0.0', - params: {}, - request: { - url: '/api/function/execute', - method: 'POST' as const, - headers: () => ({ 'Content-Type': 'application/json' }), - }, - transformResponse: vi.fn().mockResolvedValue({ - success: true, - output: { result: 'ok', __metadata: { source: 'user' }, __tag: 'important' }, - }), - } - - const originalTools = { ...tools } - ;(tools as any)['custom_test-preserve-dunder'] = mockTool - - global.fetch = Object.assign( - vi.fn().mockImplementation(async () => ({ - ok: true, - status: 200, - headers: new Headers(), - json: () => Promise.resolve({ success: true }), - })), - { preconnect: vi.fn() } - ) as typeof fetch - - const result = await executeTool('custom_test-preserve-dunder', {}, true) - - expect(result.success).toBe(true) - expect(result.output.result).toBe('ok') - expect(result.output.__metadata).toEqual({ source: 'user' }) - expect(result.output.__tag).toBe('important') + const output = postProcessToolOutput('custom_test-preserve-dunder', { + result: 'ok', + __metadata: { source: 'user' }, + __tag: 'important', + }) - Object.assign(tools, originalTools) + expect(output).toEqual({ + result: 'ok', + __metadata: { source: 'user' }, + __tag: 'important', + }) }) }) diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index c354b691c2b..290960873a1 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -26,15 +26,40 @@ import type { ToolResponse, ToolRetryConfig, } from '@/tools/types' -import { - formatRequestParams, - getTool, - getToolAsync, - validateRequiredParametersAfterMerge, -} from '@/tools/utils' +import { formatRequestParams, getTool, validateRequiredParametersAfterMerge } from '@/tools/utils' +import * as toolsUtilsServer from '@/tools/utils.server' const logger = createLogger('Tools') +interface ToolExecutionScope { + workspaceId?: string + workflowId?: string + userId?: string + executionId?: string + callChain?: string[] + isDeployedContext?: boolean + enforceCredentialAccess?: boolean +} + +function resolveToolScope( + params: Record, + executionContext?: ExecutionContext +): ToolExecutionScope { + const ctx = params._context as Record | undefined + return { + workspaceId: (executionContext?.workspaceId ?? ctx?.workspaceId) as string | undefined, + workflowId: (executionContext?.workflowId ?? ctx?.workflowId) as string | undefined, + userId: (executionContext?.userId ?? ctx?.userId) as string | undefined, + executionId: (executionContext?.executionId ?? ctx?.executionId) as string | undefined, + callChain: (executionContext?.callChain ?? ctx?.callChain) as string[] | undefined, + isDeployedContext: (executionContext?.isDeployedContext ?? ctx?.isDeployedContext) as + | boolean + | undefined, + enforceCredentialAccess: (executionContext?.enforceCredentialAccess ?? + ctx?.enforceCredentialAccess) as boolean | undefined, + } +} + /** Result from hosted key injection */ interface HostedKeyInjectionResult { isUsingHostedKey: boolean @@ -57,11 +82,7 @@ async function injectHostedKeyIfNeeded( const { envKeyPrefix, apiKeyParam, byokProviderId, rateLimit } = tool.hosting - // Derive workspace/user/workflow IDs from executionContext or params._context - const ctx = params._context as Record | undefined - const workspaceId = executionContext?.workspaceId || (ctx?.workspaceId as string | undefined) - const userId = executionContext?.userId || (ctx?.userId as string | undefined) - const workflowId = executionContext?.workflowId || (ctx?.workflowId as string | undefined) + const { workspaceId, userId, workflowId } = resolveToolScope(params, executionContext) // Check BYOK workspace key first if (byokProviderId && workspaceId) { @@ -277,10 +298,7 @@ async function processHostedKeyCost( if (cost <= 0) return { cost: 0 } - const ctx = params._context as Record | undefined - const userId = executionContext?.userId || (ctx?.userId as string | undefined) - const wsId = executionContext?.workspaceId || (ctx?.workspaceId as string | undefined) - const wfId = executionContext?.workflowId || (ctx?.workflowId as string | undefined) + const { userId } = resolveToolScope(params, executionContext) if (!userId) return { cost, metadata } @@ -305,8 +323,7 @@ async function reportCustomDimensionUsage( requestId: string ): Promise { if (tool.hosting?.rateLimit.mode !== 'custom') return - const ctx = params._context as Record | undefined - const billingActorId = executionContext?.workspaceId || (ctx?.workspaceId as string | undefined) + const { workspaceId: billingActorId } = resolveToolScope(params, executionContext) if (!billingActorId) return const rateLimiter = getHostedKeyRateLimiter() @@ -353,6 +370,10 @@ function stripInternalFields(output: Record): Record) { + return isCustomTool(toolId) ? output : stripInternalFields(output) +} + /** * Apply post-execution hosted-key cost tracking to a successful tool result. * Reports custom dimension usage, calculates cost, and merges it into the output. @@ -599,18 +620,19 @@ export async function executeTool( // Normalize tool ID to strip resource suffixes (e.g., workflow_executor_ -> workflow_executor) const normalizedToolId = normalizeToolId(toolId) + const scope = resolveToolScope(params, executionContext) + // Handle load_skill tool for agent skills progressive disclosure if (normalizedToolId === 'load_skill') { const skillName = params.skill_name - const workspaceId = params._context?.workspaceId - if (!skillName || !workspaceId) { + if (!skillName || !scope.workspaceId) { return { success: false, output: { error: 'Missing skill_name or workspace context' }, error: 'Missing skill_name or workspace context', } } - const content = await resolveSkillContent(skillName, workspaceId) + const content = await resolveSkillContent(skillName, scope.workspaceId) if (!content) { return { success: false, @@ -624,11 +646,13 @@ export async function executeTool( } } - // If it's a custom tool, use the async version with workflowId + // If it's a custom tool, use the async version if (isCustomTool(normalizedToolId)) { - const workflowId = params._context?.workflowId - const userId = params._context?.userId - tool = await getToolAsync(normalizedToolId, workflowId, userId) + tool = await toolsUtilsServer.getToolAsync(normalizedToolId, { + workflowId: scope.workflowId, + userId: scope.userId, + workspaceId: scope.workspaceId, + }) if (!tool) { logger.error(`[${requestId}] Custom tool not found: ${normalizedToolId}`) } @@ -799,9 +823,7 @@ export async function executeTool( ) } - const strippedOutput = isCustomTool(normalizedToolId) - ? finalResult.output - : stripInternalFields(finalResult.output ?? {}) + const strippedOutput = postProcessToolOutput(normalizedToolId, finalResult.output ?? {}) return { ...finalResult, @@ -856,9 +878,7 @@ export async function executeTool( ) } - const strippedOutput = isCustomTool(normalizedToolId) - ? finalResult.output - : stripInternalFields(finalResult.output ?? {}) + const strippedOutput = postProcessToolOutput(normalizedToolId, finalResult.output ?? {}) return { ...finalResult, @@ -1556,17 +1576,13 @@ async function executeMcpTool( ) } - const workspaceId = params._context?.workspaceId || executionContext?.workspaceId - const workflowId = params._context?.workflowId || executionContext?.workflowId - const userId = params._context?.userId || executionContext?.userId - const callChain = - (params._context?.callChain as string[] | undefined) || executionContext?.callChain + const mcpScope = resolveToolScope(params, executionContext) - if (callChain && callChain.length > 0) { - headers[SIM_VIA_HEADER] = serializeCallChain(callChain) + if (mcpScope.callChain && mcpScope.callChain.length > 0) { + headers[SIM_VIA_HEADER] = serializeCallChain(mcpScope.callChain) } - if (!workspaceId) { + if (!mcpScope.workspaceId) { return { success: false, output: {}, @@ -1586,8 +1602,8 @@ async function executeMcpTool( serverId, toolName, arguments: toolArguments, - workflowId, // Pass workflow context for user resolution - workspaceId, // Pass workspace context for scoping + workflowId: mcpScope.workflowId, + workspaceId: mcpScope.workspaceId, } // Include schema to skip discovery on execution @@ -1601,14 +1617,14 @@ async function executeMcpTool( validateRequestBodySize(body, actualRequestId, `mcp:${toolId}`) logger.info(`[${actualRequestId}] Making MCP tool request to ${toolName} on ${serverId}`, { - hasWorkspaceId: !!workspaceId, - hasWorkflowId: !!workflowId, + hasWorkspaceId: !!mcpScope.workspaceId, + hasWorkflowId: !!mcpScope.workflowId, hasToolSchema: !!toolSchema, }) const mcpUrl = new URL('/api/mcp/tools/execute', baseUrl) - if (userId) { - mcpUrl.searchParams.set('userId', userId) + if (mcpScope.userId) { + mcpUrl.searchParams.set('userId', mcpScope.userId) } const response = await fetch(mcpUrl.toString(), { diff --git a/apps/sim/tools/utils.server.ts b/apps/sim/tools/utils.server.ts index dca9880c4ad..7abce99e125 100644 --- a/apps/sim/tools/utils.server.ts +++ b/apps/sim/tools/utils.server.ts @@ -1,14 +1,26 @@ import { createLogger } from '@sim/logger' +import { generateInternalToken } from '@/lib/auth/internal' import { secureFetchWithPinnedIP, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { isCustomTool } from '@/executor/constants' +import type { CustomToolDefinition } from '@/hooks/queries/custom-tools' import { extractErrorMessage } from '@/tools/error-extractors' +import { tools } from '@/tools/registry' import type { ToolConfig, ToolResponse } from '@/tools/types' import type { RequestParams } from '@/tools/utils' +import { createCustomToolRequestBody, createParamSchema, createToolConfig } from '@/tools/utils' const logger = createLogger('ToolsUtils') +export interface GetToolAsyncContext { + workflowId?: string + userId?: string + workspaceId?: string +} + /** * Execute the actual request and transform the response. * Server-only: uses DNS validation and IP-pinned fetch. @@ -75,3 +87,88 @@ export async function executeRequest( } } } + +// Get a tool by its ID asynchronously (supports server-side) +export async function getToolAsync( + toolId: string, + context: GetToolAsyncContext = {} +): Promise { + const builtInTool = tools[toolId] + if (builtInTool) return builtInTool + + if (isCustomTool(toolId)) { + return fetchCustomToolFromAPI(toolId, context) + } + + return undefined +} + +async function fetchCustomToolFromAPI( + customToolId: string, + context: GetToolAsyncContext +): Promise { + const { workflowId, userId, workspaceId } = context + const identifier = customToolId.replace('custom_', '') + + try { + const baseUrl = getInternalApiBaseUrl() + const url = new URL('/api/tools/custom', baseUrl) + + if (workflowId) { + url.searchParams.append('workflowId', workflowId) + } + if (userId) { + url.searchParams.append('userId', userId) + } + if (workspaceId) { + url.searchParams.append('workspaceId', workspaceId) + } + + const headers: Record = {} + + try { + const internalToken = await generateInternalToken(userId) + headers.Authorization = `Bearer ${internalToken}` + } catch (error) { + logger.warn('Failed to generate internal token for custom tools fetch', { error }) + } + + const response = await fetch(url.toString(), { headers }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error(`Failed to fetch custom tools: ${response.statusText}`) + return undefined + } + + const result = await response.json() + + if (!result.data || !Array.isArray(result.data)) { + logger.error(`Invalid response when fetching custom tools: ${JSON.stringify(result)}`) + return undefined + } + + const customTool = result.data.find( + (tool: CustomToolDefinition) => tool.id === identifier || tool.title === identifier + ) as CustomToolDefinition | undefined + + if (!customTool) { + logger.error(`Custom tool not found: ${identifier}`) + return undefined + } + + const toolConfig = createToolConfig(customTool, customToolId) + + return { + ...toolConfig, + params: createParamSchema(customTool), + request: { + ...toolConfig.request, + body: createCustomToolRequestBody(customTool, false, workflowId), + }, + } + } catch (error) { + logger.error(`Error fetching custom tool ${identifier} from API:`, error) + return undefined + } +} diff --git a/apps/sim/tools/utils.ts b/apps/sim/tools/utils.ts index 581d4a6ac58..2f944c18bd4 100644 --- a/apps/sim/tools/utils.ts +++ b/apps/sim/tools/utils.ts @@ -1,8 +1,6 @@ import { createLogger } from '@sim/logger' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' -import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' -import { AGENT, isCustomTool } from '@/executor/constants' -import { getCustomTool } from '@/hooks/queries/custom-tools' +import type { CustomToolDefinition } from '@/hooks/queries/custom-tools' import { useEnvironmentStore } from '@/stores/settings/environment' import { tools } from '@/tools/registry' import type { ToolConfig } from '@/tools/types' @@ -286,48 +284,20 @@ export function createCustomToolRequestBody( } // Get a tool by its ID -export function getTool(toolId: string): ToolConfig | undefined { +export function getTool(toolId: string, _workspaceId?: string): ToolConfig | undefined { // Check for built-in tools const builtInTool = tools[toolId] if (builtInTool) return builtInTool - // Check if it's a custom tool - if (isCustomTool(toolId) && typeof window !== 'undefined') { - // Only try to use the sync version on the client - const identifier = toolId.slice(AGENT.CUSTOM_TOOL_PREFIX.length) - - // Try to find the tool from query cache (extracts workspaceId from URL) - const customTool = getCustomTool(identifier) - - if (customTool) { - return createToolConfig(customTool, toolId) - } - } - // If not found or running on the server, return undefined return undefined } -// Get a tool by its ID asynchronously (supports server-side) -export async function getToolAsync( - toolId: string, - workflowId?: string, - userId?: string -): Promise { - // Check for built-in tools - const builtInTool = tools[toolId] - if (builtInTool) return builtInTool - - // Check if it's a custom tool - if (isCustomTool(toolId)) { - return fetchCustomToolFromAPI(toolId, workflowId, userId) - } - - return undefined -} - // Helper function to create a tool config from a custom tool -function createToolConfig(customTool: any, customToolId: string): ToolConfig { +export function createToolConfig( + customTool: CustomToolDefinition, + customToolId: string +): ToolConfig { // Create a parameter schema from the custom tool schema const params = createParamSchema(customTool) @@ -363,102 +333,3 @@ function createToolConfig(customTool: any, customToolId: string): ToolConfig { }, } } - -// Create a tool config from a custom tool definition by fetching from API -async function fetchCustomToolFromAPI( - customToolId: string, - workflowId?: string, - userId?: string -): Promise { - const identifier = customToolId.replace('custom_', '') - - try { - const baseUrl = getInternalApiBaseUrl() - const url = new URL('/api/tools/custom', baseUrl) - - if (workflowId) { - url.searchParams.append('workflowId', workflowId) - } - if (userId) { - url.searchParams.append('userId', userId) - } - - // For server-side calls (during workflow execution), use internal JWT token - const headers: Record = {} - if (typeof window === 'undefined') { - try { - const { generateInternalToken } = await import('@/lib/auth/internal') - const internalToken = await generateInternalToken(userId) - headers.Authorization = `Bearer ${internalToken}` - } catch (error) { - logger.warn('Failed to generate internal token for custom tools fetch', { error }) - // Continue without token - will fail auth and be reported upstream - } - } - - const response = await fetch(url.toString(), { - headers, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error(`Failed to fetch custom tools: ${response.statusText}`) - return undefined - } - - const result = await response.json() - - if (!result.data || !Array.isArray(result.data)) { - logger.error(`Invalid response when fetching custom tools: ${JSON.stringify(result)}`) - return undefined - } - - // Try to find the tool by ID or title - const customTool = result.data.find( - (tool: any) => tool.id === identifier || tool.title === identifier - ) - - if (!customTool) { - logger.error(`Custom tool not found: ${identifier}`) - return undefined - } - - // Create a parameter schema - const params = createParamSchema(customTool) - - // Create a tool config for the custom tool - return { - id: customToolId, - name: customTool.title, - description: customTool.schema.function?.description || '', - version: '1.0.0', - params, - - // Request configuration - for custom tools we'll use the execute endpoint - request: { - url: '/api/function/execute', - method: 'POST', - headers: () => ({ 'Content-Type': 'application/json' }), - body: createCustomToolRequestBody(customTool, false, workflowId), - }, - - // Same response handling as client-side - transformResponse: async (response: Response) => { - const data = await response.json() - - if (!data.success) { - throw new Error(data.error || 'Custom tool execution failed') - } - - return { - success: true, - output: data.output.result || data.output, - error: undefined, - } - }, - } - } catch (error) { - logger.error(`Error fetching custom tool ${identifier} from API:`, error) - return undefined - } -}