From 7898e5d75fd1c65b9d1d6a74bbe2f3ef6c199898 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 31 Mar 2026 15:49:16 -0700 Subject: [PATCH] improvement(workflows): replace Zustand workflow sync with React Query as single source of truth (#3860) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(workflows): replace Zustand workflow sync with React Query as single source of truth * fix(workflows): address PR review feedback — sandbox execution, hydration deadlock, test mock, copy casing * lint * improvement(workflows): adopt skipToken over enabled+as-string for type-safe conditional queries * improvement(workflows): remove dead complexity, fix mutation edge cases - Throw on state PUT failure in useCreateWorkflow instead of swallowing - Use Map for O(1) lookups in duplicate/export loops (3 hooks) - Broaden invalidation scope in update/delete mutations to lists() - Switch workflow-block to useWorkflowMap for direct ID lookup - Consolidate use-workflow-operations to single useWorkflowMap hook - Remove workspace transition guard (sync body, unreachable timeout) - Make switchToWorkspace synchronous (remove async/try-catch/finally) * fix(workflows): resolve cold-start deadlock on direct URL navigation loadWorkflowState used hydration.workspaceId (null on cold start) to look up the RQ cache, causing "Workflow not found" even when the workflow exists in the DB. Now falls back to getWorkspaceIdFromUrl() and skips the cache guard when the cache is empty (letting the API fetch proceed). Also removes the redundant isRegistryReady guard in workflow.tsx that blocked setActiveWorkflow when hydration.workspaceId was null. * fix(ui): prevent flash of empty state while workflows query is pending Dashboard and EmbeddedWorkflow checked workflow list length before the RQ query resolved, briefly showing "No workflows" or "Workflow not found" on initial load. Now gates on isPending first. * fix(workflows): address PR review — await description update, revert state PUT throw - api-info-modal: use mutateAsync for description update so errors are caught by the surrounding try/catch instead of silently swallowed - useCreateWorkflow: revert state PUT to log-only — the workflow is already created in the DB, throwing rolls back the optimistic entry and makes it appear the creation failed when it actually succeeded * move folders over to react query native, restructure passage of data * pass signal correctly * fix types * fix workspace id * address comment * soft deletion accuring --------- Co-authored-by: Vikhyath Mondreti --- .../components/sandbox-canvas-provider.tsx | 39 +- .../add-resource-dropdown.tsx | 2 +- .../resource-content/resource-content.tsx | 10 +- .../resource-registry/resource-registry.tsx | 18 +- .../resource-tabs/resource-tabs.tsx | 2 +- .../home/components/user-input/user-input.tsx | 8 +- .../user-message-content.tsx | 16 +- .../[workspaceId]/home/hooks/use-chat.ts | 94 ++- .../app/workspace/[workspaceId]/layout.tsx | 2 + .../workflows-list/workflows-list.tsx | 6 +- .../logs/components/dashboard/dashboard.tsx | 8 +- .../workflow-selector/workflow-selector.tsx | 4 +- .../logs-toolbar/components/search/search.tsx | 14 +- .../components/logs-toolbar/logs-toolbar.tsx | 12 +- .../app/workspace/[workspaceId]/logs/logs.tsx | 17 +- .../providers/workspace-scope-sync.tsx | 24 + .../recently-deleted/recently-deleted.tsx | 7 +- .../components/user-input/constants.ts | 1 - .../user-input/hooks/use-mention-data.ts | 10 +- .../general/components/api-info-modal.tsx | 19 +- .../components/deploy-modal/deploy-modal.tsx | 9 +- .../panel/components/deploy/deploy.tsx | 5 +- .../components/tools/credential-selector.tsx | 6 +- .../components/tool-input/tool-input.tsx | 2 +- .../workflow-selector-input.tsx | 5 +- .../w/[workflowId]/components/panel/panel.tsx | 39 +- .../workflow-block/workflow-block.tsx | 12 +- .../hooks/use-workflow-execution.ts | 17 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 39 +- .../components/block/block.tsx | 34 +- .../preview-workflow/preview-workflow.tsx | 16 + .../components/folder-item/folder-item.tsx | 11 +- .../workflow-item/workflow-item.tsx | 23 +- .../workflow-list/workflow-list.tsx | 17 +- .../components/sidebar/hooks/use-drag-drop.ts | 21 +- .../sidebar/hooks/use-workflow-operations.ts | 13 +- .../sidebar/hooks/use-workspace-management.ts | 2 +- .../w/components/sidebar/sidebar.tsx | 20 +- .../[workspaceId]/w/hooks/use-can-delete.ts | 14 +- .../w/hooks/use-delete-selection.ts | 18 +- .../w/hooks/use-delete-workflow.ts | 23 +- .../w/hooks/use-duplicate-folder.ts | 8 +- .../w/hooks/use-duplicate-selection.ts | 14 +- .../w/hooks/use-duplicate-workflow.ts | 6 +- .../w/hooks/use-export-folder.ts | 17 +- .../w/hooks/use-export-selection.ts | 37 +- .../w/hooks/use-export-workflow.ts | 13 +- .../w/hooks/use-import-workflow.ts | 8 +- .../app/workspace/[workspaceId]/w/page.tsx | 34 +- apps/sim/blocks/blocks/agent.ts | 51 +- apps/sim/blocks/blocks/evaluator.ts | 2 +- apps/sim/blocks/blocks/router.ts | 2 +- apps/sim/blocks/utils.ts | 24 +- .../executor/handlers/agent/agent-handler.ts | 36 +- .../handlers/workflow/workflow-handler.ts | 9 +- apps/sim/hooks/queries/custom-tools.ts | 47 +- apps/sim/hooks/queries/deployments.test.ts | 51 ++ apps/sim/hooks/queries/deployments.ts | 3 +- apps/sim/hooks/queries/folders.test.ts | 146 ++-- apps/sim/hooks/queries/folders.ts | 134 ++-- apps/sim/hooks/queries/general-settings.ts | 4 +- apps/sim/hooks/queries/logs.ts | 2 +- apps/sim/hooks/queries/subscription.ts | 2 +- .../queries/utils/custom-tool-cache.test.ts | 39 + .../hooks/queries/utils/custom-tool-cache.ts | 23 + .../hooks/queries/utils/custom-tool-keys.ts | 6 + .../utils/fetch-deployment-version-state.ts | 27 + apps/sim/hooks/queries/utils/folder-cache.ts | 15 + apps/sim/hooks/queries/utils/folder-keys.ts | 5 + .../utils/invalidate-workflow-lists.test.ts | 25 + .../utils/invalidate-workflow-lists.ts | 25 + .../queries/utils/optimistic-mutation.ts | 8 +- .../queries/utils/workflow-cache.test.ts | 46 ++ .../sim/hooks/queries/utils/workflow-cache.ts | 29 + apps/sim/hooks/queries/utils/workflow-keys.ts | 13 + .../queries/utils/workflow-list-query.ts | 61 ++ apps/sim/hooks/queries/workflows.ts | 722 ++++++++++-------- apps/sim/hooks/selectors/query-keys.ts | 7 + apps/sim/hooks/selectors/registry.test.ts | 84 ++ apps/sim/hooks/selectors/registry.ts | 40 +- apps/sim/hooks/selectors/types.ts | 1 + .../sim/hooks/selectors/use-selector-query.ts | 4 +- apps/sim/hooks/use-permission-config.ts | 4 +- .../tools/client/tool-display-registry.ts | 22 +- apps/sim/lib/core/utils/optimistic-update.ts | 103 --- apps/sim/lib/folders/tree.ts | 59 ++ .../workflows/comparison/resolve-values.ts | 26 +- .../lib/workflows/subblocks/context.test.ts | 10 + apps/sim/lib/workflows/subblocks/context.ts | 3 +- apps/sim/providers/models.ts | 41 +- apps/sim/stores/folders/store.ts | 65 -- apps/sim/stores/index.ts | 1 - apps/sim/stores/panel/variables/store.ts | 11 +- apps/sim/stores/workflows/index.ts | 38 +- apps/sim/stores/workflows/registry/store.ts | 522 ++----------- apps/sim/stores/workflows/registry/types.ts | 17 +- apps/sim/stores/workflows/workflow/store.ts | 26 +- apps/sim/stores/workflows/workflow/types.ts | 2 + apps/sim/tools/index.test.ts | 146 ++-- apps/sim/tools/index.ts | 104 +-- apps/sim/tools/utils.server.ts | 97 +++ apps/sim/tools/utils.ts | 141 +--- 102 files changed, 2030 insertions(+), 1897 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx create mode 100644 apps/sim/hooks/queries/deployments.test.ts create mode 100644 apps/sim/hooks/queries/utils/custom-tool-cache.test.ts create mode 100644 apps/sim/hooks/queries/utils/custom-tool-cache.ts create mode 100644 apps/sim/hooks/queries/utils/custom-tool-keys.ts create mode 100644 apps/sim/hooks/queries/utils/fetch-deployment-version-state.ts create mode 100644 apps/sim/hooks/queries/utils/folder-cache.ts create mode 100644 apps/sim/hooks/queries/utils/folder-keys.ts create mode 100644 apps/sim/hooks/queries/utils/invalidate-workflow-lists.test.ts create mode 100644 apps/sim/hooks/queries/utils/invalidate-workflow-lists.ts create mode 100644 apps/sim/hooks/queries/utils/workflow-cache.test.ts create mode 100644 apps/sim/hooks/queries/utils/workflow-cache.ts create mode 100644 apps/sim/hooks/queries/utils/workflow-keys.ts create mode 100644 apps/sim/hooks/queries/utils/workflow-list-query.ts create mode 100644 apps/sim/hooks/selectors/query-keys.ts create mode 100644 apps/sim/hooks/selectors/registry.test.ts delete mode 100644 apps/sim/lib/core/utils/optimistic-update.ts create mode 100644 apps/sim/lib/folders/tree.ts diff --git a/apps/sim/app/academy/components/sandbox-canvas-provider.tsx b/apps/sim/app/academy/components/sandbox-canvas-provider.tsx index 62edc38bc7..3bd682f43e 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 bd0cf8cc79..821d6c4724 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 6e5913caa1..3ec33a6736 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 4b545dc298..5c8bd184cf 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 20e132089c..c630d50b5e 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 2735afc993..c44471f019 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 7b5c1cda63..5fa965d9ab 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -21,27 +21,8 @@ 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 { deploymentKeys } from '@/hooks/queries/deployments' -import { - fetchChatHistory, - type StreamSnapshot, - type TaskChatHistory, - type TaskStoredContentBlock, - type TaskStoredFileAttachment, - type TaskStoredMessage, - type TaskStoredToolCall, - taskKeys, - useChatHistory, -} from '@/hooks/queries/tasks' -import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' -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, @@ -56,7 +37,30 @@ import type { SSEPayload, SSEPayloadData, ToolCallStatus, -} from '../types' +} from '@/app/workspace/[workspaceId]/home/types' +import { deploymentKeys } from '@/hooks/queries/deployments' +import { + fetchChatHistory, + type StreamSnapshot, + type TaskChatHistory, + type TaskStoredContentBlock, + type TaskStoredFileAttachment, + type TaskStoredMessage, + type TaskStoredToolCall, + 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 type { ChatContext } from '@/stores/panel' +import { consolePersistence, useTerminalConsoleStore } from '@/stores/terminal' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +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 ad7b57e437..075d1f2d39 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 70915c2b8f..ee52e0fa0b 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 e19df1fc19..f0ff6ca54b 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 02c3173bd8..f0172a1ee1 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 2fa5a48e25..b895f447b5 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 542a64ca0e..04926c0866 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 489c44c3ad..a939a859c1 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, @@ -784,8 +783,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[] = [] @@ -1244,12 +1243,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 0000000000..6ffcb96fac --- /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 8fdb3500bc..c9b3f37a48 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 72fc8e3027..cdced3555e 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 15f5007b6b..687deb61e8 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 c701e0bbb6..4fca3c633b 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 ed55ce7881..6e228f22c3 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 3285337ab9..78081e6eea 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 75db6bedff..8193f36ff9 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 @@ -19,6 +19,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' @@ -78,9 +79,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 0adb1a286b..7c77db6e61 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 4b5abc8afd..4d1266b273 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 05eb824623..0f349d1b85 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 8f7b8b80d0..70ddefc95e 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 08bb15ab3b..2e8e4b8ce4 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 c1a2b93807..55b6d05670 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -73,6 +73,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' @@ -279,7 +280,12 @@ const WorkflowContent = React.memo( useOAuthReturnForWorkflow(workflowIdParam) const { - workflows, + data: workflows = {}, + isLoading: isWorkflowMapLoading, + isPlaceholderData: isWorkflowMapPlaceholderData, + } = useWorkflowMap(workspaceId) + + const { activeWorkflowId, hydration, setActiveWorkflow, @@ -292,7 +298,6 @@ const WorkflowContent = React.memo( clearPendingSelection, } = useWorkflowRegistry( useShallow((state) => ({ - workflows: state.workflows, activeWorkflowId: state.activeWorkflowId, hydration: state.hydration, setActiveWorkflow: state.setActiveWorkflow, @@ -357,12 +362,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, @@ -2204,23 +2211,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 } @@ -2269,6 +2275,8 @@ const WorkflowContent = React.memo( } }, [ workflowIdParam, + isWorkflowMapLoading, + isWorkflowMapPlaceholderData, currentWorkflowExists, activeWorkflowId, setActiveWorkflow, @@ -2286,8 +2294,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 } @@ -2330,9 +2342,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 f6183bbd66..f2c78c680c 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 c1487a4b72..dfde43d479 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 1b5680c16e..295f8d8803 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 07185435df..32a5834302 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 6b4d5c180b..49df9dc57a 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 4e86d19749..63f1d0d25f 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 e304f7d597..054151a5a5 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 7004584a5e..47da0573a4 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 e27de3a895..d77d9c7483 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 e109ca816c..22c96afbb8 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 b37cf32c32..bb22fb2fa0 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 37a56d24c0..0e9c5d82cc 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 43b7399e4d..56851aed4f 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 48a48146e0..4ee2329253 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 e1a14b49bf..bffe42d626 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 e7a646b2f6..ff85f17e93 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 92502caf55..deed7a4e1d 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 subfolders = collectSubfoldersForMultipleFolders(folderIds, folderMap) - const selectedFoldersData: FolderExportData[] = folderIds.map((folderId) => { - const folder = folders[folderId] - return { - id: folder.id, - name: folder.name, - parentId: null, - } - }) + 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 afa812ed54..91faca9862 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 c7f461b71f..75ed0a4577 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 e19bfd387e..1276a78527 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) + const workspaceWorkflows = workflows.filter((w) => w.workspaceId === workspaceId) - // Validate that workflows belong to the current workspace - const workspaceWorkflows = workflowIds.filter((id) => { - const workflow = workflows[id] - return workflow.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 a7a0829c88..b44ef4658c 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 4edb032ba9..d3a78fa957 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 4fae2383ff..c82c502c1c 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 373538b50e..d187b2e751 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 492d07c9b0..b277de8764 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 50db926be7..4e92948ee1 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 adb8af7183..78af01fd0e 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 0000000000..97628bf202 --- /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 0896e4d599..62f5e970d3 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 9af6eaa5bc..355b3d899e 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) + mockGetFolderMap.mockImplementation(() => folderMapState) + mockGetWorkflows.mockImplementation(() => workflowList) - 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(), - }, + 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])) - 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, - }, + workflowList = [ + { + id: 'workflow-parent-match', + name: 'Existing sibling workflow', + workspaceId: 'ws-1', + folderId: 'parent-1', + sortOrder: 2, }, - } + { + 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 5b22872fee..4ee686ee1e 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 } + const snapshot = queryClient.getQueryData( + folderKeys.list(variables.workspaceId) + ) - 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 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 1d0644499a..0fb05164dd 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 3bed38cdf1..0e684c3dc8 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 c1149bf6d8..c30fbff6c3 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 0000000000..c4b7fd5402 --- /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 0000000000..407911ddb3 --- /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 0000000000..1bb74bd8da --- /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 0000000000..ded2a7a10c --- /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 0000000000..158f558bb6 --- /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 0000000000..517d5cd9b9 --- /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 0000000000..6c5963840a --- /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 0000000000..f04e19aa9b --- /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 47482e7ffa..27f45ff26b 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 0000000000..a3ccf8da64 --- /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 0000000000..c810a236f8 --- /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 0000000000..8512e02a42 --- /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 0000000000..f88627a33c --- /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 c2ae3a4036..f850eae3f4 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 { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys' -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, -} - -/** - * 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, - }) - - 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) - - 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, }) +} - useEffect(() => { - if ( - syncRegistry && - scope === 'active' && - workspaceId && - (query.status === 'pending' || query.isPlaceholderData) - ) { - beginMetadataLoad(workspaceId) - } - }, [syncRegistry, scope, workspaceId, query.status, query.isPlaceholderData, beginMetadataLoad]) +/** + * 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 || {} - 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 + return useQuery({ + queryKey: workflowKeys.list(workspaceId, scope), + queryFn: workspaceId ? getWorkflowListQueryOptions(workspaceId, scope).queryFn : skipToken, + placeholderData: keepPreviousData, + staleTime: WORKFLOW_LIST_STALE_TIME, + select: (data) => Object.fromEntries(data.map((w) => [w.id, w])), + }) } 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,41 +254,50 @@ 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']) }, }) } +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 +} + export function useDuplicateWorkflowMutation() { const queryClient = useQueryClient() - const handlers = createWorkflowMutationHandlers( - queryClient, - 'DuplicateWorkflow', - (variables, tempId) => { - const currentWorkflows = useWorkflowRegistry.getState().workflows - const currentFolders = useFolderStore.getState().folders - const targetFolderId = variables.folderId ?? null - - 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() - ) - return useMutation({ mutationFn: async (variables: DuplicateWorkflowVariables): Promise => { const { workspaceId, sourceId, name, description, color, folderId, newId } = variables @@ -449,11 +343,81 @@ export function useDuplicateWorkflowMutation() { subflowsCount: duplicatedWorkflow.subflowsCount || 0, } }, - ...handlers, - onSuccess: (data, variables, context) => { - handlers.onSuccess(data, variables, context) + 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) => { + 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') + } - return data.deployedState + const { workflow: updatedWorkflow } = await response.json() + return mapWorkflow(updatedWorkflow) + }, + 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 ?? []).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 } - - useWorkflowRegistry.setState((state) => { - const updated = { ...state.workflows } - for (const update of variables.updates) { - if (updated[update.id]) { - updated[update.id] = { - ...updated[update.id], - sortOrder: update.sortOrder, - folderId: - update.folderId !== undefined ? update.folderId : updated[update.id].folderId, - } - } - } - return { workflows: updated } + await queryClient.cancelQueries({ + queryKey: workflowKeys.list(variables.workspaceId, 'active'), }) + 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 : w.folderId, + } + }) + ) + 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 0000000000..c5fa1afe97 --- /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 0000000000..fe3d596fac --- /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 fd050f97a6..be48437b7d 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 87e1572ef5..2324106a80 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 6486e76977..0323c7f5e7 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 f103aa3143..f52b8aae18 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 617626700d..9a9cb88ca8 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 4759255e4d..0000000000 --- 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 0000000000..41196a7e35 --- /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 6858535a6a..2fe7f24a34 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 c30f1f1b0a..29cad46d94 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 9b43bc892f..affd888e2a 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 37f973198c..e195e873de 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 c21f0cc03b..88a5211e6b 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 4ae3c335f0..d1bbbc9b22 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 70ebc2b719..e9a7db871c 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 c3cc04ec6f..e2fdf9a7c4 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 dca49b8dda..4d3fece524 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,32 +170,6 @@ export const useWorkflowRegistry = create()( } const workflowData = (await response.json()).data - let workflowState: any - - if (workflowData?.state) { - workflowState = { - blocks: workflowData.state.blocks || {}, - edges: workflowData.state.edges || [], - loops: workflowData.state.loops || {}, - parallels: workflowData.state.parallels || {}, - lastSaved: Date.now(), - deploymentStatuses: {}, - } - } else { - workflowState = { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - deploymentStatuses: {}, - lastSaved: Date.now(), - } - - logger.info( - `Workflow ${workflowId} has no state yet - will load from DB or show empty canvas` - ) - } - const nextDeploymentStatuses = workflowData?.isDeployed || workflowData?.deployedAt ? { @@ -320,6 +185,34 @@ export const useWorkflowRegistry = create()( } : 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: nextDeploymentStatuses, + } + } else { + workflowState = { + currentWorkflowId: workflowId, + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + deploymentStatuses: nextDeploymentStatuses, + lastSaved: Date.now(), + } + + logger.info( + `Workflow ${workflowId} has no state yet - will load from DB or show empty canvas` + ) + } + 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 1b22fe87d0..375ee0df23 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 10a580c36f..0ddbf8d420 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 edbc606188..21f22ff547 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 c5beca880b..a55aefa1ee 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 output = postProcessToolOutput('custom_test-preserve-dunder', { + 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') - - 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 c354b691c2..290960873a 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 dca9880c4a..7abce99e12 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 581d4a6ac5..2f944c18bd 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 - } -}