mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-01 14:59:19 +08:00
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:
@@ -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
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+6
-4
@@ -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 (
|
||||
|
||||
+12
-6
@@ -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() })
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+9
-7
@@ -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 />
|
||||
|
||||
+4
-2
@@ -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)]'>
|
||||
|
||||
+1
-3
@@ -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) => ({
|
||||
|
||||
+8
-6
@@ -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
|
||||
}
|
||||
+5
-2
@@ -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 })
|
||||
|
||||
-1
@@ -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',
|
||||
|
||||
+4
-6
@@ -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
|
||||
|
||||
+12
-7
@@ -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 (
|
||||
|
||||
+6
-3
@@ -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)
|
||||
|
||||
+1
-4
@@ -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) =>
|
||||
|
||||
+4
-2
@@ -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`
|
||||
|
||||
+1
-1
@@ -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]
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
+6
-6
@@ -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(() => {
|
||||
|
||||
+10
-7
@@ -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,
|
||||
|
||||
+29
-5
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
+16
@@ -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,
|
||||
])
|
||||
|
||||
|
||||
+6
-5
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+15
-8
@@ -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,
|
||||
|
||||
+8
-9
@@ -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',
|
||||
|
||||
+4
-9
@@ -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,
|
||||
|
||||
+1
-1
@@ -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)`)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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,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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || ''
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
|
||||
@@ -201,7 +201,6 @@ export {
|
||||
export const resetAllStores = () => {
|
||||
// Reset all stores to initial state
|
||||
useWorkflowRegistry.setState({
|
||||
workflows: {},
|
||||
activeWorkflowId: null,
|
||||
error: null,
|
||||
deploymentStatuses: {},
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user