improvement(workflows): replace Zustand workflow sync with React Query as single source of truth (#3860)

* improvement(workflows): replace Zustand workflow sync with React Query as single source of truth

* fix(workflows): address PR review feedback — sandbox execution, hydration deadlock, test mock, copy casing

* lint

* improvement(workflows): adopt skipToken over enabled+as-string for type-safe conditional queries

* improvement(workflows): remove dead complexity, fix mutation edge cases

- Throw on state PUT failure in useCreateWorkflow instead of swallowing
- Use Map for O(1) lookups in duplicate/export loops (3 hooks)
- Broaden invalidation scope in update/delete mutations to lists()
- Switch workflow-block to useWorkflowMap for direct ID lookup
- Consolidate use-workflow-operations to single useWorkflowMap hook
- Remove workspace transition guard (sync body, unreachable timeout)
- Make switchToWorkspace synchronous (remove async/try-catch/finally)

* fix(workflows): resolve cold-start deadlock on direct URL navigation

loadWorkflowState used hydration.workspaceId (null on cold start) to
look up the RQ cache, causing "Workflow not found" even when the
workflow exists in the DB. Now falls back to getWorkspaceIdFromUrl()
and skips the cache guard when the cache is empty (letting the API
fetch proceed).

Also removes the redundant isRegistryReady guard in workflow.tsx that
blocked setActiveWorkflow when hydration.workspaceId was null.

* fix(ui): prevent flash of empty state while workflows query is pending

Dashboard and EmbeddedWorkflow checked workflow list length before
the RQ query resolved, briefly showing "No workflows" or "Workflow
not found" on initial load. Now gates on isPending first.

* fix(workflows): address PR review — await description update, revert state PUT throw

- api-info-modal: use mutateAsync for description update so errors
  are caught by the surrounding try/catch instead of silently swallowed
- useCreateWorkflow: revert state PUT to log-only — the workflow is
  already created in the DB, throwing rolls back the optimistic entry
  and makes it appear the creation failed when it actually succeeded

* move folders over to react query native, restructure passage of data

* pass signal correctly

* fix types

* fix workspace id

* address comment

* soft deletion accuring

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
This commit is contained in:
Waleed
2026-03-31 15:49:16 -07:00
committed by GitHub
parent df62502903
commit 7898e5d75f
102 changed files with 2030 additions and 1897 deletions
@@ -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<WorkflowMetadata[]>(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<WorkflowMetadata[]>(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
@@ -47,7 +47,7 @@ export function useAvailableResources(
workspaceId: string,
existingKeys: Set<string>
): 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)
@@ -37,6 +37,7 @@ import {
import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/components'
import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useExecutionStore } from '@/stores/execution/store'
@@ -375,15 +376,16 @@ interface EmbeddedWorkflowProps {
}
function EmbeddedWorkflow({ workspaceId, workflowId }: EmbeddedWorkflowProps) {
const workflowExists = useWorkflowRegistry((state) => Boolean(state.workflows[workflowId]))
const isMetadataLoaded = useWorkflowRegistry(
(state) => state.hydration.phase !== 'idle' && state.hydration.phase !== 'metadata-loading'
const { data: workflowList, isPending: isWorkflowsPending } = useWorkflows(workspaceId)
const workflowExists = useMemo(
() => (workflowList ?? []).some((w) => w.id === workflowId),
[workflowList, workflowId]
)
const hasLoadError = useWorkflowRegistry(
(state) => state.hydration.phase === 'error' && state.hydration.workflowId === workflowId
)
if (!isMetadataLoaded) return LOADING_SKELETON
if (isWorkflowsPending) return LOADING_SKELETON
if (!workflowExists || hasLoadError) {
return (
@@ -1,7 +1,8 @@
'use client'
import type { ElementType, ReactNode } from 'react'
import { type ElementType, type ReactNode, useMemo } from 'react'
import type { QueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import {
Database,
File as FileIcon,
@@ -17,9 +18,9 @@ import type {
} from '@/app/workspace/[workspaceId]/home/types'
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
import { tableKeys } from '@/hooks/queries/tables'
import { workflowKeys } from '@/hooks/queries/workflows'
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
import { useWorkflows } from '@/hooks/queries/workflows'
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface DropdownItemRenderProps {
item: { id: string; name: string; [key: string]: unknown }
@@ -34,7 +35,12 @@ export interface ResourceTypeConfig {
}
function WorkflowTabSquare({ workflowId, className }: { workflowId: string; className?: string }) {
const color = useWorkflowRegistry((state) => state.workflows[workflowId]?.color ?? '#888')
const { workspaceId } = useParams<{ workspaceId: string }>()
const { data: workflowList } = useWorkflows(workspaceId)
const color = useMemo(() => {
const wf = (workflowList ?? []).find((w) => w.id === workflowId)
return wf?.color ?? '#888'
}, [workflowList, workflowId])
return (
<div
className={cn('flex-shrink-0 rounded-[3px] border-[2px]', className)}
@@ -157,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() })
@@ -53,7 +53,7 @@ const PREVIEW_MODE_LABELS: Record<PreviewMode, string> = {
* tabs always reflect the latest name even after a rename.
*/
function useResourceNameLookup(workspaceId: string): Map<string, string> {
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)
@@ -45,8 +45,8 @@ import {
computeMentionHighlightRanges,
extractContextTokens,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import { useWorkflowMap } from '@/hooks/queries/workflows'
import type { ChatContext } from '@/stores/panel'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
@@ -122,6 +122,7 @@ export function UserInput({
onContextAdd,
}: UserInputProps) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const { data: workflowsById = {} } = useWorkflowMap(workspaceId)
const { data: session } = useSession()
const [value, setValue] = useState(defaultValue)
const overlayRef = useRef<HTMLDivElement>(null)
@@ -617,7 +618,6 @@ export function UserInput({
const elements: React.ReactNode[] = []
let lastIndex = 0
for (let i = 0; i < ranges.length; i++) {
const range = ranges[i]
@@ -639,7 +639,7 @@ export function UserInput({
case 'workflow':
case 'current_workflow': {
const wfId = (matchingCtx as { workflowId: string }).workflowId
const wfColor = useWorkflowRegistry.getState().workflows[wfId]?.color ?? '#888'
const wfColor = workflowsById[wfId]?.color ?? '#888'
mentionIconNode = (
<div
className='absolute inset-0 m-auto h-[12px] w-[12px] rounded-[3px] border-[2px]'
@@ -691,7 +691,7 @@ export function UserInput({
}
return elements.length > 0 ? elements : <span>{'\u00A0'}</span>
}, [value, contextManagement.selectedContexts])
}, [value, contextManagement.selectedContexts, workflowsById])
return (
<div
@@ -1,9 +1,11 @@
'use client'
import { useMemo } from 'react'
import { useParams } from 'next/navigation'
import { Database, Table as TableIcon } from '@/components/emcn/icons'
import { getDocumentIcon } from '@/components/icons/document-icons'
import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflows } from '@/hooks/queries/workflows'
const USER_MESSAGE_CLASSES =
'whitespace-pre-wrap break-words [overflow-wrap:anywhere] font-[430] font-[family-name:var(--font-inter)] text-base text-[var(--text-primary)] leading-[23px] tracking-[0] antialiased'
@@ -44,12 +46,12 @@ function computeMentionRanges(text: string, contexts: ChatMessageContext[]): Men
}
function MentionHighlight({ context }: { context: ChatMessageContext }) {
const workflowColor = useWorkflowRegistry((state) => {
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)]'
@@ -21,27 +21,8 @@ import {
import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resource-types'
import { isWorkflowToolName } from '@/lib/copilot/workflow-tools'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import { deploymentKeys } from '@/hooks/queries/deployments'
import {
fetchChatHistory,
type StreamSnapshot,
type TaskChatHistory,
type TaskStoredContentBlock,
type TaskStoredFileAttachment,
type TaskStoredMessage,
type TaskStoredToolCall,
taskKeys,
useChatHistory,
} from '@/hooks/queries/tasks'
import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order'
import { workflowKeys } from '@/hooks/queries/workflows'
import { useExecutionStream } from '@/hooks/use-execution-stream'
import { useExecutionStore } from '@/stores/execution/store'
import { useFolderStore } from '@/stores/folders/store'
import type { ChatContext } from '@/stores/panel'
import { consolePersistence, useTerminalConsoleStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type {
ChatMessage,
ChatMessageAttachment,
@@ -56,7 +37,30 @@ import type {
SSEPayload,
SSEPayloadData,
ToolCallStatus,
} from '../types'
} from '@/app/workspace/[workspaceId]/home/types'
import { deploymentKeys } from '@/hooks/queries/deployments'
import {
fetchChatHistory,
type StreamSnapshot,
type TaskChatHistory,
type TaskStoredContentBlock,
type TaskStoredFileAttachment,
type TaskStoredMessage,
type TaskStoredToolCall,
taskKeys,
useChatHistory,
} from '@/hooks/queries/tasks'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { invalidateWorkflowSelectors } from '@/hooks/queries/utils/invalidate-workflow-lists'
import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order'
import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { workflowKeys } from '@/hooks/queries/workflows'
import { useExecutionStream } from '@/hooks/use-execution-stream'
import { useExecutionStore } from '@/stores/execution/store'
import type { ChatContext } from '@/stores/panel'
import { consolePersistence, useTerminalConsoleStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
export interface UseChatReturn {
messages: ChatMessage[]
@@ -301,31 +305,37 @@ function getPayloadData(payload: SSEPayload): SSEPayloadData | undefined {
return typeof payload.data === 'object' ? payload.data : undefined
}
/** Adds a workflow to the registry with a top-insertion sort order if it doesn't already exist. */
/** Adds a workflow to the React Query cache with a top-insertion sort order if it doesn't already exist. */
function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId: string): boolean {
const registry = useWorkflowRegistry.getState()
if (registry.workflows[resourceId]) return false
const workflows = getWorkflows(workspaceId)
if (workflows.some((w) => w.id === resourceId)) return false
const sortOrder = getTopInsertionSortOrder(
registry.workflows,
useFolderStore.getState().folders,
Object.fromEntries(workflows.map((w) => [w.id, w])),
getFolderMap(workspaceId),
workspaceId,
null
)
useWorkflowRegistry.setState((state) => ({
workflows: {
...state.workflows,
[resourceId]: {
id: resourceId,
name: title,
lastModified: new Date(),
createdAt: new Date(),
color: getNextWorkflowColor(),
workspaceId,
folderId: null,
sortOrder,
},
},
}))
const newMetadata: WorkflowMetadata = {
id: resourceId,
name: title,
lastModified: new Date(),
createdAt: new Date(),
color: getNextWorkflowColor(),
workspaceId,
folderId: null,
sortOrder,
}
const queryClient = getQueryClient()
const key = workflowKeys.list(workspaceId, 'active')
queryClient.setQueryData<WorkflowMetadata[]>(key, (current) => {
const next = current ?? workflows
if (next.some((workflow) => workflow.id === resourceId)) {
return next
}
return [...next, newMetadata]
})
void invalidateWorkflowSelectors(queryClient, workspaceId)
return true
}
@@ -1253,7 +1263,7 @@ export function useChat(
? ((args as Record<string, unknown>).workflowId as string)
: useWorkflowRegistry.getState().activeWorkflowId
if (targetWorkflowId) {
const meta = useWorkflowRegistry.getState().workflows[targetWorkflowId]
const meta = getWorkflowById(workspaceId, targetWorkflowId)
const wasAdded = addResource({
type: 'workflow',
id: targetWorkflowId,
@@ -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
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
<ImpersonationBanner />
<WorkspacePermissionsProvider>
<WorkspaceScopeSync />
<div className='flex min-h-0 flex-1'>
<div className='shrink-0' suppressHydrationWarning>
<Sidebar />
@@ -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 (
<div className='flex h-full flex-col overflow-hidden rounded-md bg-[var(--surface-2)] dark:bg-[var(--surface-1)]'>
@@ -2,12 +2,13 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
import { Skeleton } from '@/components/emcn'
import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils'
import type { DashboardStatsResponse, WorkflowStats } from '@/hooks/queries/logs'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useFilterStore } from '@/stores/logs/filters/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { LineChart, WorkflowsList } from './components'
interface WorkflowExecution {
@@ -156,7 +157,8 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
}))
)
const allWorkflows = useWorkflowRegistry((state) => state.workflows)
const { workspaceId } = useParams<{ workspaceId: string }>()
const { data: allWorkflowList = [], isPending: isWorkflowsPending } = useWorkflows(workspaceId)
const expandedWorkflowId = workflowIds.length === 1 ? workflowIds[0] : null
@@ -459,7 +461,7 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
)
}
if (Object.keys(allWorkflows).length === 0) {
if (!isWorkflowsPending && allWorkflowList.length === 0) {
return (
<div className='mt-6 flex flex-1 items-center justify-center'>
<div className='text-center text-[var(--text-secondary)]'>
@@ -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) => ({
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { Search, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Badge } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
@@ -14,8 +15,8 @@ import {
type WorkflowData,
} from '@/lib/logs/search-suggestions'
import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-search-state'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useFolderMap } from '@/hooks/queries/folders'
import { useWorkflows } from '@/hooks/queries/workflows'
function truncateFilterValue(field: string, value: string): string {
if ((field === 'executionId' || field === 'workflowId') && value.length > 12) {
@@ -42,16 +43,17 @@ export function AutocompleteSearch({
className,
onOpenChange,
}: AutocompleteSearchProps) {
const workflows = useWorkflowRegistry((state) => state.workflows)
const folders = useFolderStore((state) => state.folders)
const { workspaceId } = useParams<{ workspaceId: string }>()
const { data: workflowList = [] } = useWorkflows(workspaceId)
const { data: folders = {} } = useFolderMap(workspaceId)
const workflowsData = useMemo<WorkflowData[]>(() => {
return Object.values(workflows).map((w) => ({
return workflowList.map((w) => ({
id: w.id,
name: w.name,
description: w.description,
}))
}, [workflows])
}, [workflowList])
const foldersData = useMemo<FolderData[]>(() => {
return Object.values(folders).map((f) => ({
@@ -20,10 +20,10 @@ import { hasActiveFilters } from '@/lib/logs/filters'
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
import { type LogStatus, STATUS_CONFIG } from '@/app/workspace/[workspaceId]/logs/utils'
import { getBlock } from '@/blocks/registry'
import { useFolderStore } from '@/stores/folders/store'
import { useFolderMap } from '@/hooks/queries/folders'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useFilterStore } from '@/stores/logs/filters/store'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { AutocompleteSearch } from './components/search'
const TIME_RANGE_OPTIONS: ComboboxOption[] = [
@@ -218,17 +218,17 @@ export const LogsToolbar = memo(function LogsToolbar({
const [datePickerOpen, setDatePickerOpen] = useState(false)
const [previousTimeRange, setPreviousTimeRange] = useState(timeRange)
const folders = useFolderStore((state) => state.folders)
const { data: folders = {} } = useFolderMap(workspaceId)
const allWorkflows = useWorkflowRegistry((state) => state.workflows)
const { data: allWorkflowList = [] } = useWorkflows(workspaceId)
const workflows = useMemo(() => {
return Object.values(allWorkflows).map((w) => ({
return allWorkflowList.map((w) => ({
id: w.id,
name: w.name,
color: w.color,
}))
}, [allWorkflows])
}, [allWorkflowList])
const folderList = useMemo(() => {
return Object.values(folders).filter((f) => f.workspaceId === workspaceId)
@@ -50,19 +50,18 @@ import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-sea
import type { Suggestion } from '@/app/workspace/[workspaceId]/logs/types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { getBlock } from '@/blocks/registry'
import { useFolders } from '@/hooks/queries/folders'
import { useFolderMap, useFolders } from '@/hooks/queries/folders'
import {
prefetchLogDetail,
useDashboardStats,
useLogDetail,
useLogsList,
} from '@/hooks/queries/logs'
import { useWorkflowMap, useWorkflows } from '@/hooks/queries/workflows'
import { useDebounce } from '@/hooks/use-debounce'
import { useFolderStore } from '@/stores/folders/store'
import { useFilterStore } from '@/stores/logs/filters/store'
import type { WorkflowLog } from '@/stores/logs/filters/types'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import {
Dashboard,
ExecutionSnapshot,
@@ -784,8 +783,8 @@ export default function Logs() {
]
)
const allWorkflows = useWorkflowRegistry((state) => state.workflows)
const folders = useFolderStore((state) => state.folders)
const { data: allWorkflows = {} } = useWorkflowMap(workspaceId)
const { data: folders = {} } = useFolderMap(workspaceId)
const filterTags = useMemo<FilterTag[]>(() => {
const tags: FilterTag[] = []
@@ -1244,12 +1243,12 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr
const [datePickerOpen, setDatePickerOpen] = useState(false)
const [previousTimeRange, setPreviousTimeRange] = useState(timeRange)
const folders = useFolderStore((state) => state.folders)
const allWorkflows = useWorkflowRegistry((state) => state.workflows)
const { data: folders = {} } = useFolderMap(workspaceId)
const { data: allWorkflowList = [] } = useWorkflows(workspaceId)
const workflows = useMemo(
() => Object.values(allWorkflows).map((w) => ({ id: w.id, name: w.name, color: w.color })),
[allWorkflows]
() => allWorkflowList.map((w) => ({ id: w.id, name: w.name, color: w.color })),
[allWorkflowList]
)
const folderList = useMemo(
@@ -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
}
@@ -119,7 +119,7 @@ export function RecentlyDeleted() {
const [restoringIds, setRestoringIds] = useState<Set<string>>(new Set())
const [restoredItems, setRestoredItems] = useState<Map<string, DeletedResource>>(new Map())
const workflowsQuery = useWorkflows(workspaceId, { syncRegistry: false, scope: 'archived' })
const workflowsQuery = useWorkflows(workspaceId, { scope: 'archived' })
const tablesQuery = useTablesList(workspaceId, 'archived')
const knowledgeQuery = useKnowledgeBasesQuery(workspaceId, { scope: 'archived' })
const filesQuery = useWorkspaceFiles(workspaceId, 'archived')
@@ -245,7 +245,10 @@ export function RecentlyDeleted() {
switch (resource.type) {
case 'workflow':
restoreWorkflow.mutate(resource.id, { onSettled, onSuccess })
restoreWorkflow.mutate(
{ workflowId: resource.id, workspaceId: resource.workspaceId },
{ onSettled, onSuccess }
)
break
case 'table':
restoreTable.mutate(resource.id, { onSettled, onSuccess })
@@ -70,7 +70,6 @@ export const FOLDER_CONFIGS: Record<MentionFolderId, FolderConfig> = {
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',
@@ -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
@@ -1,6 +1,7 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'next/navigation'
import {
Badge,
Button,
@@ -19,6 +20,7 @@ import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import type { InputFormatField } from '@/lib/workflows/types'
import { useDeploymentInfo, useUpdatePublicApi } from '@/hooks/queries/deployments'
import { useUpdateWorkflow, useWorkflowMap } from '@/hooks/queries/workflows'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -33,16 +35,16 @@ interface ApiInfoModalProps {
}
export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalProps) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const blocks = useWorkflowStore((state) => state.blocks)
const setValue = useSubBlockStore((state) => state.setValue)
const subBlockValues = useSubBlockStore((state) =>
workflowId ? (state.workflowValues[workflowId] ?? {}) : {}
)
const workflowMetadata = useWorkflowRegistry((state) =>
workflowId ? state.workflows[workflowId] : undefined
)
const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow)
const { data: workflows = {} } = useWorkflowMap(workspaceId)
const workflowMetadata = workflowId ? workflows[workflowId] : undefined
const updateWorkflowMutation = useUpdateWorkflow()
const { data: deploymentData } = useDeploymentInfo(workflowId, { enabled: open })
const updatePublicApiMutation = useUpdatePublicApi()
@@ -175,7 +177,11 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
}
if (description.trim() !== (workflowMetadata?.description || '')) {
updateWorkflow(workflowId, { description: description.trim() || 'New workflow' })
await updateWorkflowMutation.mutateAsync({
workspaceId,
workflowId,
metadata: { description: description.trim() || 'New workflow' },
})
}
if (starterBlockId) {
@@ -195,16 +201,15 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
}
}, [
workflowId,
workspaceId,
description,
workflowMetadata,
updateWorkflow,
starterBlockId,
inputFormat,
paramDescriptions,
setValue,
onOpenChange,
accessMode,
updatePublicApiMutation,
])
return (
@@ -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<TabView>('general')
const [chatSubmitting, setChatSubmitting] = useState(false)
@@ -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) =>
@@ -19,6 +19,7 @@ import { ConnectCredentialModal } from '@/app/workspace/[workspaceId]/w/[workflo
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { useWorkspaceCredential } from '@/hooks/queries/credentials'
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
import { useWorkflowMap } from '@/hooks/queries/workflows'
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -78,9 +79,10 @@ export function ToolCredentialSelector({
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [editingInputValue, setEditingInputValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
const { activeWorkflowId, workflows } = useWorkflowRegistry()
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const { data: workflowMap = {} } = useWorkflowMap(workspaceId)
const effectiveWorkflowId =
activeWorkflowId && workflows[activeWorkflowId] ? activeWorkflowId : undefined
activeWorkflowId && workflowMap[activeWorkflowId] ? activeWorkflowId : undefined
const selectedId = value || ''
const effectiveLabel = label || `Select ${getProviderName(provider)} account`
@@ -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]
@@ -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 (
@@ -56,6 +56,7 @@ import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
import { useDuplicateWorkflowMutation, useWorkflowMap } from '@/hooks/queries/workflows'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
@@ -126,18 +127,15 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
const userPermissions = useUserPermissionsContext()
const { config: permissionConfig } = usePermissionConfig()
const { isImporting, handleFileChange } = useImportWorkflow({ workspaceId })
const { workflows, activeWorkflowId, duplicateWorkflow, hydration } = useWorkflowRegistry(
const duplicateWorkflowMutation = useDuplicateWorkflowMutation()
const { data: workflows = {} } = useWorkflowMap(workspaceId)
const { activeWorkflowId, hydration } = useWorkflowRegistry(
useShallow((state) => ({
workflows: state.workflows,
activeWorkflowId: state.activeWorkflowId,
duplicateWorkflow: state.duplicateWorkflow,
hydration: state.hydration,
}))
)
const isRegistryLoading =
hydration.phase === 'idle' ||
hydration.phase === 'metadata-loading' ||
hydration.phase === 'state-loading'
const isRegistryLoading = hydration.phase === 'idle' || hydration.phase === 'state-loading'
const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null)
// Check for locked blocks (disables auto-layout)
@@ -478,7 +476,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
setIsExporting(true)
try {
const workflow = getWorkflowWithValues(activeWorkflowId)
const workflow = getWorkflowWithValues(activeWorkflowId, workspaceId)
if (!workflow || !workflow.state) {
throw new Error('No workflow state found')
@@ -519,11 +517,21 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
return
}
const sourceWorkflow = workflows[activeWorkflowId]
if (!sourceWorkflow) return
setIsDuplicating(true)
try {
const newWorkflow = await duplicateWorkflow(activeWorkflowId)
if (newWorkflow) {
router.push(`/workspace/${workspaceId}/w/${newWorkflow}`)
const result = await duplicateWorkflowMutation.mutateAsync({
workspaceId,
sourceId: activeWorkflowId,
name: `${sourceWorkflow.name} (Copy)`,
description: sourceWorkflow.description,
color: sourceWorkflow.color ?? '',
folderId: sourceWorkflow.folderId,
})
if (result?.id) {
router.push(`/workspace/${workspaceId}/w/${result.id}`)
}
} catch (error) {
logger.error('Error duplicating workflow:', error)
@@ -531,14 +539,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
setIsDuplicating(false)
setIsMenuOpen(false)
}
}, [
activeWorkflowId,
userPermissions.canEdit,
isDuplicating,
duplicateWorkflow,
router,
workspaceId,
])
}, [activeWorkflowId, userPermissions.canEdit, isDuplicating, workflows, router, workspaceId])
/**
* Toggles the locked state of all blocks in the workflow
@@ -46,9 +46,9 @@ import { useCredentialName } from '@/hooks/queries/oauth/oauth-credentials'
import { useReactivateSchedule, useScheduleInfo } from '@/hooks/queries/schedules'
import { useSkills } from '@/hooks/queries/skills'
import { useTablesList } from '@/hooks/queries/tables'
import { useWorkflowMap } from '@/hooks/queries/workflows'
import { useSelectorDisplayName } from '@/hooks/use-selector-display-name'
import { useVariablesStore } from '@/stores/panel'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { wouldCreateCycle } from '@/stores/workflows/workflow/utils'
@@ -600,11 +600,11 @@ const SubBlockRow = memo(function SubBlockRow({
)
const knowledgeBaseDisplayName = kbForDisplayName?.name ?? null
const workflowMap = useWorkflowRegistry((state) => state.workflows)
const workflowSelectionName =
subBlock?.id === 'workflowId' && typeof rawValue === 'string'
? (workflowMap[rawValue]?.name ?? null)
: null
const { data: workflowMapForLookup = {} } = useWorkflowMap(workspaceId)
const workflowSelectionName = useMemo(() => {
if (subBlock?.id !== 'workflowId' || typeof rawValue !== 'string') return null
return workflowMapForLookup[rawValue]?.name ?? null
}, [workflowMapForLookup, subBlock?.id, rawValue])
const { data: mcpServers = [] } = useMcpServers(workspaceId || '')
const mcpServerDisplayName = useMemo(() => {
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import { v4 as uuidv4 } from 'uuid'
import { useShallow } from 'zustand/react/shallow'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
@@ -30,6 +31,7 @@ import type { BlockLog, BlockState, ExecutionResult, StreamingExecution } from '
import { hasExecutionResult } from '@/executor/utils/errors'
import { coerceValue } from '@/executor/utils/start-block'
import { subscriptionKeys } from '@/hooks/queries/subscription'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useExecutionStream } from '@/hooks/use-execution-stream'
import { WorkflowValidationError } from '@/serializer'
import { useCurrentWorkflowExecution, useExecutionStore } from '@/stores/execution'
@@ -102,11 +104,11 @@ function normalizeErrorMessage(error: unknown): string {
}
export function useWorkflowExecution() {
const { workspaceId: routeWorkspaceId } = useParams<{ workspaceId: string }>()
const hydrationWorkspaceId = useWorkflowRegistry((s) => s.hydration.workspaceId)
const queryClient = useQueryClient()
const currentWorkflow = useCurrentWorkflow()
const { activeWorkflowId, workflows } = useWorkflowRegistry(
useShallow((s) => ({ activeWorkflowId: s.activeWorkflowId, workflows: s.workflows }))
)
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const { toggleConsole, addConsole, updateConsole, cancelRunningEntries, clearExecutionEntries } =
useTerminalConsoleStore(
useShallow((s) => ({
@@ -382,13 +384,15 @@ export function useWorkflowExecution() {
// Sandbox exercises have no real workflow — signal the SandboxCanvasProvider
// to run mock execution by setting isExecuting, then bail out immediately.
if (workflows[activeWorkflowId]?.isSandbox) {
const scopedWorkspaceId = routeWorkspaceId ?? hydrationWorkspaceId ?? undefined
const cachedWorkflows = scopedWorkspaceId ? getWorkflows(scopedWorkspaceId) : []
const activeWorkflow = cachedWorkflows.find((w) => w.id === activeWorkflowId)
if (activeWorkflow?.isSandbox) {
setIsExecuting(activeWorkflowId, true)
return
}
// Get workspaceId from workflow metadata
const workspaceId = workflows[activeWorkflowId]?.workspaceId
const workspaceId = scopedWorkspaceId ?? activeWorkflow?.workspaceId
if (!workspaceId) {
logger.error('Cannot execute workflow without workspaceId')
@@ -748,7 +752,6 @@ export function useWorkflowExecution() {
setExecutor,
setPendingBlocks,
setActiveBlocks,
workflows,
queryClient,
]
)
@@ -73,6 +73,7 @@ import { getBlock } from '@/blocks'
import { isAnnotationOnlyBlock } from '@/executor/constants'
import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
import { useAutoConnect, useSnapToGridSize } from '@/hooks/queries/general-settings'
import { useWorkflowMap } from '@/hooks/queries/workflows'
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useOAuthReturnForWorkflow } from '@/hooks/use-oauth-return'
@@ -279,7 +280,12 @@ const WorkflowContent = React.memo(
useOAuthReturnForWorkflow(workflowIdParam)
const {
workflows,
data: workflows = {},
isLoading: isWorkflowMapLoading,
isPlaceholderData: isWorkflowMapPlaceholderData,
} = useWorkflowMap(workspaceId)
const {
activeWorkflowId,
hydration,
setActiveWorkflow,
@@ -292,7 +298,6 @@ const WorkflowContent = React.memo(
clearPendingSelection,
} = useWorkflowRegistry(
useShallow((state) => ({
workflows: state.workflows,
activeWorkflowId: state.activeWorkflowId,
hydration: state.hydration,
setActiveWorkflow: state.setActiveWorkflow,
@@ -357,12 +362,14 @@ const WorkflowContent = React.memo(
const isWorkflowReady = useMemo(
() =>
!isWorkflowMapPlaceholderData &&
hydration.phase === 'ready' &&
hydration.workflowId === workflowIdParam &&
activeWorkflowId === workflowIdParam &&
Boolean(workflows[workflowIdParam]) &&
lastSaved !== undefined,
[
isWorkflowMapPlaceholderData,
hydration.phase,
hydration.workflowId,
workflowIdParam,
@@ -2204,23 +2211,22 @@ const WorkflowContent = React.memo(
)
const loadingWorkflowRef = useRef<string | null>(null)
const currentWorkflowExists = Boolean(workflows[workflowIdParam])
const currentWorkflowExists =
!isWorkflowMapPlaceholderData && Boolean(workflows[workflowIdParam])
useEffect(() => {
// In sandbox mode the stores are pre-hydrated externally; skip the API load.
if (sandbox) return
const currentId = workflowIdParam
const currentWorkspaceHydration = hydration.workspaceId
const isRegistryReady = hydration.phase !== 'metadata-loading' && hydration.phase !== 'idle'
// Wait for registry to be ready to prevent race conditions
// Wait for workflow data to be available before attempting to load
if (
isWorkflowMapLoading ||
isWorkflowMapPlaceholderData ||
!currentId ||
!currentWorkflowExists ||
!isRegistryReady ||
(currentWorkspaceHydration && currentWorkspaceHydration !== workspaceId)
!hydration.workspaceId ||
hydration.workspaceId !== workspaceId
) {
return
}
@@ -2269,6 +2275,8 @@ const WorkflowContent = React.memo(
}
}, [
workflowIdParam,
isWorkflowMapLoading,
isWorkflowMapPlaceholderData,
currentWorkflowExists,
activeWorkflowId,
setActiveWorkflow,
@@ -2286,8 +2294,12 @@ const WorkflowContent = React.memo(
useEffect(() => {
if (embedded || sandbox) return
// Wait for metadata to finish loading before making navigation decisions
if (hydration.phase === 'metadata-loading' || hydration.phase === 'idle') {
if (
isWorkflowMapLoading ||
isWorkflowMapPlaceholderData ||
!hydration.workspaceId ||
hydration.workspaceId !== workspaceId
) {
return
}
@@ -2330,9 +2342,12 @@ const WorkflowContent = React.memo(
}, [
embedded,
workflowIdParam,
isWorkflowMapLoading,
isWorkflowMapPlaceholderData,
currentWorkflowExists,
workflowCount,
hydration.phase,
hydration.workspaceId,
workspaceId,
router,
workflows,
@@ -14,7 +14,7 @@ import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/co
import { getBlock } from '@/blocks'
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
/** Execution status for blocks in preview mode */
type ExecutionStatus = 'success' | 'error' | 'not-executed'
@@ -48,6 +48,8 @@ const ERROR_HANDLE_STYLE: CSSProperties = {
interface WorkflowPreviewBlockData {
type: string
name: string
workflowMap?: Record<string, WorkflowMetadata>
workflowLabelsReady?: boolean
isTrigger?: boolean
horizontalHandles?: boolean
enabled?: boolean
@@ -77,6 +79,8 @@ interface SubBlockRowProps {
value?: string
subBlock?: SubBlockConfig
rawValue?: unknown
workflowMap: Record<string, WorkflowMetadata>
workflowLabelsReady: boolean
}
/**
@@ -107,12 +111,14 @@ function resolveDropdownLabel(
*/
function resolveWorkflowName(
subBlock: SubBlockConfig | undefined,
rawValue: unknown
rawValue: unknown,
workflowMap: Record<string, WorkflowMetadata>,
workflowLabelsReady: boolean
): string | null {
if (subBlock?.type !== 'workflow-selector') return null
if (!rawValue || typeof rawValue !== 'string') return null
if (!workflowLabelsReady) return null
const workflowMap = useWorkflowRegistry.getState().workflows
return workflowMap[rawValue]?.name ?? DELETED_WORKFLOW_LABEL
}
@@ -228,6 +234,8 @@ const SubBlockRow = memo(function SubBlockRow({
value,
subBlock,
rawValue,
workflowMap,
workflowLabelsReady,
}: SubBlockRowProps) {
const isPasswordField = subBlock?.password === true
const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null
@@ -235,7 +243,7 @@ const SubBlockRow = memo(function SubBlockRow({
const dropdownLabel = resolveDropdownLabel(subBlock, rawValue)
const variablesDisplay = resolveVariablesDisplay(subBlock, rawValue)
const toolsDisplay = resolveToolsDisplay(subBlock, rawValue)
const workflowName = resolveWorkflowName(subBlock, rawValue)
const workflowName = resolveWorkflowName(subBlock, rawValue, workflowMap, workflowLabelsReady)
const isSelectorType = subBlock?.type && SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlock.type)
@@ -272,6 +280,8 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
const {
type,
name,
workflowMap = {},
workflowLabelsReady = false,
isTrigger = false,
horizontalHandles = false,
enabled = true,
@@ -492,6 +502,8 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
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<WorkflowPreviewBlockData>
key='context'
title='Context'
value={lightweight ? undefined : getDisplayValue(rawValues.context)}
workflowMap={workflowMap}
workflowLabelsReady={workflowLabelsReady}
/>
{routerRows.map((route, index) => (
<SubBlockRow
key={route.id}
title={`Route ${index + 1}`}
value={lightweight ? undefined : getDisplayValue(route.value)}
workflowMap={workflowMap}
workflowLabelsReady={workflowLabelsReady}
/>
))}
</>
@@ -519,12 +535,20 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
value={lightweight ? undefined : getDisplayValue(rawValue)}
subBlock={lightweight ? undefined : subBlock}
rawValue={rawValue}
workflowMap={workflowMap}
workflowLabelsReady={workflowLabelsReady}
/>
)
})
)}
{/* Error row for non-trigger blocks */}
{shouldShowDefaultHandles && <SubBlockRow title='error' />}
{shouldShowDefaultHandles && (
<SubBlockRow
title='error'
workflowMap={workflowMap}
workflowLabelsReady={workflowLabelsReady}
/>
)}
</div>
)}
@@ -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<HTMLDivElement>(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,
])
@@ -27,10 +27,11 @@ import {
useExportSelection,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useCreateWorkflow } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
import type { FolderTreeNode } from '@/stores/folders/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
const logger = createLogger('FolderItem')
@@ -245,16 +246,16 @@ export function FolderItem({
const workflowIds = Array.from(finalWorkflowSelection)
const isMixed = folderIds.length > 0 && workflowIds.length > 0
const { folders } = useFolderStore.getState()
const { workflows } = useWorkflowRegistry.getState()
const folderMap = getFolderMap(workspaceId)
const workflows = getWorkflows(workspaceId)
const names: string[] = []
for (const id of folderIds) {
const f = folders[id]
const f = folderMap[id]
if (f) names.push(f.name)
}
for (const id of workflowIds) {
const w = workflows[id]
const w = workflows.find((wf) => wf.id === id)
if (w) names.push(w.name)
}
@@ -25,6 +25,9 @@ import {
useExportSelection,
useExportWorkflow,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useUpdateWorkflow } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
@@ -60,7 +63,7 @@ export function WorkflowItem({
const params = useParams()
const workspaceId = params.workspaceId as string
const selectedWorkflows = useFolderStore((state) => state.selectedWorkflows)
const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow)
const updateWorkflowMutation = useUpdateWorkflow()
const userPermissions = useUserPermissionsContext()
const isSelected = selectedWorkflows.has(workflow.id)
@@ -166,9 +169,9 @@ export function WorkflowItem({
const handleColorChange = useCallback(
(color: string) => {
updateWorkflow(workflow.id, { color })
updateWorkflowMutation.mutate({ workspaceId, workflowId: workflow.id, metadata: { color } })
},
[workflow.id, updateWorkflow]
[workflow.id, workspaceId]
)
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
@@ -227,16 +230,16 @@ export function WorkflowItem({
const folderIds = Array.from(finalFolderSelection)
const isMixed = workflowIds.length > 0 && folderIds.length > 0
const { workflows } = useWorkflowRegistry.getState()
const { folders } = useFolderStore.getState()
const workflows = getWorkflows(workspaceId)
const folderMap = getFolderMap(workspaceId)
const names: string[] = []
for (const id of workflowIds) {
const w = workflows[id]
const w = workflows.find((wf) => wf.id === id)
if (w) names.push(w.name)
}
for (const id of folderIds) {
const f = folders[id]
const f = folderMap[id]
if (f) names.push(f.name)
}
@@ -301,7 +304,11 @@ export function WorkflowItem({
} = useItemRename({
initialName: workflow.name,
onSave: async (newName) => {
await updateWorkflow(workflow.id, { name: newName })
await updateWorkflowMutation.mutateAsync({
workspaceId,
workflowId: workflow.id,
metadata: { name: newName },
})
},
itemType: 'workflow',
itemId: workflow.id,
@@ -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) => {
@@ -1,10 +1,12 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { getFolderPath } from '@/lib/folders/tree'
import { useReorderFolders } from '@/hooks/queries/folders'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useReorderWorkflows } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('WorkflowList:DragDrop')
@@ -233,8 +235,8 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
const cached = siblingsCacheRef.current.get(cacheKey)
if (cached) return cached
const currentFolders = useFolderStore.getState().folders
const currentWorkflows = useWorkflowRegistry.getState().workflows
const currentFolders = workspaceId ? getFolderMap(workspaceId) : {}
const currentWorkflows = workspaceId ? getWorkflows(workspaceId) : []
const siblings = [
...Object.values(currentFolders)
.filter((f) => f.parentId === folderId)
@@ -244,7 +246,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
sortOrder: f.sortOrder,
createdAt: f.createdAt,
})),
...Object.values(currentWorkflows)
...currentWorkflows
.filter((w) => w.folderId === folderId)
.map((w) => ({
type: 'workflow' as const,
@@ -294,10 +296,11 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
(folderId: string, destinationFolderId: string | null): boolean => {
if (folderId === destinationFolderId) return false
if (!destinationFolderId) return true
const targetPath = useFolderStore.getState().getFolderPath(destinationFolderId)
if (!workspaceId) return false
const targetPath = getFolderPath(getFolderMap(workspaceId), destinationFolderId)
return !targetPath.some((f) => f.id === folderId)
},
[]
[workspaceId]
)
const collectMovingItems = useCallback(
@@ -306,14 +309,14 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
folderIds: string[],
destinationFolderId: string | null
): { fromDestination: SiblingItem[]; fromOther: SiblingItem[] } => {
const { folders } = useFolderStore.getState()
const { workflows } = useWorkflowRegistry.getState()
const folders = workspaceId ? getFolderMap(workspaceId) : {}
const workflows = workspaceId ? getWorkflows(workspaceId) : []
const fromDestination: SiblingItem[] = []
const fromOther: SiblingItem[] = []
for (const id of workflowIds) {
const workflow = workflows[id]
const workflow = workflows.find((w) => w.id === id)
if (!workflow) continue
const item: SiblingItem = {
type: 'workflow',
@@ -1,11 +1,9 @@
import { useCallback, useMemo } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { useCreateWorkflow, useWorkflows } from '@/hooks/queries/workflows'
import { useCreateWorkflow, useWorkflowMap } from '@/hooks/queries/workflows'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
const logger = createLogger('useWorkflowOperations')
@@ -16,17 +14,14 @@ interface UseWorkflowOperationsProps {
export function useWorkflowOperations({ workspaceId }: UseWorkflowOperationsProps) {
const router = useRouter()
const workflows = useWorkflowRegistry(useShallow((state) => state.workflows))
const workflowsQuery = useWorkflows(workspaceId)
const { data: workflows = {}, isLoading: workflowsLoading } = useWorkflowMap(workspaceId)
const createWorkflowMutation = useCreateWorkflow()
const regularWorkflows = useMemo(
() =>
Object.values(workflows)
.filter((workflow) => workflow.workspaceId === workspaceId)
.sort((a, b) => {
return b.createdAt.getTime() - a.createdAt.getTime()
}),
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()),
[workflows, workspaceId]
)
@@ -59,7 +54,7 @@ export function useWorkflowOperations({ workspaceId }: UseWorkflowOperationsProp
return {
workflows,
regularWorkflows,
workflowsLoading: workflowsQuery.isLoading,
workflowsLoading,
isCreatingWorkflow: createWorkflowMutation.isPending,
handleCreateWorkflow,
@@ -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) {
@@ -38,6 +38,7 @@ import {
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { isMacPlatform } from '@/lib/core/utils/platform'
import { buildFolderTree } from '@/lib/folders/tree'
import {
START_NAV_TOUR_EVENT,
START_WORKFLOW_TOUR_EVENT,
@@ -77,7 +78,7 @@ import {
useImportWorkspace,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { getBrandConfig } from '@/ee/whitelabeling'
import { useFolders } from '@/hooks/queries/folders'
import { useFolderMap, useFolders } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useTablesList } from '@/hooks/queries/tables'
import {
@@ -88,6 +89,7 @@ import {
useRenameTask,
useTasks,
} from '@/hooks/queries/tasks'
import { useUpdateWorkflow } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
@@ -96,7 +98,6 @@ import { SIDEBAR_WIDTH } from '@/stores/constants'
import { useFolderStore } from '@/stores/folders/store'
import { useSearchModalStore } from '@/stores/modals/search/store'
import { useSidebarStore } from '@/stores/sidebar/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('Sidebar')
@@ -436,13 +437,12 @@ export const Sidebar = memo(function Sidebar() {
})
useFolders(workspaceId)
const folders = useFolderStore((s) => s.folders)
const getFolderTree = useFolderStore((s) => s.getFolderTree)
const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow)
const { data: folderMap = {} } = useFolderMap(workspaceId)
const updateWorkflowMutation = useUpdateWorkflow()
const folderTree = useMemo(
() => (isCollapsed && workspaceId ? getFolderTree(workspaceId) : []),
[isCollapsed, workspaceId, folders, getFolderTree]
() => (isCollapsed && workspaceId ? buildFolderTree(folderMap, workspaceId) : []),
[isCollapsed, workspaceId, folderMap]
)
const workflowsByFolder = useMemo(
@@ -814,7 +814,11 @@ export const Sidebar = memo(function Sidebar() {
const workflowFlyoutRename = useFlyoutInlineRename({
itemType: 'workflow',
onSave: async (workflowIdToRename, name) => {
await updateWorkflow(workflowIdToRename, { name })
await updateWorkflowMutation.mutateAsync({
workspaceId,
workflowId: workflowIdToRename,
metadata: { name },
})
},
})
@@ -1,6 +1,6 @@
import { useCallback, useMemo } from 'react'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useFolderMap } from '@/hooks/queries/folders'
import { useWorkflows } from '@/hooks/queries/workflows'
interface UseCanDeleteProps {
/**
@@ -36,17 +36,15 @@ interface UseCanDeleteReturn {
* @returns Functions to check deletion eligibility
*/
export function useCanDelete({ workspaceId }: UseCanDeleteProps): UseCanDeleteReturn {
const workflows = useWorkflowRegistry((s) => s.workflows)
const folders = useFolderStore((s) => s.folders)
const { data: workflowList = [] } = useWorkflows(workspaceId)
const { data: folders = {} } = useFolderMap(workspaceId)
/**
* Pre-computed data structures for efficient lookups
*/
const { totalWorkflows, workflowIdSet, workflowsByFolderId, childFoldersByParentId } =
useMemo(() => {
const workspaceWorkflows = Object.values(workflows).filter(
(w) => w.workspaceId === workspaceId
)
const workspaceWorkflows = workflowList.filter((w) => w.workspaceId === workspaceId)
const idSet = new Set(workspaceWorkflows.map((w) => w.id))
@@ -72,7 +70,7 @@ export function useCanDelete({ workspaceId }: UseCanDeleteProps): UseCanDeleteRe
workflowsByFolderId: byFolderId,
childFoldersByParentId: childrenByParent,
}
}, [workflows, folders, workspaceId])
}, [workflowList, folders, workspaceId])
/**
* Count workflows in a folder and all its subfolders recursively.
@@ -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,
])
@@ -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 {
@@ -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)
@@ -1,11 +1,13 @@
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { getChildFolders, getFolderById } from '@/lib/folders/tree'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { useDuplicateFolderMutation } from '@/hooks/queries/folders'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useDuplicateWorkflowMutation } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('useDuplicateSelection')
@@ -62,21 +64,21 @@ export function useDuplicateSelection({ workspaceId, onSuccess }: UseDuplicateSe
setIsDuplicating(true)
try {
const { workflows } = useWorkflowRegistry.getState()
const folderStore = useFolderStore.getState()
const workflowMap = new Map(getWorkflows(workspaceIdRef.current).map((w) => [w.id, w]))
const folderMap = getFolderMap(workspaceIdRef.current)
const duplicatedWorkflowIds: string[] = []
const duplicatedFolderIds: string[] = []
for (const folderId of folderIds) {
const folder = folderStore.getFolderById(folderId)
const folder = getFolderById(folderMap, folderId)
if (!folder) {
logger.warn(`Folder ${folderId} not found, skipping`)
continue
}
const siblingNames = new Set(
folderStore.getChildFolders(folder.parentId).map((sibling) => sibling.name)
getChildFolders(folderMap, folder.parentId).map((sibling) => sibling.name)
)
siblingNames.add(folder.name)
@@ -97,7 +99,7 @@ export function useDuplicateSelection({ workspaceId, onSuccess }: UseDuplicateSe
}
for (const workflowId of workflowIds) {
const workflow = workflows[workflowId]
const workflow = workflowMap.get(workflowId)
if (!workflow) {
logger.warn(`Workflow ${workflowId} not found, skipping`)
continue
@@ -2,9 +2,9 @@ import { useCallback, useRef } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useDuplicateWorkflowMutation } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('useDuplicateWorkflow')
@@ -61,10 +61,10 @@ export function useDuplicateWorkflow({ workspaceId, onSuccess }: UseDuplicateWor
const duplicatedIds: string[] = []
try {
const { workflows } = useWorkflowRegistry.getState()
const workflowMap = new Map(getWorkflows(workspaceIdRef.current).map((w) => [w.id, w]))
for (const sourceId of workflowIdsToDuplicate) {
const sourceWorkflow = workflows[sourceId]
const sourceWorkflow = workflowMap.get(sourceId)
if (!sourceWorkflow) {
logger.warn(`Workflow ${sourceId} not found, skipping`)
continue
@@ -1,5 +1,7 @@
import { useCallback, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { getFolderById } from '@/lib/folders/tree'
import {
downloadFile,
exportFolderToZip,
@@ -8,9 +10,10 @@ import {
sanitizePathSegment,
type WorkflowExportData,
} from '@/lib/workflows/operations/import-export'
import { useFolderMap } from '@/hooks/queries/folders'
import { useWorkflowMap } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
import type { WorkflowFolder } from '@/stores/folders/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
const logger = createLogger('useExportFolder')
@@ -89,8 +92,9 @@ function collectSubfolders(
* Hook for managing folder export to ZIP.
*/
export function useExportFolder({ folderId, onSuccess }: UseExportFolderProps) {
const workflows = useWorkflowRegistry((s) => s.workflows)
const folders = useFolderStore((s) => s.folders)
const { workspaceId } = useParams<{ workspaceId: string }>()
const { data: workflows = {} } = useWorkflowMap(workspaceId)
const { data: folders = {} } = useFolderMap(workspaceId)
const [isExporting, setIsExporting] = useState(false)
const hasWorkflows = useMemo(() => {
@@ -105,22 +109,21 @@ export function useExportFolder({ folderId, onSuccess }: UseExportFolderProps) {
setIsExporting(true)
try {
const folderStore = useFolderStore.getState()
const folder = folderStore.getFolderById(folderId)
const folder = getFolderById(folders, folderId)
if (!folder) {
logger.warn('Folder not found for export', { folderId })
return
}
const workflowsToExport = collectWorkflowsInFolder(folderId, workflows, folderStore.folders)
const workflowsToExport = collectWorkflowsInFolder(folderId, workflows, folders)
if (workflowsToExport.length === 0) {
logger.warn('No workflows found in folder to export', { folderId, folderName: folder.name })
return
}
const subfolders = collectSubfolders(folderId, folderStore.folders)
const subfolders = collectSubfolders(folderId, folders)
logger.info('Starting folder export', {
folderId,
@@ -1,5 +1,6 @@
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import {
downloadFile,
exportWorkflowsToZip,
@@ -7,9 +8,10 @@ import {
fetchWorkflowForExport,
type WorkflowExportData,
} from '@/lib/workflows/operations/import-export'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useFolderStore } from '@/stores/folders/store'
import type { WorkflowFolder } from '@/stores/folders/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
const logger = createLogger('useExportSelection')
@@ -88,10 +90,15 @@ function collectSubfoldersForMultipleFolders(
*/
export function useExportSelection({ onSuccess }: UseExportSelectionProps = {}) {
const [isExporting, setIsExporting] = useState(false)
const params = useParams()
const workspaceId = params.workspaceId as string | undefined
const onSuccessRef = useRef(onSuccess)
onSuccessRef.current = onSuccess
const workspaceIdRef = useRef(workspaceId)
workspaceIdRef.current = workspaceId
/**
* Export all selected workflows and folders to a ZIP file.
* - Collects workflows from selected folders recursively
@@ -113,25 +120,29 @@ export function useExportSelection({ onSuccess }: UseExportSelectionProps = {})
setIsExporting(true)
try {
const { workflows } = useWorkflowRegistry.getState()
const { folders } = useFolderStore.getState()
if (!workspaceIdRef.current) return
const workflowsArray = getWorkflows(workspaceIdRef.current)
const workflows = Object.fromEntries(workflowsArray.map((w) => [w.id, w]))
const folderMap = getFolderMap(workspaceIdRef.current)
const workflowsFromFolders: CollectedWorkflow[] = []
for (const folderId of folderIds) {
const collected = collectWorkflowsInFolder(folderId, workflows, folders)
const collected = collectWorkflowsInFolder(folderId, workflows, folderMap)
workflowsFromFolders.push(...collected)
}
const subfolders = collectSubfoldersForMultipleFolders(folderIds, folders)
const subfolders = collectSubfoldersForMultipleFolders(folderIds, folderMap)
const selectedFoldersData: FolderExportData[] = folderIds.map((folderId) => {
const folder = folders[folderId]
return {
id: folder.id,
name: folder.name,
parentId: null,
}
})
const selectedFoldersData: FolderExportData[] = folderIds
.filter((folderId) => folderMap[folderId])
.map((folderId) => {
const folder = folderMap[folderId]
return {
id: folder.id,
name: folder.name,
parentId: null,
}
})
const allFolders = [...selectedFoldersData, ...subfolders]
const workflowIdsFromFolders = workflowsFromFolders.map((w) => w.id)
@@ -1,5 +1,6 @@
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import {
downloadFile,
exportWorkflowsToZip,
@@ -7,8 +8,8 @@ import {
fetchWorkflowForExport,
sanitizePathSegment,
} from '@/lib/workflows/operations/import-export'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('useExportWorkflow')
@@ -24,10 +25,15 @@ interface UseExportWorkflowProps {
*/
export function useExportWorkflow({ onSuccess }: UseExportWorkflowProps = {}) {
const [isExporting, setIsExporting] = useState(false)
const params = useParams()
const workspaceId = params.workspaceId as string | undefined
const onSuccessRef = useRef(onSuccess)
onSuccessRef.current = onSuccess
const workspaceIdRef = useRef(workspaceId)
workspaceIdRef.current = workspaceId
/**
* Export the workflow(s) to JSON or ZIP
* - Single workflow: exports as JSON file
@@ -52,11 +58,12 @@ export function useExportWorkflow({ onSuccess }: UseExportWorkflowProps = {}) {
count: workflowIdsToExport.length,
})
const { workflows } = useWorkflowRegistry.getState()
if (!workspaceIdRef.current) return
const workflowMap = new Map(getWorkflows(workspaceIdRef.current).map((w) => [w.id, w]))
const exportedWorkflows = []
for (const workflowId of workflowIdsToExport) {
const workflowMeta = workflows[workflowId]
const workflowMeta = workflowMap.get(workflowId)
if (!workflowMeta) {
logger.warn(`Workflow ${workflowId} not found in registry`)
continue
@@ -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)`)
+14 -20
View File
@@ -12,47 +12,41 @@ const logger = createLogger('WorkflowsPage')
export default function WorkflowsPage() {
const router = useRouter()
const workflows = useWorkflowRegistry((s) => s.workflows)
const setActiveWorkflow = useWorkflowRegistry((s) => s.setActiveWorkflow)
const params = useParams()
const workspaceId = params.workspaceId as string
const [isMounted, setIsMounted] = useState(false)
// Fetch workflows using React Query
const { isLoading, isError } = useWorkflows(workspaceId)
const { data: workflows = [], isLoading, isError, isPlaceholderData } = useWorkflows(workspaceId)
// Track when component is mounted to avoid hydration issues
useEffect(() => {
setIsMounted(true)
}, [])
// Handle redirection once workflows are loaded and component is mounted
useEffect(() => {
// Wait for component to be mounted to avoid hydration mismatches
if (!isMounted) return
// Only proceed if workflows are done loading
if (isLoading) return
if (isLoading || isPlaceholderData) return
if (isError) {
logger.error('Failed to load workflows for workspace')
return
}
const workflowIds = Object.keys(workflows)
const workspaceWorkflows = workflows.filter((w) => w.workspaceId === workspaceId)
// Validate that workflows belong to the current workspace
const workspaceWorkflows = workflowIds.filter((id) => {
const workflow = workflows[id]
return workflow.workspaceId === workspaceId
})
// If we have valid workspace workflows, redirect to the first one
if (workspaceWorkflows.length > 0) {
const firstWorkflowId = workspaceWorkflows[0]
router.replace(`/workspace/${workspaceId}/w/${firstWorkflowId}`)
router.replace(`/workspace/${workspaceId}/w/${workspaceWorkflows[0].id}`)
}
}, [isMounted, isLoading, workflows, workspaceId, router, setActiveWorkflow, isError])
}, [
isMounted,
isLoading,
isPlaceholderData,
workflows,
workspaceId,
router,
setActiveWorkflow,
isError,
])
// Always show loading state until redirect happens
// There should always be a default workflow, so we never show "no workflows found"
+26 -25
View File
@@ -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,
},
},
{
+1 -1
View File
@@ -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')
+1 -1
View File
@@ -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 {
+14 -10
View File
@@ -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,
},
},
]
@@ -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<string>()
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<string, string> = {}
@@ -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,
})
@@ -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}`
+1 -46
View File
@@ -1,6 +1,6 @@
import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getQueryClient } from '@/app/_shell/providers/query-provider'
import { customToolsKeys } from '@/hooks/queries/utils/custom-tool-keys'
const logger = createLogger('CustomToolsQueries')
const API_ENDPOINT = '/api/tools/custom'
@@ -29,16 +29,6 @@ export interface CustomToolDefinition {
updatedAt?: string
}
/**
* Query key factories for custom tools queries
*/
export const customToolsKeys = {
all: ['customTools'] as const,
lists: () => [...customToolsKeys.all, 'list'] as const,
list: (workspaceId: string) => [...customToolsKeys.lists(), workspaceId] as const,
detail: (toolId: string) => [...customToolsKeys.all, 'detail', toolId] as const,
}
export type CustomTool = CustomToolDefinition
type ApiCustomTool = Partial<CustomToolDefinition> & {
@@ -87,41 +77,6 @@ function normalizeCustomTool(tool: ApiCustomTool, workspaceId: string): CustomTo
}
}
/**
* Extract workspaceId from the current URL path
* Expected format: /workspace/{workspaceId}/...
*/
function getWorkspaceIdFromUrl(): string | null {
if (typeof window === 'undefined') return null
const match = window.location.pathname.match(/^\/workspace\/([^/]+)/)
return match?.[1] ?? null
}
/**
* Get all custom tools from the query cache (for non-React code)
* If workspaceId is not provided, extracts it from the current URL
*/
export function getCustomTools(workspaceId?: string): CustomToolDefinition[] {
if (typeof window === 'undefined') return []
const wsId = workspaceId ?? getWorkspaceIdFromUrl()
if (!wsId) return []
const queryClient = getQueryClient()
return queryClient.getQueryData<CustomToolDefinition[]>(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
*/
@@ -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,
})
})
})
+2 -1
View File
@@ -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')
+69 -77
View File
@@ -1,33 +1,32 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockLogger, queryClient, useFolderStoreMock, useWorkflowRegistryMock } = vi.hoisted(() => ({
const { mockLogger, mockGetFolderMap, mockGetWorkflows, queryClient } = vi.hoisted(() => ({
mockLogger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
mockGetFolderMap: vi.fn(() => ({})),
mockGetWorkflows: vi.fn(() => []),
queryClient: {
cancelQueries: vi.fn().mockResolvedValue(undefined),
invalidateQueries: vi.fn().mockResolvedValue(undefined),
getQueryData: vi.fn(),
setQueryData: vi.fn(),
},
useFolderStoreMock: Object.assign(vi.fn(), {
getState: vi.fn(),
setState: vi.fn(),
}),
useWorkflowRegistryMock: Object.assign(vi.fn(), {
getState: vi.fn(),
setState: vi.fn(),
}),
}))
let folderState: {
folders: Record<string, any>
}
let folderMapState: Record<string, any>
let folderListState: any[]
let workflowRegistryState: {
workflows: Record<string, any>
}
let workflowList: Array<{
id: string
name: string
workspaceId: string
folderId: string
sortOrder: number
}>
vi.mock('@sim/logger', () => ({
createLogger: vi.fn(() => mockLogger),
@@ -40,15 +39,15 @@ vi.mock('@tanstack/react-query', () => ({
useMutation: vi.fn((options) => options),
}))
vi.mock('@/stores/folders/store', () => ({
useFolderStore: useFolderStoreMock,
vi.mock('@/hooks/queries/utils/workflow-cache', () => ({
getWorkflows: mockGetWorkflows,
}))
vi.mock('@/stores/workflows/registry/store', () => ({
useWorkflowRegistry: useWorkflowRegistryMock,
vi.mock('@/hooks/queries/utils/folder-cache', () => ({
getFolderMap: mockGetFolderMap,
}))
vi.mock('@/hooks/queries/workflows', () => ({
vi.mock('@/hooks/queries/utils/workflow-keys', () => ({
workflowKeys: {
list: (workspaceId: string | undefined) => ['workflows', 'list', workspaceId ?? ''],
},
@@ -57,7 +56,7 @@ vi.mock('@/hooks/queries/workflows', () => ({
import { useCreateFolder, useDuplicateFolderMutation } from '@/hooks/queries/folders'
function getOptimisticFolderByName(name: string) {
return Object.values(folderState.folders).find((folder: any) => folder.name === name) as
return Object.values(folderMapState).find((folder: any) => folder.name === name) as
| { sortOrder: number }
| undefined
}
@@ -65,67 +64,60 @@ function getOptimisticFolderByName(name: string) {
describe('folder optimistic top insertion ordering', () => {
beforeEach(() => {
vi.clearAllMocks()
useFolderStoreMock.getState.mockImplementation(() => folderState)
useFolderStoreMock.setState.mockImplementation((updater: any) => {
if (typeof updater === 'function') {
const next = updater(folderState)
if (next) {
folderState = { ...folderState, ...next }
}
return
}
folderState = { ...folderState, ...updater }
queryClient.getQueryData.mockImplementation(() => folderListState)
queryClient.setQueryData.mockImplementation((_key: unknown, updater: any) => {
folderListState = typeof updater === 'function' ? updater(folderListState) : updater
folderMapState = Object.fromEntries(
(folderListState ?? []).map((folder: any) => [folder.id, folder])
)
})
useWorkflowRegistryMock.getState.mockImplementation(() => workflowRegistryState)
mockGetFolderMap.mockImplementation(() => folderMapState)
mockGetWorkflows.mockImplementation(() => workflowList)
folderState = {
folders: {
'folder-parent-match': {
id: 'folder-parent-match',
name: 'Existing sibling folder',
userId: 'user-1',
workspaceId: 'ws-1',
parentId: 'parent-1',
color: '#808080',
isExpanded: false,
sortOrder: 5,
createdAt: new Date(),
updatedAt: new Date(),
},
'folder-other-parent': {
id: 'folder-other-parent',
name: 'Other parent folder',
userId: 'user-1',
workspaceId: 'ws-1',
parentId: 'parent-2',
color: '#808080',
isExpanded: false,
sortOrder: -100,
createdAt: new Date(),
updatedAt: new Date(),
},
folderListState = [
{
id: 'folder-parent-match',
name: 'Existing sibling folder',
userId: 'user-1',
workspaceId: 'ws-1',
parentId: 'parent-1',
color: '#808080',
isExpanded: false,
sortOrder: 5,
createdAt: new Date(),
updatedAt: new Date(),
},
}
{
id: 'folder-other-parent',
name: 'Other parent folder',
userId: 'user-1',
workspaceId: 'ws-1',
parentId: 'parent-2',
color: '#808080',
isExpanded: false,
sortOrder: -100,
createdAt: new Date(),
updatedAt: new Date(),
},
]
folderMapState = Object.fromEntries(folderListState.map((folder) => [folder.id, folder]))
workflowRegistryState = {
workflows: {
'workflow-parent-match': {
id: 'workflow-parent-match',
name: 'Existing sibling workflow',
workspaceId: 'ws-1',
folderId: 'parent-1',
sortOrder: 2,
},
'workflow-other-parent': {
id: 'workflow-other-parent',
name: 'Other parent workflow',
workspaceId: 'ws-1',
folderId: 'parent-2',
sortOrder: -50,
},
workflowList = [
{
id: 'workflow-parent-match',
name: 'Existing sibling workflow',
workspaceId: 'ws-1',
folderId: 'parent-1',
sortOrder: 2,
},
}
{
id: 'workflow-other-parent',
name: 'Other parent workflow',
workspaceId: 'ws-1',
folderId: 'parent-2',
sortOrder: -50,
},
]
})
it('creates folders at top of mixed non-root siblings', async () => {
+52 -82
View File
@@ -1,24 +1,18 @@
import { useEffect } from 'react'
import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
import {
createOptimisticMutationHandlers,
generateTempId,
} from '@/hooks/queries/utils/optimistic-mutation'
import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order'
import { workflowKeys } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import type { WorkflowFolder } from '@/stores/folders/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('FolderQueries')
export const folderKeys = {
all: ['folders'] as const,
lists: () => [...folderKeys.all, 'list'] as const,
list: (workspaceId: string | undefined) => [...folderKeys.lists(), workspaceId ?? ''] as const,
}
function mapFolder(folder: any): WorkflowFolder {
return {
id: folder.id,
@@ -46,23 +40,24 @@ async function fetchFolders(workspaceId: string, signal?: AbortSignal): Promise<
}
export function useFolders(workspaceId?: string) {
const setFolders = useFolderStore((state) => state.setFolders)
const query = useQuery({
return useQuery({
queryKey: folderKeys.list(workspaceId),
queryFn: ({ signal }) => fetchFolders(workspaceId as string, signal),
enabled: Boolean(workspaceId),
placeholderData: keepPreviousData,
staleTime: 60 * 1000,
})
}
useEffect(() => {
if (query.data) {
setFolders(query.data)
}
}, [query.data, setFolders])
return query
export function useFolderMap(workspaceId?: string) {
return useQuery({
queryKey: folderKeys.list(workspaceId),
queryFn: ({ signal }) => fetchFolders(workspaceId as string, signal),
enabled: Boolean(workspaceId),
placeholderData: keepPreviousData,
staleTime: 60 * 1000,
select: (folders) => Object.fromEntries(folders.map((folder) => [folder.id, folder])),
})
}
interface CreateFolderVariables {
@@ -110,54 +105,25 @@ function createFolderMutationHandlers<TVariables extends { workspaceId: string }
return createOptimisticMutationHandlers<WorkflowFolder, TVariables, WorkflowFolder>(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<WorkflowFolder[]>(folderKeys.list(item.workspaceId), (old) => [
...(old ?? []),
item,
])
},
replaceOptimisticEntry: (tempId, data) => {
useFolderStore.setState((state) => {
const { [tempId]: _, ...remainingFolders } = state.folders
const update: Record<string, unknown> = {
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<WorkflowFolder[]>(folderKeys.list(data.workspaceId), (old) =>
(old ?? []).map((folder) => (folder.id === tempId ? data : folder))
)
},
rollback: (snapshot) => {
useFolderStore.setState({ folders: snapshot })
rollback: (snapshot, variables) => {
queryClient.setQueryData(folderKeys.list(variables.workspaceId), Object.values(snapshot))
},
})
}
@@ -169,7 +135,9 @@ export function useCreateFolder() {
queryClient,
'CreateFolder',
(variables, tempId, previousFolders) => {
const currentWorkflows = useWorkflowRegistry.getState().workflows
const currentWorkflows = Object.fromEntries(
getWorkflows(variables.workspaceId).map((w) => [w.id, w])
)
return {
id: tempId,
@@ -233,7 +201,7 @@ export function useUpdateFolder() {
const { folder } = await response.json()
return mapFolder(folder)
},
onSuccess: (_data, variables) => {
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) })
},
})
@@ -253,9 +221,9 @@ export function useDeleteFolderMutation() {
return response.json()
},
onSuccess: async (_data, variables) => {
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) })
queryClient.invalidateQueries({ queryKey: workflowKeys.lists() })
return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived'])
},
})
}
@@ -267,7 +235,9 @@ export function useDuplicateFolderMutation() {
queryClient,
'DuplicateFolder',
(variables, tempId, previousFolders) => {
const currentWorkflows = useWorkflowRegistry.getState().workflows
const currentWorkflows = Object.fromEntries(
getWorkflows(variables.workspaceId).map((w) => [w.id, w])
)
const sourceFolder = previousFolders[variables.id]
const targetParentId = variables.parentId ?? sourceFolder?.parentId ?? null
@@ -324,7 +294,7 @@ export function useDuplicateFolderMutation() {
...handlers,
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) })
queryClient.invalidateQueries({ queryKey: workflowKeys.lists() })
return invalidateWorkflowLists(queryClient, variables.workspaceId)
},
})
}
@@ -357,28 +327,28 @@ export function useReorderFolders() {
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: folderKeys.list(variables.workspaceId) })
const snapshot = { ...useFolderStore.getState().folders }
const snapshot = queryClient.getQueryData<WorkflowFolder[]>(
folderKeys.list(variables.workspaceId)
)
useFolderStore.setState((state) => {
const updated = { ...state.folders }
for (const update of variables.updates) {
if (updated[update.id]) {
updated[update.id] = {
...updated[update.id],
sortOrder: update.sortOrder,
parentId:
update.parentId !== undefined ? update.parentId : updated[update.id].parentId,
}
const updatesById = new Map(variables.updates.map((update) => [update.id, update]))
queryClient.setQueryData<WorkflowFolder[]>(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) => {
+2 -2
View File
@@ -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
},
+1 -1
View File
@@ -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,
})
}
+1 -1
View File
@@ -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,
})
}
@@ -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')
})
})
@@ -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<CustomToolDefinition[]>(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)
}
@@ -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,
}
@@ -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<WorkflowState> {
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
}
@@ -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<WorkflowFolder[]>(folderKeys.list(workspaceId)) ?? EMPTY_FOLDERS
)
}
export function getFolderMap(workspaceId: string): Record<string, WorkflowFolder> {
return Object.fromEntries(getFolders(workspaceId).map((folder) => [folder.id, folder]))
}
@@ -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,
}
@@ -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'],
})
})
})
@@ -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),
])
}
@@ -6,12 +6,12 @@ const logger = createLogger('OptimisticMutation')
export interface OptimisticMutationConfig<TData, TVariables, TItem, TContext> {
name: string
getQueryKey: (variables: TVariables) => readonly unknown[]
getSnapshot: () => Record<string, TItem>
getSnapshot: (variables: TVariables) => Record<string, TItem>
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<string, TItem>) => void
rollback: (snapshot: Record<string, TItem>, variables: TVariables) => void
onSuccessExtra?: (data: TData, variables: TVariables) => void
}
@@ -40,7 +40,7 @@ export function createOptimisticMutationHandlers<TData, TVariables, TItem>(
onMutate: async (variables: TVariables): Promise<OptimisticMutationContext<TItem>> => {
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<TData, TVariables, TItem>(
) => {
logger.error(`[${name}] Failed:`, error)
if (context?.previousState) {
rollback(context.previousState)
rollback(context.previousState, _variables)
logger.info(`[${name}] Rolled back to previous state`)
}
},
@@ -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()
})
})
@@ -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<WorkflowMetadata[]>(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)
}
@@ -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,
}
@@ -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<WorkflowMetadata[]> {
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,
}
}
+391 -331
View File
@@ -1,14 +1,29 @@
import { useEffect } from 'react'
/**
* React Query hooks for managing workflow metadata and mutations.
*/
import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
keepPreviousData,
skipToken,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { deploymentKeys } from '@/hooks/queries/deployments'
import {
createOptimisticMutationHandlers,
generateTempId,
} from '@/hooks/queries/utils/optimistic-mutation'
import { fetchDeploymentVersionState } from '@/hooks/queries/utils/fetch-deployment-version-state'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys'
import {
getWorkflowListQueryOptions,
mapWorkflow,
WORKFLOW_LIST_STALE_TIME,
} from '@/hooks/queries/utils/workflow-list-query'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
@@ -18,24 +33,8 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowQueries')
type WorkflowQueryScope = 'active' | 'archived' | 'all'
export { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys'
export const workflowKeys = {
all: ['workflows'] as const,
lists: () => [...workflowKeys.all, 'list'] as const,
list: (workspaceId: string | undefined, scope: WorkflowQueryScope = 'active') =>
[...workflowKeys.lists(), workspaceId ?? '', scope] as const,
deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const,
deploymentVersion: (workflowId: string | undefined, version: number | undefined) =>
[...workflowKeys.deploymentVersions(), workflowId ?? '', version ?? 0] as const,
state: (workflowId: string | undefined) =>
[...workflowKeys.all, 'state', workflowId ?? ''] as const,
}
/**
* Fetches workflow state from the API.
* Used as the base query for both state preview and input fields extraction.
*/
async function fetchWorkflowState(
workflowId: string,
signal?: AbortSignal
@@ -47,113 +46,44 @@ async function fetchWorkflowState(
}
/**
* Hook to fetch workflow state.
* Fetches the full workflow state for a single workflow.
* Used by workflow blocks to show a preview of the child workflow
* and as a base query for input fields extraction.
*
* @param workflowId - The workflow ID to fetch state for
* @returns Query result with workflow state
*/
export function useWorkflowState(workflowId: string | undefined) {
return useQuery({
queryKey: workflowKeys.state(workflowId),
queryFn: ({ signal }) => fetchWorkflowState(workflowId!, signal),
enabled: Boolean(workflowId),
staleTime: 30 * 1000, // 30 seconds
placeholderData: keepPreviousData,
queryFn: workflowId ? ({ signal }) => fetchWorkflowState(workflowId, signal) : skipToken,
staleTime: 30 * 1000,
})
}
function mapWorkflow(workflow: any): WorkflowMetadata {
return {
id: workflow.id,
name: workflow.name,
description: workflow.description,
color: workflow.color,
workspaceId: workflow.workspaceId,
folderId: workflow.folderId,
sortOrder: workflow.sortOrder ?? 0,
createdAt: new Date(workflow.createdAt),
lastModified: new Date(workflow.updatedAt || workflow.createdAt),
archivedAt: workflow.archivedAt ? new Date(workflow.archivedAt) : null,
}
}
export function useWorkflows(workspaceId?: string, options?: { scope?: WorkflowQueryScope }) {
const { scope = 'active' } = options || {}
async function fetchWorkflows(
workspaceId: string,
scope: WorkflowQueryScope = 'active',
signal?: AbortSignal
): Promise<WorkflowMetadata[]> {
const response = await fetch(`/api/workflows?workspaceId=${workspaceId}&scope=${scope}`, {
signal,
})
if (!response.ok) {
throw new Error('Failed to fetch workflows')
}
const { data }: { data: any[] } = await response.json()
return data.map(mapWorkflow)
}
export function useWorkflows(
workspaceId?: string,
options?: { syncRegistry?: boolean; scope?: WorkflowQueryScope }
) {
const { syncRegistry = true, scope = 'active' } = options || {}
const beginMetadataLoad = useWorkflowRegistry((state) => state.beginMetadataLoad)
const completeMetadataLoad = useWorkflowRegistry((state) => state.completeMetadataLoad)
const failMetadataLoad = useWorkflowRegistry((state) => state.failMetadataLoad)
const query = useQuery({
return useQuery({
queryKey: workflowKeys.list(workspaceId, scope),
queryFn: ({ signal }) => fetchWorkflows(workspaceId as string, scope, signal),
enabled: Boolean(workspaceId),
queryFn: workspaceId ? getWorkflowListQueryOptions(workspaceId, scope).queryFn : skipToken,
placeholderData: keepPreviousData,
staleTime: 60 * 1000,
staleTime: WORKFLOW_LIST_STALE_TIME,
})
}
useEffect(() => {
if (
syncRegistry &&
scope === 'active' &&
workspaceId &&
(query.status === 'pending' || query.isPlaceholderData)
) {
beginMetadataLoad(workspaceId)
}
}, [syncRegistry, scope, workspaceId, query.status, query.isPlaceholderData, beginMetadataLoad])
/**
* Returns workflows as a `Record<string, WorkflowMetadata>` keyed by ID.
* Uses the `select` option so the transformation runs inside React Query
* with structural sharing components only re-render when the record changes.
*/
export function useWorkflowMap(workspaceId?: string, options?: { scope?: WorkflowQueryScope }) {
const { scope = 'active' } = options || {}
useEffect(() => {
if (
syncRegistry &&
scope === 'active' &&
workspaceId &&
query.status === 'success' &&
query.data &&
!query.isPlaceholderData
) {
completeMetadataLoad(workspaceId, query.data)
}
}, [
syncRegistry,
scope,
workspaceId,
query.status,
query.data,
query.isPlaceholderData,
completeMetadataLoad,
])
useEffect(() => {
if (syncRegistry && scope === 'active' && workspaceId && query.status === 'error') {
const message =
query.error instanceof Error ? query.error.message : 'Failed to fetch workflows'
failMetadataLoad(workspaceId, message)
}
}, [syncRegistry, scope, workspaceId, query.status, query.error, failMetadataLoad])
return query
return useQuery({
queryKey: workflowKeys.list(workspaceId, scope),
queryFn: workspaceId ? getWorkflowListQueryOptions(workspaceId, scope).queryFn : skipToken,
placeholderData: keepPreviousData,
staleTime: WORKFLOW_LIST_STALE_TIME,
select: (data) => Object.fromEntries(data.map((w) => [w.id, w])),
})
}
interface CreateWorkflowVariables {
@@ -177,128 +107,9 @@ interface CreateWorkflowResult {
sortOrder: number
}
interface DuplicateWorkflowVariables {
workspaceId: string
sourceId: string
name: string
description?: string
color: string
folderId?: string | null
newId?: string
}
interface DuplicateWorkflowResult {
id: string
name: string
description?: string
color: string
workspaceId: string
folderId?: string | null
sortOrder: number
blocksCount: number
edgesCount: number
subflowsCount: number
}
/**
* Creates optimistic mutation handlers for workflow operations
*/
function createWorkflowMutationHandlers<TVariables extends { workspaceId: string }>(
queryClient: ReturnType<typeof useQueryClient>,
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<CreateWorkflowVariables>(
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<CreateWorkflowResult> => {
const { workspaceId, name, description, color, folderId, sortOrder, id, deduplicate } =
@@ -343,9 +154,7 @@ export function useCreateWorkflow() {
if (!stateResponse.ok) {
const text = await stateResponse.text()
logger.error('Failed to persist default Start block:', text)
} else {
logger.info('Successfully persisted default Start block')
logger.error('Failed to persist default workflow state:', text)
}
return {
@@ -358,9 +167,85 @@ export function useCreateWorkflow() {
sortOrder: createdWorkflow.sortOrder ?? 0,
}
},
...handlers,
onMutate: async (variables) => {
await queryClient.cancelQueries({
queryKey: workflowKeys.list(variables.workspaceId, 'active'),
})
const snapshot = queryClient.getQueryData<WorkflowMetadata[]>(
workflowKeys.list(variables.workspaceId, 'active')
)
const tempId = variables.id ?? crypto.randomUUID()
let sortOrder: number
if (variables.sortOrder !== undefined) {
sortOrder = variables.sortOrder
} else {
const currentWorkflows = Object.fromEntries(
getWorkflows(variables.workspaceId).map((w) => [w.id, w])
)
sortOrder = getTopInsertionSortOrder(
currentWorkflows,
getFolderMap(variables.workspaceId),
variables.workspaceId,
variables.folderId
)
}
const optimistic: WorkflowMetadata = {
id: tempId,
name: variables.name || generateCreativeWorkflowName(),
lastModified: new Date(),
createdAt: new Date(),
description: variables.description || 'New workflow',
color: variables.color || getNextWorkflowColor(),
workspaceId: variables.workspaceId,
folderId: variables.folderId || null,
sortOrder,
}
queryClient.setQueryData<WorkflowMetadata[]>(
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<WorkflowMetadata[]>(
workflowKeys.list(variables.workspaceId, 'active'),
(old) =>
(old ?? []).map((w) =>
w.id === tempId
? {
id: data.id,
name: data.name,
lastModified: new Date(),
createdAt: new Date(),
description: data.description,
color: data.color,
workspaceId: data.workspaceId,
folderId: data.folderId,
sortOrder: data.sortOrder,
}
: w
)
)
if (tempId !== data.id) {
useFolderStore.setState((state) => {
const selectedWorkflows = new Set(state.selectedWorkflows)
if (selectedWorkflows.has(tempId)) {
selectedWorkflows.delete(tempId)
selectedWorkflows.add(data.id)
}
return { selectedWorkflows }
})
}
const { subBlockValues } = buildDefaultWorkflowArtifacts()
useSubBlockStore.setState((state) => ({
@@ -369,41 +254,50 @@ export function useCreateWorkflow() {
[data.id]: subBlockValues,
},
}))
logger.info(`[CreateWorkflow] Success, replaced temp entry ${tempId}`)
},
onError: (_error, variables, context) => {
if (context?.snapshot) {
queryClient.setQueryData(
workflowKeys.list(variables.workspaceId, 'active'),
context.snapshot
)
logger.info('[CreateWorkflow] Rolled back to previous state')
}
},
onSettled: (_data, _error, variables) => {
return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived'])
},
})
}
interface DuplicateWorkflowVariables {
workspaceId: string
sourceId: string
name: string
description?: string
color: string
folderId?: string | null
newId?: string
}
interface DuplicateWorkflowResult {
id: string
name: string
description?: string
color: string
workspaceId: string
folderId?: string | null
sortOrder: number
blocksCount: number
edgesCount: number
subflowsCount: number
}
export function useDuplicateWorkflowMutation() {
const queryClient = useQueryClient()
const handlers = createWorkflowMutationHandlers<DuplicateWorkflowVariables>(
queryClient,
'DuplicateWorkflow',
(variables, tempId) => {
const currentWorkflows = useWorkflowRegistry.getState().workflows
const currentFolders = useFolderStore.getState().folders
const targetFolderId = variables.folderId ?? null
return {
id: tempId,
name: variables.name,
lastModified: new Date(),
createdAt: new Date(),
description: variables.description,
color: variables.color,
workspaceId: variables.workspaceId,
folderId: targetFolderId,
sortOrder: getTopInsertionSortOrder(
currentWorkflows,
currentFolders,
variables.workspaceId,
targetFolderId
),
}
},
(variables) => variables.newId ?? crypto.randomUUID()
)
return useMutation({
mutationFn: async (variables: DuplicateWorkflowVariables): Promise<DuplicateWorkflowResult> => {
const { workspaceId, sourceId, name, description, color, folderId, newId } = variables
@@ -449,11 +343,81 @@ export function useDuplicateWorkflowMutation() {
subflowsCount: duplicatedWorkflow.subflowsCount || 0,
}
},
...handlers,
onSuccess: (data, variables, context) => {
handlers.onSuccess(data, variables, context)
onMutate: async (variables) => {
await queryClient.cancelQueries({
queryKey: workflowKeys.list(variables.workspaceId, 'active'),
})
const snapshot = queryClient.getQueryData<WorkflowMetadata[]>(
workflowKeys.list(variables.workspaceId, 'active')
)
const tempId = variables.newId ?? crypto.randomUUID()
const currentWorkflows = Object.fromEntries(
getWorkflows(variables.workspaceId).map((w) => [w.id, w])
)
const targetFolderId = variables.folderId ?? null
const optimistic: WorkflowMetadata = {
id: tempId,
name: variables.name,
lastModified: new Date(),
createdAt: new Date(),
description: variables.description,
color: variables.color,
workspaceId: variables.workspaceId,
folderId: targetFolderId,
sortOrder: getTopInsertionSortOrder(
currentWorkflows,
getFolderMap(variables.workspaceId),
variables.workspaceId,
targetFolderId
),
}
queryClient.setQueryData<WorkflowMetadata[]>(
workflowKeys.list(variables.workspaceId, 'active'),
(old) => [...(old ?? []), optimistic]
)
logger.info(`[DuplicateWorkflow] Added optimistic entry: ${tempId}`)
return { snapshot, tempId }
},
onSuccess: (data, variables, context) => {
if (!context) return
const { tempId } = context
queryClient.setQueryData<WorkflowMetadata[]>(
workflowKeys.list(variables.workspaceId, 'active'),
(old) =>
(old ?? []).map((w) =>
w.id === tempId
? {
id: data.id,
name: data.name,
lastModified: new Date(),
createdAt: new Date(),
description: data.description,
color: data.color,
workspaceId: data.workspaceId,
folderId: data.folderId,
sortOrder: data.sortOrder,
}
: w
)
)
if (tempId !== data.id) {
useFolderStore.setState((state) => {
const selectedWorkflows = new Set(state.selectedWorkflows)
if (selectedWorkflows.has(tempId)) {
selectedWorkflows.delete(tempId)
selectedWorkflows.add(data.id)
}
return { selectedWorkflows }
})
}
// Copy subblock values from source if it's the active workflow
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (variables.sourceId === activeWorkflowId) {
const sourceSubblockValues =
@@ -465,48 +429,143 @@ export function useDuplicateWorkflowMutation() {
},
}))
}
logger.info(`[DuplicateWorkflow] Success, replaced temp entry ${tempId}`)
},
onError: (_error, variables, context) => {
if (context?.snapshot) {
queryClient.setQueryData(
workflowKeys.list(variables.workspaceId, 'active'),
context.snapshot
)
logger.info('[DuplicateWorkflow] Rolled back to previous state')
}
},
onSettled: (_data, _error, variables) => {
return invalidateWorkflowLists(queryClient, variables.workspaceId)
},
})
}
interface DeploymentVersionStateResponse {
deployedState: WorkflowState
interface UpdateWorkflowVariables {
workspaceId: string
workflowId: string
metadata: Partial<WorkflowMetadata>
}
/**
* 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<WorkflowState> {
const response = await fetch(`/api/workflows/${workflowId}/deployments/${version}`, { signal })
export function useUpdateWorkflow() {
const queryClient = useQueryClient()
if (!response.ok) {
throw new Error(`Failed to fetch deployment version: ${response.statusText}`)
}
return useMutation({
mutationFn: async (variables: UpdateWorkflowVariables) => {
const response = await fetch(`/api/workflows/${variables.workflowId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(variables.metadata),
})
const data: DeploymentVersionStateResponse = await response.json()
if (!data.deployedState) {
throw new Error('No deployed state returned')
}
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to update workflow')
}
return data.deployedState
const { workflow: updatedWorkflow } = await response.json()
return mapWorkflow(updatedWorkflow)
},
onMutate: async (variables) => {
await queryClient.cancelQueries({
queryKey: workflowKeys.list(variables.workspaceId, 'active'),
})
const snapshot = queryClient.getQueryData<WorkflowMetadata[]>(
workflowKeys.list(variables.workspaceId, 'active')
)
queryClient.setQueryData<WorkflowMetadata[]>(
workflowKeys.list(variables.workspaceId, 'active'),
(old) =>
(old ?? []).map((w) =>
w.id === variables.workflowId
? { ...w, ...variables.metadata, lastModified: new Date() }
: w
)
)
return { snapshot }
},
onError: (_error, variables, context) => {
if (context?.snapshot) {
queryClient.setQueryData(
workflowKeys.list(variables.workspaceId, 'active'),
context.snapshot
)
}
},
onSettled: (_data, _error, variables) => {
return invalidateWorkflowLists(queryClient, variables.workspaceId)
},
})
}
interface DeleteWorkflowVariables {
workspaceId: string
workflowId: string
}
export function useDeleteWorkflowMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (variables: DeleteWorkflowVariables) => {
const response = await fetch(`/api/workflows/${variables.workflowId}`, {
method: 'DELETE',
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(error.error || 'Failed to delete workflow')
}
logger.info(`Successfully deleted workflow ${variables.workflowId} from database`)
},
onMutate: async (variables) => {
await queryClient.cancelQueries({
queryKey: workflowKeys.list(variables.workspaceId, 'active'),
})
const snapshot = queryClient.getQueryData<WorkflowMetadata[]>(
workflowKeys.list(variables.workspaceId, 'active')
)
queryClient.setQueryData<WorkflowMetadata[]>(
workflowKeys.list(variables.workspaceId, 'active'),
(old) => (old ?? []).filter((w) => w.id !== variables.workflowId)
)
return { snapshot }
},
onError: (_error, variables, context) => {
if (context?.snapshot) {
queryClient.setQueryData(
workflowKeys.list(variables.workspaceId, 'active'),
context.snapshot
)
}
},
onSettled: (_data, _error, variables) => {
return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived'])
},
})
}
/**
* Hook for fetching the workflow state of a specific deployment version.
* Used in the deploy modal to preview historical versions.
*/
export function useDeploymentVersionState(workflowId: string | null, version: number | null) {
return useQuery({
queryKey: workflowKeys.deploymentVersion(workflowId ?? undefined, version ?? undefined),
queryFn: ({ signal }) =>
fetchDeploymentVersionState(workflowId as string, version as number, signal),
enabled: Boolean(workflowId) && version !== null,
staleTime: 5 * 60 * 1000, // 5 minutes - deployment versions don't change
queryFn:
workflowId && version !== null
? ({ signal }) => fetchDeploymentVersionState(workflowId, version, signal)
: skipToken,
staleTime: 5 * 60 * 1000,
})
}
@@ -515,9 +574,6 @@ interface RevertToVersionVariables {
version: number
}
/**
* Mutation hook for reverting (loading) a deployment version into the current workflow.
*/
export function useRevertToVersion() {
const queryClient = useQueryClient()
@@ -531,7 +587,7 @@ export function useRevertToVersion() {
throw new Error('Failed to load deployment')
}
},
onSuccess: (_data, variables) => {
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: workflowKeys.state(variables.workflowId),
})
@@ -574,41 +630,45 @@ export function useReorderWorkflows() {
}
},
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: workflowKeys.lists() })
const snapshot = { ...useWorkflowRegistry.getState().workflows }
useWorkflowRegistry.setState((state) => {
const updated = { ...state.workflows }
for (const update of variables.updates) {
if (updated[update.id]) {
updated[update.id] = {
...updated[update.id],
sortOrder: update.sortOrder,
folderId:
update.folderId !== undefined ? update.folderId : updated[update.id].folderId,
}
}
}
return { workflows: updated }
await queryClient.cancelQueries({
queryKey: workflowKeys.list(variables.workspaceId, 'active'),
})
const snapshot = queryClient.getQueryData<WorkflowMetadata[]>(
workflowKeys.list(variables.workspaceId, 'active')
)
const updateMap = new Map(variables.updates.map((u) => [u.id, u]))
queryClient.setQueryData<WorkflowMetadata[]>(
workflowKeys.list(variables.workspaceId, 'active'),
(old) =>
(old ?? []).map((w) => {
const update = updateMap.get(w.id)
if (!update) return w
return {
...w,
sortOrder: update.sortOrder,
folderId: update.folderId !== undefined ? update.folderId : w.folderId,
}
})
)
return { snapshot }
},
onError: (_error, _variables, context) => {
onError: (_error, variables, context) => {
if (context?.snapshot) {
useWorkflowRegistry.setState({ workflows: context.snapshot })
queryClient.setQueryData(
workflowKeys.list(variables.workspaceId, 'active'),
context.snapshot
)
}
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: workflowKeys.lists() })
return invalidateWorkflowLists(queryClient, variables.workspaceId)
},
})
}
/**
* Import workflow mutation (superuser debug)
*/
interface ImportWorkflowParams {
workflowId: string
targetWorkspaceId: string
@@ -641,8 +701,8 @@ export function useImportWorkflow() {
return data
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: workflowKeys.lists() })
onSettled: (_data, _error, variables) => {
return invalidateWorkflowLists(queryClient, variables.targetWorkspaceId)
},
})
}
@@ -651,7 +711,7 @@ export function useRestoreWorkflow() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (workflowId: string) => {
mutationFn: async ({ workflowId }: { workflowId: string; workspaceId: string }) => {
const res = await fetch(`/api/workflows/${workflowId}/restore`, { method: 'POST' })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
@@ -659,8 +719,8 @@ export function useRestoreWorkflow() {
}
return res.json()
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: workflowKeys.lists() })
onSettled: (_data, _error, variables) => {
return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived'])
},
})
}
+7
View File
@@ -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,
}
+84
View File
@@ -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' })
})
})
+22 -18
View File
@@ -1,4 +1,8 @@
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query'
import { fetchJson, fetchOAuthToken } from '@/hooks/selectors/helpers'
import { selectorKeys } from '@/hooks/selectors/query-keys'
import type {
SelectorContext,
SelectorDefinition,
@@ -6,7 +10,6 @@ import type {
SelectorOption,
SelectorQueryArgs,
} from '@/hooks/selectors/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const SELECTOR_STALE = 60 * 1000
@@ -1685,27 +1688,28 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
},
'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<SelectorOption[]> => {
const { workflows } = useWorkflowRegistry.getState()
return Object.entries(workflows)
.filter(([id]) => id !== context.excludeWorkflowId)
.map(([id, workflow]) => ({
id,
label: workflow.name || `Workflow ${id.slice(0, 8)}`,
if (!context.workspaceId) return []
await getQueryClient().ensureQueryData(getWorkflowListQueryOptions(context.workspaceId))
const workflows = getWorkflows(context.workspaceId)
return workflows
.filter((w) => w.id !== context.excludeWorkflowId)
.map((w) => ({
id: w.id,
label: w.name || `Workflow ${w.id.slice(0, 8)}`,
}))
.sort((a, b) => a.label.localeCompare(b.label))
},
fetchById: async ({ detailId }: SelectorQueryArgs): Promise<SelectorOption | null> => {
if (!detailId) return null
const { workflows } = useWorkflowRegistry.getState()
const workflow = workflows[detailId]
fetchById: async ({ context, detailId }: SelectorQueryArgs): Promise<SelectorOption | null> => {
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,
+1
View File
@@ -84,6 +84,7 @@ export interface SelectorQueryArgs {
context: SelectorContext
search?: string
detailId?: string
signal?: AbortSignal
}
export interface SelectorDefinition {
@@ -21,7 +21,7 @@ export function useSelectorOptions(key: SelectorKey, args: SelectorHookArgs) {
const isEnabled = args.enabled ?? (definition.enabled ? definition.enabled(queryArgs) : true)
return useQuery<SelectorOption[]>({
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<SelectorOption | null>({
queryKey: [...definition.getQueryKey(queryArgs), 'detail', resolvedDetailId ?? 'none'],
queryFn: () => definition.fetchById!(queryArgs),
queryFn: ({ signal }) => definition.fetchById!({ ...queryArgs, signal }),
enabled,
staleTime: definition.staleTime ?? 300_000,
})
+2 -2
View File
@@ -30,8 +30,8 @@ interface AllowedIntegrationsResponse {
function useAllowedIntegrationsFromEnv() {
return useQuery<AllowedIntegrationsResponse>({
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()
},
@@ -40,7 +40,8 @@ import {
XCircle,
Zap,
} from 'lucide-react'
import { getCustomTool } from '@/hooks/queries/custom-tools'
import { getCustomTool } from '@/hooks/queries/utils/custom-tool-cache'
import { getWorkflowById } from '@/hooks/queries/utils/workflow-cache'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -137,6 +138,15 @@ function formatDuration(seconds: number): string {
return `${hours}h`
}
function getScopedWorkspaceId(params: Record<string, any>): string | undefined {
const paramWorkspaceId = params?.workspaceId
if (typeof paramWorkspaceId === 'string' && paramWorkspaceId.length > 0) {
return paramWorkspaceId
}
return useWorkflowRegistry.getState().hydration.workspaceId ?? undefined
}
function toUiConfig(metadata?: ToolMetadata): ToolUIConfig | undefined {
const legacy = metadata?.uiConfig
const subagent = legacy?.subagent
@@ -1036,13 +1046,14 @@ const META_manage_custom_tool: ToolMetadata = {
},
getDynamicText: (params, state) => {
const operation = params?.operation as 'add' | 'edit' | 'delete' | 'list' | undefined
const workspaceId = getScopedWorkspaceId(params)
if (!operation) return undefined
let toolName = params?.schema?.function?.name
if (!toolName && params?.toolId) {
if (!toolName && params?.toolId && workspaceId) {
try {
const tool = getCustomTool(params.toolId)
const tool = getCustomTool(params.toolId, workspaceId)
toolName = tool?.schema?.function?.name
} catch {
// Ignore errors accessing cache
@@ -1629,8 +1640,9 @@ const META_run_workflow: ToolMetadata = {
},
getDynamicText: (params, state) => {
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
if (workflowId) {
const workflowName = useWorkflowRegistry.getState().workflows[workflowId]?.name
const workspaceId = getScopedWorkspaceId(params)
if (workflowId && workspaceId) {
const workflowName = getWorkflowById(workspaceId, workflowId)?.name
if (workflowName) {
switch (state) {
case ClientToolCallState.success:
@@ -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<T> {
/**
* 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<void>
/**
* 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<T>(options: OptimisticUpdateOptions<T>): Promise<void> {
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()
}
}
}
+59
View File
@@ -0,0 +1,59 @@
import type { FolderTreeNode, WorkflowFolder } from '@/stores/folders/types'
export function buildFolderMap(folders: WorkflowFolder[]): Record<string, WorkflowFolder> {
return Object.fromEntries(folders.map((folder) => [folder.id, folder]))
}
export function buildFolderTree(
folders: Record<string, WorkflowFolder>,
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<string, WorkflowFolder>,
folderId: string
): WorkflowFolder | undefined {
return folders[folderId]
}
export function getChildFolders(
folders: Record<string, WorkflowFolder>,
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<string, WorkflowFolder>,
folderId: string
): WorkflowFolder[] {
const path: WorkflowFolder[] = []
let currentId: string | null = folderId
while (currentId && folders[currentId]) {
const folder: WorkflowFolder = folders[currentId]
path.unshift(folder)
currentId = folder.parentId
}
return path
}
@@ -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<string | null> {
async function resolveWorkflow(workflowId: string, workspaceId?: string): Promise<string | null> {
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 }
}
@@ -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' },
+2 -1
View File
@@ -34,10 +34,11 @@ export const SELECTOR_CONTEXT_FIELDS = new Set<keyof SelectorContext>([
export function buildSelectorContextFromBlock(
blockType: string,
subBlocks: Record<string, SubBlockState | { value?: unknown }>,
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
+40 -1
View File
@@ -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<string, ProviderId> {
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<string, ProviderId>
)
}
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 || ''
}
-65
View File
@@ -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<string, WorkflowFolder>
expandedFolders: Set<string>
selectedWorkflows: Set<string>
selectedFolders: Set<string>
@@ -14,7 +12,6 @@ interface FolderState {
selectedTasks: Set<string>
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<FolderState>()(
devtools(
(set, get) => ({
folders: {},
expandedFolders: new Set(),
selectedWorkflows: new Set(),
selectedFolders: new Set(),
@@ -67,17 +57,6 @@ export const useFolderStore = create<FolderState>()(
selectedTasks: new Set(),
lastSelectedTaskId: null,
setFolders: (folders) =>
set(() => ({
folders: folders.reduce(
(acc, folder) => {
acc[folder.id] = folder
return acc
},
{} as Record<string, WorkflowFolder>
),
})),
toggleExpanded: (folderId) =>
set((state) => {
const newExpanded = new Set(state.expandedFolders)
@@ -312,50 +291,6 @@ export const useFolderStore = create<FolderState>()(
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' }
)
-1
View File
@@ -201,7 +201,6 @@ export {
export const resetAllStores = () => {
// Reset all stores to initial state
useWorkflowRegistry.setState({
workflows: {},
activeWorkflowId: null,
error: null,
deploymentStatuses: {},
+5 -6
View File
@@ -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<VariablesStore>()(
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<VariablesStore>()(
useSubBlockStore.setState({
workflowValues: {
...subBlockStore.workflowValues,
[activeWorkflowId]: updatedWorkflowValues,
[targetWorkflowId]: updatedWorkflowValues,
},
})
@@ -242,7 +241,7 @@ export const useVariablesStore = create<VariablesStore>()(
target: 'subblock',
payload: { blockId, subblockId: subBlockId, value },
},
workflowId: activeWorkflowId,
workflowId: targetWorkflowId,
userId: 'system',
})
}
+27 -11
View File
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -10,14 +11,15 @@ const logger = createLogger('Workflows')
* Get a workflow with its state merged in by ID
* Note: Since localStorage has been removed, this only works for the active workflow
* @param workflowId ID of the workflow to retrieve
* @param workspaceId Workspace containing the workflow metadata
* @returns The workflow with merged state values or null if not found/not active
*/
export function getWorkflowWithValues(workflowId: string) {
const { workflows } = useWorkflowRegistry.getState()
export function getWorkflowWithValues(workflowId: string, workspaceId: string) {
const workflows = getWorkflows(workspaceId)
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
const currentState = useWorkflowStore.getState()
if (!workflows[workflowId]) {
const metadata = workflows.find((w) => w.id === workflowId)
if (!metadata) {
logger.warn(`Workflow ${workflowId} not found`)
return null
}
@@ -28,8 +30,6 @@ export function getWorkflowWithValues(workflowId: string) {
return null
}
const metadata = workflows[workflowId]
// Get deployment status from registry
const deploymentStatus = useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)
@@ -77,17 +77,33 @@ export function getBlockWithValues(blockId: string): BlockState | null {
/**
* Get all workflows with their values merged
* Note: Since localStorage has been removed, this only includes the active workflow state
* @param workspaceId Workspace containing the workflow metadata
* @returns An object containing workflows, with state only for the active workflow
*/
export function getAllWorkflowsWithValues() {
const { workflows } = useWorkflowRegistry.getState()
const result: Record<string, any> = {}
export function getAllWorkflowsWithValues(workspaceId: string) {
const workflows = getWorkflows(workspaceId)
const result: Record<
string,
{
id: string
name: string
description?: string
color: string
folderId?: string | null
workspaceId?: string
apiKey?: string
state: WorkflowState & { isDeployed: boolean; deployedAt?: Date }
}
> = {}
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
const currentState = useWorkflowStore.getState()
// Only sync the active workflow to ensure we always send valid state data
if (activeWorkflowId && workflows[activeWorkflowId]) {
const metadata = workflows[activeWorkflowId]
const activeMetadata = activeWorkflowId
? workflows.find((w) => w.id === activeWorkflowId)
: undefined
if (activeWorkflowId && activeMetadata) {
const metadata = activeMetadata
// Get deployment status from registry
const deploymentStatus = useWorkflowRegistry
+54 -468
View File
@@ -1,21 +1,20 @@
import { createLogger } from '@sim/logger'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { withOptimisticUpdate } from '@/lib/core/utils/optimistic-update'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
import { useVariablesStore } from '@/stores/panel/variables/store'
import type { Variable } from '@/stores/panel/variables/types'
import type {
DeploymentStatus,
HydrationState,
WorkflowMetadata,
WorkflowRegistry,
} from '@/stores/workflows/registry/types'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getUniqueBlockName, regenerateBlockIds } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowRegistry')
const initialHydration: HydrationState = {
@@ -28,14 +27,9 @@ const initialHydration: HydrationState = {
const createRequestId = () => `${Date.now()}-${Math.random().toString(16).slice(2)}`
// Track workspace transitions to prevent race conditions
let isWorkspaceTransitioning = false
const TRANSITION_TIMEOUT = 5000 // 5 seconds maximum for workspace transitions
// Resets workflow and subblock stores to prevent data leakage between workspaces
function resetWorkflowStores() {
// Reset the workflow store to prevent data leakage between workspaces
useWorkflowStore.setState({
currentWorkflowId: null,
blocks: {},
edges: [],
loops: {},
@@ -44,33 +38,14 @@ function resetWorkflowStores() {
lastSaved: Date.now(),
})
// Reset the subblock store
useSubBlockStore.setState({
workflowValues: {},
})
}
/**
* Handles workspace transition state tracking
* @param isTransitioning Whether workspace is currently transitioning
*/
function setWorkspaceTransitioning(isTransitioning: boolean): void {
isWorkspaceTransitioning = isTransitioning
if (isTransitioning) {
setTimeout(() => {
if (isWorkspaceTransitioning) {
logger.warn('Forcing workspace transition to complete due to timeout')
isWorkspaceTransitioning = false
}
}, TRANSITION_TIMEOUT)
}
}
export const useWorkflowRegistry = create<WorkflowRegistry>()(
devtools(
(set, get) => ({
workflows: {},
activeWorkflowId: null,
error: null,
deploymentStatuses: {},
@@ -78,108 +53,26 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
clipboard: null,
pendingSelection: null,
beginMetadataLoad: (workspaceId: string) => {
set((state) => ({
switchToWorkspace: (workspaceId: string) => {
logger.info(`Switching to workspace: ${workspaceId}`)
resetWorkflowStores()
void invalidateWorkflowLists(getQueryClient(), workspaceId)
set({
activeWorkflowId: null,
deploymentStatuses: {},
error: null,
hydration: {
phase: 'metadata-loading',
phase: 'idle',
workspaceId,
workflowId: null,
requestId: null,
error: null,
},
}))
},
completeMetadataLoad: (workspaceId: string, workflows: WorkflowMetadata[]) => {
const mapped = workflows.reduce<Record<string, WorkflowMetadata>>((acc, workflow) => {
acc[workflow.id] = workflow
return acc
}, {})
set((state) => {
const shouldPreserveHydration =
state.hydration.phase === 'state-loading' ||
(state.hydration.phase === 'ready' &&
state.hydration.workflowId &&
mapped[state.hydration.workflowId])
return {
workflows: mapped,
error: null,
hydration: shouldPreserveHydration
? state.hydration
: {
phase: 'metadata-ready',
workspaceId,
workflowId: null,
requestId: null,
error: null,
},
}
})
},
failMetadataLoad: (workspaceId: string | null, errorMessage: string) => {
set((state) => ({
error: errorMessage,
hydration: {
phase: 'error',
workspaceId: workspaceId ?? state.hydration.workspaceId,
workflowId: state.hydration.workflowId,
requestId: null,
error: errorMessage,
},
}))
},
switchToWorkspace: async (workspaceId: string) => {
if (isWorkspaceTransitioning) {
logger.warn(
`Ignoring workspace switch to ${workspaceId} - transition already in progress`
)
return
}
setWorkspaceTransitioning(true)
try {
logger.info(`Switching to workspace: ${workspaceId}`)
resetWorkflowStores()
set({
activeWorkflowId: null,
workflows: {},
deploymentStatuses: {},
error: null,
hydration: {
phase: 'metadata-loading',
workspaceId,
workflowId: null,
requestId: null,
error: null,
},
})
logger.info(`Successfully switched to workspace: ${workspaceId}`)
} catch (error) {
logger.error(`Error switching to workspace ${workspaceId}:`, { error })
set({
error: `Failed to switch workspace: ${error instanceof Error ? error.message : 'Unknown error'}`,
hydration: {
phase: 'error',
workspaceId,
workflowId: null,
requestId: null,
error: error instanceof Error ? error.message : 'Unknown error',
},
})
} finally {
setWorkspaceTransitioning(false)
}
},
getWorkflowDeploymentStatus: (workflowId: string | null): DeploymentStatus | null => {
if (!workflowId) {
workflowId = get().activeWorkflowId
@@ -215,8 +108,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
apiKey,
needsRedeployment: isDeployed
? false
: ((state.deploymentStatuses?.[workflowId as string] as any)?.needsRedeployment ??
false),
: (state.deploymentStatuses?.[workflowId as string]?.needsRedeployment ?? false),
},
},
}))
@@ -250,10 +142,9 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
},
loadWorkflowState: async (workflowId: string) => {
const { workflows } = get()
if (!workflows[workflowId]) {
const message = `Workflow not found: ${workflowId}`
const workspaceId = get().hydration.workspaceId
if (!workspaceId) {
const message = `Cannot load workflow ${workflowId} without a workspace scope`
logger.error(message)
set({ error: message })
throw new Error(message)
@@ -265,7 +156,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
error: null,
hydration: {
phase: 'state-loading',
workspaceId: state.hydration.workspaceId,
workspaceId: workspaceId ?? state.hydration.workspaceId,
workflowId,
requestId,
error: null,
@@ -279,32 +170,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
}
const workflowData = (await response.json()).data
let workflowState: any
if (workflowData?.state) {
workflowState = {
blocks: workflowData.state.blocks || {},
edges: workflowData.state.edges || [],
loops: workflowData.state.loops || {},
parallels: workflowData.state.parallels || {},
lastSaved: Date.now(),
deploymentStatuses: {},
}
} else {
workflowState = {
blocks: {},
edges: [],
loops: {},
parallels: {},
deploymentStatuses: {},
lastSaved: Date.now(),
}
logger.info(
`Workflow ${workflowId} has no state yet - will load from DB or show empty canvas`
)
}
const nextDeploymentStatuses =
workflowData?.isDeployed || workflowData?.deployedAt
? {
@@ -320,6 +185,34 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
}
: get().deploymentStatuses
let workflowState: WorkflowState
if (workflowData?.state) {
workflowState = {
currentWorkflowId: workflowId,
blocks: workflowData.state.blocks || {},
edges: workflowData.state.edges || [],
loops: workflowData.state.loops || {},
parallels: workflowData.state.parallels || {},
lastSaved: Date.now(),
deploymentStatuses: nextDeploymentStatuses,
}
} else {
workflowState = {
currentWorkflowId: workflowId,
blocks: {},
edges: [],
loops: {},
parallels: {},
deploymentStatuses: nextDeploymentStatuses,
lastSaved: Date.now(),
}
logger.info(
`Workflow ${workflowId} has no state yet - will load from DB or show empty canvas`
)
}
const currentHydration = get().hydration
if (
currentHydration.requestId !== requestId ||
@@ -338,7 +231,9 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
if (workflowData?.variables && typeof workflowData.variables === 'object') {
useVariablesStore.setState((state) => {
const withoutWorkflow = Object.fromEntries(
Object.entries(state.variables).filter(([, v]: any) => v.workflowId !== workflowId)
Object.entries(state.variables).filter(
(entry): entry is [string, Variable] => entry[1].workflowId !== workflowId
)
)
return {
variables: { ...withoutWorkflow, ...workflowData.variables },
@@ -392,10 +287,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
const workflowStoreState = useWorkflowStore.getState()
const hasWorkflowData = Object.keys(workflowStoreState.blocks).length > 0
// Skip loading only if:
// - Same workflow is already active
// - Workflow data exists
// - Hydration is complete (phase is 'ready')
const isFullyHydrated =
activeWorkflowId === id &&
hasWorkflowData &&
@@ -410,320 +301,16 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
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<string, Record<string, any>> = {}
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<WorkflowMetadata>) => {
const { workflows } = get()
const workflow = workflows[id]
if (!workflow) {
logger.warn(`Cannot update workflow ${id}: not found in registry`)
return
}
await withOptimisticUpdate({
getCurrentState: () => workflow,
optimisticUpdate: () => {
set((state) => ({
workflows: {
...state.workflows,
[id]: {
...workflow,
...metadata,
lastModified: new Date(),
createdAt: workflow.createdAt, // Preserve creation date
},
},
error: null,
}))
},
apiCall: async () => {
const response = await fetch(`/api/workflows/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(metadata),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to update workflow')
}
const { workflow: updatedWorkflow } = await response.json()
logger.info(`Successfully updated workflow ${id} metadata`, metadata)
set((state) => ({
workflows: {
...state.workflows,
[id]: {
...state.workflows[id],
name: updatedWorkflow.name,
description: updatedWorkflow.description,
color: updatedWorkflow.color,
folderId: updatedWorkflow.folderId,
lastModified: new Date(updatedWorkflow.updatedAt),
createdAt: updatedWorkflow.createdAt
? new Date(updatedWorkflow.createdAt)
: state.workflows[id].createdAt,
},
},
}))
},
rollback: (originalWorkflow) => {
set((state) => ({
workflows: {
...state.workflows,
[id]: originalWorkflow, // Revert to original state
},
error: `Failed to update workflow: ${metadata.name ? 'name' : 'metadata'}`,
}))
},
errorMessage: `Failed to update workflow ${id} metadata`,
})
},
logout: () => {
logger.info('Logging out - clearing all workflow data')
resetWorkflowStores()
// Clear the React Query cache to remove all server state
getQueryClient().clear()
set({
activeWorkflowId: null,
workflows: {},
deploymentStatuses: {},
error: null,
hydration: initialHydration,
@@ -744,7 +331,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
const copiedSubBlockValues: Record<string, Record<string, unknown>> = {}
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))
+2 -15
View File
@@ -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<string, WorkflowMetadata>
activeWorkflowId: string | null
error: string | null
deploymentStatuses: Record<string, DeploymentStatus>
@@ -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<void>
loadWorkflowState: (workflowId: string) => Promise<void>
switchToWorkspace: (id: string) => Promise<void>
removeWorkflow: (id: string) => Promise<void>
updateWorkflow: (id: string, metadata: Partial<WorkflowMetadata>) => Promise<void>
duplicateWorkflow: (sourceId: string) => Promise<string | null>
switchToWorkspace: (id: string) => void
getWorkflowDeploymentStatus: (workflowId: string | null) => DeploymentStatus | null
setDeploymentStatus: (
workflowId: string | null,
+17 -9
View File
@@ -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<WorkflowStore>()(
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<WorkflowStore>()(
})
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<WorkflowStore>()(
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<WorkflowStore>()(
clear: () => {
const newState = {
currentWorkflowId: get().currentWorkflowId,
blocks: {},
edges: [],
loops: {},
@@ -502,6 +507,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
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<WorkflowStore>()(
return {
...state,
currentWorkflowId:
nextState.currentWorkflowId !== undefined
? nextState.currentWorkflowId
: state.currentWorkflowId,
blocks: nextBlocks,
edges: nextEdges,
loops: nextLoops,
@@ -613,7 +623,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
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<WorkflowStore>()(
// 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<WorkflowStore>()(
},
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,
@@ -160,6 +160,7 @@ export interface DragStartPosition {
}
export interface WorkflowState {
currentWorkflowId?: string | null
blocks: Record<string, BlockState>
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
+72 -74
View File
@@ -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<string, string | undefined>,
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<string, string | undefined>,
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<typeof import('@/tools/utils.server')>()
mockGetToolAsync.mockImplementation(actual.getToolAsync)
return {
...actual,
getToolAsync: mockGetToolAsync,
}
})
import { executeTool, postProcessToolOutput } from '@/tools'
import { tools } from '@/tools/registry'
import { getTool } from '@/tools/utils'
import { getToolAsync } from '@/tools/utils.server'
/**
* Sets up global fetch mock with Next.js preconnect support.
@@ -304,18 +302,45 @@ describe('Tools Registry', () => {
})
describe('Custom Tools', () => {
it('should get custom tool by ID', () => {
const customTool = getTool('custom_custom-tool-123')
expect(customTool).toBeDefined()
expect(customTool?.name).toBe('Custom Weather Tool')
expect(customTool?.description).toBe('Get weather information')
expect(customTool?.params.location).toBeDefined()
expect(customTool?.params.location.required).toBe(true)
it('does not resolve custom tools through the synchronous client helper', () => {
expect(getTool('custom_remote-tool-123', 'workspace-1')).toBeUndefined()
})
it('should handle non-existent custom tool', () => {
const nonExistentTool = getTool('custom_non-existent')
expect(nonExistentTool).toBeUndefined()
it('resolves custom tools through the async helper', async () => {
setupFetchMock({
json: {
data: [
{
id: 'remote-tool-123',
title: 'Custom Weather Tool',
schema: {
function: {
name: 'weather_tool',
description: 'Get weather information',
parameters: {
type: 'object',
properties: {
location: { type: 'string', description: 'City name' },
},
required: ['location'],
},
},
},
},
],
},
status: 200,
headers: { 'content-type': 'application/json' },
})
const customTool = await getToolAsync('custom_remote-tool-123', {
workflowId: 'workflow-1',
userId: 'user-1',
workspaceId: 'workspace-1',
})
expect(customTool?.name).toBe('Custom Weather Tool')
expect(customTool?.params.location.required).toBe(true)
})
})
@@ -1962,44 +1987,17 @@ describe('stripInternalFields Safety', () => {
})
it('should preserve __-prefixed fields in custom tool output', async () => {
const mockTool = {
id: 'custom_test-preserve-dunder',
name: 'Custom Preserve Dunder',
description: 'A custom tool whose output has __ fields',
version: '1.0.0',
params: {},
request: {
url: '/api/function/execute',
method: 'POST' as const,
headers: () => ({ 'Content-Type': 'application/json' }),
},
transformResponse: vi.fn().mockResolvedValue({
success: true,
output: { result: 'ok', __metadata: { source: 'user' }, __tag: 'important' },
}),
}
const output = postProcessToolOutput('custom_test-preserve-dunder', {
result: 'ok',
__metadata: { source: 'user' },
__tag: 'important',
})
const originalTools = { ...tools }
;(tools as any)['custom_test-preserve-dunder'] = mockTool
global.fetch = Object.assign(
vi.fn().mockImplementation(async () => ({
ok: true,
status: 200,
headers: new Headers(),
json: () => Promise.resolve({ success: true }),
})),
{ preconnect: vi.fn() }
) as typeof fetch
const result = await executeTool('custom_test-preserve-dunder', {}, true)
expect(result.success).toBe(true)
expect(result.output.result).toBe('ok')
expect(result.output.__metadata).toEqual({ source: 'user' })
expect(result.output.__tag).toBe('important')
Object.assign(tools, originalTools)
expect(output).toEqual({
result: 'ok',
__metadata: { source: 'user' },
__tag: 'important',
})
})
})
+60 -44
View File
@@ -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<string, unknown>,
executionContext?: ExecutionContext
): ToolExecutionScope {
const ctx = params._context as Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<void> {
if (tool.hosting?.rateLimit.mode !== 'custom') return
const ctx = params._context as Record<string, unknown> | 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<string, unknown>): Record<string, un
return result
}
export function postProcessToolOutput(toolId: string, output: Record<string, unknown>) {
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_<uuid> -> 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(), {

Some files were not shown because too many files have changed in this diff Show More