From a579f22db1359091d17fd341a1aa4f5e620a22cf Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 30 Mar 2026 19:51:59 -0700 Subject: [PATCH 01/14] improvement(workflows): replace Zustand workflow sync with React Query as single source of truth --- .../components/sandbox-canvas-provider.tsx | 39 +- .../add-resource-dropdown.tsx | 2 +- .../resource-content/resource-content.tsx | 9 +- .../resource-registry/resource-registry.tsx | 13 +- .../resource-tabs/resource-tabs.tsx | 2 +- .../home/components/user-input/user-input.tsx | 4 +- .../user-message-content.tsx | 16 +- .../[workspaceId]/home/hooks/use-chat.ts | 44 +- .../workflows-list/workflows-list.tsx | 6 +- .../logs/components/dashboard/dashboard.tsx | 8 +- .../workflow-selector/workflow-selector.tsx | 4 +- .../logs-toolbar/components/search/search.tsx | 10 +- .../components/logs-toolbar/logs-toolbar.tsx | 8 +- .../app/workspace/[workspaceId]/logs/logs.tsx | 10 +- .../recently-deleted/recently-deleted.tsx | 2 +- .../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 +- .../w/[workflowId]/components/panel/panel.tsx | 37 +- .../workflow-block/workflow-block.tsx | 12 +- .../hooks/use-workflow-execution.ts | 14 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 9 +- .../components/block/block.tsx | 6 +- .../components/folder-item/folder-item.tsx | 7 +- .../workflow-item/workflow-item.tsx | 17 +- .../components/sidebar/hooks/use-drag-drop.ts | 11 +- .../sidebar/hooks/use-workflow-operations.ts | 11 +- .../w/components/sidebar/sidebar.tsx | 10 +- .../[workspaceId]/w/hooks/use-can-delete.ts | 10 +- .../w/hooks/use-delete-selection.ts | 18 +- .../w/hooks/use-delete-workflow.ts | 23 +- .../w/hooks/use-duplicate-selection.ts | 7 +- .../w/hooks/use-duplicate-workflow.ts | 7 +- .../w/hooks/use-export-folder.ts | 6 +- .../w/hooks/use-export-selection.ts | 11 +- .../w/hooks/use-export-workflow.ts | 12 +- .../app/workspace/[workspaceId]/w/page.tsx | 21 +- .../handlers/workflow/workflow-handler.ts | 9 +- apps/sim/hooks/queries/custom-tools.ts | 7 +- apps/sim/hooks/queries/folders.ts | 11 +- .../utils/get-workspace-id-from-url.ts | 10 + apps/sim/hooks/queries/utils/workflow-keys.ts | 13 + apps/sim/hooks/queries/workflows.ts | 670 +++++++++++------- apps/sim/hooks/selectors/registry.ts | 18 +- .../tools/client/tool-display-registry.ts | 3 +- apps/sim/lib/core/utils/optimistic-update.ts | 103 --- apps/sim/stores/index.ts | 1 - apps/sim/stores/workflows/index.ts | 19 +- apps/sim/stores/workflows/registry/store.ts | 404 +---------- apps/sim/stores/workflows/registry/types.ts | 15 +- 54 files changed, 742 insertions(+), 1019 deletions(-) create mode 100644 apps/sim/hooks/queries/utils/get-workspace-id-from-url.ts create mode 100644 apps/sim/hooks/queries/utils/workflow-keys.ts delete mode 100644 apps/sim/lib/core/utils/optimistic-update.ts diff --git a/apps/sim/app/academy/components/sandbox-canvas-provider.tsx b/apps/sim/app/academy/components/sandbox-canvas-provider.tsx index 62edc38bc76..3bd682f43ef 100644 --- a/apps/sim/app/academy/components/sandbox-canvas-provider.tsx +++ b/apps/sim/app/academy/components/sandbox-canvas-provider.tsx @@ -13,10 +13,12 @@ import type { import { validateExercise } from '@/lib/academy/validation' import { cn } from '@/lib/core/utils/cn' import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { SandboxWorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import Workflow from '@/app/workspace/[workspaceId]/w/[workflowId]/workflow' import { getBlock } from '@/blocks/registry' +import { workflowKeys } from '@/hooks/queries/workflows' import { SandboxBlockConstraintsContext } from '@/hooks/use-sandbox-block-constraints' import { useExecutionStore } from '@/stores/execution/store' import { useTerminalConsoleStore } from '@/stores/terminal/console/store' @@ -218,8 +220,13 @@ export function SandboxCanvasProvider({ useWorkflowStore.getState().replaceWorkflowState(workflowState) useSubBlockStore.getState().initializeFromWorkflow(workflowId, workflowState.blocks) - useWorkflowRegistry.setState((state) => ({ - workflows: { ...state.workflows, [workflowId]: syntheticMetadata }, + + const qc = getQueryClient() + const cacheKey = workflowKeys.list(SANDBOX_WORKSPACE_ID, 'active') + const cached = qc.getQueryData(cacheKey) ?? [] + qc.setQueryData(cacheKey, [...cached.filter((w) => w.id !== workflowId), syntheticMetadata]) + + useWorkflowRegistry.setState({ activeWorkflowId: workflowId, hydration: { phase: 'ready', @@ -228,7 +235,7 @@ export function SandboxCanvasProvider({ requestId: null, error: null, }, - })) + }) logger.info('Sandbox stores hydrated', { workflowId }) setIsReady(true) @@ -262,17 +269,21 @@ export function SandboxCanvasProvider({ unsubWorkflow() unsubSubBlock() unsubExecution() - useWorkflowRegistry.setState((state) => { - const { [workflowId]: _removed, ...rest } = state.workflows - return { - workflows: rest, - activeWorkflowId: state.activeWorkflowId === workflowId ? null : state.activeWorkflowId, - hydration: - state.hydration.workflowId === workflowId - ? { phase: 'idle', workspaceId: null, workflowId: null, requestId: null, error: null } - : state.hydration, - } - }) + const cleanupQc = getQueryClient() + const cleanupKey = workflowKeys.list(SANDBOX_WORKSPACE_ID, 'active') + const cleanupCached = cleanupQc.getQueryData(cleanupKey) ?? [] + cleanupQc.setQueryData( + cleanupKey, + cleanupCached.filter((w) => w.id !== workflowId) + ) + + useWorkflowRegistry.setState((state) => ({ + activeWorkflowId: state.activeWorkflowId === workflowId ? null : state.activeWorkflowId, + hydration: + state.hydration.workflowId === workflowId + ? { phase: 'idle', workspaceId: null, workflowId: null, requestId: null, error: null } + : state.hydration, + })) useWorkflowStore.setState({ blocks: {}, edges: [], loops: {}, parallels: {} }) useSubBlockStore.setState((state) => { const { [workflowId]: _removed, ...rest } = state.workflowValues diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx index bd0cf8cc792..821d6c47242 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -47,7 +47,7 @@ export function useAvailableResources( workspaceId: string, existingKeys: Set ): AvailableItemsByType[] { - const { data: workflows = [] } = useWorkflows(workspaceId, { syncRegistry: false }) + const { data: workflows = [] } = useWorkflows(workspaceId) const { data: tables = [] } = useTablesList(workspaceId) const { data: files = [] } = useWorkspaceFiles(workspaceId) const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 6e5913caa1e..f6a0edd5628 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,10 +376,12 @@ 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 } = useWorkflows(workspaceId) + const workflowExists = useMemo( + () => (workflowList ?? []).some((w) => w.id === workflowId), + [workflowList, workflowId] ) + const isMetadataLoaded = useWorkflowRegistry((state) => state.hydration.phase !== 'idle') const hasLoadError = useWorkflowRegistry( (state) => state.hydration.phase === 'error' && state.hydration.workflowId === workflowId ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index 4b545dc298b..01126734d21 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,8 @@ 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 { useWorkflows, workflowKeys } 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 +34,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 (
= { * tabs always reflect the latest name even after a rename. */ function useResourceNameLookup(workspaceId: string): Map { - const { data: workflows = [] } = useWorkflows(workspaceId, { syncRegistry: false }) + const { data: workflows = [] } = useWorkflows(workspaceId) const { data: tables = [] } = useTablesList(workspaceId) const { data: files = [] } = useWorkspaceFiles(workspaceId) const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index 2735afc993e..9fb923d97b1 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 { getWorkflows } 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' @@ -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 = getWorkflows().find((w) => w.id === wfId)?.color ?? '#888' mentionIconNode = (
{ - if (context.kind === 'workflow' || context.kind === 'current_workflow') { - return state.workflows[context.workflowId || '']?.color ?? null - } - return null - }) + const { workspaceId } = useParams<{ workspaceId: string }>() + const { data: workflowList } = useWorkflows(workspaceId) + const workflowColor = useMemo(() => { + if (context.kind !== 'workflow' && context.kind !== 'current_workflow') return null + return (workflowList ?? []).find((w) => w.id === context.workflowId)?.color ?? null + }, [workflowList, context.kind, context.workflowId]) let icon: React.ReactNode = null const iconClasses = 'h-[12px] w-[12px] flex-shrink-0 text-[var(--text-icon)]' diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 7b5c1cda635..97b392f32ff 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -21,6 +21,7 @@ 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 { @@ -35,7 +36,7 @@ import { useChatHistory, } from '@/hooks/queries/tasks' import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' -import { workflowKeys } from '@/hooks/queries/workflows' +import { getWorkflows, workflowKeys } from '@/hooks/queries/workflows' import { useExecutionStream } from '@/hooks/use-execution-stream' import { useExecutionStore } from '@/stores/execution/store' import { useFolderStore } from '@/stores/folders/store' @@ -301,31 +302,32 @@ 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.find((w) => w.id === resourceId)) return false const sortOrder = getTopInsertionSortOrder( - registry.workflows, + Object.fromEntries(workflows.map((w) => [w.id, w])), useFolderStore.getState().folders, 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: import('@/stores/workflows/registry/types').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') + const current = + queryClient.getQueryData(key) ?? + [] + queryClient.setQueryData(key, [...current, newMetadata]) return true } @@ -1253,7 +1255,7 @@ export function useChat( ? ((args as Record).workflowId as string) : useWorkflowRegistry.getState().activeWorkflowId if (targetWorkflowId) { - const meta = useWorkflowRegistry.getState().workflows[targetWorkflowId] + const meta = getWorkflows().find((w) => w.id === targetWorkflowId) const wasAdded = addResource({ type: 'workflow', id: targetWorkflowId, diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx index 70915c2b8f3..ee52e0fa0bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx @@ -1,10 +1,11 @@ import { memo } from 'react' +import { useParams } from 'next/navigation' import { cn } from '@/lib/core/utils/cn' import { DELETED_WORKFLOW_COLOR, DELETED_WORKFLOW_LABEL, } from '@/app/workspace/[workspaceId]/logs/utils' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useWorkflowMap } from '@/hooks/queries/workflows' import { StatusBar, type StatusBarSegment } from '..' export interface WorkflowExecutionItem { @@ -36,7 +37,8 @@ function WorkflowsListInner({ searchQuery: string segmentDurationMs: number }) { - const workflows = useWorkflowRegistry((s) => s.workflows) + const { workspaceId } = useParams<{ workspaceId: string }>() + const { data: workflows = {} } = useWorkflowMap(workspaceId) return (
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx index e19df1fc194..c21184c9cc3 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 = [] } = 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 (allWorkflowList.length === 0) { return (
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/workflow-selector/workflow-selector.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/workflow-selector/workflow-selector.tsx index 02c3173bd83..f0172a1ee17 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/workflow-selector/workflow-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/workflow-selector/workflow-selector.tsx @@ -25,9 +25,7 @@ export function WorkflowSelector({ onChange, error, }: WorkflowSelectorProps) { - const { data: workflows = [], isPending: isLoading } = useWorkflows(workspaceId, { - syncRegistry: false, - }) + const { data: workflows = [], isPending: isLoading } = useWorkflows(workspaceId) const options: ComboboxOption[] = useMemo(() => { return workflows.map((w) => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx index cb8b795276b..5bce8435212 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 { useWorkflows } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' 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 { workspaceId } = useParams<{ workspaceId: string }>() + const { data: workflowList = [] } = useWorkflows(workspaceId) const folders = useFolderStore((state) => state.folders) const workflowsData = useMemo(() => { - return Object.values(workflows).map((w) => ({ + return workflowList.map((w) => ({ id: w.id, name: w.name, description: w.description, })) - }, [workflows]) + }, [workflowList]) const foldersData = useMemo(() => { return Object.values(folders).map((f) => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx index 542a64ca0ed..e529e74803f 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 { useWorkflows } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' 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[] = [ @@ -220,15 +220,15 @@ export const LogsToolbar = memo(function LogsToolbar({ const [previousTimeRange, setPreviousTimeRange] = useState(timeRange) const folders = useFolderStore((state) => state.folders) - const allWorkflows = useWorkflowRegistry((state) => state.workflows) + const { data: allWorkflowList = [] } = useWorkflows(workspaceId) const workflows = useMemo(() => { - return Object.values(allWorkflows).map((w) => ({ + return allWorkflowList.map((w) => ({ id: w.id, name: w.name, color: w.color, })) - }, [allWorkflows]) + }, [allWorkflowList]) const folderList = useMemo(() => { return Object.values(folders).filter((f) => f.workspaceId === workspaceId) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 098f23158a8..cd7c174a0b9 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -57,12 +57,12 @@ import { useLogDetail, useLogsList, } from '@/hooks/queries/logs' +import { useWorkflowMap, useWorkflows } from '@/hooks/queries/workflows' import { useDebounce } from '@/hooks/use-debounce' import { useFolderStore } from '@/stores/folders/store' import { useFilterStore } from '@/stores/logs/filters/store' import type { WorkflowLog } from '@/stores/logs/filters/types' import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { Dashboard, ExecutionSnapshot, @@ -783,7 +783,7 @@ export default function Logs() { ] ) - const allWorkflows = useWorkflowRegistry((state) => state.workflows) + const { data: allWorkflows = {} } = useWorkflowMap(workspaceId) const folders = useFolderStore((state) => state.folders) const filterTags = useMemo(() => { @@ -1244,11 +1244,11 @@ 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: 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]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index 8fdb3500bcc..86b933a18d7 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') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts index 72fc8e3027b..cdced3555eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts @@ -70,7 +70,6 @@ export const FOLDER_CONFIGS: Record = { title: 'All workflows', dataKey: 'workflows', loadingKey: 'isLoadingWorkflows', - // No ensureLoadedKey - workflows auto-load from registry store getLabel: (item) => item.name || 'Untitled Workflow', getId: (item) => item.id, emptyMessage: 'No workflows', diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts index 15f5007b6bb..687deb61e86 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react' import { createLogger } from '@sim/logger' import { useShallow } from 'zustand/react/shallow' +import { useWorkflows } from '@/hooks/queries/workflows' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -151,14 +152,11 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn { useShallow(useCallback((state) => Object.keys(state.blocks), [])) ) - const registryWorkflows = useWorkflowRegistry(useShallow((state) => state.workflows)) + const { data: registryWorkflowList = [] } = useWorkflows(workspaceId) const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase) - const isLoadingWorkflows = - hydrationPhase === 'idle' || - hydrationPhase === 'metadata-loading' || - hydrationPhase === 'state-loading' + const isLoadingWorkflows = hydrationPhase === 'idle' || hydrationPhase === 'state-loading' - const workflows: WorkflowItem[] = Object.values(registryWorkflows) + const workflows: WorkflowItem[] = registryWorkflowList .filter((w) => w.workspaceId === workspaceId) .sort((a, b) => { const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0 diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx index c701e0bbb6e..12224bbc503 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' }) + updateWorkflowMutation.mutate({ + workspaceId, + workflowId, + metadata: { description: description.trim() || 'New workflow' }, + }) } if (starterBlockId) { @@ -195,16 +201,15 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro } }, [ workflowId, + workspaceId, description, workflowMetadata, - updateWorkflow, starterBlockId, inputFormat, paramDescriptions, setValue, onOpenChange, accessMode, - updatePublicApiMutation, ]) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index ed55ce78817..6e228f22c39 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' +import { useParams } from 'next/navigation' import { Badge, Button, @@ -35,6 +36,7 @@ import { } from '@/hooks/queries/deployments' // import { useTemplateByWorkflow } from '@/hooks/queries/templates' import { useWorkflowMcpServers } from '@/hooks/queries/workflow-mcp-servers' +import { useWorkflowMap } from '@/hooks/queries/workflows' import { useWorkspaceSettings } from '@/hooks/queries/workspace' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -85,14 +87,15 @@ export function DeployModal({ isLoadingDeployedState, }: DeployModalProps) { const queryClient = useQueryClient() + const params = useParams() + const workspaceId = params?.workspaceId as string const { navigateToSettings } = useSettingsNavigation() const deploymentStatus = useWorkflowRegistry((state) => state.getWorkflowDeploymentStatus(workflowId) ) const isDeployed = deploymentStatus?.isDeployed ?? isDeployedProp - const workflowMetadata = useWorkflowRegistry((state) => - workflowId ? state.workflows[workflowId] : undefined - ) + const { data: workflowMap = {} } = useWorkflowMap(workspaceId) + const workflowMetadata = workflowId ? workflowMap[workflowId] : undefined const workflowWorkspaceId = workflowMetadata?.workspaceId ?? null const [activeTab, setActiveTab] = useState('general') const [chatSubmitting, setChatSubmitting] = useState(false) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx index 3285337ab96..78081e6eeac 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx @@ -22,10 +22,7 @@ interface DeployProps { export function Deploy({ activeWorkflowId, userPermissions, className }: DeployProps) { const [isModalOpen, setIsModalOpen] = useState(false) const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase) - const isRegistryLoading = - hydrationPhase === 'idle' || - hydrationPhase === 'metadata-loading' || - hydrationPhase === 'state-loading' + const isRegistryLoading = hydrationPhase === 'idle' || hydrationPhase === 'state-loading' const { hasBlocks } = useCurrentWorkflow() const deploymentStatus = useWorkflowRegistry((state) => diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx index d7dce93c7f9..af69db47468 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx @@ -18,6 +18,7 @@ import { ConnectCredentialModal } from '@/app/workspace/[workspaceId]/w/[workflo import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal' import { useWorkspaceCredential } from '@/hooks/queries/credentials' import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials' +import { useWorkflowMap } from '@/hooks/queries/workflows' import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -77,9 +78,10 @@ export function ToolCredentialSelector({ const [showOAuthModal, setShowOAuthModal] = useState(false) const [editingInputValue, setEditingInputValue] = useState('') const [isEditing, setIsEditing] = useState(false) - const { activeWorkflowId, workflows } = useWorkflowRegistry() + const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) + const { data: workflowMap = {} } = useWorkflowMap(workspaceId) const effectiveWorkflowId = - activeWorkflowId && workflows[activeWorkflowId] ? activeWorkflowId : undefined + activeWorkflowId && workflowMap[activeWorkflowId] ? activeWorkflowId : undefined const selectedId = value || '' const effectiveLabel = label || `Select ${getProviderName(provider)} account` diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 0adb1a286bf..7c77db6e61c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -500,7 +500,7 @@ export const ToolInput = memo(function ToolInput({ const availableEnvVars = useAvailableEnvVarKeys(workspaceId) const mcpDataLoading = mcpLoading || mcpServersLoading - const { data: workflowsList = [] } = useWorkflows(workspaceId, { syncRegistry: false }) + const { data: workflowsList = [] } = useWorkflows(workspaceId) const availableWorkflows = useMemo( () => workflowsList.filter((w) => w.id !== workflowId), [workflowsList, workflowId] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 05eb824623c..83474ab234d 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) @@ -519,11 +517,21 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel return } + const sourceWorkflow = workflows[activeWorkflowId] + if (!sourceWorkflow) return + setIsDuplicating(true) try { - const newWorkflow = await duplicateWorkflow(activeWorkflowId) - if (newWorkflow) { - router.push(`/workspace/${workspaceId}/w/${newWorkflow}`) + const result = await duplicateWorkflowMutation.mutateAsync({ + workspaceId, + sourceId: activeWorkflowId, + name: `${sourceWorkflow.name} (copy)`, + description: sourceWorkflow.description, + color: sourceWorkflow.color ?? '', + folderId: sourceWorkflow.folderId, + }) + if (result?.id) { + router.push(`/workspace/${workspaceId}/w/${result.id}`) } } catch (error) { logger.error('Error duplicating workflow:', error) @@ -531,14 +539,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel setIsDuplicating(false) setIsMenuOpen(false) } - }, [ - activeWorkflowId, - userPermissions.canEdit, - isDuplicating, - duplicateWorkflow, - router, - workspaceId, - ]) + }, [activeWorkflowId, userPermissions.canEdit, isDuplicating, workflows, router, workspaceId]) /** * Toggles the locked state of all blocks in the workflow diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 8f7b8b80d0b..135efbf3348 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 { useWorkflows } 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: workflowListForLookup } = useWorkflows(workspaceId) + const workflowSelectionName = useMemo(() => { + if (subBlock?.id !== 'workflowId' || typeof rawValue !== 'string') return null + return (workflowListForLookup ?? []).find((w) => w.id === rawValue)?.name ?? null + }, [workflowListForLookup, subBlock?.id, rawValue]) const { data: mcpServers = [] } = useMcpServers(workspaceId || '') const mcpServerDisplayName = useMemo(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 08bb15ab3b9..3380e47481c 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/workflows' import { useExecutionStream } from '@/hooks/use-execution-stream' import { WorkflowValidationError } from '@/serializer' import { useCurrentWorkflowExecution, useExecutionStore } from '@/stores/execution' @@ -102,11 +104,10 @@ function normalizeErrorMessage(error: unknown): string { } export function useWorkflowExecution() { + const { workspaceId: routeWorkspaceId } = useParams<{ workspaceId: string }>() 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 +383,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 cachedWorkflows = getWorkflows(routeWorkspaceId) + 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 = activeWorkflow?.workspaceId if (!workspaceId) { logger.error('Cannot execute workflow without workspaceId') @@ -748,7 +751,6 @@ export function useWorkflowExecution() { setExecutor, setPendingBlocks, setActiveBlocks, - workflows, queryClient, ] ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index fd69e48bbc8..faf2fbe5f5f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -72,6 +72,7 @@ import { getBlock } from '@/blocks' import { isAnnotationOnlyBlock } from '@/executor/constants' import { useWorkspaceEnvironment } from '@/hooks/queries/environment' import { useAutoConnect, useSnapToGridSize } from '@/hooks/queries/general-settings' +import { useWorkflowMap } from '@/hooks/queries/workflows' import { useCanvasViewport } from '@/hooks/use-canvas-viewport' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useOAuthReturnForWorkflow } from '@/hooks/use-oauth-return' @@ -277,8 +278,9 @@ const WorkflowContent = React.memo( useOAuthReturnForWorkflow(workflowIdParam) + const { data: workflows = {} } = useWorkflowMap(workspaceId) + const { - workflows, activeWorkflowId, hydration, setActiveWorkflow, @@ -291,7 +293,6 @@ const WorkflowContent = React.memo( clearPendingSelection, } = useWorkflowRegistry( useShallow((state) => ({ - workflows: state.workflows, activeWorkflowId: state.activeWorkflowId, hydration: state.hydration, setActiveWorkflow: state.setActiveWorkflow, @@ -2201,7 +2202,7 @@ const WorkflowContent = React.memo( const currentId = workflowIdParam const currentWorkspaceHydration = hydration.workspaceId - const isRegistryReady = hydration.phase !== 'metadata-loading' && hydration.phase !== 'idle' + const isRegistryReady = hydration.phase !== 'idle' // Wait for registry to be ready to prevent race conditions if ( @@ -2275,7 +2276,7 @@ const WorkflowContent = React.memo( if (embedded || sandbox) return // Wait for metadata to finish loading before making navigation decisions - if (hydration.phase === 'metadata-loading' || hydration.phase === 'idle') { + if (hydration.phase === 'idle') { return } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx index f6183bbd661..2069c249160 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 @@ -13,8 +13,8 @@ import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { getBlock } from '@/blocks' import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types' +import { getWorkflows } from '@/hooks/queries/workflows' import { useVariablesStore } from '@/stores/panel/variables/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' /** Execution status for blocks in preview mode */ type ExecutionStatus = 'success' | 'error' | 'not-executed' @@ -112,8 +112,8 @@ function resolveWorkflowName( if (subBlock?.type !== 'workflow-selector') return null if (!rawValue || typeof rawValue !== 'string') return null - const workflowMap = useWorkflowRegistry.getState().workflows - return workflowMap[rawValue]?.name ?? DELETED_WORKFLOW_LABEL + const workflows = getWorkflows() + return workflows.find((w) => w.id === rawValue)?.name ?? DELETED_WORKFLOW_LABEL } /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx index 1b5680c16e7..ba072e09c5e 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,9 @@ import { useExportSelection, } from '@/app/workspace/[workspaceId]/w/hooks' import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders' -import { useCreateWorkflow } from '@/hooks/queries/workflows' +import { getWorkflows, 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') @@ -246,7 +245,7 @@ export function FolderItem({ const isMixed = folderIds.length > 0 && workflowIds.length > 0 const { folders } = useFolderStore.getState() - const { workflows } = useWorkflowRegistry.getState() + const workflows = getWorkflows(workspaceId) const names: string[] = [] for (const id of folderIds) { @@ -254,7 +253,7 @@ export function FolderItem({ if (f) names.push(f.name) } for (const id of workflowIds) { - const w = workflows[id] + const w = workflows.find((wf) => wf.id === id) if (w) names.push(w.name) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx index 37f276ff686..734f9041b89 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,7 @@ import { useExportSelection, useExportWorkflow, } from '@/app/workspace/[workspaceId]/w/hooks' +import { getWorkflows, 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 +61,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 +167,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,12 +228,12 @@ export function WorkflowItem({ const folderIds = Array.from(finalFolderSelection) const isMixed = workflowIds.length > 0 && folderIds.length > 0 - const { workflows } = useWorkflowRegistry.getState() + const workflows = getWorkflows(workspaceId) const { folders } = useFolderStore.getState() 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) { @@ -301,7 +302,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/hooks/use-drag-drop.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts index 4e86d19749e..cff8229eb6c 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 @@ -2,9 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useParams } from 'next/navigation' import { useReorderFolders } from '@/hooks/queries/folders' -import { useReorderWorkflows } from '@/hooks/queries/workflows' +import { getWorkflows, useReorderWorkflows } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('WorkflowList:DragDrop') @@ -234,7 +233,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) { if (cached) return cached const currentFolders = useFolderStore.getState().folders - const currentWorkflows = useWorkflowRegistry.getState().workflows + const currentWorkflows = getWorkflows(workspaceId) const siblings = [ ...Object.values(currentFolders) .filter((f) => f.parentId === folderId) @@ -244,7 +243,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, @@ -307,13 +306,13 @@ export function useDragDrop(options: UseDragDropOptions = {}) { destinationFolderId: string | null ): { fromDestination: SiblingItem[]; fromOther: SiblingItem[] } => { const { folders } = useFolderStore.getState() - const { workflows } = useWorkflowRegistry.getState() + const workflows = getWorkflows(workspaceId) const fromDestination: SiblingItem[] = [] const fromOther: SiblingItem[] = [] for (const id of workflowIds) { - const workflow = workflows[id] + const workflow = workflows.find((w) => w.id === id) if (!workflow) continue const item: SiblingItem = { type: 'workflow', diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts index e304f7d5975..c9533c049aa 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, useWorkflows } 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,18 +14,19 @@ interface UseWorkflowOperationsProps { export function useWorkflowOperations({ workspaceId }: UseWorkflowOperationsProps) { const router = useRouter() - const workflows = useWorkflowRegistry(useShallow((state) => state.workflows)) const workflowsQuery = useWorkflows(workspaceId) + const { data: workflowList = [] } = workflowsQuery + const { data: workflows = {} } = useWorkflowMap(workspaceId) const createWorkflowMutation = useCreateWorkflow() const regularWorkflows = useMemo( () => - Object.values(workflows) + workflowList .filter((workflow) => workflow.workspaceId === workspaceId) .sort((a, b) => { return b.createdAt.getTime() - a.createdAt.getTime() }), - [workflows, workspaceId] + [workflowList, workspaceId] ) const handleCreateWorkflow = useCallback(async (): Promise => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 7c89b20e191..e9f9e837fdf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -88,6 +88,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 +97,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') @@ -438,7 +438,7 @@ 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 updateWorkflowMutation = useUpdateWorkflow() const folderTree = useMemo( () => (isCollapsed && workspaceId ? getFolderTree(workspaceId) : []), @@ -814,7 +814,11 @@ export const Sidebar = memo(function Sidebar() { const workflowFlyoutRename = useFlyoutInlineRename({ itemType: 'workflow', onSave: async (workflowIdToRename, name) => { - await updateWorkflow(workflowIdToRename, { name }) + await updateWorkflowMutation.mutateAsync({ + workspaceId, + workflowId: workflowIdToRename, + metadata: { name }, + }) }, }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-can-delete.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-can-delete.ts index e109ca816cb..8d576c47d3e 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 { useWorkflows } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' interface UseCanDeleteProps { /** @@ -36,7 +36,7 @@ interface UseCanDeleteReturn { * @returns Functions to check deletion eligibility */ export function useCanDelete({ workspaceId }: UseCanDeleteProps): UseCanDeleteReturn { - const workflows = useWorkflowRegistry((s) => s.workflows) + const { data: workflowList = [] } = useWorkflows(workspaceId) const folders = useFolderStore((s) => s.folders) /** @@ -44,9 +44,7 @@ export function useCanDelete({ workspaceId }: UseCanDeleteProps): UseCanDeleteRe */ const { totalWorkflows, workflowIdSet, workflowsByFolderId, childFoldersByParentId } = useMemo(() => { - const workspaceWorkflows = Object.values(workflows).filter( - (w) => w.workspaceId === workspaceId - ) + const workspaceWorkflows = workflowList.filter((w) => w.workspaceId === workspaceId) const idSet = new Set(workspaceWorkflows.map((w) => w.id)) @@ -72,7 +70,7 @@ export function useCanDelete({ workspaceId }: UseCanDeleteProps): UseCanDeleteRe workflowsByFolderId: byFolderId, childFoldersByParentId: childrenByParent, } - }, [workflows, folders, workspaceId]) + }, [workflowList, folders, workspaceId]) /** * Count workflows in a folder and all its subfolders recursively. diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-selection.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-selection.ts index b37cf32c322..bb22fb2fa01 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-selection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-selection.ts @@ -2,8 +2,8 @@ import { useCallback, useState } from 'react' import { createLogger } from '@sim/logger' import { useRouter } from 'next/navigation' import { useDeleteFolderMutation } from '@/hooks/queries/folders' +import { useDeleteWorkflowMutation, useWorkflows } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('useDeleteSelection') @@ -46,8 +46,8 @@ export function useDeleteSelection({ onSuccess, }: UseDeleteSelectionProps) { const router = useRouter() - const workflows = useWorkflowRegistry((s) => s.workflows) - const removeWorkflow = useWorkflowRegistry((s) => s.removeWorkflow) + const { data: workflowList = [] } = useWorkflows(workspaceId) + const deleteWorkflowMutation = useDeleteWorkflowMutation() const deleteFolderMutation = useDeleteFolderMutation() const [isDeleting, setIsDeleting] = useState(false) @@ -72,7 +72,7 @@ export function useDeleteSelection({ ? workflowIds.some((id) => isActiveWorkflow(id)) : false - const sidebarWorkflows = Object.values(workflows).filter((w) => w.workspaceId === workspaceId) + const sidebarWorkflows = workflowList.filter((w) => w.workspaceId === workspaceId) const workflowsInFolders = sidebarWorkflows .filter((w) => w.folderId && folderIds.includes(w.folderId)) @@ -128,7 +128,11 @@ export function useDeleteSelection({ } const standaloneWorkflowIds = workflowIds.filter((id) => !workflowsInFolders.includes(id)) - await Promise.all(standaloneWorkflowIds.map((id) => removeWorkflow(id))) + await Promise.all( + standaloneWorkflowIds.map((id) => + deleteWorkflowMutation.mutateAsync({ workspaceId, workflowId: id }) + ) + ) const { clearSelection, clearFolderSelection } = useFolderStore.getState() clearSelection() @@ -151,12 +155,10 @@ export function useDeleteSelection({ workflowIds, folderIds, isDeleting, - workflows, + workflowList, workspaceId, isActiveWorkflow, router, - removeWorkflow, - deleteFolderMutation, onSuccess, ]) diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts index 37a56d24c0d..0e9c5d82cc5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts @@ -1,10 +1,8 @@ import { useCallback, useState } from 'react' import { createLogger } from '@sim/logger' -import { useQueryClient } from '@tanstack/react-query' import { useRouter } from 'next/navigation' -import { workflowKeys } from '@/hooks/queries/workflows' +import { useDeleteWorkflowMutation, useWorkflows } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('useDeleteWorkflow') @@ -41,9 +39,8 @@ export function useDeleteWorkflow({ onSuccess, }: UseDeleteWorkflowProps) { const router = useRouter() - const queryClient = useQueryClient() - const workflows = useWorkflowRegistry((s) => s.workflows) - const removeWorkflow = useWorkflowRegistry((s) => s.removeWorkflow) + const { data: workflowList = [] } = useWorkflows(workspaceId) + const deleteWorkflowMutation = useDeleteWorkflowMutation() const [isDeleting, setIsDeleting] = useState(false) /** @@ -65,7 +62,7 @@ export function useDeleteWorkflow({ const isActiveWorkflowBeingDeleted = typeof isActive === 'function' ? isActive(workflowIdsToDelete) : isActive - const sidebarWorkflows = Object.values(workflows).filter((w) => w.workspaceId === workspaceId) + const sidebarWorkflows = workflowList.filter((w) => w.workspaceId === workspaceId) let activeWorkflowId: string | null = null if (isActiveWorkflowBeingDeleted && typeof isActive === 'function') { @@ -105,8 +102,11 @@ export function useDeleteWorkflow({ } } - await Promise.all(workflowIdsToDelete.map((id) => removeWorkflow(id))) - await queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }) + await Promise.all( + workflowIdsToDelete.map((id) => + deleteWorkflowMutation.mutateAsync({ workspaceId, workflowId: id }) + ) + ) const { clearSelection } = useFolderStore.getState() clearSelection() @@ -122,13 +122,12 @@ export function useDeleteWorkflow({ }, [ workflowIds, isDeleting, - workflows, + workflowList, workspaceId, isActive, router, - removeWorkflow, + deleteWorkflowMutation, onSuccess, - queryClient, ]) return { diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-selection.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-selection.ts index 48a48146e07..ffb6d5a22d3 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 @@ -3,9 +3,8 @@ import { createLogger } from '@sim/logger' import { useRouter } from 'next/navigation' import { getNextWorkflowColor } from '@/lib/workflows/colors' import { useDuplicateFolderMutation } from '@/hooks/queries/folders' -import { useDuplicateWorkflowMutation } from '@/hooks/queries/workflows' +import { getWorkflows, useDuplicateWorkflowMutation } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('useDuplicateSelection') @@ -62,7 +61,7 @@ export function useDuplicateSelection({ workspaceId, onSuccess }: UseDuplicateSe setIsDuplicating(true) try { - const { workflows } = useWorkflowRegistry.getState() + const workflows = getWorkflows(workspaceIdRef.current) const folderStore = useFolderStore.getState() const duplicatedWorkflowIds: string[] = [] @@ -97,7 +96,7 @@ export function useDuplicateSelection({ workspaceId, onSuccess }: UseDuplicateSe } for (const workflowId of workflowIds) { - const workflow = workflows[workflowId] + const workflow = workflows.find((w) => w.id === workflowId) if (!workflow) { logger.warn(`Workflow ${workflowId} not found, skipping`) continue diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workflow.ts index e1a14b49bfc..d0f793d2b0a 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,8 @@ import { useCallback, useRef } from 'react' import { createLogger } from '@sim/logger' import { useRouter } from 'next/navigation' import { getNextWorkflowColor } from '@/lib/workflows/colors' -import { useDuplicateWorkflowMutation } from '@/hooks/queries/workflows' +import { getWorkflows, useDuplicateWorkflowMutation } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('useDuplicateWorkflow') @@ -61,10 +60,10 @@ export function useDuplicateWorkflow({ workspaceId, onSuccess }: UseDuplicateWor const duplicatedIds: string[] = [] try { - const { workflows } = useWorkflowRegistry.getState() + const workflows = getWorkflows(workspaceIdRef.current) for (const sourceId of workflowIdsToDuplicate) { - const sourceWorkflow = workflows[sourceId] + const sourceWorkflow = workflows.find((w) => w.id === sourceId) if (!sourceWorkflow) { logger.warn(`Workflow ${sourceId} not found, skipping`) continue diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts index e7a646b2f68..8391bd9f1e8 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,6 @@ import { useCallback, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' +import { useParams } from 'next/navigation' import { downloadFile, exportFolderToZip, @@ -8,9 +9,9 @@ import { sanitizePathSegment, type WorkflowExportData, } from '@/lib/workflows/operations/import-export' +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,7 +90,8 @@ function collectSubfolders( * Hook for managing folder export to ZIP. */ export function useExportFolder({ folderId, onSuccess }: UseExportFolderProps) { - const workflows = useWorkflowRegistry((s) => s.workflows) + const { workspaceId } = useParams<{ workspaceId: string }>() + const { data: workflows = {} } = useWorkflowMap(workspaceId) const folders = useFolderStore((s) => s.folders) const [isExporting, setIsExporting] = useState(false) diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-selection.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-selection.ts index 92502caf55f..a240372b4d5 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,9 @@ import { fetchWorkflowForExport, type WorkflowExportData, } from '@/lib/workflows/operations/import-export' +import { getWorkflows } 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('useExportSelection') @@ -88,10 +89,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,7 +119,8 @@ export function useExportSelection({ onSuccess }: UseExportSelectionProps = {}) setIsExporting(true) try { - const { workflows } = useWorkflowRegistry.getState() + const workflowsArray = getWorkflows(workspaceIdRef.current) + const workflows = Object.fromEntries(workflowsArray.map((w) => [w.id, w])) const { folders } = useFolderStore.getState() const workflowsFromFolders: CollectedWorkflow[] = [] diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts index afa812ed549..9b4a5a628a0 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/workflows' 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,11 @@ export function useExportWorkflow({ onSuccess }: UseExportWorkflowProps = {}) { count: workflowIdsToExport.length, }) - const { workflows } = useWorkflowRegistry.getState() + const workflows = getWorkflows(workspaceIdRef.current) const exportedWorkflows = [] for (const workflowId of workflowIdsToExport) { - const workflowMeta = workflows[workflowId] + const workflowMeta = workflows.find((w) => w.id === workflowId) if (!workflowMeta) { logger.warn(`Workflow ${workflowId} not found in registry`) continue diff --git a/apps/sim/app/workspace/[workspaceId]/w/page.tsx b/apps/sim/app/workspace/[workspaceId]/w/page.tsx index e19bfd387e4..0c2802d8e63 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/page.tsx @@ -12,26 +12,19 @@ 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 } = 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 (isError) { @@ -39,18 +32,10 @@ export default function WorkflowsPage() { return } - const workflowIds = Object.keys(workflows) - - // Validate that workflows belong to the current workspace - const workspaceWorkflows = workflowIds.filter((id) => { - const workflow = workflows[id] - return workflow.workspaceId === workspaceId - }) + const workspaceWorkflows = workflows.filter((w) => w.workspaceId === workspaceId) - // If we have valid workspace workflows, redirect to the first one if (workspaceWorkflows.length > 0) { - const firstWorkflowId = workspaceWorkflows[0] - router.replace(`/workspace/${workspaceId}/w/${firstWorkflowId}`) + router.replace(`/workspace/${workspaceId}/w/${workspaceWorkflows[0].id}`) } }, [isMounted, isLoading, workflows, workspaceId, router, setActiveWorkflow, isError]) diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index 50db926be7d..4e92948ee17 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -21,7 +21,6 @@ import { parseJSON } from '@/executor/utils/json' import { lazyCleanupInputMapping } from '@/executor/utils/lazy-cleanup' import { Serializer } from '@/serializer' import type { SerializedBlock } from '@/serializer/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('WorkflowBlockHandler') @@ -74,10 +73,7 @@ export class WorkflowBlockHandler implements BlockHandler { throw new Error('No workflow selected for execution') } - // Initialize with registry name, will be updated with loaded workflow name - const { workflows } = useWorkflowRegistry.getState() - const workflowMetadata = workflows[workflowId] - let childWorkflowName = workflowMetadata?.name || workflowId + let childWorkflowName = workflowId // Unique ID per invocation — used to correlate child block events with this specific // workflow block execution, preventing cross-iteration child mixing in loop contexts. @@ -111,8 +107,7 @@ export class WorkflowBlockHandler implements BlockHandler { throw new Error(`Child workflow ${workflowId} not found`) } - // Update with loaded workflow name (more reliable than registry) - childWorkflowName = workflowMetadata?.name || childWorkflow.name || 'Unknown Workflow' + childWorkflowName = childWorkflow.name || 'Unknown Workflow' logger.info( `Executing child workflow: ${childWorkflowName} (${workflowId}), call chain depth ${ctx.callChain?.length || 0}` diff --git a/apps/sim/hooks/queries/custom-tools.ts b/apps/sim/hooks/queries/custom-tools.ts index adb8af7183b..f99cbe22eeb 100644 --- a/apps/sim/hooks/queries/custom-tools.ts +++ b/apps/sim/hooks/queries/custom-tools.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { getQueryClient } from '@/app/_shell/providers/query-provider' +import { getWorkspaceIdFromUrl } from '@/hooks/queries/utils/get-workspace-id-from-url' const logger = createLogger('CustomToolsQueries') const API_ENDPOINT = '/api/tools/custom' @@ -91,12 +92,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 diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts index 5b22872feec..8f125fb7c74 100644 --- a/apps/sim/hooks/queries/folders.ts +++ b/apps/sim/hooks/queries/folders.ts @@ -6,10 +6,9 @@ import { generateTempId, } from '@/hooks/queries/utils/optimistic-mutation' import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' -import { workflowKeys } from '@/hooks/queries/workflows' +import { getWorkflows, workflowKeys } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' import type { WorkflowFolder } from '@/stores/folders/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('FolderQueries') @@ -169,7 +168,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, @@ -267,7 +268,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 diff --git a/apps/sim/hooks/queries/utils/get-workspace-id-from-url.ts b/apps/sim/hooks/queries/utils/get-workspace-id-from-url.ts new file mode 100644 index 00000000000..039584ba4d9 --- /dev/null +++ b/apps/sim/hooks/queries/utils/get-workspace-id-from-url.ts @@ -0,0 +1,10 @@ +/** + * Extracts the workspace ID from the current URL pathname. + * Returns `null` on the server or when the URL doesn't match `/workspace/{id}/...`. + * Used as a fallback for synchronous cache-read helpers that can't access React hooks. + */ +export function getWorkspaceIdFromUrl(): string | null { + if (typeof window === 'undefined') return null + const match = window.location.pathname.match(/^\/workspace\/([^/]+)/) + return match?.[1] ?? null +} diff --git a/apps/sim/hooks/queries/utils/workflow-keys.ts b/apps/sim/hooks/queries/utils/workflow-keys.ts new file mode 100644 index 00000000000..8512e02a42b --- /dev/null +++ b/apps/sim/hooks/queries/utils/workflow-keys.ts @@ -0,0 +1,13 @@ +export type WorkflowQueryScope = 'active' | 'archived' | 'all' + +export const workflowKeys = { + all: ['workflows'] as const, + lists: () => [...workflowKeys.all, 'list'] as const, + list: (workspaceId: string | undefined, scope: WorkflowQueryScope = 'active') => + [...workflowKeys.lists(), workspaceId ?? '', scope] as const, + deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const, + deploymentVersion: (workflowId: string | undefined, version: number | undefined) => + [...workflowKeys.deploymentVersions(), workflowId ?? '', version ?? 0] as const, + state: (workflowId: string | undefined) => + [...workflowKeys.all, 'state', workflowId ?? ''] as const, +} diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index c2ae3a40363..b398a8f38c4 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -1,14 +1,16 @@ -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 { getNextWorkflowColor } from '@/lib/workflows/colors' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { deploymentKeys } from '@/hooks/queries/deployments' -import { - createOptimisticMutationHandlers, - generateTempId, -} from '@/hooks/queries/utils/optimistic-mutation' +import { getWorkspaceIdFromUrl } from '@/hooks/queries/utils/get-workspace-id-from-url' import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' +import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { useFolderStore } from '@/stores/folders/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -18,24 +20,23 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowQueries') -type WorkflowQueryScope = 'active' | 'archived' | 'all' - -export const workflowKeys = { - all: ['workflows'] as const, - lists: () => [...workflowKeys.all, 'list'] as const, - list: (workspaceId: string | undefined, scope: WorkflowQueryScope = 'active') => - [...workflowKeys.lists(), workspaceId ?? '', scope] as const, - deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const, - deploymentVersion: (workflowId: string | undefined, version: number | undefined) => - [...workflowKeys.deploymentVersions(), workflowId ?? '', version ?? 0] as const, - state: (workflowId: string | undefined) => - [...workflowKeys.all, 'state', workflowId ?? ''] as const, -} +export { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys' /** - * Fetches workflow state from the API. - * Used as the base query for both state preview and input fields extraction. + * Reads the workflow list from the React Query cache synchronously. + * For use in non-React code (stores, event handlers, utilities). + * Falls back to the URL workspace when `workspaceId` is omitted. */ +export function getWorkflows( + workspaceId?: string, + scope: WorkflowQueryScope = 'active' +): WorkflowMetadata[] { + if (typeof window === 'undefined') return [] + const wsId = workspaceId ?? getWorkspaceIdFromUrl() + if (!wsId) return [] + return getQueryClient().getQueryData(workflowKeys.list(wsId, scope)) ?? [] +} + async function fetchWorkflowState( workflowId: string, signal?: AbortSignal @@ -47,31 +48,40 @@ 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, + staleTime: 30 * 1000, }) } -function mapWorkflow(workflow: any): WorkflowMetadata { +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 +} + +function mapWorkflow(workflow: WorkflowApiRow): WorkflowMetadata { return { id: workflow.id, name: workflow.name, - description: workflow.description, + description: workflow.description ?? undefined, color: workflow.color, workspaceId: workflow.workspaceId, - folderId: workflow.folderId, + folderId: workflow.folderId ?? undefined, sortOrder: workflow.sortOrder ?? 0, createdAt: new Date(workflow.createdAt), lastModified: new Date(workflow.updatedAt || workflow.createdAt), @@ -92,68 +102,38 @@ async function fetchWorkflows( throw new Error('Failed to fetch workflows') } - const { data }: { data: any[] } = await response.json() + const { data }: { data: WorkflowApiRow[] } = 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({ +export function useWorkflows(workspaceId?: string, options?: { scope?: WorkflowQueryScope }) { + const { scope = 'active' } = options || {} + + return useQuery({ queryKey: workflowKeys.list(workspaceId, scope), queryFn: ({ signal }) => fetchWorkflows(workspaceId as string, scope, signal), enabled: Boolean(workspaceId), placeholderData: keepPreviousData, staleTime: 60 * 1000, }) +} + +/** + * 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 === 'pending' || query.isPlaceholderData) - ) { - beginMetadataLoad(workspaceId) - } - }, [syncRegistry, scope, workspaceId, query.status, query.isPlaceholderData, beginMetadataLoad]) - - useEffect(() => { - if ( - syncRegistry && - scope === 'active' && - workspaceId && - query.status === 'success' && - query.data && - !query.isPlaceholderData - ) { - completeMetadataLoad(workspaceId, query.data) - } - }, [ - syncRegistry, - scope, - workspaceId, - query.status, - query.data, - query.isPlaceholderData, - completeMetadataLoad, - ]) - - useEffect(() => { - if (syncRegistry && scope === 'active' && workspaceId && query.status === 'error') { - const message = - query.error instanceof Error ? query.error.message : 'Failed to fetch workflows' - failMetadataLoad(workspaceId, message) - } - }, [syncRegistry, scope, workspaceId, query.status, query.error, failMetadataLoad]) - - return query + return useQuery({ + queryKey: workflowKeys.list(workspaceId, scope), + queryFn: ({ signal }) => fetchWorkflows(workspaceId as string, scope, signal), + enabled: Boolean(workspaceId), + placeholderData: keepPreviousData, + staleTime: 60 * 1000, + select: (data) => Object.fromEntries(data.map((w) => [w.id, w])), + }) } interface CreateWorkflowVariables { @@ -177,128 +157,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 } = @@ -358,9 +219,86 @@ 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]) + ) + const currentFolders = useFolderStore.getState().folders + sortOrder = getTopInsertionSortOrder( + currentWorkflows, + currentFolders, + variables.workspaceId, + variables.folderId + ) + } + + const optimistic: WorkflowMetadata = { + id: tempId, + name: variables.name || generateCreativeWorkflowName(), + lastModified: new Date(), + createdAt: new Date(), + description: variables.description || 'New workflow', + color: variables.color || getNextWorkflowColor(), + workspaceId: variables.workspaceId, + folderId: variables.folderId || null, + sortOrder, + } + + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + (old) => [...(old ?? []), optimistic] + ) + logger.info(`[CreateWorkflow] Added optimistic entry: ${tempId}`) + + return { snapshot, tempId } + }, onSuccess: (data, variables, context) => { - handlers.onSuccess(data, variables, context) + if (!context) return + const { tempId } = context + + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + (old) => + (old ?? []).map((w) => + w.id === tempId + ? { + id: data.id, + name: data.name, + lastModified: new Date(), + createdAt: new Date(), + description: data.description, + color: data.color, + workspaceId: data.workspaceId, + folderId: data.folderId, + sortOrder: data.sortOrder, + } + : w + ) + ) + + if (tempId !== data.id) { + useFolderStore.setState((state) => { + const selectedWorkflows = new Set(state.selectedWorkflows) + if (selectedWorkflows.has(tempId)) { + selectedWorkflows.delete(tempId) + selectedWorkflows.add(data.id) + } + return { selectedWorkflows } + }) + } const { subBlockValues } = buildDefaultWorkflowArtifacts() useSubBlockStore.setState((state) => ({ @@ -369,40 +307,51 @@ 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) => { + queryClient.invalidateQueries({ + queryKey: workflowKeys.list(variables.workspaceId, 'active'), + }) }, }) } -export function useDuplicateWorkflowMutation() { - const queryClient = useQueryClient() +interface DuplicateWorkflowVariables { + workspaceId: string + sourceId: string + name: string + description?: string + color: string + folderId?: string | null + newId?: string +} - const handlers = createWorkflowMutationHandlers( - queryClient, - 'DuplicateWorkflow', - (variables, tempId) => { - const currentWorkflows = useWorkflowRegistry.getState().workflows - const currentFolders = useFolderStore.getState().folders - const targetFolderId = variables.folderId ?? null +interface DuplicateWorkflowResult { + id: string + name: string + description?: string + color: string + workspaceId: string + folderId?: string | null + sortOrder: number + blocksCount: number + edgesCount: number + subflowsCount: number +} - return { - id: tempId, - name: variables.name, - lastModified: new Date(), - createdAt: new Date(), - description: variables.description, - color: variables.color, - workspaceId: variables.workspaceId, - folderId: targetFolderId, - sortOrder: getTopInsertionSortOrder( - currentWorkflows, - currentFolders, - variables.workspaceId, - targetFolderId - ), - } - }, - (variables) => variables.newId ?? crypto.randomUUID() - ) +export function useDuplicateWorkflowMutation() { + const queryClient = useQueryClient() return useMutation({ mutationFn: async (variables: DuplicateWorkflowVariables): Promise => { @@ -449,11 +398,82 @@ export function useDuplicateWorkflowMutation() { subflowsCount: duplicatedWorkflow.subflowsCount || 0, } }, - ...handlers, + onMutate: async (variables) => { + await queryClient.cancelQueries({ + queryKey: workflowKeys.list(variables.workspaceId, 'active'), + }) + + const snapshot = queryClient.getQueryData( + workflowKeys.list(variables.workspaceId, 'active') + ) + const tempId = variables.newId ?? crypto.randomUUID() + + const currentWorkflows = Object.fromEntries( + getWorkflows(variables.workspaceId).map((w) => [w.id, w]) + ) + const currentFolders = useFolderStore.getState().folders + 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, + currentFolders, + variables.workspaceId, + targetFolderId + ), + } + + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + (old) => [...(old ?? []), optimistic] + ) + logger.info(`[DuplicateWorkflow] Added optimistic entry: ${tempId}`) + + return { snapshot, tempId } + }, onSuccess: (data, variables, context) => { - handlers.onSuccess(data, variables, context) + if (!context) return + const { tempId } = context + + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + (old) => + (old ?? []).map((w) => + w.id === tempId + ? { + id: data.id, + name: data.name, + lastModified: new Date(), + createdAt: new Date(), + description: data.description, + color: data.color, + workspaceId: data.workspaceId, + folderId: data.folderId, + sortOrder: data.sortOrder, + } + : w + ) + ) + + if (tempId !== data.id) { + useFolderStore.setState((state) => { + const selectedWorkflows = new Set(state.selectedWorkflows) + if (selectedWorkflows.has(tempId)) { + selectedWorkflows.delete(tempId) + selectedWorkflows.add(data.id) + } + return { selectedWorkflows } + }) + } - // Copy subblock values from source if it's the active workflow const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId if (variables.sourceId === activeWorkflowId) { const sourceSubblockValues = @@ -465,6 +485,137 @@ 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) => { + queryClient.invalidateQueries({ + queryKey: workflowKeys.list(variables.workspaceId, 'active'), + }) + }, + }) +} + +interface UpdateWorkflowVariables { + workspaceId: string + workflowId: string + metadata: Partial +} + +export function useUpdateWorkflow() { + const queryClient = useQueryClient() + + 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), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Failed to update workflow') + } + + const { workflow: updatedWorkflow } = await response.json() + return mapWorkflow(updatedWorkflow) + }, + onMutate: async (variables) => { + await queryClient.cancelQueries({ + queryKey: workflowKeys.list(variables.workspaceId, 'active'), + }) + + 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) => { + queryClient.invalidateQueries({ + queryKey: workflowKeys.list(variables.workspaceId, 'active'), + }) + }, + }) +} + +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) => { + queryClient.invalidateQueries({ + queryKey: workflowKeys.list(variables.workspaceId, 'active'), + }) }, }) } @@ -496,17 +647,13 @@ export async function fetchDeploymentVersionState( return data.deployedState } -/** - * 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 + staleTime: 5 * 60 * 1000, }) } @@ -515,9 +662,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 +675,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), }) @@ -576,28 +720,33 @@ 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], + const snapshot = queryClient.getQueryData( + workflowKeys.list(variables.workspaceId, 'active') + ) + + const updateMap = new Map(variables.updates.map((u) => [u.id, u])) + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + (old) => + (old ?? []).map((w) => { + const update = updateMap.get(w.id) + if (!update) return w + return { + ...w, sortOrder: update.sortOrder, - folderId: - update.folderId !== undefined ? update.folderId : updated[update.id].folderId, + folderId: update.folderId !== undefined ? update.folderId : w.folderId, } - } - } - return { workflows: updated } - }) + }) + ) return { snapshot } }, - onError: (_error, _variables, context) => { + onError: (_error, variables, context) => { if (context?.snapshot) { - useWorkflowRegistry.setState({ workflows: context.snapshot }) + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + context.snapshot + ) } }, onSettled: (_data, _error, variables) => { @@ -606,9 +755,6 @@ export function useReorderWorkflows() { }) } -/** - * Import workflow mutation (superuser debug) - */ interface ImportWorkflowParams { workflowId: string targetWorkspaceId: string @@ -641,7 +787,7 @@ export function useImportWorkflow() { return data }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }) }, }) diff --git a/apps/sim/hooks/selectors/registry.ts b/apps/sim/hooks/selectors/registry.ts index fd050f97a6a..7b4d433db7a 100644 --- a/apps/sim/hooks/selectors/registry.ts +++ b/apps/sim/hooks/selectors/registry.ts @@ -1,3 +1,4 @@ +import { getWorkflows } from '@/hooks/queries/workflows' import { fetchJson, fetchOAuthToken } from '@/hooks/selectors/helpers' import type { SelectorContext, @@ -6,7 +7,6 @@ import type { SelectorOption, SelectorQueryArgs, } from '@/hooks/selectors/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const SELECTOR_STALE = 60 * 1000 @@ -1693,19 +1693,19 @@ const registry: Record = { ], enabled: () => true, 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)}`, + const workflows = getWorkflows() + 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] + const workflows = getWorkflows() + const workflow = workflows.find((w) => w.id === detailId) if (!workflow) return null return { id: detailId, diff --git a/apps/sim/lib/copilot/tools/client/tool-display-registry.ts b/apps/sim/lib/copilot/tools/client/tool-display-registry.ts index 617626700d7..35d90f1189d 100644 --- a/apps/sim/lib/copilot/tools/client/tool-display-registry.ts +++ b/apps/sim/lib/copilot/tools/client/tool-display-registry.ts @@ -41,6 +41,7 @@ import { Zap, } from 'lucide-react' import { getCustomTool } from '@/hooks/queries/custom-tools' +import { getWorkflows } from '@/hooks/queries/workflows' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -1630,7 +1631,7 @@ 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 workflowName = getWorkflows().find((w) => w.id === workflowId)?.name if (workflowName) { switch (state) { case ClientToolCallState.success: diff --git a/apps/sim/lib/core/utils/optimistic-update.ts b/apps/sim/lib/core/utils/optimistic-update.ts deleted file mode 100644 index 4759255e4db..00000000000 --- a/apps/sim/lib/core/utils/optimistic-update.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { createLogger } from '@sim/logger' - -const logger = createLogger('OptimisticUpdate') - -/** - * Options for performing an optimistic update with automatic rollback on error - */ -export interface OptimisticUpdateOptions { - /** - * Function that returns the current state value (for rollback purposes) - */ - getCurrentState: () => T - /** - * Function that performs the optimistic update to the UI state - */ - optimisticUpdate: () => void - /** - * Async function that performs the actual API call - */ - apiCall: () => Promise - /** - * Function that rolls back the state to the original value - * @param originalValue - The value returned by getCurrentState before the update - */ - rollback: (originalValue: T) => void - /** - * Optional error message to log if the operation fails - */ - errorMessage?: string - /** - * Optional callback to execute on error (e.g., show toast notification) - */ - onError?: (error: Error, originalValue: T) => void - /** - * Optional callback that always runs regardless of success or error (e.g., to clear loading states) - */ - onComplete?: () => void -} - -/** - * Performs an optimistic update with automatic rollback on error. - * This utility standardizes the pattern of: - * 1. Save current state - * 2. Update UI optimistically - * 3. Make API call - * 4. Rollback on error - * - * @example - * ```typescript - * await withOptimisticUpdate({ - * getCurrentState: () => get().folders[id], - * optimisticUpdate: () => set(state => ({ - * folders: { ...state.folders, [id]: { ...folder, name: newName } } - * })), - * apiCall: async () => { - * await fetch(`/api/folders/${id}`, { - * method: 'PUT', - * body: JSON.stringify({ name: newName }) - * }) - * }, - * rollback: (originalFolder) => set(state => ({ - * folders: { ...state.folders, [id]: originalFolder } - * })), - * errorMessage: 'Failed to rename folder', - * onError: (error) => toast.error('Could not rename folder') - * }) - * ``` - */ -export async function withOptimisticUpdate(options: OptimisticUpdateOptions): Promise { - const { - getCurrentState, - optimisticUpdate, - apiCall, - rollback, - errorMessage, - onError, - onComplete, - } = options - - const originalValue = getCurrentState() - - optimisticUpdate() - - try { - await apiCall() - } catch (error) { - rollback(originalValue) - - if (errorMessage) { - logger.error(errorMessage, { error }) - } - - if (onError && error instanceof Error) { - onError(error, originalValue) - } - - throw error - } finally { - if (onComplete) { - onComplete() - } - } -} diff --git a/apps/sim/stores/index.ts b/apps/sim/stores/index.ts index 4ae3c335f07..d1bbbc9b227 100644 --- a/apps/sim/stores/index.ts +++ b/apps/sim/stores/index.ts @@ -201,7 +201,6 @@ export { export const resetAllStores = () => { // Reset all stores to initial state useWorkflowRegistry.setState({ - workflows: {}, activeWorkflowId: null, error: null, deploymentStatuses: {}, diff --git a/apps/sim/stores/workflows/index.ts b/apps/sim/stores/workflows/index.ts index c3cc04ec6f8..9f223ba9310 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/workflows' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { mergeSubblockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -13,11 +14,13 @@ const logger = createLogger('Workflows') * @returns The workflow with merged state values or null if not found/not active */ export function getWorkflowWithValues(workflowId: string) { - const { workflows } = useWorkflowRegistry.getState() + const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId ?? undefined + 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 +31,6 @@ export function getWorkflowWithValues(workflowId: string) { return null } - const metadata = workflows[workflowId] - // Get deployment status from registry const deploymentStatus = useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId) @@ -80,14 +81,18 @@ export function getBlockWithValues(blockId: string): BlockState | null { * @returns An object containing workflows, with state only for the active workflow */ export function getAllWorkflowsWithValues() { - const { workflows } = useWorkflowRegistry.getState() + const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId ?? undefined + const workflows = getWorkflows(workspaceId) const result: Record = {} const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId const currentState = useWorkflowStore.getState() // Only sync the active workflow to ensure we always send valid state data - if (activeWorkflowId && workflows[activeWorkflowId]) { - const metadata = workflows[activeWorkflowId] + const activeMetadata = activeWorkflowId + ? workflows.find((w) => w.id === activeWorkflowId) + : undefined + if (activeWorkflowId && activeMetadata) { + const metadata = activeMetadata // Get deployment status from registry const deploymentStatus = useWorkflowRegistry diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index dca49b8ddae..0f68a18c23f 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -1,10 +1,9 @@ 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 { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { useVariablesStore } from '@/stores/panel/variables/store' import type { DeploymentStatus, @@ -28,13 +27,10 @@ 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 +const TRANSITION_TIMEOUT = 5000 -// 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({ blocks: {}, edges: [], @@ -44,16 +40,11 @@ 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 @@ -70,7 +61,6 @@ function setWorkspaceTransitioning(isTransitioning: boolean): void { export const useWorkflowRegistry = create()( devtools( (set, get) => ({ - workflows: {}, activeWorkflowId: null, error: null, deploymentStatuses: {}, @@ -78,61 +68,6 @@ export const useWorkflowRegistry = create()( clipboard: null, pendingSelection: null, - beginMetadataLoad: (workspaceId: string) => { - set((state) => ({ - error: null, - hydration: { - phase: 'metadata-loading', - 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( @@ -148,13 +83,15 @@ export const useWorkflowRegistry = create()( resetWorkflowStores() + // Invalidate the old workspace workflow cache so a fresh fetch happens + getQueryClient().invalidateQueries({ queryKey: workflowKeys.lists() }) + set({ activeWorkflowId: null, - workflows: {}, deploymentStatuses: {}, error: null, hydration: { - phase: 'metadata-loading', + phase: 'idle', workspaceId, workflowId: null, requestId: null, @@ -250,9 +187,15 @@ export const useWorkflowRegistry = create()( }, loadWorkflowState: async (workflowId: string) => { - const { workflows } = get() - - if (!workflows[workflowId]) { + // Check if the workflow exists in the React Query cache + const workspaceId = get().hydration.workspaceId + const workflows = workspaceId + ? (getQueryClient().getQueryData( + workflowKeys.list(workspaceId, 'active') + ) ?? []) + : [] + + if (!workflows.find((w) => w.id === workflowId)) { const message = `Workflow not found: ${workflowId}` logger.error(message) set({ error: message }) @@ -392,10 +335,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 +349,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 +379,6 @@ export const useWorkflowRegistry = create()( const copiedSubBlockValues: Record> = {} const blockIdSet = new Set(blockIds) - // Auto-include nested nodes from selected subflows blockIds.forEach((blockId) => { const loop = workflowStore.loops[blockId] if (loop?.nodes) loop.nodes.forEach((n) => blockIdSet.add(n)) diff --git a/apps/sim/stores/workflows/registry/types.ts b/apps/sim/stores/workflows/registry/types.ts index 1b22fe87d04..550aa0f4a04 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 getWorkflowDeploymentStatus: (workflowId: string | null) => DeploymentStatus | null setDeploymentStatus: ( workflowId: string | null, From d01df7aa18cf388316ebe9e0ece448f193d71f4d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 30 Mar 2026 20:06:01 -0700 Subject: [PATCH 02/14] =?UTF-8?q?fix(workflows):=20address=20PR=20review?= =?UTF-8?q?=20feedback=20=E2=80=94=20sandbox=20execution,=20hydration=20de?= =?UTF-8?q?adlock,=20test=20mock,=20copy=20casing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../w/[workflowId]/components/panel/panel.tsx | 2 +- .../hooks/use-workflow-execution.ts | 3 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 2 +- apps/sim/hooks/queries/folders.test.ts | 81 +++++++++---------- 4 files changed, 44 insertions(+), 44 deletions(-) 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 83474ab234d..ccb1858cf16 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 @@ -525,7 +525,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel const result = await duplicateWorkflowMutation.mutateAsync({ workspaceId, sourceId: activeWorkflowId, - name: `${sourceWorkflow.name} (copy)`, + name: `${sourceWorkflow.name} (Copy)`, description: sourceWorkflow.description, color: sourceWorkflow.color ?? '', folderId: sourceWorkflow.folderId, 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 3380e47481c..82a0f6bfbc4 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 @@ -105,6 +105,7 @@ 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 = useWorkflowRegistry((s) => s.activeWorkflowId) @@ -383,7 +384,7 @@ export function useWorkflowExecution() { // Sandbox exercises have no real workflow — signal the SandboxCanvasProvider // to run mock execution by setting isExecuting, then bail out immediately. - const cachedWorkflows = getWorkflows(routeWorkspaceId) + const cachedWorkflows = getWorkflows(routeWorkspaceId ?? hydrationWorkspaceId ?? undefined) const activeWorkflow = cachedWorkflows.find((w) => w.id === activeWorkflowId) if (activeWorkflow?.isSandbox) { setIsExecuting(activeWorkflowId, true) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index faf2fbe5f5f..e79c823ca54 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -2202,7 +2202,7 @@ const WorkflowContent = React.memo( const currentId = workflowIdParam const currentWorkspaceHydration = hydration.workspaceId - const isRegistryReady = hydration.phase !== 'idle' + const isRegistryReady = hydration.workspaceId !== null // Wait for registry to be ready to prevent race conditions if ( diff --git a/apps/sim/hooks/queries/folders.test.ts b/apps/sim/hooks/queries/folders.test.ts index 9af6eaa5bc0..d00c6f2d42c 100644 --- a/apps/sim/hooks/queries/folders.test.ts +++ b/apps/sim/hooks/queries/folders.test.ts @@ -1,33 +1,33 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockLogger, queryClient, useFolderStoreMock, useWorkflowRegistryMock } = vi.hoisted(() => ({ - mockLogger: { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }, - queryClient: { - cancelQueries: vi.fn().mockResolvedValue(undefined), - invalidateQueries: vi.fn().mockResolvedValue(undefined), - }, - useFolderStoreMock: Object.assign(vi.fn(), { - getState: vi.fn(), - setState: vi.fn(), - }), - useWorkflowRegistryMock: Object.assign(vi.fn(), { - getState: vi.fn(), - setState: vi.fn(), - }), -})) +const { mockLogger, mockGetWorkflows, queryClient, useFolderStoreMock, useWorkflowRegistryMock } = + vi.hoisted(() => ({ + mockLogger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + mockGetWorkflows: vi.fn(() => []), + queryClient: { + cancelQueries: vi.fn().mockResolvedValue(undefined), + invalidateQueries: vi.fn().mockResolvedValue(undefined), + }, + 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 workflowRegistryState: { - workflows: Record -} +let workflowList: Array<{ id: string; name: string; workspaceId: string; folderId: string; sortOrder: number }> vi.mock('@sim/logger', () => ({ createLogger: vi.fn(() => mockLogger), @@ -49,6 +49,7 @@ vi.mock('@/stores/workflows/registry/store', () => ({ })) vi.mock('@/hooks/queries/workflows', () => ({ + getWorkflows: mockGetWorkflows, workflowKeys: { list: (workspaceId: string | undefined) => ['workflows', 'list', workspaceId ?? ''], }, @@ -77,7 +78,7 @@ describe('folder optimistic top insertion ordering', () => { folderState = { ...folderState, ...updater } }) - useWorkflowRegistryMock.getState.mockImplementation(() => workflowRegistryState) + mockGetWorkflows.mockImplementation(() => workflowList) folderState = { folders: { @@ -108,24 +109,22 @@ describe('folder optimistic top insertion ordering', () => { }, } - 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 () => { From 23c6121e846c28dbc467b56bb7b5aec6b7f61145 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 30 Mar 2026 20:08:18 -0700 Subject: [PATCH 03/14] lint --- apps/sim/hooks/queries/folders.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/sim/hooks/queries/folders.test.ts b/apps/sim/hooks/queries/folders.test.ts index d00c6f2d42c..120336e87b3 100644 --- a/apps/sim/hooks/queries/folders.test.ts +++ b/apps/sim/hooks/queries/folders.test.ts @@ -27,7 +27,13 @@ let folderState: { folders: Record } -let workflowList: Array<{ id: string; name: string; workspaceId: string; folderId: string; sortOrder: number }> +let workflowList: Array<{ + id: string + name: string + workspaceId: string + folderId: string + sortOrder: number +}> vi.mock('@sim/logger', () => ({ createLogger: vi.fn(() => mockLogger), From 0183e243a7b1af267619fd16c593665e0f957cc1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 30 Mar 2026 20:18:00 -0700 Subject: [PATCH 04/14] improvement(workflows): adopt skipToken over enabled+as-string for type-safe conditional queries --- apps/sim/hooks/queries/workflows.ts | 30 +++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index b398a8f38c4..b7c1c82ce83 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -3,7 +3,13 @@ */ 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 { getQueryClient } from '@/app/_shell/providers/get-query-client' @@ -55,8 +61,9 @@ async function fetchWorkflowState( export function useWorkflowState(workflowId: string | undefined) { return useQuery({ queryKey: workflowKeys.state(workflowId), - queryFn: ({ signal }) => fetchWorkflowState(workflowId!, signal), - enabled: Boolean(workflowId), + queryFn: workflowId + ? ({ signal }) => fetchWorkflowState(workflowId, signal) + : skipToken, staleTime: 30 * 1000, }) } @@ -111,8 +118,9 @@ export function useWorkflows(workspaceId?: string, options?: { scope?: WorkflowQ return useQuery({ queryKey: workflowKeys.list(workspaceId, scope), - queryFn: ({ signal }) => fetchWorkflows(workspaceId as string, scope, signal), - enabled: Boolean(workspaceId), + queryFn: workspaceId + ? ({ signal }) => fetchWorkflows(workspaceId, scope, signal) + : skipToken, placeholderData: keepPreviousData, staleTime: 60 * 1000, }) @@ -128,8 +136,9 @@ export function useWorkflowMap(workspaceId?: string, options?: { scope?: Workflo return useQuery({ queryKey: workflowKeys.list(workspaceId, scope), - queryFn: ({ signal }) => fetchWorkflows(workspaceId as string, scope, signal), - enabled: Boolean(workspaceId), + queryFn: workspaceId + ? ({ signal }) => fetchWorkflows(workspaceId, scope, signal) + : skipToken, placeholderData: keepPreviousData, staleTime: 60 * 1000, select: (data) => Object.fromEntries(data.map((w) => [w.id, w])), @@ -650,9 +659,10 @@ export async function fetchDeploymentVersionState( 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, + queryFn: + workflowId && version !== null + ? ({ signal }) => fetchDeploymentVersionState(workflowId, version, signal) + : skipToken, staleTime: 5 * 60 * 1000, }) } From c73ee9e7eca33ea50a5df5a02b65589a4162e788 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 30 Mar 2026 20:33:01 -0700 Subject: [PATCH 05/14] 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) --- .../workflow-block/workflow-block.tsx | 8 +- .../sidebar/hooks/use-workflow-operations.ts | 16 ++-- .../sidebar/hooks/use-workspace-management.ts | 2 +- .../w/hooks/use-duplicate-selection.ts | 4 +- .../w/hooks/use-duplicate-workflow.ts | 4 +- .../w/hooks/use-export-workflow.ts | 4 +- apps/sim/hooks/queries/workflows.ts | 20 ++--- apps/sim/stores/workflows/registry/store.ts | 75 ++++--------------- apps/sim/stores/workflows/registry/types.ts | 2 +- 9 files changed, 39 insertions(+), 96 deletions(-) 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 135efbf3348..70ddefc95e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -46,7 +46,7 @@ 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 { useWorkflows } from '@/hooks/queries/workflows' +import { useWorkflowMap } from '@/hooks/queries/workflows' import { useSelectorDisplayName } from '@/hooks/use-selector-display-name' import { useVariablesStore } from '@/stores/panel' import { useSubBlockStore } from '@/stores/workflows/subblock/store' @@ -600,11 +600,11 @@ const SubBlockRow = memo(function SubBlockRow({ ) const knowledgeBaseDisplayName = kbForDisplayName?.name ?? null - const { data: workflowListForLookup } = useWorkflows(workspaceId) + const { data: workflowMapForLookup = {} } = useWorkflowMap(workspaceId) const workflowSelectionName = useMemo(() => { if (subBlock?.id !== 'workflowId' || typeof rawValue !== 'string') return null - return (workflowListForLookup ?? []).find((w) => w.id === rawValue)?.name ?? null - }, [workflowListForLookup, subBlock?.id, rawValue]) + 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/components/sidebar/hooks/use-workflow-operations.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts index c9533c049aa..054151a5a5b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts @@ -2,7 +2,7 @@ import { useCallback, useMemo } from 'react' import { createLogger } from '@sim/logger' import { useRouter } from 'next/navigation' import { getNextWorkflowColor } from '@/lib/workflows/colors' -import { useCreateWorkflow, useWorkflowMap, useWorkflows } from '@/hooks/queries/workflows' +import { useCreateWorkflow, useWorkflowMap } from '@/hooks/queries/workflows' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils' @@ -14,19 +14,15 @@ interface UseWorkflowOperationsProps { export function useWorkflowOperations({ workspaceId }: UseWorkflowOperationsProps) { const router = useRouter() - const workflowsQuery = useWorkflows(workspaceId) - const { data: workflowList = [] } = workflowsQuery - const { data: workflows = {} } = useWorkflowMap(workspaceId) + const { data: workflows = {}, isLoading: workflowsLoading } = useWorkflowMap(workspaceId) const createWorkflowMutation = useCreateWorkflow() const regularWorkflows = useMemo( () => - workflowList + Object.values(workflows) .filter((workflow) => workflow.workspaceId === workspaceId) - .sort((a, b) => { - return b.createdAt.getTime() - a.createdAt.getTime() - }), - [workflowList, workspaceId] + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()), + [workflows, workspaceId] ) const handleCreateWorkflow = useCallback(async (): Promise => { @@ -58,7 +54,7 @@ export function useWorkflowOperations({ workspaceId }: UseWorkflowOperationsProp return { workflows, regularWorkflows, - workflowsLoading: workflowsQuery.isLoading, + workflowsLoading, isCreatingWorkflow: createWorkflowMutation.isPending, handleCreateWorkflow, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts index 7004584a5e9..47da0573a4f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts @@ -110,7 +110,7 @@ export function useWorkspaceManagement({ } try { - await switchToWorkspace(workspace.id) + switchToWorkspace(workspace.id) routerRef.current?.push(`/workspace/${workspace.id}/home`) logger.info(`Switched to workspace: ${workspace.name} (${workspace.id})`) } catch (error) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-selection.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-selection.ts index ffb6d5a22d3..8ff0873df0b 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 @@ -61,7 +61,7 @@ export function useDuplicateSelection({ workspaceId, onSuccess }: UseDuplicateSe setIsDuplicating(true) try { - const workflows = getWorkflows(workspaceIdRef.current) + const workflowMap = new Map(getWorkflows(workspaceIdRef.current).map((w) => [w.id, w])) const folderStore = useFolderStore.getState() const duplicatedWorkflowIds: string[] = [] @@ -96,7 +96,7 @@ export function useDuplicateSelection({ workspaceId, onSuccess }: UseDuplicateSe } for (const workflowId of workflowIds) { - const workflow = workflows.find((w) => w.id === 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 d0f793d2b0a..2af28a66f8f 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 @@ -60,10 +60,10 @@ export function useDuplicateWorkflow({ workspaceId, onSuccess }: UseDuplicateWor const duplicatedIds: string[] = [] try { - const workflows = getWorkflows(workspaceIdRef.current) + const workflowMap = new Map(getWorkflows(workspaceIdRef.current).map((w) => [w.id, w])) for (const sourceId of workflowIdsToDuplicate) { - const sourceWorkflow = workflows.find((w) => w.id === 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-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts index 9b4a5a628a0..1d493db09ad 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 @@ -58,11 +58,11 @@ export function useExportWorkflow({ onSuccess }: UseExportWorkflowProps = {}) { count: workflowIdsToExport.length, }) - const workflows = getWorkflows(workspaceIdRef.current) + const workflowMap = new Map(getWorkflows(workspaceIdRef.current).map((w) => [w.id, w])) const exportedWorkflows = [] for (const workflowId of workflowIdsToExport) { - const workflowMeta = workflows.find((w) => w.id === workflowId) + const workflowMeta = workflowMap.get(workflowId) if (!workflowMeta) { logger.warn(`Workflow ${workflowId} not found in registry`) continue diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index b7c1c82ce83..3b2bdc16635 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -61,9 +61,7 @@ async function fetchWorkflowState( export function useWorkflowState(workflowId: string | undefined) { return useQuery({ queryKey: workflowKeys.state(workflowId), - queryFn: workflowId - ? ({ signal }) => fetchWorkflowState(workflowId, signal) - : skipToken, + queryFn: workflowId ? ({ signal }) => fetchWorkflowState(workflowId, signal) : skipToken, staleTime: 30 * 1000, }) } @@ -118,9 +116,7 @@ export function useWorkflows(workspaceId?: string, options?: { scope?: WorkflowQ return useQuery({ queryKey: workflowKeys.list(workspaceId, scope), - queryFn: workspaceId - ? ({ signal }) => fetchWorkflows(workspaceId, scope, signal) - : skipToken, + queryFn: workspaceId ? ({ signal }) => fetchWorkflows(workspaceId, scope, signal) : skipToken, placeholderData: keepPreviousData, staleTime: 60 * 1000, }) @@ -136,9 +132,7 @@ export function useWorkflowMap(workspaceId?: string, options?: { scope?: Workflo return useQuery({ queryKey: workflowKeys.list(workspaceId, scope), - queryFn: workspaceId - ? ({ signal }) => fetchWorkflows(workspaceId, scope, signal) - : skipToken, + queryFn: workspaceId ? ({ signal }) => fetchWorkflows(workspaceId, scope, signal) : skipToken, placeholderData: keepPreviousData, staleTime: 60 * 1000, select: (data) => Object.fromEntries(data.map((w) => [w.id, w])), @@ -213,9 +207,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') + throw new Error(`Failed to persist default workflow state: ${text}`) } return { @@ -570,7 +562,7 @@ export function useUpdateWorkflow() { }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ - queryKey: workflowKeys.list(variables.workspaceId, 'active'), + queryKey: workflowKeys.lists(), }) }, }) @@ -623,7 +615,7 @@ export function useDeleteWorkflowMutation() { }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ - queryKey: workflowKeys.list(variables.workspaceId, 'active'), + queryKey: workflowKeys.lists(), }) }, }) diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index 0f68a18c23f..d5525a84fe8 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -27,9 +27,6 @@ const initialHydration: HydrationState = { const createRequestId = () => `${Date.now()}-${Math.random().toString(16).slice(2)}` -let isWorkspaceTransitioning = false -const TRANSITION_TIMEOUT = 5000 - function resetWorkflowStores() { useWorkflowStore.setState({ blocks: {}, @@ -45,19 +42,6 @@ function resetWorkflowStores() { }) } -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) => ({ @@ -68,53 +52,24 @@ export const useWorkflowRegistry = create()( clipboard: null, pendingSelection: null, - switchToWorkspace: async (workspaceId: string) => { - if (isWorkspaceTransitioning) { - logger.warn( - `Ignoring workspace switch to ${workspaceId} - transition already in progress` - ) - return - } - - setWorkspaceTransitioning(true) + switchToWorkspace: (workspaceId: string) => { + logger.info(`Switching to workspace: ${workspaceId}`) - try { - logger.info(`Switching to workspace: ${workspaceId}`) - - resetWorkflowStores() - - // Invalidate the old workspace workflow cache so a fresh fetch happens - getQueryClient().invalidateQueries({ queryKey: workflowKeys.lists() }) + resetWorkflowStores() + getQueryClient().invalidateQueries({ queryKey: workflowKeys.lists() }) - set({ - activeWorkflowId: null, - deploymentStatuses: {}, + set({ + activeWorkflowId: null, + deploymentStatuses: {}, + error: null, + hydration: { + phase: 'idle', + workspaceId, + workflowId: null, + requestId: null, error: null, - hydration: { - phase: 'idle', - 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 => { diff --git a/apps/sim/stores/workflows/registry/types.ts b/apps/sim/stores/workflows/registry/types.ts index 550aa0f4a04..375ee0df239 100644 --- a/apps/sim/stores/workflows/registry/types.ts +++ b/apps/sim/stores/workflows/registry/types.ts @@ -54,7 +54,7 @@ export interface WorkflowRegistryState { export interface WorkflowRegistryActions { setActiveWorkflow: (id: string) => Promise loadWorkflowState: (workflowId: string) => Promise - switchToWorkspace: (id: string) => Promise + switchToWorkspace: (id: string) => void getWorkflowDeploymentStatus: (workflowId: string | null) => DeploymentStatus | null setDeploymentStatus: ( workflowId: string | null, From 51492d8d7d29005cee00d87bbf5ec7cb1f6a453f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 30 Mar 2026 20:41:52 -0700 Subject: [PATCH 06/14] 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. --- .../workspace/[workspaceId]/w/[workflowId]/workflow.tsx | 9 ++------- apps/sim/stores/workflows/registry/store.ts | 9 +++++---- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index e79c823ca54..273cb7e887d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -2200,16 +2200,11 @@ const WorkflowContent = React.memo( if (sandbox) return const currentId = workflowIdParam - const currentWorkspaceHydration = hydration.workspaceId - - const isRegistryReady = hydration.workspaceId !== null - - // Wait for registry to be ready to prevent race conditions + // Wait for workflow data to be available before attempting to load if ( !currentId || !currentWorkflowExists || - !isRegistryReady || - (currentWorkspaceHydration && currentWorkspaceHydration !== workspaceId) + (hydration.workspaceId && hydration.workspaceId !== workspaceId) ) { return } diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index d5525a84fe8..71fab2be235 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -3,6 +3,7 @@ import { create } from 'zustand' import { devtools } from 'zustand/middleware' import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants' import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import { getWorkspaceIdFromUrl } from '@/hooks/queries/utils/get-workspace-id-from-url' import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { useVariablesStore } from '@/stores/panel/variables/store' import type { @@ -142,15 +143,15 @@ export const useWorkflowRegistry = create()( }, loadWorkflowState: async (workflowId: string) => { - // Check if the workflow exists in the React Query cache - const workspaceId = get().hydration.workspaceId + const workspaceId = get().hydration.workspaceId ?? getWorkspaceIdFromUrl() + const workflows = workspaceId ? (getQueryClient().getQueryData( workflowKeys.list(workspaceId, 'active') ) ?? []) : [] - if (!workflows.find((w) => w.id === workflowId)) { + if (workflows.length > 0 && !workflows.find((w) => w.id === workflowId)) { const message = `Workflow not found: ${workflowId}` logger.error(message) set({ error: message }) @@ -163,7 +164,7 @@ export const useWorkflowRegistry = create()( error: null, hydration: { phase: 'state-loading', - workspaceId: state.hydration.workspaceId, + workspaceId: workspaceId ?? state.hydration.workspaceId, workflowId, requestId, error: null, From a9fa508a4bba21d3cc89f0a7ce60079a1ce08ec0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 30 Mar 2026 20:43:17 -0700 Subject: [PATCH 07/14] 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. --- .../components/resource-content/resource-content.tsx | 5 ++--- .../[workspaceId]/logs/components/dashboard/dashboard.tsx | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) 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 f6a0edd5628..3ec33a67366 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -376,17 +376,16 @@ interface EmbeddedWorkflowProps { } function EmbeddedWorkflow({ workspaceId, workflowId }: EmbeddedWorkflowProps) { - const { data: workflowList } = useWorkflows(workspaceId) + const { data: workflowList, isPending: isWorkflowsPending } = useWorkflows(workspaceId) const workflowExists = useMemo( () => (workflowList ?? []).some((w) => w.id === workflowId), [workflowList, workflowId] ) - const isMetadataLoaded = useWorkflowRegistry((state) => state.hydration.phase !== 'idle') 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]/logs/components/dashboard/dashboard.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx index c21184c9cc3..f0ff6ca54b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx @@ -158,7 +158,7 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) { ) const { workspaceId } = useParams<{ workspaceId: string }>() - const { data: allWorkflowList = [] } = useWorkflows(workspaceId) + const { data: allWorkflowList = [], isPending: isWorkflowsPending } = useWorkflows(workspaceId) const expandedWorkflowId = workflowIds.length === 1 ? workflowIds[0] : null @@ -461,7 +461,7 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) { ) } - if (allWorkflowList.length === 0) { + if (!isWorkflowsPending && allWorkflowList.length === 0) { return (
From c28285869df366ec9bddff6bd5f39c60eaefe717 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 30 Mar 2026 21:12:06 -0700 Subject: [PATCH 08/14] =?UTF-8?q?fix(workflows):=20address=20PR=20review?= =?UTF-8?q?=20=E2=80=94=20await=20description=20update,=20revert=20state?= =?UTF-8?q?=20PUT=20throw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../components/general/components/api-info-modal.tsx | 2 +- apps/sim/hooks/queries/workflows.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 12224bbc503..4fca3c633b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx @@ -177,7 +177,7 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro } if (description.trim() !== (workflowMetadata?.description || '')) { - updateWorkflowMutation.mutate({ + await updateWorkflowMutation.mutateAsync({ workspaceId, workflowId, metadata: { description: description.trim() || 'New workflow' }, diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index 3b2bdc16635..d04336ddafc 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -207,7 +207,7 @@ export function useCreateWorkflow() { if (!stateResponse.ok) { const text = await stateResponse.text() - throw new Error(`Failed to persist default workflow state: ${text}`) + logger.error('Failed to persist default workflow state:', text) } return { From cf3409e6462520318a8994d3cc2a0012e23ab2f7 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 31 Mar 2026 13:28:55 -0700 Subject: [PATCH 09/14] move folders over to react query native, restructure passage of data --- .../resource-registry/resource-registry.tsx | 7 +- .../home/components/user-input/user-input.tsx | 8 +- .../[workspaceId]/home/hooks/use-chat.ts | 58 ++++--- .../app/workspace/[workspaceId]/layout.tsx | 2 + .../logs-toolbar/components/search/search.tsx | 4 +- .../components/logs-toolbar/logs-toolbar.tsx | 4 +- .../app/workspace/[workspaceId]/logs/logs.tsx | 7 +- .../providers/workspace-scope-sync.tsx | 24 +++ .../recently-deleted/recently-deleted.tsx | 5 +- .../workflow-selector-input.tsx | 5 +- .../w/[workflowId]/components/panel/panel.tsx | 2 +- .../hooks/use-workflow-execution.ts | 5 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 29 +++- .../components/block/block.tsx | 36 ++++- .../preview-workflow/preview-workflow.tsx | 16 ++ .../components/folder-item/folder-item.tsx | 8 +- .../workflow-item/workflow-item.tsx | 8 +- .../workflow-list/workflow-list.tsx | 17 +- .../components/sidebar/hooks/use-drag-drop.ts | 14 +- .../w/components/sidebar/sidebar.tsx | 10 +- .../[workspaceId]/w/hooks/use-can-delete.ts | 4 +- .../w/hooks/use-duplicate-folder.ts | 8 +- .../w/hooks/use-duplicate-selection.ts | 11 +- .../w/hooks/use-duplicate-workflow.ts | 3 +- .../w/hooks/use-export-folder.ts | 11 +- .../w/hooks/use-export-selection.ts | 11 +- .../w/hooks/use-export-workflow.ts | 2 +- .../w/hooks/use-import-workflow.ts | 8 +- .../app/workspace/[workspaceId]/w/page.tsx | 15 +- 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 ++--- apps/sim/hooks/queries/custom-tools.ts | 42 +---- apps/sim/hooks/queries/deployments.test.ts | 51 ++++++ apps/sim/hooks/queries/deployments.ts | 3 +- apps/sim/hooks/queries/folders.test.ts | 129 +++++++--------- apps/sim/hooks/queries/folders.ts | 123 ++++++--------- .../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/get-workspace-id-from-url.ts | 10 -- .../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 ++++ .../queries/utils/workflow-list-query.ts | 61 ++++++++ apps/sim/hooks/queries/workflows.ts | 142 ++++------------- apps/sim/hooks/selectors/query-keys.ts | 7 + apps/sim/hooks/selectors/registry.test.ts | 84 ++++++++++ apps/sim/hooks/selectors/registry.ts | 30 ++-- .../tools/client/tool-display-registry.ts | 23 ++- 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/panel/variables/store.ts | 11 +- apps/sim/stores/workflows/index.ts | 25 ++- apps/sim/stores/workflows/registry/store.ts | 68 ++++---- 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 +---------------- 72 files changed, 1343 insertions(+), 891 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 delete mode 100644 apps/sim/hooks/queries/utils/get-workspace-id-from-url.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-list-query.ts create mode 100644 apps/sim/hooks/selectors/query-keys.ts create mode 100644 apps/sim/hooks/selectors/registry.test.ts create mode 100644 apps/sim/lib/folders/tree.ts 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 01126734d21..5c8bd184cf5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -18,7 +18,8 @@ import type { } from '@/app/workspace/[workspaceId]/home/types' import { knowledgeKeys } from '@/hooks/queries/kb/knowledge' import { tableKeys } from '@/hooks/queries/tables' -import { useWorkflows, 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' interface DropdownItemRenderProps { @@ -162,8 +163,8 @@ const RESOURCE_INVALIDATORS: Record< qc.invalidateQueries({ queryKey: workspaceFilesKeys.contentFile(wId, id) }) qc.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() }) }, - workflow: (qc, _wId) => { - 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/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index 9fb923d97b1..c44471f019a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -45,7 +45,7 @@ import { computeMentionHighlightRanges, extractContextTokens, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' -import { getWorkflows } from '@/hooks/queries/workflows' +import { useWorkflowMap } from '@/hooks/queries/workflows' import type { ChatContext } from '@/stores/panel' 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 = getWorkflows().find((w) => w.id === wfId)?.color ?? '#888' + const wfColor = workflowsById[wfId]?.color ?? '#888' mentionIconNode = (
0 ? elements : {'\u00A0'} - }, [value, contextManagement.selectedContexts]) + }, [value, contextManagement.selectedContexts, workflowsById]) return (
w.id === resourceId)) return false + if (workflows.some((w) => w.id === resourceId)) return false const sortOrder = getTopInsertionSortOrder( Object.fromEntries(workflows.map((w) => [w.id, w])), - useFolderStore.getState().folders, + getFolderMap(workspaceId), workspaceId, null ) - const newMetadata: import('@/stores/workflows/registry/types').WorkflowMetadata = { + const newMetadata: WorkflowMetadata = { id: resourceId, name: title, lastModified: new Date(), @@ -324,10 +327,15 @@ function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId } const queryClient = getQueryClient() const key = workflowKeys.list(workspaceId, 'active') - const current = - queryClient.getQueryData(key) ?? - [] - queryClient.setQueryData(key, [...current, newMetadata]) + 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 } @@ -1255,7 +1263,7 @@ export function useChat( ? ((args as Record).workflowId as string) : useWorkflowRegistry.getState().activeWorkflowId if (targetWorkflowId) { - const meta = getWorkflows().find((w) => w.id === targetWorkflowId) + const meta = getWorkflowById(workspaceId, targetWorkflowId) const wasAdded = addResource({ type: 'workflow', id: targetWorkflowId, diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index ad7b57e437f..075d1f2d39a 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -5,6 +5,7 @@ import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/ import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader' import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader' import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { WorkspaceScopeSync } from '@/app/workspace/[workspaceId]/providers/workspace-scope-sync' import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { @@ -16,6 +17,7 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
+
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx index 5bce8435212..1ebc721e795 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx @@ -15,8 +15,8 @@ import { type WorkflowData, } from '@/lib/logs/search-suggestions' import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-search-state' +import { useFolderMap } from '@/hooks/queries/folders' import { useWorkflows } from '@/hooks/queries/workflows' -import { useFolderStore } from '@/stores/folders/store' function truncateFilterValue(field: string, value: string): string { if ((field === 'executionId' || field === 'workflowId') && value.length > 12) { @@ -45,7 +45,7 @@ export function AutocompleteSearch({ }: AutocompleteSearchProps) { const { workspaceId } = useParams<{ workspaceId: string }>() const { data: workflowList = [] } = useWorkflows(workspaceId) - const folders = useFolderStore((state) => state.folders) + const { data: folders = {} } = useFolderMap(workspaceId) const workflowsData = useMemo(() => { return workflowList.map((w) => ({ 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 e529e74803f..04926c08665 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx @@ -20,8 +20,8 @@ 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 { useFolderMap } from '@/hooks/queries/folders' import { useWorkflows } from '@/hooks/queries/workflows' -import { useFolderStore } from '@/stores/folders/store' import { useFilterStore } from '@/stores/logs/filters/store' import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' import { AutocompleteSearch } from './components/search' @@ -218,7 +218,7 @@ 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 { data: allWorkflowList = [] } = useWorkflows(workspaceId) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index cd7c174a0b9..49521f6ff87 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -50,7 +50,7 @@ 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, @@ -59,7 +59,6 @@ import { } 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' @@ -784,7 +783,7 @@ export default function Logs() { ) const { data: allWorkflows = {} } = useWorkflowMap(workspaceId) - const folders = useFolderStore((state) => state.folders) + const { data: folders = {} } = useFolderMap(workspaceId) const filterTags = useMemo(() => { const tags: FilterTag[] = [] @@ -1243,7 +1242,7 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr const [datePickerOpen, setDatePickerOpen] = useState(false) const [previousTimeRange, setPreviousTimeRange] = useState(timeRange) - const folders = useFolderStore((state) => state.folders) + const { data: folders = {} } = useFolderMap(workspaceId) const { data: allWorkflowList = [] } = useWorkflows(workspaceId) const workflows = useMemo( diff --git a/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx b/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx new file mode 100644 index 00000000000..6ffcb96facc --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx @@ -0,0 +1,24 @@ +'use client' + +import { useEffect } from 'react' +import { useParams } from 'next/navigation' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +/** + * Keeps workflow registry workspace scope synchronized with the current route. + */ +export function WorkspaceScopeSync() { + const { workspaceId } = useParams<{ workspaceId: string }>() + const hydrationWorkspaceId = useWorkflowRegistry((state) => state.hydration.workspaceId) + const switchToWorkspace = useWorkflowRegistry((state) => state.switchToWorkspace) + + useEffect(() => { + if (!workspaceId || hydrationWorkspaceId === workspaceId) { + return + } + + switchToWorkspace(workspaceId) + }, [hydrationWorkspaceId, switchToWorkspace, workspaceId]) + + return null +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index 86b933a18d7..c9b3f37a48c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -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/editor/components/sub-block/components/workflow-selector/workflow-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-selector/workflow-selector-input.tsx index 4b5abc8afd0..4d1266b273f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-selector/workflow-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-selector/workflow-selector-input.tsx @@ -1,6 +1,7 @@ 'use client' import { useMemo } from 'react' +import { useParams } from 'next/navigation' import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import type { SubBlockConfig } from '@/blocks/types' @@ -22,13 +23,15 @@ export function WorkflowSelectorInput({ isPreview = false, previewValue, }: WorkflowSelectorInputProps) { + const { workspaceId } = useParams<{ workspaceId: string }>() const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) const context: SelectorContext = useMemo( () => ({ + workspaceId, excludeWorkflowId: activeWorkflowId ?? undefined, }), - [activeWorkflowId] + [activeWorkflowId, workspaceId] ) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index ccb1858cf16..0f349d1b85f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -476,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') 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 82a0f6bfbc4..67ea76d97f8 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 @@ -31,7 +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/workflows' +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' @@ -384,7 +384,8 @@ export function useWorkflowExecution() { // Sandbox exercises have no real workflow — signal the SandboxCanvasProvider // to run mock execution by setting isExecuting, then bail out immediately. - const cachedWorkflows = getWorkflows(routeWorkspaceId ?? hydrationWorkspaceId ?? undefined) + const scopedWorkspaceId = routeWorkspaceId ?? hydrationWorkspaceId ?? undefined + const cachedWorkflows = scopedWorkspaceId ? getWorkflows(scopedWorkspaceId) : [] const activeWorkflow = cachedWorkflows.find((w) => w.id === activeWorkflowId) if (activeWorkflow?.isSandbox) { setIsExecuting(activeWorkflowId, true) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 273cb7e887d..7f9479b64d8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -278,7 +278,11 @@ const WorkflowContent = React.memo( useOAuthReturnForWorkflow(workflowIdParam) - const { data: workflows = {} } = useWorkflowMap(workspaceId) + const { + data: workflows = {}, + isLoading: isWorkflowMapLoading, + isPlaceholderData: isWorkflowMapPlaceholderData, + } = useWorkflowMap(workspaceId) const { activeWorkflowId, @@ -357,12 +361,14 @@ const WorkflowContent = React.memo( const isWorkflowReady = useMemo( () => + !isWorkflowMapPlaceholderData && hydration.phase === 'ready' && hydration.workflowId === workflowIdParam && activeWorkflowId === workflowIdParam && Boolean(workflows[workflowIdParam]) && lastSaved !== undefined, [ + isWorkflowMapPlaceholderData, hydration.phase, hydration.workflowId, workflowIdParam, @@ -2193,7 +2199,8 @@ 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. @@ -2202,9 +2209,12 @@ const WorkflowContent = React.memo( const currentId = workflowIdParam // Wait for workflow data to be available before attempting to load if ( + isWorkflowMapLoading || + isWorkflowMapPlaceholderData || !currentId || !currentWorkflowExists || - (hydration.workspaceId && hydration.workspaceId !== workspaceId) + !hydration.workspaceId || + hydration.workspaceId !== workspaceId ) { return } @@ -2253,6 +2263,8 @@ const WorkflowContent = React.memo( } }, [ workflowIdParam, + isWorkflowMapLoading, + isWorkflowMapPlaceholderData, currentWorkflowExists, activeWorkflowId, setActiveWorkflow, @@ -2270,8 +2282,12 @@ const WorkflowContent = React.memo( useEffect(() => { if (embedded || sandbox) return - // Wait for metadata to finish loading before making navigation decisions - if (hydration.phase === 'idle') { + if ( + isWorkflowMapLoading || + isWorkflowMapPlaceholderData || + !hydration.workspaceId || + hydration.workspaceId !== workspaceId + ) { return } @@ -2314,9 +2330,12 @@ const WorkflowContent = React.memo( }, [ embedded, workflowIdParam, + isWorkflowMapLoading, + isWorkflowMapPlaceholderData, currentWorkflowExists, workflowCount, hydration.phase, + hydration.workspaceId, workspaceId, router, workflows, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx index 2069c249160..f2c78c680c3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx @@ -13,8 +13,8 @@ import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { getBlock } from '@/blocks' import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types' -import { getWorkflows } from '@/hooks/queries/workflows' import { useVariablesStore } from '@/stores/panel/variables/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,13 +111,15 @@ 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 workflows = getWorkflows() - return workflows.find((w) => w.id === rawValue)?.name ?? DELETED_WORKFLOW_LABEL + return workflowMap[rawValue]?.name ?? DELETED_WORKFLOW_LABEL } /** @@ -228,6 +234,8 @@ const SubBlockRow = memo(function SubBlockRow({ value, subBlock, rawValue, + workflowMap, + workflowLabelsReady, }: SubBlockRowProps) { const isPasswordField = subBlock?.password === true const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null @@ -235,7 +243,7 @@ const SubBlockRow = memo(function SubBlockRow({ const dropdownLabel = resolveDropdownLabel(subBlock, rawValue) const variablesDisplay = resolveVariablesDisplay(subBlock, rawValue) const toolsDisplay = resolveToolsDisplay(subBlock, rawValue) - const workflowName = resolveWorkflowName(subBlock, rawValue) + const workflowName = resolveWorkflowName(subBlock, rawValue, workflowMap, workflowLabelsReady) const isSelectorType = subBlock?.type && SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlock.type) @@ -272,6 +280,8 @@ function WorkflowPreviewBlockInner({ data }: NodeProps const { type, name, + workflowMap = {}, + workflowLabelsReady = false, isTrigger = false, horizontalHandles = false, enabled = true, @@ -492,6 +502,8 @@ function WorkflowPreviewBlockInner({ data }: NodeProps key={cond.id} title={cond.title} value={lightweight ? undefined : getDisplayValue(cond.value)} + workflowMap={workflowMap} + workflowLabelsReady={workflowLabelsReady} /> )) ) : type === 'router_v2' ? ( @@ -500,12 +512,16 @@ function WorkflowPreviewBlockInner({ data }: NodeProps key='context' title='Context' value={lightweight ? undefined : getDisplayValue(rawValues.context)} + workflowMap={workflowMap} + workflowLabelsReady={workflowLabelsReady} /> {routerRows.map((route, index) => ( ))} @@ -519,12 +535,20 @@ function WorkflowPreviewBlockInner({ data }: NodeProps value={lightweight ? undefined : getDisplayValue(rawValue)} subBlock={lightweight ? undefined : subBlock} rawValue={rawValue} + workflowMap={workflowMap} + workflowLabelsReady={workflowLabelsReady} /> ) }) )} {/* Error row for non-trigger blocks */} - {shouldShowDefaultHandles && } + {shouldShowDefaultHandles && ( + + )}
)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx index c1487a4b72b..dfde43d4792 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx @@ -1,6 +1,7 @@ 'use client' import { useEffect, useMemo, useRef } from 'react' +import { useParams } from 'next/navigation' import ReactFlow, { ConnectionLineType, type Edge, @@ -19,6 +20,7 @@ import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/compo import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' import { PreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block' import { PreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow' +import { useWorkflowMap } from '@/hooks/queries/workflows' import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('PreviewWorkflow') @@ -130,6 +132,7 @@ function calculateAbsolutePosition( interface PreviewWorkflowProps { workflowState: WorkflowState + workspaceId?: string className?: string height?: string | number width?: string | number @@ -213,6 +216,7 @@ function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeP /** Readonly workflow visualization with execution status highlighting. */ export function PreviewWorkflow({ workflowState, + workspaceId: propWorkspaceId, className, height = '100%', width = '100%', @@ -228,6 +232,14 @@ export function PreviewWorkflow({ selectedBlockId, lightweight = false, }: PreviewWorkflowProps) { + const params = useParams<{ workspaceId: string }>() + const workspaceId = propWorkspaceId ?? params.workspaceId + const { + data: workflowMap = {}, + isLoading: isWorkflowMapLoading, + isPlaceholderData: isWorkflowMapPlaceholderData, + } = useWorkflowMap(workspaceId) + const workflowLabelsReady = !isWorkflowMapLoading && !isWorkflowMapPlaceholderData const containerRef = useRef(null) const nodeTypes = previewNodeTypes const isValidWorkflowState = workflowState?.blocks && workflowState.edges @@ -424,6 +436,8 @@ export function PreviewWorkflow({ data: { type: block.type, name: block.name, + workflowMap, + workflowLabelsReady, isTrigger: block.triggerMode === true, horizontalHandles: block.horizontalHandles ?? false, enabled: block.enabled ?? true, @@ -445,6 +459,8 @@ export function PreviewWorkflow({ executedBlocks, selectedBlockId, getSubflowExecutionStatus, + workflowMap, + workflowLabelsReady, lightweight, ]) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx index ba072e09c5e..295f8d88033 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx @@ -27,7 +27,9 @@ import { useExportSelection, } from '@/app/workspace/[workspaceId]/w/hooks' import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders' -import { getWorkflows, useCreateWorkflow } from '@/hooks/queries/workflows' +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 { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils' @@ -244,12 +246,12 @@ export function FolderItem({ const workflowIds = Array.from(finalWorkflowSelection) const isMixed = folderIds.length > 0 && workflowIds.length > 0 - const { folders } = useFolderStore.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) { 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 734f9041b89..2075bc26781 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx @@ -25,7 +25,9 @@ import { useExportSelection, useExportWorkflow, } from '@/app/workspace/[workspaceId]/w/hooks' -import { getWorkflows, useUpdateWorkflow } from '@/hooks/queries/workflows' +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' @@ -229,7 +231,7 @@ export function WorkflowItem({ const isMixed = workflowIds.length > 0 && folderIds.length > 0 const workflows = getWorkflows(workspaceId) - const { folders } = useFolderStore.getState() + const folderMap = getFolderMap(workspaceId) const names: string[] = [] for (const id of workflowIds) { @@ -237,7 +239,7 @@ export function WorkflowItem({ 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) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx index 6b4d5c180b4..49df9dc57a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx @@ -3,6 +3,7 @@ import { memo, useCallback, useEffect, useMemo } from 'react' import clsx from 'clsx' import { useShallow } from 'zustand/react/shallow' +import { buildFolderTree, getFolderPath } from '@/lib/folders/tree' import { EmptyAreaContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/empty-area-context-menu' import { FolderItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item' import { WorkflowItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item' @@ -18,7 +19,7 @@ import { compareByOrder, groupWorkflowsByFolder, } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils' -import { useFolders } from '@/hooks/queries/folders' +import { useFolderMap, useFolders } from '@/hooks/queries/folders' import { useFolderStore } from '@/stores/folders/store' import type { FolderTreeNode } from '@/stores/folders/types' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -78,12 +79,10 @@ export const WorkflowList = memo(function WorkflowList({ disableCreate = false, }: WorkflowListProps) { const { isLoading: foldersLoading } = useFolders(workspaceId) - const folders = useFolderStore((state) => state.folders) - const { getFolderTree, expandedFolders, getFolderPath, setExpanded } = useFolderStore( + const { data: folderMap = {} } = useFolderMap(workspaceId) + const { expandedFolders, setExpanded } = useFolderStore( useShallow((s) => ({ - getFolderTree: s.getFolderTree, expandedFolders: s.expandedFolders, - getFolderPath: s.getFolderPath, setExpanded: s.setExpanded, })) ) @@ -120,8 +119,8 @@ export const WorkflowList = memo(function WorkflowList({ }, [scrollContainerRef, setScrollContainer]) const folderTree = useMemo( - () => (workspaceId ? getFolderTree(workspaceId) : []), - [workspaceId, folders, getFolderTree] + () => (workspaceId ? buildFolderTree(folderMap, workspaceId) : []), + [workspaceId, folderMap] ) const activeWorkflowFolderId = useMemo(() => { @@ -354,7 +353,7 @@ export const WorkflowList = memo(function WorkflowList({ if (!workflowId || isLoading || foldersLoading) return if (activeWorkflowFolderId) { - const folderPath = getFolderPath(activeWorkflowFolderId) + const folderPath = getFolderPath(folderMap, activeWorkflowFolderId) folderPath.forEach((folder) => setExpanded(folder.id, true)) } @@ -362,7 +361,7 @@ export const WorkflowList = memo(function WorkflowList({ if (!selectedWorkflows.has(workflowId)) { selectOnly(workflowId) } - }, [workflowId, activeWorkflowFolderId, isLoading, foldersLoading, getFolderPath, setExpanded]) + }, [workflowId, activeWorkflowFolderId, isLoading, foldersLoading, folderMap, setExpanded]) const renderWorkflowItem = useCallback( (workflow: WorkflowMetadata, level: number, folderId: string | null = null) => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts index cff8229eb6c..4e1b7ea95f5 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,8 +1,11 @@ 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 { getWorkflows, useReorderWorkflows } from '@/hooks/queries/workflows' +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' const logger = createLogger('WorkflowList:DragDrop') @@ -232,7 +235,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) { const cached = siblingsCacheRef.current.get(cacheKey) if (cached) return cached - const currentFolders = useFolderStore.getState().folders + const currentFolders = workspaceId ? getFolderMap(workspaceId) : {} const currentWorkflows = getWorkflows(workspaceId) const siblings = [ ...Object.values(currentFolders) @@ -293,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( @@ -305,7 +309,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) { folderIds: string[], destinationFolderId: string | null ): { fromDestination: SiblingItem[]; fromOther: SiblingItem[] } => { - const { folders } = useFolderStore.getState() + const folders = workspaceId ? getFolderMap(workspaceId) : {} const workflows = getWorkflows(workspaceId) const fromDestination: SiblingItem[] = [] 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 e9f9e837fdf..f54a0fe8e37 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -38,6 +38,7 @@ import { import { useSession } from '@/lib/auth/auth-client' import { cn } from '@/lib/core/utils/cn' import { isMacPlatform } from '@/lib/core/utils/platform' +import { buildFolderTree } from '@/lib/folders/tree' import { START_NAV_TOUR_EVENT, START_WORKFLOW_TOUR_EVENT, @@ -77,7 +78,7 @@ import { useImportWorkspace, } from '@/app/workspace/[workspaceId]/w/hooks' import { getBrandConfig } from '@/ee/whitelabeling' -import { useFolders } from '@/hooks/queries/folders' +import { useFolderMap, useFolders } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' import { useTablesList } from '@/hooks/queries/tables' import { @@ -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 { 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( 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 8d576c47d3e..22c96afbb85 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-can-delete.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-can-delete.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo } from 'react' +import { useFolderMap } from '@/hooks/queries/folders' import { useWorkflows } from '@/hooks/queries/workflows' -import { useFolderStore } from '@/stores/folders/store' interface UseCanDeleteProps { /** @@ -37,7 +37,7 @@ interface UseCanDeleteReturn { */ export function useCanDelete({ workspaceId }: UseCanDeleteProps): UseCanDeleteReturn { const { data: workflowList = [] } = useWorkflows(workspaceId) - const folders = useFolderStore((s) => s.folders) + const { data: folders = {} } = useFolderMap(workspaceId) /** * Pre-computed data structures for efficient lookups diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-folder.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-folder.ts index 43b7399e4db..56851aed4f0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-folder.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-folder.ts @@ -1,6 +1,8 @@ import { useCallback, useState } from 'react' import { createLogger } from '@sim/logger' +import { getChildFolders, getFolderById } from '@/lib/folders/tree' import { useDuplicateFolderMutation } from '@/hooks/queries/folders' +import { getFolderMap } from '@/hooks/queries/utils/folder-cache' import { useFolderStore } from '@/stores/folders/store' const logger = createLogger('useDuplicateFolder') @@ -54,10 +56,10 @@ export function useDuplicateFolder({ workspaceId, folderIds, onSuccess }: UseDup const folderIdsToDuplicate = Array.isArray(folderIds) ? folderIds : [folderIds] const duplicatedIds: string[] = [] - const folderStore = useFolderStore.getState() + const folderMap = getFolderMap(workspaceId) for (const folderId of folderIdsToDuplicate) { - const folder = folderStore.getFolderById(folderId) + const folder = getFolderById(folderMap, folderId) if (!folder) { logger.warn('Attempted to duplicate folder that no longer exists', { folderId }) @@ -65,7 +67,7 @@ export function useDuplicateFolder({ workspaceId, folderIds, onSuccess }: UseDup } const siblingNames = new Set( - folderStore.getChildFolders(folder.parentId).map((sibling) => sibling.name) + getChildFolders(folderMap, folder.parentId).map((sibling) => sibling.name) ) siblingNames.add(folder.name) diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-selection.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-selection.ts index 8ff0873df0b..4ee23292533 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-selection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-selection.ts @@ -1,9 +1,12 @@ 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 { getWorkflows, useDuplicateWorkflowMutation } from '@/hooks/queries/workflows' +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' const logger = createLogger('useDuplicateSelection') @@ -62,20 +65,20 @@ export function useDuplicateSelection({ workspaceId, onSuccess }: UseDuplicateSe setIsDuplicating(true) try { const workflowMap = new Map(getWorkflows(workspaceIdRef.current).map((w) => [w.id, w])) - const folderStore = useFolderStore.getState() + 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) 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 2af28a66f8f..bffe42d6266 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workflow.ts @@ -2,7 +2,8 @@ import { useCallback, useRef } from 'react' import { createLogger } from '@sim/logger' import { useRouter } from 'next/navigation' import { getNextWorkflowColor } from '@/lib/workflows/colors' -import { getWorkflows, useDuplicateWorkflowMutation } from '@/hooks/queries/workflows' +import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' +import { useDuplicateWorkflowMutation } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' const logger = createLogger('useDuplicateWorkflow') 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 8391bd9f1e8..ff85f17e938 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts @@ -1,6 +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, @@ -9,6 +10,7 @@ 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' @@ -92,7 +94,7 @@ function collectSubfolders( export function useExportFolder({ folderId, onSuccess }: UseExportFolderProps) { const { workspaceId } = useParams<{ workspaceId: string }>() const { data: workflows = {} } = useWorkflowMap(workspaceId) - const folders = useFolderStore((s) => s.folders) + const { data: folders = {} } = useFolderMap(workspaceId) const [isExporting, setIsExporting] = useState(false) const hasWorkflows = useMemo(() => { @@ -107,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 a240372b4d5..93b9a5f9da8 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 @@ -8,7 +8,8 @@ import { fetchWorkflowForExport, type WorkflowExportData, } from '@/lib/workflows/operations/import-export' -import { getWorkflows } from '@/hooks/queries/workflows' +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 type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -121,18 +122,18 @@ export function useExportSelection({ onSuccess }: UseExportSelectionProps = {}) try { const workflowsArray = getWorkflows(workspaceIdRef.current) const workflows = Object.fromEntries(workflowsArray.map((w) => [w.id, w])) - const { folders } = useFolderStore.getState() + 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] + const folder = folderMap[folderId] return { id: folder.id, name: folder.name, 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 1d493db09ad..7dc24349f12 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 @@ -8,7 +8,7 @@ import { fetchWorkflowForExport, sanitizePathSegment, } from '@/lib/workflows/operations/import-export' -import { getWorkflows } from '@/hooks/queries/workflows' +import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' import { useFolderStore } from '@/stores/folders/store' const logger = createLogger('useExportWorkflow') diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts index c7f461b71f6..75ed0a4577f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts @@ -8,8 +8,10 @@ import { persistImportedWorkflow, sanitizePathSegment, } from '@/lib/workflows/operations/import-export' -import { folderKeys, useCreateFolder } from '@/hooks/queries/folders' -import { useCreateWorkflow, workflowKeys } from '@/hooks/queries/workflows' +import { useCreateFolder } from '@/hooks/queries/folders' +import { folderKeys } from '@/hooks/queries/utils/folder-keys' +import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' +import { useCreateWorkflow } from '@/hooks/queries/workflows' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' const logger = createLogger('useImportWorkflow') @@ -196,7 +198,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) { } } - await queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }) + await invalidateWorkflowLists(queryClient, workspaceId) await queryClient.invalidateQueries({ queryKey: folderKeys.list(workspaceId) }) logger.info(`Import complete. Imported ${importedWorkflowIds.length} workflow(s)`) diff --git a/apps/sim/app/workspace/[workspaceId]/w/page.tsx b/apps/sim/app/workspace/[workspaceId]/w/page.tsx index 0c2802d8e63..1276a785276 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/page.tsx @@ -17,7 +17,7 @@ export default function WorkflowsPage() { const workspaceId = params.workspaceId as string const [isMounted, setIsMounted] = useState(false) - const { data: workflows = [], isLoading, isError } = useWorkflows(workspaceId) + const { data: workflows = [], isLoading, isError, isPlaceholderData } = useWorkflows(workspaceId) useEffect(() => { setIsMounted(true) @@ -25,7 +25,7 @@ export default function WorkflowsPage() { useEffect(() => { if (!isMounted) return - if (isLoading) return + if (isLoading || isPlaceholderData) return if (isError) { logger.error('Failed to load workflows for workspace') @@ -37,7 +37,16 @@ export default function WorkflowsPage() { if (workspaceWorkflows.length > 0) { router.replace(`/workspace/${workspaceId}/w/${workspaceWorkflows[0].id}`) } - }, [isMounted, isLoading, workflows, workspaceId, router, setActiveWorkflow, isError]) + }, [ + isMounted, + isLoading, + isPlaceholderData, + workflows, + workspaceId, + router, + setActiveWorkflow, + isError, + ]) // Always show loading state until redirect happens // There should always be a default workflow, so we never show "no workflows found" diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts index a7a0829c882..b44ef4658c3 100644 --- a/apps/sim/blocks/blocks/agent.ts +++ b/apps/sim/blocks/blocks/agent.ts @@ -7,20 +7,30 @@ import { getApiKeyCondition, getModelOptions, RESPONSE_FORMAT_WAND_CONFIG } from import { getBaseModelProviders, getMaxTemperature, + getModelsWithDeepResearch, + getModelsWithoutMemory, + getModelsWithReasoningEffort, + getModelsWithThinking, + getModelsWithVerbosity, + getProviderModels, getReasoningEffortValuesForModel, getThinkingLevelsForModel, getVerbosityValuesForModel, - MODELS_WITH_DEEP_RESEARCH, - MODELS_WITH_REASONING_EFFORT, - MODELS_WITH_THINKING, - MODELS_WITH_VERBOSITY, - MODELS_WITHOUT_MEMORY, - providers, supportsTemperature, -} from '@/providers/utils' +} from '@/providers/models' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' import type { ToolResponse } from '@/tools/types' const logger = createLogger('AgentBlock') +const VERTEX_MODELS = getProviderModels('vertex') +const BEDROCK_MODELS = getProviderModels('bedrock') +const AZURE_MODELS = [...getProviderModels('azure-openai'), ...getProviderModels('azure-anthropic')] +const MODELS_WITH_REASONING_EFFORT = getModelsWithReasoningEffort() +const MODELS_WITH_VERBOSITY = getModelsWithVerbosity() +const MODELS_WITH_THINKING = getModelsWithThinking() +const MODELS_WITH_DEEP_RESEARCH = getModelsWithDeepResearch() +const MODELS_WITHOUT_MEMORY = getModelsWithoutMemory() interface AgentResponse extends ToolResponse { output: { @@ -136,7 +146,7 @@ Return ONLY the JSON array.`, required: true, condition: { field: 'model', - value: providers.vertex.models, + value: VERTEX_MODELS, }, }, { @@ -149,7 +159,7 @@ Return ONLY the JSON array.`, required: true, condition: { field: 'model', - value: providers.vertex.models, + value: VERTEX_MODELS, }, }, { @@ -165,9 +175,6 @@ Return ONLY the JSON array.`, ], dependsOn: ['model'], fetchOptions: async (blockId: string) => { - const { useSubBlockStore } = await import('@/stores/workflows/subblock/store') - const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') - const autoOption = { label: 'auto', id: 'auto' } const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId @@ -224,9 +231,6 @@ Return ONLY the JSON array.`, ], dependsOn: ['model'], fetchOptions: async (blockId: string) => { - const { useSubBlockStore } = await import('@/stores/workflows/subblock/store') - const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') - const autoOption = { label: 'auto', id: 'auto' } const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId @@ -285,9 +289,6 @@ Return ONLY the JSON array.`, ], dependsOn: ['model'], fetchOptions: async (blockId: string) => { - const { useSubBlockStore } = await import('@/stores/workflows/subblock/store') - const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') - const noneOption = { label: 'none', id: 'none' } const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId @@ -326,7 +327,7 @@ Return ONLY the JSON array.`, connectionDroppable: false, condition: { field: 'model', - value: [...providers['azure-openai'].models, ...providers['azure-anthropic'].models], + value: AZURE_MODELS, }, }, { @@ -337,7 +338,7 @@ Return ONLY the JSON array.`, connectionDroppable: false, condition: { field: 'model', - value: [...providers['azure-openai'].models, ...providers['azure-anthropic'].models], + value: AZURE_MODELS, }, }, { @@ -349,7 +350,7 @@ Return ONLY the JSON array.`, required: true, condition: { field: 'model', - value: providers.vertex.models, + value: VERTEX_MODELS, }, }, { @@ -361,7 +362,7 @@ Return ONLY the JSON array.`, required: true, condition: { field: 'model', - value: providers.vertex.models, + value: VERTEX_MODELS, }, }, { @@ -374,7 +375,7 @@ Return ONLY the JSON array.`, required: true, condition: { field: 'model', - value: providers.bedrock.models, + value: BEDROCK_MODELS, }, }, { @@ -387,7 +388,7 @@ Return ONLY the JSON array.`, required: true, condition: { field: 'model', - value: providers.bedrock.models, + value: BEDROCK_MODELS, }, }, { @@ -398,7 +399,7 @@ Return ONLY the JSON array.`, connectionDroppable: false, condition: { field: 'model', - value: providers.bedrock.models, + value: BEDROCK_MODELS, }, }, { diff --git a/apps/sim/blocks/blocks/evaluator.ts b/apps/sim/blocks/blocks/evaluator.ts index 4edb032ba9f..d3a78fa9574 100644 --- a/apps/sim/blocks/blocks/evaluator.ts +++ b/apps/sim/blocks/blocks/evaluator.ts @@ -6,8 +6,8 @@ import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS, } from '@/blocks/utils' +import { getBaseModelProviders } from '@/providers/models' import type { ProviderId } from '@/providers/types' -import { getBaseModelProviders } from '@/providers/utils' import type { ToolResponse } from '@/tools/types' const logger = createLogger('EvaluatorBlock') diff --git a/apps/sim/blocks/blocks/router.ts b/apps/sim/blocks/blocks/router.ts index 4fae2383ff4..c82c502c1c0 100644 --- a/apps/sim/blocks/blocks/router.ts +++ b/apps/sim/blocks/blocks/router.ts @@ -5,8 +5,8 @@ import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS, } from '@/blocks/utils' +import { getBaseModelProviders } from '@/providers/models' import type { ProviderId } from '@/providers/types' -import { getBaseModelProviders } from '@/providers/utils' import type { ToolResponse } from '@/tools/types' interface RouterResponse extends ToolResponse { diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index 373538b50e3..d187b2e751c 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -4,10 +4,14 @@ import { getHostedModels, getProviderFromModel, getProviderIcon, - providers, -} from '@/providers/utils' + getProviderModels, +} from '@/providers/models' import { useProvidersStore } from '@/stores/providers/store' +const VERTEX_MODELS = getProviderModels('vertex') +const BEDROCK_MODELS = getProviderModels('bedrock') +const AZURE_MODELS = [...getProviderModels('azure-openai'), ...getProviderModels('azure-anthropic')] + /** * Returns model options for combobox subblocks, combining all provider sources. */ @@ -152,7 +156,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { required: true, condition: { field: 'model', - value: providers.vertex.models, + value: VERTEX_MODELS, }, }, { @@ -174,7 +178,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { connectionDroppable: false, condition: { field: 'model', - value: [...providers['azure-openai'].models, ...providers['azure-anthropic'].models], + value: AZURE_MODELS, }, }, { @@ -185,7 +189,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { connectionDroppable: false, condition: { field: 'model', - value: [...providers['azure-openai'].models, ...providers['azure-anthropic'].models], + value: AZURE_MODELS, }, }, { @@ -197,7 +201,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { required: true, condition: { field: 'model', - value: providers.vertex.models, + value: VERTEX_MODELS, }, }, { @@ -209,7 +213,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { required: true, condition: { field: 'model', - value: providers.vertex.models, + value: VERTEX_MODELS, }, }, { @@ -222,7 +226,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { required: true, condition: { field: 'model', - value: providers.bedrock.models, + value: BEDROCK_MODELS, }, }, { @@ -235,7 +239,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { required: true, condition: { field: 'model', - value: providers.bedrock.models, + value: BEDROCK_MODELS, }, }, { @@ -246,7 +250,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { connectionDroppable: false, condition: { field: 'model', - value: providers.bedrock.models, + value: BEDROCK_MODELS, }, }, ] diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 492d07c9b00..b277de87649 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -34,7 +34,9 @@ import { resolveVertexCredential } from '@/executor/utils/vertex-credential' import { executeProviderRequest } from '@/providers' import { getProviderFromModel, transformBlockTool } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' -import { getTool, getToolAsync } from '@/tools/utils' +import { filterSchemaForLLM } from '@/tools/params' +import { getTool } from '@/tools/utils' +import { getToolAsync } from '@/tools/utils.server' const logger = createLogger('AgentBlockHandler') @@ -140,8 +142,13 @@ export class AgentBlockHandler implements BlockHandler { const serverIds = [...new Set(mcpTools.map((t) => t.params?.serverId).filter(Boolean))] if (serverIds.length === 0) return tools + if (!ctx.workspaceId) { + logger.warn('Skipping MCP availability filtering without workspace scope') + return tools + } + const availableServerIds = new Set() - if (ctx.workspaceId && serverIds.length > 0) { + if (serverIds.length > 0) { try { const servers = await db .select({ id: mcpServers.id, connectionStatus: mcpServers.connectionStatus }) @@ -245,8 +252,6 @@ export class AgentBlockHandler implements BlockHandler { return null } - const { filterSchemaForLLM } = await import('@/tools/params') - const filteredSchema = filterSchemaForLLM(schema.function.parameters, userProvidedParams) const toolId = `${AGENT.CUSTOM_TOOL_PREFIX}${title}` @@ -272,22 +277,6 @@ export class AgentBlockHandler implements BlockHandler { ctx: ExecutionContext, customToolId: string ): Promise<{ schema: any; title: string } | null> { - if (typeof window !== 'undefined') { - try { - const { getCustomTool } = await import('@/hooks/queries/custom-tools') - const tool = getCustomTool(customToolId, ctx.workspaceId) - if (tool) { - return { - schema: tool.schema, - title: tool.title, - } - } - logger.warn(`Custom tool not found in cache: ${customToolId}`) - } catch (error) { - logger.error('Error accessing custom tools cache:', { error }) - } - } - try { const headers = await buildAuthHeaders(ctx.userId) const params: Record = {} @@ -572,7 +561,12 @@ export class AgentBlockHandler implements BlockHandler { const transformedTool = await transformBlockTool(tool, { selectedOperation: tool.operation, getAllBlocks, - getToolAsync: (toolId: string) => getToolAsync(toolId, ctx.workflowId), + getToolAsync: (toolId: string) => + getToolAsync(toolId, { + workflowId: ctx.workflowId, + userId: ctx.userId, + workspaceId: ctx.workspaceId, + }), getTool, canonicalModes, }) diff --git a/apps/sim/hooks/queries/custom-tools.ts b/apps/sim/hooks/queries/custom-tools.ts index f99cbe22eeb..78af01fd0ee 100644 --- a/apps/sim/hooks/queries/custom-tools.ts +++ b/apps/sim/hooks/queries/custom-tools.ts @@ -1,7 +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 { getWorkspaceIdFromUrl } from '@/hooks/queries/utils/get-workspace-id-from-url' +import { customToolsKeys } from '@/hooks/queries/utils/custom-tool-keys' const logger = createLogger('CustomToolsQueries') const API_ENDPOINT = '/api/tools/custom' @@ -30,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 & { @@ -88,35 +77,6 @@ function normalizeCustomTool(tool: ApiCustomTool, workspaceId: string): CustomTo } } -/** - * Extract workspaceId from the current URL path - * Expected format: /workspace/{workspaceId}/... - */ -/** - * Get all custom tools from the query cache (for non-React code) - * If workspaceId is not provided, extracts it from the current URL - */ -export function getCustomTools(workspaceId?: string): CustomToolDefinition[] { - if (typeof window === 'undefined') return [] - const wsId = workspaceId ?? getWorkspaceIdFromUrl() - if (!wsId) return [] - const queryClient = getQueryClient() - return queryClient.getQueryData(customToolsKeys.list(wsId)) ?? [] -} - -/** - * Get a specific custom tool from the query cache by ID or title (for non-React code) - * Custom tools are referenced by title in the system (custom_${title}), so title lookup is required. - * If workspaceId is not provided, extracts it from the current URL - */ -export function getCustomTool( - identifier: string, - workspaceId?: string -): CustomToolDefinition | undefined { - const tools = getCustomTools(workspaceId) - return tools.find((tool) => tool.id === identifier || tool.title === identifier) -} - /** * Fetch custom tools for a workspace */ diff --git a/apps/sim/hooks/queries/deployments.test.ts b/apps/sim/hooks/queries/deployments.test.ts new file mode 100644 index 00000000000..97628bf202b --- /dev/null +++ b/apps/sim/hooks/queries/deployments.test.ts @@ -0,0 +1,51 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { invalidateDeploymentQueries } from '@/hooks/queries/deployments' +import { fetchDeploymentVersionState } from '@/hooks/queries/utils/fetch-deployment-version-state' + +describe('deployment query helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('invalidates the deployment info, state, and versions queries', async () => { + const queryClient = { + invalidateQueries: vi.fn().mockResolvedValue(undefined), + } + + await invalidateDeploymentQueries(queryClient as any, 'wf-1') + + expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(1, { + queryKey: ['deployments', 'info', 'wf-1'], + }) + expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(2, { + queryKey: ['deployments', 'deployedState', 'wf-1'], + }) + expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(3, { + queryKey: ['deployments', 'versions', 'wf-1'], + }) + }) + + it('fetches deployment version state through the shared helper', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + deployedState: { blocks: {}, edges: [], loops: {}, parallels: {}, lastSaved: 1 }, + }), + }) as typeof fetch + + await expect(fetchDeploymentVersionState('wf-1', 3)).resolves.toEqual({ + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + lastSaved: 1, + }) + + expect(global.fetch).toHaveBeenCalledWith('/api/workflows/wf-1/deployments/3', { + signal: undefined, + }) + }) +}) diff --git a/apps/sim/hooks/queries/deployments.ts b/apps/sim/hooks/queries/deployments.ts index 0896e4d599b..62f5e970d3b 100644 --- a/apps/sim/hooks/queries/deployments.ts +++ b/apps/sim/hooks/queries/deployments.ts @@ -3,9 +3,10 @@ import { createLogger } from '@sim/logger' import type { QueryClient } from '@tanstack/react-query' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils' +import { fetchDeploymentVersionState } from '@/hooks/queries/utils/fetch-deployment-version-state' +import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' -import { fetchDeploymentVersionState, workflowKeys } from './workflows' const logger = createLogger('DeploymentQueries') diff --git a/apps/sim/hooks/queries/folders.test.ts b/apps/sim/hooks/queries/folders.test.ts index 120336e87b3..355b3d899e3 100644 --- a/apps/sim/hooks/queries/folders.test.ts +++ b/apps/sim/hooks/queries/folders.test.ts @@ -1,31 +1,24 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockLogger, mockGetWorkflows, queryClient, useFolderStoreMock, useWorkflowRegistryMock } = - vi.hoisted(() => ({ - mockLogger: { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }, - mockGetWorkflows: vi.fn(() => []), - queryClient: { - cancelQueries: vi.fn().mockResolvedValue(undefined), - invalidateQueries: vi.fn().mockResolvedValue(undefined), - }, - 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 -} +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(), + }, +})) + +let folderMapState: Record +let folderListState: any[] let workflowList: Array<{ id: string @@ -46,16 +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', () => ({ - getWorkflows: mockGetWorkflows, +vi.mock('@/hooks/queries/utils/workflow-keys', () => ({ workflowKeys: { list: (workspaceId: string | undefined) => ['workflows', 'list', workspaceId ?? ''], }, @@ -64,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 } @@ -72,48 +64,43 @@ 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]) + ) }) + 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])) workflowList = [ { diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts index 8f125fb7c74..f72cce210eb 100644 --- a/apps/sim/hooks/queries/folders.ts +++ b/apps/sim/hooks/queries/folders.ts @@ -1,23 +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 { getWorkflows, 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' 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, @@ -45,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 { @@ -109,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)) }, }) } @@ -256,7 +223,7 @@ export function useDeleteFolderMutation() { }, onSuccess: async (_data, variables) => { queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) }) - queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }) + await invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived']) }, }) } @@ -327,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) }, }) } @@ -360,28 +327,28 @@ export function useReorderFolders() { onMutate: async (variables) => { await queryClient.cancelQueries({ queryKey: folderKeys.list(variables.workspaceId) }) - const snapshot = { ...useFolderStore.getState().folders } - - useFolderStore.setState((state) => { - const updated = { ...state.folders } - for (const update of variables.updates) { - if (updated[update.id]) { - updated[update.id] = { - ...updated[update.id], - sortOrder: update.sortOrder, - parentId: - update.parentId !== undefined ? update.parentId : updated[update.id].parentId, - } + const snapshot = queryClient.getQueryData( + folderKeys.list(variables.workspaceId) + ) + + const updatesById = new Map(variables.updates.map((update) => [update.id, update])) + queryClient.setQueryData(folderKeys.list(variables.workspaceId), (old) => + (old ?? []).map((folder) => { + const update = updatesById.get(folder.id) + if (!update) return folder + return { + ...folder, + sortOrder: update.sortOrder, + parentId: update.parentId !== undefined ? update.parentId : folder.parentId, } - } - return { folders: updated } - }) + }) + ) return { snapshot } }, - onError: (_error, _variables, context) => { + onError: (_error, variables, context) => { if (context?.snapshot) { - useFolderStore.setState({ folders: context.snapshot }) + queryClient.setQueryData(folderKeys.list(variables.workspaceId), context.snapshot) } }, onSettled: (_data, _error, variables) => { diff --git a/apps/sim/hooks/queries/utils/custom-tool-cache.test.ts b/apps/sim/hooks/queries/utils/custom-tool-cache.test.ts new file mode 100644 index 00000000000..c4b7fd54021 --- /dev/null +++ b/apps/sim/hooks/queries/utils/custom-tool-cache.test.ts @@ -0,0 +1,39 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { getQueryDataMock } = vi.hoisted(() => ({ + getQueryDataMock: vi.fn(), +})) + +vi.mock('@/app/_shell/providers/get-query-client', () => ({ + getQueryClient: vi.fn(() => ({ + getQueryData: getQueryDataMock, + })), +})) + +import { getCustomTool, getCustomTools } from '@/hooks/queries/utils/custom-tool-cache' + +describe('custom tool cache helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('reads workspace-scoped custom tools from the cache', () => { + const tools = [{ id: 'tool-1', title: 'Weather', schema: {}, code: '', workspaceId: 'ws-1' }] + getQueryDataMock.mockReturnValue(tools) + + expect(getCustomTools('ws-1')).toBe(tools) + expect(getQueryDataMock).toHaveBeenCalledWith(['customTools', 'list', 'ws-1']) + }) + + it('resolves custom tools by id or title', () => { + getQueryDataMock.mockReturnValue([ + { id: 'tool-1', title: 'Weather', schema: {}, code: '', workspaceId: 'ws-1' }, + ]) + + expect(getCustomTool('tool-1', 'ws-1')?.title).toBe('Weather') + expect(getCustomTool('Weather', 'ws-1')?.id).toBe('tool-1') + }) +}) diff --git a/apps/sim/hooks/queries/utils/custom-tool-cache.ts b/apps/sim/hooks/queries/utils/custom-tool-cache.ts new file mode 100644 index 00000000000..407911ddb37 --- /dev/null +++ b/apps/sim/hooks/queries/utils/custom-tool-cache.ts @@ -0,0 +1,23 @@ +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import type { CustomToolDefinition } from '@/hooks/queries/custom-tools' +import { customToolsKeys } from '@/hooks/queries/utils/custom-tool-keys' + +/** + * Reads custom tools for a workspace directly from the React Query cache. + */ +export function getCustomTools(workspaceId: string): CustomToolDefinition[] { + return ( + getQueryClient().getQueryData(customToolsKeys.list(workspaceId)) ?? [] + ) +} + +/** + * Resolves a custom tool from the cache by id or title. + */ +export function getCustomTool( + identifier: string, + workspaceId: string +): CustomToolDefinition | undefined { + const tools = getCustomTools(workspaceId) + return tools.find((tool) => tool.id === identifier || tool.title === identifier) +} diff --git a/apps/sim/hooks/queries/utils/custom-tool-keys.ts b/apps/sim/hooks/queries/utils/custom-tool-keys.ts new file mode 100644 index 00000000000..1bb74bd8da5 --- /dev/null +++ b/apps/sim/hooks/queries/utils/custom-tool-keys.ts @@ -0,0 +1,6 @@ +export const customToolsKeys = { + all: ['customTools'] as const, + lists: () => [...customToolsKeys.all, 'list'] as const, + list: (workspaceId: string) => [...customToolsKeys.lists(), workspaceId] as const, + detail: (toolId: string) => [...customToolsKeys.all, 'detail', toolId] as const, +} diff --git a/apps/sim/hooks/queries/utils/fetch-deployment-version-state.ts b/apps/sim/hooks/queries/utils/fetch-deployment-version-state.ts new file mode 100644 index 00000000000..ded2a7a10cf --- /dev/null +++ b/apps/sim/hooks/queries/utils/fetch-deployment-version-state.ts @@ -0,0 +1,27 @@ +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +interface DeploymentVersionStateResponse { + deployedState: WorkflowState +} + +/** + * Fetches the deployed state for a specific deployment version. + */ +export async function fetchDeploymentVersionState( + workflowId: string, + version: number, + signal?: AbortSignal +): Promise { + const response = await fetch(`/api/workflows/${workflowId}/deployments/${version}`, { signal }) + + if (!response.ok) { + throw new Error(`Failed to fetch deployment version: ${response.statusText}`) + } + + const data: DeploymentVersionStateResponse = await response.json() + if (!data.deployedState) { + throw new Error('No deployed state returned') + } + + return data.deployedState +} diff --git a/apps/sim/hooks/queries/utils/folder-cache.ts b/apps/sim/hooks/queries/utils/folder-cache.ts new file mode 100644 index 00000000000..158f558bb60 --- /dev/null +++ b/apps/sim/hooks/queries/utils/folder-cache.ts @@ -0,0 +1,15 @@ +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import { folderKeys } from '@/hooks/queries/utils/folder-keys' +import type { WorkflowFolder } from '@/stores/folders/types' + +const EMPTY_FOLDERS: WorkflowFolder[] = [] + +export function getFolders(workspaceId: string): WorkflowFolder[] { + return ( + getQueryClient().getQueryData(folderKeys.list(workspaceId)) ?? EMPTY_FOLDERS + ) +} + +export function getFolderMap(workspaceId: string): Record { + return Object.fromEntries(getFolders(workspaceId).map((folder) => [folder.id, folder])) +} diff --git a/apps/sim/hooks/queries/utils/folder-keys.ts b/apps/sim/hooks/queries/utils/folder-keys.ts new file mode 100644 index 00000000000..517d5cd9b94 --- /dev/null +++ b/apps/sim/hooks/queries/utils/folder-keys.ts @@ -0,0 +1,5 @@ +export const folderKeys = { + all: ['folders'] as const, + lists: () => [...folderKeys.all, 'list'] as const, + list: (workspaceId: string | undefined) => [...folderKeys.lists(), workspaceId ?? ''] as const, +} diff --git a/apps/sim/hooks/queries/utils/get-workspace-id-from-url.ts b/apps/sim/hooks/queries/utils/get-workspace-id-from-url.ts deleted file mode 100644 index 039584ba4d9..00000000000 --- a/apps/sim/hooks/queries/utils/get-workspace-id-from-url.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Extracts the workspace ID from the current URL pathname. - * Returns `null` on the server or when the URL doesn't match `/workspace/{id}/...`. - * Used as a fallback for synchronous cache-read helpers that can't access React hooks. - */ -export function getWorkspaceIdFromUrl(): string | null { - if (typeof window === 'undefined') return null - const match = window.location.pathname.match(/^\/workspace\/([^/]+)/) - return match?.[1] ?? null -} diff --git a/apps/sim/hooks/queries/utils/invalidate-workflow-lists.test.ts b/apps/sim/hooks/queries/utils/invalidate-workflow-lists.test.ts new file mode 100644 index 00000000000..6c5963840ae --- /dev/null +++ b/apps/sim/hooks/queries/utils/invalidate-workflow-lists.test.ts @@ -0,0 +1,25 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' + +describe('invalidateWorkflowLists', () => { + it('invalidates scoped workflow lists and workflow selector caches', async () => { + const queryClient = { + invalidateQueries: vi.fn().mockResolvedValue(undefined), + } + + await invalidateWorkflowLists(queryClient as any, 'ws-1', ['active', 'archived']) + + expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(1, { + queryKey: ['workflows', 'list', 'ws-1', 'active'], + }) + expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(2, { + queryKey: ['workflows', 'list', 'ws-1', 'archived'], + }) + expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(3, { + queryKey: ['selectors', 'sim.workflows', 'ws-1'], + }) + }) +}) diff --git a/apps/sim/hooks/queries/utils/invalidate-workflow-lists.ts b/apps/sim/hooks/queries/utils/invalidate-workflow-lists.ts new file mode 100644 index 00000000000..f04e19aa9b8 --- /dev/null +++ b/apps/sim/hooks/queries/utils/invalidate-workflow-lists.ts @@ -0,0 +1,25 @@ +import type { QueryClient } from '@tanstack/react-query' +import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys' +import { selectorKeys } from '@/hooks/selectors/query-keys' + +export async function invalidateWorkflowSelectors(queryClient: QueryClient, workspaceId: string) { + await queryClient.invalidateQueries({ queryKey: selectorKeys.simWorkflowsPrefix(workspaceId) }) +} + +/** + * Invalidates workflow list consumers for a single workspace. + */ +export async function invalidateWorkflowLists( + queryClient: QueryClient, + workspaceId: string, + scopes: WorkflowQueryScope[] = ['active'] +) { + const uniqueScopes = [...new Set(scopes)] + + await Promise.all([ + ...uniqueScopes.map((scope) => + queryClient.invalidateQueries({ queryKey: workflowKeys.list(workspaceId, scope) }) + ), + invalidateWorkflowSelectors(queryClient, workspaceId), + ]) +} diff --git a/apps/sim/hooks/queries/utils/optimistic-mutation.ts b/apps/sim/hooks/queries/utils/optimistic-mutation.ts index 47482e7ffa9..27f45ff26b6 100644 --- a/apps/sim/hooks/queries/utils/optimistic-mutation.ts +++ b/apps/sim/hooks/queries/utils/optimistic-mutation.ts @@ -6,12 +6,12 @@ const logger = createLogger('OptimisticMutation') export interface OptimisticMutationConfig { name: string getQueryKey: (variables: TVariables) => readonly unknown[] - getSnapshot: () => Record + getSnapshot: (variables: TVariables) => Record generateTempId: (variables: TVariables) => string createOptimisticItem: (variables: TVariables, tempId: string) => TItem applyOptimisticUpdate: (tempId: string, item: TItem) => void replaceOptimisticEntry: (tempId: string, data: TData) => void - rollback: (snapshot: Record) => void + rollback: (snapshot: Record, variables: TVariables) => void onSuccessExtra?: (data: TData, variables: TVariables) => void } @@ -40,7 +40,7 @@ export function createOptimisticMutationHandlers( onMutate: async (variables: TVariables): Promise> => { const queryKey = getQueryKey(variables) await queryClient.cancelQueries({ queryKey }) - const previousState = getSnapshot() + const previousState = getSnapshot(variables) const tempId = generateTempId(variables) const optimisticItem = createOptimisticItem(variables, tempId) applyOptimisticUpdate(tempId, optimisticItem) @@ -61,7 +61,7 @@ export function createOptimisticMutationHandlers( ) => { logger.error(`[${name}] Failed:`, error) if (context?.previousState) { - rollback(context.previousState) + rollback(context.previousState, _variables) logger.info(`[${name}] Rolled back to previous state`) } }, diff --git a/apps/sim/hooks/queries/utils/workflow-cache.test.ts b/apps/sim/hooks/queries/utils/workflow-cache.test.ts new file mode 100644 index 00000000000..a3ccf8da645 --- /dev/null +++ b/apps/sim/hooks/queries/utils/workflow-cache.test.ts @@ -0,0 +1,46 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { getQueryDataMock } = vi.hoisted(() => ({ + getQueryDataMock: vi.fn(), +})) + +vi.mock('@/app/_shell/providers/get-query-client', () => ({ + getQueryClient: vi.fn(() => ({ + getQueryData: getQueryDataMock, + })), +})) + +import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache' + +describe('getWorkflows', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('reads the active workflow list from the cache', () => { + const workflows = [{ id: 'wf-1', name: 'Workflow 1' }] + getQueryDataMock.mockReturnValue(workflows) + + expect(getWorkflows('ws-1')).toBe(workflows) + expect(getQueryDataMock).toHaveBeenCalledWith(['workflows', 'list', 'ws-1', 'active']) + }) + + it('supports alternate workflow scopes', () => { + getQueryDataMock.mockReturnValue([]) + + getWorkflows('ws-2', 'archived') + + expect(getQueryDataMock).toHaveBeenCalledWith(['workflows', 'list', 'ws-2', 'archived']) + }) + + it('reads a single workflow by id from the cache', () => { + const workflows = [{ id: 'wf-1', name: 'Workflow 1' }] + getQueryDataMock.mockReturnValue(workflows) + + expect(getWorkflowById('ws-1', 'wf-1')).toEqual(workflows[0]) + expect(getWorkflowById('ws-1', 'missing')).toBeUndefined() + }) +}) diff --git a/apps/sim/hooks/queries/utils/workflow-cache.ts b/apps/sim/hooks/queries/utils/workflow-cache.ts new file mode 100644 index 00000000000..c810a236f87 --- /dev/null +++ b/apps/sim/hooks/queries/utils/workflow-cache.ts @@ -0,0 +1,29 @@ +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys' +import type { WorkflowMetadata } from '@/stores/workflows/registry/types' + +const EMPTY_WORKFLOWS: WorkflowMetadata[] = [] + +/** + * Reads workflow metadata for a workspace directly from the React Query cache. + */ +export function getWorkflows( + workspaceId: string, + scope: WorkflowQueryScope = 'active' +): WorkflowMetadata[] { + return ( + getQueryClient().getQueryData(workflowKeys.list(workspaceId, scope)) ?? + EMPTY_WORKFLOWS + ) +} + +/** + * Reads a single workflow by id from the React Query cache. + */ +export function getWorkflowById( + workspaceId: string, + workflowId: string, + scope: WorkflowQueryScope = 'active' +): WorkflowMetadata | undefined { + return getWorkflows(workspaceId, scope).find((workflow) => workflow.id === workflowId) +} diff --git a/apps/sim/hooks/queries/utils/workflow-list-query.ts b/apps/sim/hooks/queries/utils/workflow-list-query.ts new file mode 100644 index 00000000000..f88627a33cb --- /dev/null +++ b/apps/sim/hooks/queries/utils/workflow-list-query.ts @@ -0,0 +1,61 @@ +import type { QueryFunctionContext } from '@tanstack/react-query' +import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys' +import type { WorkflowMetadata } from '@/stores/workflows/registry/types' + +interface WorkflowApiRow { + id: string + name: string + description?: string | null + color: string + workspaceId: string + folderId?: string | null + sortOrder?: number | null + createdAt: string + updatedAt?: string | null + archivedAt?: string | null +} + +export const WORKFLOW_LIST_STALE_TIME = 60 * 1000 + +export function mapWorkflow(workflow: WorkflowApiRow): WorkflowMetadata { + return { + id: workflow.id, + name: workflow.name, + description: workflow.description ?? undefined, + color: workflow.color, + workspaceId: workflow.workspaceId, + folderId: workflow.folderId ?? undefined, + sortOrder: workflow.sortOrder ?? 0, + createdAt: new Date(workflow.createdAt), + lastModified: new Date(workflow.updatedAt || workflow.createdAt), + archivedAt: workflow.archivedAt ? new Date(workflow.archivedAt) : null, + } +} + +export async function fetchWorkflows( + workspaceId: string, + scope: WorkflowQueryScope = 'active', + signal?: AbortSignal +): Promise { + const response = await fetch(`/api/workflows?workspaceId=${workspaceId}&scope=${scope}`, { + signal, + }) + + if (!response.ok) { + throw new Error('Failed to fetch workflows') + } + + const { data }: { data: WorkflowApiRow[] } = await response.json() + return data.map(mapWorkflow) +} + +export function getWorkflowListQueryOptions( + workspaceId: string, + scope: WorkflowQueryScope = 'active' +) { + return { + queryKey: workflowKeys.list(workspaceId, scope), + queryFn: ({ signal }: QueryFunctionContext) => fetchWorkflows(workspaceId, scope, signal), + staleTime: WORKFLOW_LIST_STALE_TIME, + } +} diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index d04336ddafc..b719e9d4714 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -12,11 +12,18 @@ import { } from '@tanstack/react-query' import { getNextWorkflowColor } from '@/lib/workflows/colors' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' -import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { deploymentKeys } from '@/hooks/queries/deployments' -import { getWorkspaceIdFromUrl } from '@/hooks/queries/utils/get-workspace-id-from-url' +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' @@ -28,21 +35,6 @@ const logger = createLogger('WorkflowQueries') export { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys' -/** - * Reads the workflow list from the React Query cache synchronously. - * For use in non-React code (stores, event handlers, utilities). - * Falls back to the URL workspace when `workspaceId` is omitted. - */ -export function getWorkflows( - workspaceId?: string, - scope: WorkflowQueryScope = 'active' -): WorkflowMetadata[] { - if (typeof window === 'undefined') return [] - const wsId = workspaceId ?? getWorkspaceIdFromUrl() - if (!wsId) return [] - return getQueryClient().getQueryData(workflowKeys.list(wsId, scope)) ?? [] -} - async function fetchWorkflowState( workflowId: string, signal?: AbortSignal @@ -66,59 +58,14 @@ export function useWorkflowState(workflowId: string | undefined) { }) } -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 -} - -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, - } -} - -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 useWorkflows(workspaceId?: string, options?: { scope?: WorkflowQueryScope }) { const { scope = 'active' } = options || {} return useQuery({ queryKey: workflowKeys.list(workspaceId, scope), - queryFn: workspaceId ? ({ signal }) => fetchWorkflows(workspaceId, scope, signal) : skipToken, + queryFn: workspaceId ? getWorkflowListQueryOptions(workspaceId, scope).queryFn : skipToken, placeholderData: keepPreviousData, - staleTime: 60 * 1000, + staleTime: WORKFLOW_LIST_STALE_TIME, }) } @@ -132,9 +79,9 @@ export function useWorkflowMap(workspaceId?: string, options?: { scope?: Workflo return useQuery({ queryKey: workflowKeys.list(workspaceId, scope), - queryFn: workspaceId ? ({ signal }) => fetchWorkflows(workspaceId, scope, signal) : skipToken, + queryFn: workspaceId ? getWorkflowListQueryOptions(workspaceId, scope).queryFn : skipToken, placeholderData: keepPreviousData, - staleTime: 60 * 1000, + staleTime: WORKFLOW_LIST_STALE_TIME, select: (data) => Object.fromEntries(data.map((w) => [w.id, w])), }) } @@ -237,10 +184,9 @@ export function useCreateWorkflow() { const currentWorkflows = Object.fromEntries( getWorkflows(variables.workspaceId).map((w) => [w.id, w]) ) - const currentFolders = useFolderStore.getState().folders sortOrder = getTopInsertionSortOrder( currentWorkflows, - currentFolders, + getFolderMap(variables.workspaceId), variables.workspaceId, variables.folderId ) @@ -321,9 +267,7 @@ export function useCreateWorkflow() { } }, onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ - queryKey: workflowKeys.list(variables.workspaceId, 'active'), - }) + return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived']) }, }) } @@ -412,7 +356,6 @@ export function useDuplicateWorkflowMutation() { const currentWorkflows = Object.fromEntries( getWorkflows(variables.workspaceId).map((w) => [w.id, w]) ) - const currentFolders = useFolderStore.getState().folders const targetFolderId = variables.folderId ?? null const optimistic: WorkflowMetadata = { @@ -426,7 +369,7 @@ export function useDuplicateWorkflowMutation() { folderId: targetFolderId, sortOrder: getTopInsertionSortOrder( currentWorkflows, - currentFolders, + getFolderMap(variables.workspaceId), variables.workspaceId, targetFolderId ), @@ -499,9 +442,7 @@ export function useDuplicateWorkflowMutation() { } }, onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ - queryKey: workflowKeys.list(variables.workspaceId, 'active'), - }) + return invalidateWorkflowLists(queryClient, variables.workspaceId) }, }) } @@ -561,9 +502,7 @@ export function useUpdateWorkflow() { } }, onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ - queryKey: workflowKeys.lists(), - }) + return invalidateWorkflowLists(queryClient, variables.workspaceId) }, }) } @@ -614,40 +553,11 @@ export function useDeleteWorkflowMutation() { } }, onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ - queryKey: workflowKeys.lists(), - }) + return invalidateWorkflowLists(queryClient, variables.workspaceId) }, }) } -interface DeploymentVersionStateResponse { - deployedState: WorkflowState -} - -/** - * 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 }) - - 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 -} - export function useDeploymentVersionState(workflowId: string | null, version: number | null) { return useQuery({ queryKey: workflowKeys.deploymentVersion(workflowId ?? undefined, version ?? undefined), @@ -720,7 +630,9 @@ export function useReorderWorkflows() { } }, onMutate: async (variables) => { - await queryClient.cancelQueries({ queryKey: workflowKeys.lists() }) + await queryClient.cancelQueries({ + queryKey: workflowKeys.list(variables.workspaceId, 'active'), + }) const snapshot = queryClient.getQueryData( workflowKeys.list(variables.workspaceId, 'active') @@ -752,7 +664,7 @@ export function useReorderWorkflows() { } }, onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }) + return invalidateWorkflowLists(queryClient, variables.workspaceId) }, }) } @@ -790,7 +702,7 @@ export function useImportWorkflow() { return data }, onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }) + return invalidateWorkflowLists(queryClient, variables.targetWorkspaceId) }, }) } @@ -799,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(() => ({})) @@ -807,8 +719,8 @@ export function useRestoreWorkflow() { } return res.json() }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }) + onSettled: (_data, _error, variables) => { + return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived']) }, }) } diff --git a/apps/sim/hooks/selectors/query-keys.ts b/apps/sim/hooks/selectors/query-keys.ts new file mode 100644 index 00000000000..c5fa1afe97a --- /dev/null +++ b/apps/sim/hooks/selectors/query-keys.ts @@ -0,0 +1,7 @@ +export const selectorKeys = { + all: ['selectors'] as const, + simWorkflowsPrefix: (workspaceId: string) => + [...selectorKeys.all, 'sim.workflows', workspaceId] as const, + simWorkflows: (workspaceId: string, excludeWorkflowId?: string) => + [...selectorKeys.simWorkflowsPrefix(workspaceId), excludeWorkflowId ?? 'none'] as const, +} diff --git a/apps/sim/hooks/selectors/registry.test.ts b/apps/sim/hooks/selectors/registry.test.ts new file mode 100644 index 00000000000..fe3d596fac0 --- /dev/null +++ b/apps/sim/hooks/selectors/registry.test.ts @@ -0,0 +1,84 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockEnsureQueryData, mockGetWorkflows } = vi.hoisted(() => ({ + mockEnsureQueryData: vi.fn().mockResolvedValue(undefined), + mockGetWorkflows: vi.fn(), +})) + +vi.mock('@/app/_shell/providers/get-query-client', () => ({ + getQueryClient: vi.fn(() => ({ + ensureQueryData: mockEnsureQueryData, + })), +})) + +vi.mock('@/hooks/queries/utils/workflow-cache', () => ({ + getWorkflows: mockGetWorkflows, + getWorkflowById: vi.fn((workspaceId: string, workflowId: string) => + mockGetWorkflows(workspaceId).find((workflow: { id: string }) => workflow.id === workflowId) + ), +})) + +vi.mock('@/hooks/queries/utils/workflow-list-query', () => ({ + getWorkflowListQueryOptions: vi.fn((workspaceId: string) => ({ + queryKey: ['workflows', 'list', workspaceId, 'active'], + })), +})) + +import { getSelectorDefinition } from '@/hooks/selectors/registry' + +describe('sim.workflows selector', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetWorkflows.mockReturnValue([ + { id: 'wf-1', name: 'Alpha Workflow' }, + { id: 'wf-2', name: 'Bravo Workflow' }, + ]) + }) + + it('requires an explicit workspaceId in selector context', () => { + const definition = getSelectorDefinition('sim.workflows') + + expect(definition.enabled?.({ key: 'sim.workflows', context: {} })).toBe(false) + expect(definition.staleTime).toBe(60_000) + expect( + definition.getQueryKey({ + key: 'sim.workflows', + context: { workspaceId: 'ws-1', excludeWorkflowId: 'wf-2' }, + }) + ).toEqual(['selectors', 'sim.workflows', 'ws-1', 'wf-2']) + }) + + it('reads workflow options from the scoped workflow cache', async () => { + const definition = getSelectorDefinition('sim.workflows') + + const options = await definition.fetchList({ + key: 'sim.workflows', + context: { workspaceId: 'ws-1', excludeWorkflowId: 'wf-2' }, + }) + + expect(mockEnsureQueryData).toHaveBeenCalledWith({ + queryKey: ['workflows', 'list', 'ws-1', 'active'], + }) + expect(mockGetWorkflows).toHaveBeenCalledWith('ws-1') + expect(options).toEqual([{ id: 'wf-1', label: 'Alpha Workflow' }]) + }) + + it('resolves workflow labels by id using the same workspace scope', async () => { + const definition = getSelectorDefinition('sim.workflows') + + const option = await definition.fetchById?.({ + key: 'sim.workflows', + context: { workspaceId: 'ws-1' }, + detailId: 'wf-2', + }) + + expect(mockEnsureQueryData).toHaveBeenCalledWith({ + queryKey: ['workflows', 'list', 'ws-1', 'active'], + }) + expect(mockGetWorkflows).toHaveBeenCalledWith('ws-1') + expect(option).toEqual({ id: 'wf-2', label: 'Bravo Workflow' }) + }) +}) diff --git a/apps/sim/hooks/selectors/registry.ts b/apps/sim/hooks/selectors/registry.ts index 7b4d433db7a..be48437b7d7 100644 --- a/apps/sim/hooks/selectors/registry.ts +++ b/apps/sim/hooks/selectors/registry.ts @@ -1,5 +1,8 @@ -import { getWorkflows } from '@/hooks/queries/workflows' +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, @@ -1685,15 +1688,16 @@ 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 = getWorkflows() + 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) => ({ @@ -1702,10 +1706,10 @@ const registry: Record = { })) .sort((a, b) => a.label.localeCompare(b.label)) }, - fetchById: async ({ detailId }: SelectorQueryArgs): Promise => { - if (!detailId) return null - const workflows = getWorkflows() - const workflow = workflows.find((w) => w.id === 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/lib/copilot/tools/client/tool-display-registry.ts b/apps/sim/lib/copilot/tools/client/tool-display-registry.ts index 35d90f1189d..9a9cb88ca8a 100644 --- a/apps/sim/lib/copilot/tools/client/tool-display-registry.ts +++ b/apps/sim/lib/copilot/tools/client/tool-display-registry.ts @@ -40,8 +40,8 @@ import { XCircle, Zap, } from 'lucide-react' -import { getCustomTool } from '@/hooks/queries/custom-tools' -import { getWorkflows } from '@/hooks/queries/workflows' +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' @@ -138,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 @@ -1037,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 @@ -1630,8 +1640,9 @@ const META_run_workflow: ToolMetadata = { }, getDynamicText: (params, state) => { const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId - if (workflowId) { - const workflowName = getWorkflows().find((w) => w.id === 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/folders/tree.ts b/apps/sim/lib/folders/tree.ts new file mode 100644 index 00000000000..6f0829e57dd --- /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 = folders[currentId] + path.unshift(folder) + currentId = folder.parentId + } + + return path +} diff --git a/apps/sim/lib/workflows/comparison/resolve-values.ts b/apps/sim/lib/workflows/comparison/resolve-values.ts index 6858535a6ae..2fe7f24a34d 100644 --- a/apps/sim/lib/workflows/comparison/resolve-values.ts +++ b/apps/sim/lib/workflows/comparison/resolve-values.ts @@ -34,6 +34,8 @@ interface ResolutionContext { subBlockId: string /** The workflow ID for API calls */ workflowId: string + /** The workspace scope for selector-based lookups */ + workspaceId?: string /** The current workflow state for extracting additional context */ currentState: WorkflowState /** The block ID being resolved */ @@ -64,13 +66,15 @@ async function resolveCredential(credentialId: string, workflowId: string): Prom } } -async function resolveWorkflow(workflowId: string): Promise { +async function resolveWorkflow(workflowId: string, workspaceId?: string): Promise { + if (!workspaceId) return null + try { const definition = getSelectorDefinition('sim.workflows') if (definition.fetchById) { const result = await definition.fetchById({ key: 'sim.workflows', - context: {}, + context: { workspaceId }, detailId: workflowId, }) return result?.label ?? null @@ -141,11 +145,12 @@ export function formatValueForDisplay(value: unknown): string { function extractSelectorContext( blockId: string, currentState: WorkflowState, - workflowId: string + workflowId: string, + workspaceId?: string ): SelectorContext { const block = currentState.blocks?.[blockId] - if (!block?.subBlocks) return { workflowId } - return buildSelectorContextFromBlock(block.type, block.subBlocks, { workflowId }) + if (!block?.subBlocks) return { workflowId, workspaceId } + return buildSelectorContextFromBlock(block.type, block.subBlocks, { workflowId, workspaceId }) } /** @@ -177,8 +182,13 @@ export async function resolveValueForDisplay( const semanticFallback = getSemanticFallback(subBlockConfig) const selectorCtx = context.blockId - ? extractSelectorContext(context.blockId, context.currentState, context.workflowId) - : { workflowId: context.workflowId } + ? extractSelectorContext( + context.blockId, + context.currentState, + context.workflowId, + context.workspaceId + ) + : { workflowId: context.workflowId, workspaceId: context.workspaceId } // Credential fields (oauth-input or credential subBlockId) const isCredentialField = @@ -194,7 +204,7 @@ export async function resolveValueForDisplay( // Workflow selector if (subBlockConfig?.type === 'workflow-selector' && isUuid(value)) { - const label = await resolveWorkflow(value) + const label = await resolveWorkflow(value, selectorCtx.workspaceId) if (label) { return { original: value, displayLabel: label, resolved: true } } diff --git a/apps/sim/lib/workflows/subblocks/context.test.ts b/apps/sim/lib/workflows/subblocks/context.test.ts index c30f1f1b0af..29cad46d941 100644 --- a/apps/sim/lib/workflows/subblocks/context.test.ts +++ b/apps/sim/lib/workflows/subblocks/context.test.ts @@ -66,6 +66,16 @@ describe('buildSelectorContextFromBlock', () => { expect(ctx.workflowId).toBe('wf-123') }) + it('should pass through workspaceId from opts', () => { + const ctx = buildSelectorContextFromBlock( + 'knowledge', + { operation: { id: 'operation', type: 'dropdown', value: 'search' } }, + { workspaceId: 'ws-123' } + ) + + expect(ctx.workspaceId).toBe('ws-123') + }) + it('should ignore subblock keys not in SELECTOR_CONTEXT_FIELDS', () => { const ctx = buildSelectorContextFromBlock('knowledge', { operation: { id: 'operation', type: 'dropdown', value: 'search' }, diff --git a/apps/sim/lib/workflows/subblocks/context.ts b/apps/sim/lib/workflows/subblocks/context.ts index 9b43bc892f3..affd888e2aa 100644 --- a/apps/sim/lib/workflows/subblocks/context.ts +++ b/apps/sim/lib/workflows/subblocks/context.ts @@ -34,10 +34,11 @@ export const SELECTOR_CONTEXT_FIELDS = new Set([ export function buildSelectorContextFromBlock( blockType: string, subBlocks: Record, - opts?: { workflowId?: string } + opts?: { workflowId?: string; workspaceId?: string } ): SelectorContext { const context: SelectorContext = {} if (opts?.workflowId) context.workflowId = opts.workflowId + if (opts?.workspaceId) context.workspaceId = opts.workspaceId const blockConfig = getBlock(blockType) if (!blockConfig) return context diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 37f973198ce..e195e873de4 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -24,7 +24,7 @@ import { VllmIcon, xAIIcon, } from '@/components/icons' -import type { ModelPricing } from '@/providers/types' +import type { ModelPricing, ProviderId } from '@/providers/types' export interface ModelCapabilities { temperature?: { @@ -2284,6 +2284,45 @@ export function getProviderModels(providerId: string): string[] { return PROVIDER_DEFINITIONS[providerId]?.models.map((m) => m.id) || [] } +export function getBaseModelProviders(): Record { + return Object.entries(PROVIDER_DEFINITIONS) + .filter(([providerId]) => !['ollama', 'vllm', 'openrouter'].includes(providerId)) + .reduce( + (map, [providerId, provider]) => { + provider.models.forEach((model) => { + map[model.id.toLowerCase()] = providerId as ProviderId + }) + return map + }, + {} as Record + ) +} + +export function getProviderFromModel(model: string): ProviderId { + const normalizedModel = model.toLowerCase() + + for (const [providerId, provider] of Object.entries(PROVIDER_DEFINITIONS)) { + if ( + provider.models.some((providerModel) => providerModel.id.toLowerCase() === normalizedModel) + ) { + return providerId as ProviderId + } + } + + for (const [providerId, provider] of Object.entries(PROVIDER_DEFINITIONS)) { + if (provider.modelPatterns?.some((pattern) => pattern.test(normalizedModel))) { + return providerId as ProviderId + } + } + + return 'ollama' +} + +export function getProviderIcon(model: string): React.ComponentType<{ className?: string }> | null { + const providerId = getProviderFromModel(model) + return PROVIDER_DEFINITIONS[providerId]?.icon || null +} + export function getProviderDefaultModel(providerId: string): string { return PROVIDER_DEFINITIONS[providerId]?.defaultModel || '' } diff --git a/apps/sim/stores/folders/store.ts b/apps/sim/stores/folders/store.ts index c21f0cc03b7..88a5211e6bf 100644 --- a/apps/sim/stores/folders/store.ts +++ b/apps/sim/stores/folders/store.ts @@ -1,12 +1,10 @@ import { createLogger } from '@sim/logger' import { create } from 'zustand' import { devtools } from 'zustand/middleware' -import type { FolderTreeNode, WorkflowFolder } from './types' const logger = createLogger('FoldersStore') interface FolderState { - folders: Record expandedFolders: Set selectedWorkflows: Set selectedFolders: Set @@ -14,7 +12,6 @@ interface FolderState { selectedTasks: Set lastSelectedTaskId: string | null - setFolders: (folders: WorkflowFolder[]) => void toggleExpanded: (folderId: string) => void setExpanded: (folderId: string, expanded: boolean) => void @@ -48,18 +45,11 @@ interface FolderState { hasAnySelection: () => boolean isMixedSelection: () => boolean clearAllSelection: () => void - - // Computed values - getFolderTree: (workspaceId: string) => FolderTreeNode[] - getFolderById: (id: string) => WorkflowFolder | undefined - getChildFolders: (parentId: string | null) => WorkflowFolder[] - getFolderPath: (folderId: string) => WorkflowFolder[] } export const useFolderStore = create()( devtools( (set, get) => ({ - folders: {}, expandedFolders: new Set(), selectedWorkflows: new Set(), selectedFolders: new Set(), @@ -67,17 +57,6 @@ export const useFolderStore = create()( selectedTasks: new Set(), lastSelectedTaskId: null, - setFolders: (folders) => - set(() => ({ - folders: folders.reduce( - (acc, folder) => { - acc[folder.id] = folder - return acc - }, - {} as Record - ), - })), - toggleExpanded: (folderId) => set((state) => { const newExpanded = new Set(state.expandedFolders) @@ -312,50 +291,6 @@ export const useFolderStore = create()( selectedTasks: new Set(), lastSelectedTaskId: null, }), - - getFolderTree: (workspaceId) => { - const folders = Object.values(get().folders).filter((f) => f.workspaceId === workspaceId) - - const buildTree = (parentId: string | null, level = 0): FolderTreeNode[] => { - return folders - .filter((folder) => folder.parentId === parentId) - .sort( - (a: WorkflowFolder, b: WorkflowFolder) => - a.sortOrder - b.sortOrder || a.name.localeCompare(b.name) - ) - .map((folder) => ({ - ...folder, - children: buildTree(folder.id, level + 1), - level, - })) - } - - return buildTree(null) - }, - - getFolderById: (id) => get().folders[id], - - getChildFolders: (parentId) => - Object.values(get().folders) - .filter((folder) => folder.parentId === parentId) - .sort( - (a: WorkflowFolder, b: WorkflowFolder) => - a.sortOrder - b.sortOrder || a.name.localeCompare(b.name) - ), - - getFolderPath: (folderId) => { - const folders = get().folders - const path: WorkflowFolder[] = [] - let currentId: string | null = folderId - - while (currentId && folders[currentId]) { - const folder: WorkflowFolder = folders[currentId] - path.unshift(folder) - currentId = folder.parentId - } - - return path - }, }), { name: 'folder-store' } ) diff --git a/apps/sim/stores/panel/variables/store.ts b/apps/sim/stores/panel/variables/store.ts index 70ebc2b7191..e9a7db871c5 100644 --- a/apps/sim/stores/panel/variables/store.ts +++ b/apps/sim/stores/panel/variables/store.ts @@ -5,7 +5,6 @@ import { devtools } from 'zustand/middleware' import { normalizeName } from '@/executor/constants' import { useOperationQueueStore } from '@/stores/operation-queue/store' import type { Variable, VariablesStore } from '@/stores/panel/variables/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' const logger = createLogger('VariablesStore') @@ -175,10 +174,10 @@ export const useVariablesStore = create()( update.name = undefined } else if (newName !== oldVariableName) { const subBlockStore = useSubBlockStore.getState() - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + const targetWorkflowId = oldVariable.workflowId - if (activeWorkflowId) { - const workflowValues = subBlockStore.workflowValues[activeWorkflowId] || {} + if (targetWorkflowId) { + const workflowValues = subBlockStore.workflowValues[targetWorkflowId] || {} const updatedWorkflowValues = { ...workflowValues } const changedSubBlocks: Array<{ blockId: string; subBlockId: string; value: any }> = [] @@ -227,7 +226,7 @@ export const useVariablesStore = create()( useSubBlockStore.setState({ workflowValues: { ...subBlockStore.workflowValues, - [activeWorkflowId]: updatedWorkflowValues, + [targetWorkflowId]: updatedWorkflowValues, }, }) @@ -242,7 +241,7 @@ export const useVariablesStore = create()( target: 'subblock', payload: { blockId, subblockId: subBlockId, value }, }, - workflowId: activeWorkflowId, + workflowId: targetWorkflowId, userId: 'system', }) } diff --git a/apps/sim/stores/workflows/index.ts b/apps/sim/stores/workflows/index.ts index 9f223ba9310..e2fdf9a7c4a 100644 --- a/apps/sim/stores/workflows/index.ts +++ b/apps/sim/stores/workflows/index.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { getWorkflows } from '@/hooks/queries/workflows' +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' @@ -11,13 +11,12 @@ 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 workspaceId = useWorkflowRegistry.getState().hydration.workspaceId ?? undefined +export function getWorkflowWithValues(workflowId: string, workspaceId: string) { const workflows = getWorkflows(workspaceId) const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - const currentState = useWorkflowStore.getState() const metadata = workflows.find((w) => w.id === workflowId) if (!metadata) { @@ -78,12 +77,24 @@ 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 workspaceId = useWorkflowRegistry.getState().hydration.workspaceId ?? undefined +export function getAllWorkflowsWithValues(workspaceId: string) { const workflows = getWorkflows(workspaceId) - const result: Record = {} + 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() diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index 71fab2be235..4d3fece524a 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -3,19 +3,18 @@ import { create } from 'zustand' import { devtools } from 'zustand/middleware' import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants' import { getQueryClient } from '@/app/_shell/providers/get-query-client' -import { getWorkspaceIdFromUrl } from '@/hooks/queries/utils/get-workspace-id-from-url' -import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' +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 = { @@ -30,6 +29,7 @@ const createRequestId = () => `${Date.now()}-${Math.random().toString(16).slice( function resetWorkflowStores() { useWorkflowStore.setState({ + currentWorkflowId: null, blocks: {}, edges: [], loops: {}, @@ -57,7 +57,7 @@ export const useWorkflowRegistry = create()( logger.info(`Switching to workspace: ${workspaceId}`) resetWorkflowStores() - getQueryClient().invalidateQueries({ queryKey: workflowKeys.lists() }) + void invalidateWorkflowLists(getQueryClient(), workspaceId) set({ activeWorkflowId: null, @@ -108,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), }, }, })) @@ -143,16 +142,9 @@ export const useWorkflowRegistry = create()( }, loadWorkflowState: async (workflowId: string) => { - const workspaceId = get().hydration.workspaceId ?? getWorkspaceIdFromUrl() - - const workflows = workspaceId - ? (getQueryClient().getQueryData( - workflowKeys.list(workspaceId, 'active') - ) ?? []) - : [] - - if (workflows.length > 0 && !workflows.find((w) => w.id === 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) @@ -178,24 +170,41 @@ export const useWorkflowRegistry = create()( } const workflowData = (await response.json()).data - let workflowState: any + const nextDeploymentStatuses = + workflowData?.isDeployed || workflowData?.deployedAt + ? { + ...get().deploymentStatuses, + [workflowId]: { + isDeployed: workflowData.isDeployed || false, + deployedAt: workflowData.deployedAt + ? new Date(workflowData.deployedAt) + : undefined, + apiKey: workflowData.apiKey || undefined, + needsRedeployment: false, + }, + } + : get().deploymentStatuses + + let workflowState: WorkflowState if (workflowData?.state) { workflowState = { + currentWorkflowId: workflowId, blocks: workflowData.state.blocks || {}, edges: workflowData.state.edges || [], loops: workflowData.state.loops || {}, parallels: workflowData.state.parallels || {}, lastSaved: Date.now(), - deploymentStatuses: {}, + deploymentStatuses: nextDeploymentStatuses, } } else { workflowState = { + currentWorkflowId: workflowId, blocks: {}, edges: [], loops: {}, parallels: {}, - deploymentStatuses: {}, + deploymentStatuses: nextDeploymentStatuses, lastSaved: Date.now(), } @@ -204,21 +213,6 @@ export const useWorkflowRegistry = create()( ) } - const nextDeploymentStatuses = - workflowData?.isDeployed || workflowData?.deployedAt - ? { - ...get().deploymentStatuses, - [workflowId]: { - isDeployed: workflowData.isDeployed || false, - deployedAt: workflowData.deployedAt - ? new Date(workflowData.deployedAt) - : undefined, - apiKey: workflowData.apiKey || undefined, - needsRedeployment: false, - }, - } - : get().deploymentStatuses - const currentHydration = get().hydration if ( currentHydration.requestId !== requestId || @@ -237,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 }, diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index 10a580c36f6..0ddbf8d420b 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -9,7 +9,6 @@ import { } from '@/lib/workflows/dynamic-handle-topology' import type { SubBlockConfig } from '@/blocks/types' import { normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { filterNewEdges, @@ -102,6 +101,7 @@ function resolveInitialSubblockValue(config: SubBlockConfig): unknown { } const initialState = { + currentWorkflowId: null, blocks: {}, edges: [], loops: {}, @@ -120,6 +120,10 @@ export const useWorkflowStore = create()( set({ needsRedeployment }) }, + setCurrentWorkflowId: (currentWorkflowId) => { + set({ currentWorkflowId }) + }, + updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => { set((state) => { const block = state.blocks[id] @@ -289,7 +293,7 @@ export const useWorkflowStore = create()( }) if (subBlockValues && Object.keys(subBlockValues).length > 0) { - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + const activeWorkflowId = get().currentWorkflowId if (activeWorkflowId) { const subBlockStore = useSubBlockStore.getState() const updatedWorkflowValues = { @@ -343,7 +347,7 @@ export const useWorkflowStore = create()( delete newBlocks[blockId] }) - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + const activeWorkflowId = get().currentWorkflowId if (activeWorkflowId) { const subBlockStore = useSubBlockStore.getState() if (subBlockStore.workflowValues[activeWorkflowId]) { @@ -485,6 +489,7 @@ export const useWorkflowStore = create()( clear: () => { const newState = { + currentWorkflowId: get().currentWorkflowId, blocks: {}, edges: [], loops: {}, @@ -502,6 +507,7 @@ export const useWorkflowStore = create()( getWorkflowState: (): WorkflowState => { const state = get() return { + currentWorkflowId: state.currentWorkflowId, blocks: state.blocks, edges: state.edges, loops: state.loops, @@ -539,6 +545,10 @@ export const useWorkflowStore = create()( return { ...state, + currentWorkflowId: + nextState.currentWorkflowId !== undefined + ? nextState.currentWorkflowId + : state.currentWorkflowId, blocks: nextBlocks, edges: nextEdges, loops: nextLoops, @@ -613,7 +623,7 @@ export const useWorkflowStore = create()( const newName = getUniqueBlockName(block.name, get().blocks) - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + const activeWorkflowId = get().currentWorkflowId const mergedBlock = mergeSubblockState(get().blocks, activeWorkflowId || undefined, id)[id] const newSubBlocks = Object.entries(mergedBlock.subBlocks).reduce( @@ -739,7 +749,7 @@ export const useWorkflowStore = create()( // Update references in subblock store const subBlockStore = useSubBlockStore.getState() - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + const activeWorkflowId = get().currentWorkflowId const changedSubblocks: Array<{ blockId: string; subBlockId: string; newValue: any }> = [] if (activeWorkflowId) { @@ -1105,16 +1115,14 @@ export const useWorkflowStore = create()( }, revertToDeployedState: async (deployedState: WorkflowState) => { - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + const activeWorkflowId = get().currentWorkflowId if (!activeWorkflowId) { logger.error('Cannot revert: no active workflow ID') return } - const deploymentStatus = useWorkflowRegistry - .getState() - .getWorkflowDeploymentStatus(activeWorkflowId) + const deploymentStatus = get().deploymentStatuses?.[activeWorkflowId] get().replaceWorkflowState({ ...deployedState, diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts index edbc606188e..21f22ff5478 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -160,6 +160,7 @@ export interface DragStartPosition { } export interface WorkflowState { + currentWorkflowId?: string | null blocks: Record edges: Edge[] lastSaved?: number @@ -239,6 +240,7 @@ export interface WorkflowActions { ) => void setBlockLocked: (id: string, locked: boolean) => void batchToggleLocked: (ids: string[]) => void + setCurrentWorkflowId: (workflowId: string | null) => void } export type WorkflowStore = WorkflowState & WorkflowActions diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index c5beca880b7..a55aefa1ee2 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -16,16 +16,19 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // Hoisted mock state - these are available to vi.mock factories -const { mockIsHosted, mockEnv, mockGetBYOKKey, mockRateLimiterFns } = vi.hoisted(() => ({ - mockIsHosted: { value: false }, - mockEnv: { NEXT_PUBLIC_APP_URL: 'http://localhost:3000' } as Record, - mockGetBYOKKey: vi.fn(), - mockRateLimiterFns: { - acquireKey: vi.fn(), - preConsumeCapacity: vi.fn(), - consumeCapacity: vi.fn(), - }, -})) +const { mockIsHosted, mockEnv, mockGetBYOKKey, mockGetToolAsync, mockRateLimiterFns } = vi.hoisted( + () => ({ + mockIsHosted: { value: false }, + mockEnv: { NEXT_PUBLIC_APP_URL: 'http://localhost:3000' } as Record, + mockGetBYOKKey: vi.fn(), + mockGetToolAsync: vi.fn(), + mockRateLimiterFns: { + acquireKey: vi.fn(), + preConsumeCapacity: vi.fn(), + consumeCapacity: vi.fn(), + }, + }) +) // Mock feature flags vi.mock('@/lib/core/config/feature-flags', () => ({ @@ -176,27 +179,12 @@ vi.mock('@/tools/registry', () => { params: {}, request: { url: '/api/tools/serper/search', method: 'GET' }, }, - 'custom_custom-tool-123': { - id: 'custom_custom-tool-123', - name: 'Custom Weather Tool', - description: 'Get weather information', - version: '1.0.0', - params: { - location: { type: 'string', required: true, description: 'City name' }, - unit: { type: 'string', required: false, description: 'Unit (metric/imperial)' }, - }, - request: { - url: '/api/function/execute', - method: 'POST', - headers: () => ({ 'Content-Type': 'application/json' }), - }, - }, } return { tools: mockTools } }) // Mock custom tools - define mock data inside factory function -vi.mock('@/hooks/queries/custom-tools', () => { +vi.mock('@/hooks/queries/utils/custom-tool-cache', () => { const mockCustomTool = { id: 'custom-tool-123', title: 'Custom Weather Tool', @@ -226,9 +214,19 @@ vi.mock('@/hooks/queries/custom-tools', () => { } }) -import { executeTool } from '@/tools' +vi.mock('@/tools/utils.server', async (importOriginal) => { + const actual = await importOriginal() + mockGetToolAsync.mockImplementation(actual.getToolAsync) + return { + ...actual, + getToolAsync: mockGetToolAsync, + } +}) + +import { executeTool, postProcessToolOutput } from '@/tools' import { tools } from '@/tools/registry' import { getTool } from '@/tools/utils' +import { getToolAsync } from '@/tools/utils.server' /** * Sets up global fetch mock with Next.js preconnect support. @@ -304,18 +302,45 @@ describe('Tools Registry', () => { }) describe('Custom Tools', () => { - it('should get custom tool by ID', () => { - const customTool = getTool('custom_custom-tool-123') - expect(customTool).toBeDefined() - expect(customTool?.name).toBe('Custom Weather Tool') - expect(customTool?.description).toBe('Get weather information') - expect(customTool?.params.location).toBeDefined() - expect(customTool?.params.location.required).toBe(true) + it('does not resolve custom tools through the synchronous client helper', () => { + expect(getTool('custom_remote-tool-123', 'workspace-1')).toBeUndefined() }) - it('should handle non-existent custom tool', () => { - const nonExistentTool = getTool('custom_non-existent') - expect(nonExistentTool).toBeUndefined() + it('resolves custom tools through the async helper', async () => { + setupFetchMock({ + json: { + data: [ + { + id: 'remote-tool-123', + title: 'Custom Weather Tool', + schema: { + function: { + name: 'weather_tool', + description: 'Get weather information', + parameters: { + type: 'object', + properties: { + location: { type: 'string', description: 'City name' }, + }, + required: ['location'], + }, + }, + }, + }, + ], + }, + status: 200, + headers: { 'content-type': 'application/json' }, + }) + + const customTool = await getToolAsync('custom_remote-tool-123', { + workflowId: 'workflow-1', + userId: 'user-1', + workspaceId: 'workspace-1', + }) + + expect(customTool?.name).toBe('Custom Weather Tool') + expect(customTool?.params.location.required).toBe(true) }) }) @@ -1962,44 +1987,17 @@ describe('stripInternalFields Safety', () => { }) it('should preserve __-prefixed fields in custom tool output', async () => { - const mockTool = { - id: 'custom_test-preserve-dunder', - name: 'Custom Preserve Dunder', - description: 'A custom tool whose output has __ fields', - version: '1.0.0', - params: {}, - request: { - url: '/api/function/execute', - method: 'POST' as const, - headers: () => ({ 'Content-Type': 'application/json' }), - }, - transformResponse: vi.fn().mockResolvedValue({ - success: true, - output: { result: 'ok', __metadata: { source: 'user' }, __tag: 'important' }, - }), - } - - const originalTools = { ...tools } - ;(tools as any)['custom_test-preserve-dunder'] = mockTool - - global.fetch = Object.assign( - vi.fn().mockImplementation(async () => ({ - ok: true, - status: 200, - headers: new Headers(), - json: () => Promise.resolve({ success: true }), - })), - { preconnect: vi.fn() } - ) as typeof fetch - - const result = await executeTool('custom_test-preserve-dunder', {}, true) - - expect(result.success).toBe(true) - expect(result.output.result).toBe('ok') - expect(result.output.__metadata).toEqual({ source: 'user' }) - expect(result.output.__tag).toBe('important') + const output = postProcessToolOutput('custom_test-preserve-dunder', { + result: 'ok', + __metadata: { source: 'user' }, + __tag: 'important', + }) - Object.assign(tools, originalTools) + expect(output).toEqual({ + result: 'ok', + __metadata: { source: 'user' }, + __tag: 'important', + }) }) }) diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index c354b691c2b..290960873a1 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -26,15 +26,40 @@ import type { ToolResponse, ToolRetryConfig, } from '@/tools/types' -import { - formatRequestParams, - getTool, - getToolAsync, - validateRequiredParametersAfterMerge, -} from '@/tools/utils' +import { formatRequestParams, getTool, validateRequiredParametersAfterMerge } from '@/tools/utils' +import * as toolsUtilsServer from '@/tools/utils.server' const logger = createLogger('Tools') +interface ToolExecutionScope { + workspaceId?: string + workflowId?: string + userId?: string + executionId?: string + callChain?: string[] + isDeployedContext?: boolean + enforceCredentialAccess?: boolean +} + +function resolveToolScope( + params: Record, + executionContext?: ExecutionContext +): ToolExecutionScope { + const ctx = params._context as Record | undefined + return { + workspaceId: (executionContext?.workspaceId ?? ctx?.workspaceId) as string | undefined, + workflowId: (executionContext?.workflowId ?? ctx?.workflowId) as string | undefined, + userId: (executionContext?.userId ?? ctx?.userId) as string | undefined, + executionId: (executionContext?.executionId ?? ctx?.executionId) as string | undefined, + callChain: (executionContext?.callChain ?? ctx?.callChain) as string[] | undefined, + isDeployedContext: (executionContext?.isDeployedContext ?? ctx?.isDeployedContext) as + | boolean + | undefined, + enforceCredentialAccess: (executionContext?.enforceCredentialAccess ?? + ctx?.enforceCredentialAccess) as boolean | undefined, + } +} + /** Result from hosted key injection */ interface HostedKeyInjectionResult { isUsingHostedKey: boolean @@ -57,11 +82,7 @@ async function injectHostedKeyIfNeeded( const { envKeyPrefix, apiKeyParam, byokProviderId, rateLimit } = tool.hosting - // Derive workspace/user/workflow IDs from executionContext or params._context - const ctx = params._context as Record | undefined - const workspaceId = executionContext?.workspaceId || (ctx?.workspaceId as string | undefined) - const userId = executionContext?.userId || (ctx?.userId as string | undefined) - const workflowId = executionContext?.workflowId || (ctx?.workflowId as string | undefined) + const { workspaceId, userId, workflowId } = resolveToolScope(params, executionContext) // Check BYOK workspace key first if (byokProviderId && workspaceId) { @@ -277,10 +298,7 @@ async function processHostedKeyCost( if (cost <= 0) return { cost: 0 } - const ctx = params._context as Record | undefined - const userId = executionContext?.userId || (ctx?.userId as string | undefined) - const wsId = executionContext?.workspaceId || (ctx?.workspaceId as string | undefined) - const wfId = executionContext?.workflowId || (ctx?.workflowId as string | undefined) + const { userId } = resolveToolScope(params, executionContext) if (!userId) return { cost, metadata } @@ -305,8 +323,7 @@ async function reportCustomDimensionUsage( requestId: string ): Promise { if (tool.hosting?.rateLimit.mode !== 'custom') return - const ctx = params._context as Record | undefined - const billingActorId = executionContext?.workspaceId || (ctx?.workspaceId as string | undefined) + const { workspaceId: billingActorId } = resolveToolScope(params, executionContext) if (!billingActorId) return const rateLimiter = getHostedKeyRateLimiter() @@ -353,6 +370,10 @@ function stripInternalFields(output: Record): Record) { + return isCustomTool(toolId) ? output : stripInternalFields(output) +} + /** * Apply post-execution hosted-key cost tracking to a successful tool result. * Reports custom dimension usage, calculates cost, and merges it into the output. @@ -599,18 +620,19 @@ export async function executeTool( // Normalize tool ID to strip resource suffixes (e.g., workflow_executor_ -> workflow_executor) const normalizedToolId = normalizeToolId(toolId) + const scope = resolveToolScope(params, executionContext) + // Handle load_skill tool for agent skills progressive disclosure if (normalizedToolId === 'load_skill') { const skillName = params.skill_name - const workspaceId = params._context?.workspaceId - if (!skillName || !workspaceId) { + if (!skillName || !scope.workspaceId) { return { success: false, output: { error: 'Missing skill_name or workspace context' }, error: 'Missing skill_name or workspace context', } } - const content = await resolveSkillContent(skillName, workspaceId) + const content = await resolveSkillContent(skillName, scope.workspaceId) if (!content) { return { success: false, @@ -624,11 +646,13 @@ export async function executeTool( } } - // If it's a custom tool, use the async version with workflowId + // If it's a custom tool, use the async version if (isCustomTool(normalizedToolId)) { - const workflowId = params._context?.workflowId - const userId = params._context?.userId - tool = await getToolAsync(normalizedToolId, workflowId, userId) + tool = await toolsUtilsServer.getToolAsync(normalizedToolId, { + workflowId: scope.workflowId, + userId: scope.userId, + workspaceId: scope.workspaceId, + }) if (!tool) { logger.error(`[${requestId}] Custom tool not found: ${normalizedToolId}`) } @@ -799,9 +823,7 @@ export async function executeTool( ) } - const strippedOutput = isCustomTool(normalizedToolId) - ? finalResult.output - : stripInternalFields(finalResult.output ?? {}) + const strippedOutput = postProcessToolOutput(normalizedToolId, finalResult.output ?? {}) return { ...finalResult, @@ -856,9 +878,7 @@ export async function executeTool( ) } - const strippedOutput = isCustomTool(normalizedToolId) - ? finalResult.output - : stripInternalFields(finalResult.output ?? {}) + const strippedOutput = postProcessToolOutput(normalizedToolId, finalResult.output ?? {}) return { ...finalResult, @@ -1556,17 +1576,13 @@ async function executeMcpTool( ) } - const workspaceId = params._context?.workspaceId || executionContext?.workspaceId - const workflowId = params._context?.workflowId || executionContext?.workflowId - const userId = params._context?.userId || executionContext?.userId - const callChain = - (params._context?.callChain as string[] | undefined) || executionContext?.callChain + const mcpScope = resolveToolScope(params, executionContext) - if (callChain && callChain.length > 0) { - headers[SIM_VIA_HEADER] = serializeCallChain(callChain) + if (mcpScope.callChain && mcpScope.callChain.length > 0) { + headers[SIM_VIA_HEADER] = serializeCallChain(mcpScope.callChain) } - if (!workspaceId) { + if (!mcpScope.workspaceId) { return { success: false, output: {}, @@ -1586,8 +1602,8 @@ async function executeMcpTool( serverId, toolName, arguments: toolArguments, - workflowId, // Pass workflow context for user resolution - workspaceId, // Pass workspace context for scoping + workflowId: mcpScope.workflowId, + workspaceId: mcpScope.workspaceId, } // Include schema to skip discovery on execution @@ -1601,14 +1617,14 @@ async function executeMcpTool( validateRequestBodySize(body, actualRequestId, `mcp:${toolId}`) logger.info(`[${actualRequestId}] Making MCP tool request to ${toolName} on ${serverId}`, { - hasWorkspaceId: !!workspaceId, - hasWorkflowId: !!workflowId, + hasWorkspaceId: !!mcpScope.workspaceId, + hasWorkflowId: !!mcpScope.workflowId, hasToolSchema: !!toolSchema, }) const mcpUrl = new URL('/api/mcp/tools/execute', baseUrl) - if (userId) { - mcpUrl.searchParams.set('userId', userId) + if (mcpScope.userId) { + mcpUrl.searchParams.set('userId', mcpScope.userId) } const response = await fetch(mcpUrl.toString(), { diff --git a/apps/sim/tools/utils.server.ts b/apps/sim/tools/utils.server.ts index dca9880c4ad..7abce99e125 100644 --- a/apps/sim/tools/utils.server.ts +++ b/apps/sim/tools/utils.server.ts @@ -1,14 +1,26 @@ import { createLogger } from '@sim/logger' +import { generateInternalToken } from '@/lib/auth/internal' import { secureFetchWithPinnedIP, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { isCustomTool } from '@/executor/constants' +import type { CustomToolDefinition } from '@/hooks/queries/custom-tools' import { extractErrorMessage } from '@/tools/error-extractors' +import { tools } from '@/tools/registry' import type { ToolConfig, ToolResponse } from '@/tools/types' import type { RequestParams } from '@/tools/utils' +import { createCustomToolRequestBody, createParamSchema, createToolConfig } from '@/tools/utils' const logger = createLogger('ToolsUtils') +export interface GetToolAsyncContext { + workflowId?: string + userId?: string + workspaceId?: string +} + /** * Execute the actual request and transform the response. * Server-only: uses DNS validation and IP-pinned fetch. @@ -75,3 +87,88 @@ export async function executeRequest( } } } + +// Get a tool by its ID asynchronously (supports server-side) +export async function getToolAsync( + toolId: string, + context: GetToolAsyncContext = {} +): Promise { + const builtInTool = tools[toolId] + if (builtInTool) return builtInTool + + if (isCustomTool(toolId)) { + return fetchCustomToolFromAPI(toolId, context) + } + + return undefined +} + +async function fetchCustomToolFromAPI( + customToolId: string, + context: GetToolAsyncContext +): Promise { + const { workflowId, userId, workspaceId } = context + const identifier = customToolId.replace('custom_', '') + + try { + const baseUrl = getInternalApiBaseUrl() + const url = new URL('/api/tools/custom', baseUrl) + + if (workflowId) { + url.searchParams.append('workflowId', workflowId) + } + if (userId) { + url.searchParams.append('userId', userId) + } + if (workspaceId) { + url.searchParams.append('workspaceId', workspaceId) + } + + const headers: Record = {} + + try { + const internalToken = await generateInternalToken(userId) + headers.Authorization = `Bearer ${internalToken}` + } catch (error) { + logger.warn('Failed to generate internal token for custom tools fetch', { error }) + } + + const response = await fetch(url.toString(), { headers }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error(`Failed to fetch custom tools: ${response.statusText}`) + return undefined + } + + const result = await response.json() + + if (!result.data || !Array.isArray(result.data)) { + logger.error(`Invalid response when fetching custom tools: ${JSON.stringify(result)}`) + return undefined + } + + const customTool = result.data.find( + (tool: CustomToolDefinition) => tool.id === identifier || tool.title === identifier + ) as CustomToolDefinition | undefined + + if (!customTool) { + logger.error(`Custom tool not found: ${identifier}`) + return undefined + } + + const toolConfig = createToolConfig(customTool, customToolId) + + return { + ...toolConfig, + params: createParamSchema(customTool), + request: { + ...toolConfig.request, + body: createCustomToolRequestBody(customTool, false, workflowId), + }, + } + } catch (error) { + logger.error(`Error fetching custom tool ${identifier} from API:`, error) + return undefined + } +} diff --git a/apps/sim/tools/utils.ts b/apps/sim/tools/utils.ts index 581d4a6ac58..2f944c18bd4 100644 --- a/apps/sim/tools/utils.ts +++ b/apps/sim/tools/utils.ts @@ -1,8 +1,6 @@ import { createLogger } from '@sim/logger' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' -import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' -import { AGENT, isCustomTool } from '@/executor/constants' -import { getCustomTool } from '@/hooks/queries/custom-tools' +import type { CustomToolDefinition } from '@/hooks/queries/custom-tools' import { useEnvironmentStore } from '@/stores/settings/environment' import { tools } from '@/tools/registry' import type { ToolConfig } from '@/tools/types' @@ -286,48 +284,20 @@ export function createCustomToolRequestBody( } // Get a tool by its ID -export function getTool(toolId: string): ToolConfig | undefined { +export function getTool(toolId: string, _workspaceId?: string): ToolConfig | undefined { // Check for built-in tools const builtInTool = tools[toolId] if (builtInTool) return builtInTool - // Check if it's a custom tool - if (isCustomTool(toolId) && typeof window !== 'undefined') { - // Only try to use the sync version on the client - const identifier = toolId.slice(AGENT.CUSTOM_TOOL_PREFIX.length) - - // Try to find the tool from query cache (extracts workspaceId from URL) - const customTool = getCustomTool(identifier) - - if (customTool) { - return createToolConfig(customTool, toolId) - } - } - // If not found or running on the server, return undefined return undefined } -// Get a tool by its ID asynchronously (supports server-side) -export async function getToolAsync( - toolId: string, - workflowId?: string, - userId?: string -): Promise { - // Check for built-in tools - const builtInTool = tools[toolId] - if (builtInTool) return builtInTool - - // Check if it's a custom tool - if (isCustomTool(toolId)) { - return fetchCustomToolFromAPI(toolId, workflowId, userId) - } - - return undefined -} - // Helper function to create a tool config from a custom tool -function createToolConfig(customTool: any, customToolId: string): ToolConfig { +export function createToolConfig( + customTool: CustomToolDefinition, + customToolId: string +): ToolConfig { // Create a parameter schema from the custom tool schema const params = createParamSchema(customTool) @@ -363,102 +333,3 @@ function createToolConfig(customTool: any, customToolId: string): ToolConfig { }, } } - -// Create a tool config from a custom tool definition by fetching from API -async function fetchCustomToolFromAPI( - customToolId: string, - workflowId?: string, - userId?: string -): Promise { - const identifier = customToolId.replace('custom_', '') - - try { - const baseUrl = getInternalApiBaseUrl() - const url = new URL('/api/tools/custom', baseUrl) - - if (workflowId) { - url.searchParams.append('workflowId', workflowId) - } - if (userId) { - url.searchParams.append('userId', userId) - } - - // For server-side calls (during workflow execution), use internal JWT token - const headers: Record = {} - if (typeof window === 'undefined') { - try { - const { generateInternalToken } = await import('@/lib/auth/internal') - const internalToken = await generateInternalToken(userId) - headers.Authorization = `Bearer ${internalToken}` - } catch (error) { - logger.warn('Failed to generate internal token for custom tools fetch', { error }) - // Continue without token - will fail auth and be reported upstream - } - } - - const response = await fetch(url.toString(), { - headers, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error(`Failed to fetch custom tools: ${response.statusText}`) - return undefined - } - - const result = await response.json() - - if (!result.data || !Array.isArray(result.data)) { - logger.error(`Invalid response when fetching custom tools: ${JSON.stringify(result)}`) - return undefined - } - - // Try to find the tool by ID or title - const customTool = result.data.find( - (tool: any) => tool.id === identifier || tool.title === identifier - ) - - if (!customTool) { - logger.error(`Custom tool not found: ${identifier}`) - return undefined - } - - // Create a parameter schema - const params = createParamSchema(customTool) - - // Create a tool config for the custom tool - return { - id: customToolId, - name: customTool.title, - description: customTool.schema.function?.description || '', - version: '1.0.0', - params, - - // Request configuration - for custom tools we'll use the execute endpoint - request: { - url: '/api/function/execute', - method: 'POST', - headers: () => ({ 'Content-Type': 'application/json' }), - body: createCustomToolRequestBody(customTool, false, workflowId), - }, - - // Same response handling as client-side - transformResponse: async (response: Response) => { - const data = await response.json() - - if (!data.success) { - throw new Error(data.error || 'Custom tool execution failed') - } - - return { - success: true, - output: data.output.result || data.output, - error: undefined, - } - }, - } - } catch (error) { - logger.error(`Error fetching custom tool ${identifier} from API:`, error) - return undefined - } -} From 82cbf5f9cc171a25e08ebf30a96e59a9457e029f Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 31 Mar 2026 13:33:50 -0700 Subject: [PATCH 10/14] pass signal correctly --- apps/sim/hooks/queries/folders.ts | 6 +++--- apps/sim/hooks/queries/general-settings.ts | 4 ++-- apps/sim/hooks/queries/logs.ts | 2 +- apps/sim/hooks/queries/subscription.ts | 2 +- apps/sim/hooks/selectors/types.ts | 1 + apps/sim/hooks/selectors/use-selector-query.ts | 4 ++-- apps/sim/hooks/use-permission-config.ts | 4 ++-- 7 files changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts index f72cce210eb..4ee686ee1ea 100644 --- a/apps/sim/hooks/queries/folders.ts +++ b/apps/sim/hooks/queries/folders.ts @@ -201,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) }) }, }) @@ -221,9 +221,9 @@ export function useDeleteFolderMutation() { return response.json() }, - onSuccess: async (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) }) - await invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived']) + return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived']) }, }) } diff --git a/apps/sim/hooks/queries/general-settings.ts b/apps/sim/hooks/queries/general-settings.ts index 1d0644499af..0fb05164dd1 100644 --- a/apps/sim/hooks/queries/general-settings.ts +++ b/apps/sim/hooks/queries/general-settings.ts @@ -83,8 +83,8 @@ export function useGeneralSettings() { export function prefetchGeneralSettings(queryClient: QueryClient) { queryClient.prefetchQuery({ queryKey: generalSettingsKeys.settings(), - queryFn: async () => { - const settings = await fetchGeneralSettings() + queryFn: async ({ signal }) => { + const settings = await fetchGeneralSettings(signal) syncThemeToNextThemes(settings.theme) return settings }, diff --git a/apps/sim/hooks/queries/logs.ts b/apps/sim/hooks/queries/logs.ts index 3bed38cdf19..0e684c3dc85 100644 --- a/apps/sim/hooks/queries/logs.ts +++ b/apps/sim/hooks/queries/logs.ts @@ -176,7 +176,7 @@ export function useLogDetail(logId: string | undefined, options?: UseLogDetailOp export function prefetchLogDetail(queryClient: QueryClient, logId: string) { queryClient.prefetchQuery({ queryKey: logKeys.detail(logId), - queryFn: () => fetchLogDetail(logId), + queryFn: ({ signal }) => fetchLogDetail(logId, signal), staleTime: 30 * 1000, }) } diff --git a/apps/sim/hooks/queries/subscription.ts b/apps/sim/hooks/queries/subscription.ts index c1149bf6d8e..c30fbff6c3b 100644 --- a/apps/sim/hooks/queries/subscription.ts +++ b/apps/sim/hooks/queries/subscription.ts @@ -142,7 +142,7 @@ export function useSubscriptionData(options: UseSubscriptionDataOptions = {}) { export function prefetchSubscriptionData(queryClient: QueryClient) { queryClient.prefetchQuery({ queryKey: subscriptionKeys.user(false), - queryFn: () => fetchSubscriptionData(false), + queryFn: ({ signal }) => fetchSubscriptionData(false, signal), staleTime: 30 * 1000, }) } diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts index 87e1572ef57..2324106a802 100644 --- a/apps/sim/hooks/selectors/types.ts +++ b/apps/sim/hooks/selectors/types.ts @@ -84,6 +84,7 @@ export interface SelectorQueryArgs { context: SelectorContext search?: string detailId?: string + signal?: AbortSignal } export interface SelectorDefinition { diff --git a/apps/sim/hooks/selectors/use-selector-query.ts b/apps/sim/hooks/selectors/use-selector-query.ts index 6486e769773..0323c7f5e7d 100644 --- a/apps/sim/hooks/selectors/use-selector-query.ts +++ b/apps/sim/hooks/selectors/use-selector-query.ts @@ -21,7 +21,7 @@ export function useSelectorOptions(key: SelectorKey, args: SelectorHookArgs) { const isEnabled = args.enabled ?? (definition.enabled ? definition.enabled(queryArgs) : true) return useQuery({ queryKey: definition.getQueryKey(queryArgs), - queryFn: () => definition.fetchList(queryArgs), + queryFn: ({ signal }) => definition.fetchList({ ...queryArgs, signal }), enabled: isEnabled, staleTime: definition.staleTime ?? 30_000, }) @@ -60,7 +60,7 @@ export function useSelectorOptionDetail( const query = useQuery({ queryKey: [...definition.getQueryKey(queryArgs), 'detail', resolvedDetailId ?? 'none'], - queryFn: () => definition.fetchById!(queryArgs), + queryFn: ({ signal }) => definition.fetchById!({ ...queryArgs, signal }), enabled, staleTime: definition.staleTime ?? 300_000, }) diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index f103aa31436..f52b8aae18c 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -30,8 +30,8 @@ interface AllowedIntegrationsResponse { function useAllowedIntegrationsFromEnv() { return useQuery({ queryKey: ['allowedIntegrations', 'env'], - queryFn: async () => { - const response = await fetch('/api/settings/allowed-integrations') + queryFn: async ({ signal }) => { + const response = await fetch('/api/settings/allowed-integrations', { signal }) if (!response.ok) return { allowedIntegrations: null } return response.json() }, From 8b21676a0980a0639e396f57fc6a1aa04af73574 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 31 Mar 2026 13:39:43 -0700 Subject: [PATCH 11/14] fix types --- .../[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts | 4 ++-- .../workspace/[workspaceId]/w/hooks/use-export-selection.ts | 1 + .../workspace/[workspaceId]/w/hooks/use-export-workflow.ts | 1 + apps/sim/lib/folders/tree.ts | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) 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 4e1b7ea95f5..63f1d0d25f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts @@ -236,7 +236,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) { if (cached) return cached const currentFolders = workspaceId ? getFolderMap(workspaceId) : {} - const currentWorkflows = getWorkflows(workspaceId) + const currentWorkflows = workspaceId ? getWorkflows(workspaceId) : [] const siblings = [ ...Object.values(currentFolders) .filter((f) => f.parentId === folderId) @@ -310,7 +310,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) { destinationFolderId: string | null ): { fromDestination: SiblingItem[]; fromOther: SiblingItem[] } => { const folders = workspaceId ? getFolderMap(workspaceId) : {} - const workflows = getWorkflows(workspaceId) + const workflows = workspaceId ? getWorkflows(workspaceId) : [] const fromDestination: SiblingItem[] = [] const fromOther: SiblingItem[] = [] 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 93b9a5f9da8..300eb344b9e 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 @@ -120,6 +120,7 @@ export function useExportSelection({ onSuccess }: UseExportSelectionProps = {}) setIsExporting(true) try { + if (!workspaceIdRef.current) return const workflowsArray = getWorkflows(workspaceIdRef.current) const workflows = Object.fromEntries(workflowsArray.map((w) => [w.id, w])) const folderMap = getFolderMap(workspaceIdRef.current) 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 7dc24349f12..91faca98627 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts @@ -58,6 +58,7 @@ export function useExportWorkflow({ onSuccess }: UseExportWorkflowProps = {}) { count: workflowIdsToExport.length, }) + if (!workspaceIdRef.current) return const workflowMap = new Map(getWorkflows(workspaceIdRef.current).map((w) => [w.id, w])) const exportedWorkflows = [] diff --git a/apps/sim/lib/folders/tree.ts b/apps/sim/lib/folders/tree.ts index 6f0829e57dd..41196a7e35b 100644 --- a/apps/sim/lib/folders/tree.ts +++ b/apps/sim/lib/folders/tree.ts @@ -50,7 +50,7 @@ export function getFolderPath( let currentId: string | null = folderId while (currentId && folders[currentId]) { - const folder = folders[currentId] + const folder: WorkflowFolder = folders[currentId] path.unshift(folder) currentId = folder.parentId } From e5f104e4a5e152c9efb0fc77b4aa485b4e6510e3 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 31 Mar 2026 13:41:37 -0700 Subject: [PATCH 12/14] fix workspace id --- .../w/[workflowId]/hooks/use-workflow-execution.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 67ea76d97f8..2e8e4b8ce4a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -392,8 +392,7 @@ export function useWorkflowExecution() { return } - // Get workspaceId from workflow metadata - const workspaceId = activeWorkflow?.workspaceId + const workspaceId = scopedWorkspaceId ?? activeWorkflow?.workspaceId if (!workspaceId) { logger.error('Cannot execute workflow without workspaceId') From dd8dc69b5a534d53d8c38e143fb100cfd3de1bb5 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 31 Mar 2026 15:01:20 -0700 Subject: [PATCH 13/14] address comment --- .../w/hooks/use-export-selection.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) 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 300eb344b9e..deed7a4e1dc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-selection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-selection.ts @@ -133,14 +133,16 @@ export function useExportSelection({ onSuccess }: UseExportSelectionProps = {}) const subfolders = collectSubfoldersForMultipleFolders(folderIds, folderMap) - const selectedFoldersData: FolderExportData[] = folderIds.map((folderId) => { - const folder = folderMap[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) From 298a0ad031e07a757574210ad5f433e91393d3c2 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 31 Mar 2026 15:15:15 -0700 Subject: [PATCH 14/14] soft deletion accuring --- apps/sim/hooks/queries/workflows.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index b719e9d4714..f850eae3f40 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -553,7 +553,7 @@ export function useDeleteWorkflowMutation() { } }, onSettled: (_data, _error, variables) => { - return invalidateWorkflowLists(queryClient, variables.workspaceId) + return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived']) }, }) }