Compare commits

...

11 Commits

Author SHA1 Message Date
Waleed
0b9019d9a2 v0.6.23: MCP fixes, remove local state in favor of server state, mothership workflow edits via sockets, ui improvements 2026-04-03 23:30:26 -07:00
Waleed
6d00d6bf2c fix(modals): center modals in visible content area and remove open/close animation (#3937)
* fix(modals): center modals in visible content area accounting for sidebar and panel

* fix(modals): address pr feedback — comment clarity and document panel assumption

* fix(modals): remove open/close animation from modal content
2026-04-03 20:06:10 -07:00
Waleed
3267d8cc24 fix(modals): center modals in visible content area accounting for sidebar and panel (#3934)
* fix(modals): center modals in visible content area accounting for sidebar and panel

* fix(modals): address pr feedback — comment clarity and document panel assumption
2026-04-03 19:19:36 -07:00
Theodore Li
2e69f85364 Fix "fix in copilot" button (#3931)
* Fix "fix in copilot" button

* Auto send message to copilot for fix in copilot

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-03 22:11:45 -04:00
Waleed
57e5bac121 fix(mcp): resolve userId before JWT generation for agent block auth (#3932)
* fix(mcp): resolve userId before JWT generation for agent block auth

* test(mcp): add regression test for agent block JWT userId resolution
2026-04-03 19:05:10 -07:00
Theodore Li
8ce0299400 fix(ui) Fix oauth redirect on connector modal (#3926)
* Fix oauth redirect on connector modal

* Fix lint

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-03 21:58:42 -04:00
Vikhyath Mondreti
a0796f088b improvement(mothership): workflow edits via sockets (#3927)
* improvement(mothership): workflow edits via sockets

* make embedded view join room

* fix cursor positioning bug
2026-04-03 18:44:14 -07:00
Waleed
98fe4cd40b refactor(stores): consolidate variables stores into stores/variables/ (#3930)
* refactor(stores): consolidate variables stores into stores/variables/

Move variable data store from stores/panel/variables/ to stores/variables/
since the panel variables tab no longer exists. Rename the modal UI store
to useVariablesModalStore to eliminate naming collision with the data store.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove unused workflowId variable in deleteVariable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 18:43:47 -07:00
Waleed
34d210c66c chore(stores): remove Zustand environment store and dead init scaffolding (#3929) 2026-04-03 17:54:49 -07:00
Waleed
2334f2dca4 fix(loading): remove jarring workflow loading spinners (#3928)
* fix(loading): remove jarring workflow loading spinners

* fix(loading): remove home page skeleton loading state

* fix(loading): remove plain spinner loading states from task and file view
2026-04-03 17:45:30 -07:00
Waleed
65fc138bfc improvement(stores): remove deployment state from Zustand in favor of React Query (#3923) 2026-04-03 17:44:10 -07:00
72 changed files with 805 additions and 1719 deletions

View File

@@ -10,7 +10,7 @@
* @see stores/constants.ts for the source of truth
*/
:root {
--sidebar-width: 248px; /* SIDEBAR_WIDTH.DEFAULT */
--sidebar-width: 0px; /* 0 outside workspace; blocking script always sets actual value on workspace pages */
--panel-width: 320px; /* PANEL_WIDTH.DEFAULT */
--toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */
--editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */

View File

@@ -304,7 +304,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
loops: {},
parallels: {},
isDeployed: true,
deploymentStatuses: { production: 'deployed' },
},
}
@@ -349,7 +348,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
loops: {},
parallels: {},
isDeployed: true,
deploymentStatuses: { production: 'deployed' },
lastSaved: 1640995200000,
},
},
@@ -370,7 +368,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
loops: {},
parallels: {},
isDeployed: true,
deploymentStatuses: { production: 'deployed' },
lastSaved: 1640995200000,
}),
}
@@ -473,7 +470,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
edges: undefined,
loops: null,
parallels: undefined,
deploymentStatuses: null,
},
}
@@ -508,7 +504,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
loops: {},
parallels: {},
isDeployed: false,
deploymentStatuses: {},
lastSaved: 1640995200000,
})
})
@@ -768,10 +763,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
parallel1: { branches: ['branch1', 'branch2'] },
},
isDeployed: true,
deploymentStatuses: {
production: 'deployed',
staging: 'pending',
},
deployedAt: '2024-01-01T10:00:00.000Z',
},
}
@@ -816,10 +807,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
parallel1: { branches: ['branch1', 'branch2'] },
},
isDeployed: true,
deploymentStatuses: {
production: 'deployed',
staging: 'pending',
},
deployedAt: '2024-01-01T10:00:00.000Z',
lastSaved: 1640995200000,
})

View File

@@ -82,7 +82,6 @@ export async function POST(request: NextRequest) {
loops: checkpointState?.loops || {},
parallels: checkpointState?.parallels || {},
isDeployed: checkpointState?.isDeployed || false,
deploymentStatuses: checkpointState?.deploymentStatuses || {},
lastSaved: Date.now(),
...(checkpointState?.deployedAt &&
checkpointState.deployedAt !== null &&

View File

@@ -79,7 +79,6 @@ export async function POST(
loops: deployedState.loops || {},
parallels: deployedState.parallels || {},
lastSaved: Date.now(),
deploymentStatuses: deployedState.deploymentStatuses || {},
})
if (!saveResult.success) {

View File

@@ -89,7 +89,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const finalWorkflowData = {
...workflowData,
state: {
deploymentStatuses: {},
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
@@ -115,7 +114,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const emptyWorkflowData = {
...workflowData,
state: {
deploymentStatuses: {},
blocks: {},
edges: [],
loops: {},

View File

@@ -8,7 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import type { Variable } from '@/stores/panel/variables/types'
import type { Variable } from '@/stores/variables/types'
const logger = createLogger('WorkflowVariablesAPI')

View File

@@ -90,6 +90,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
}
// Sidebar width
var defaultSidebarWidth = '248px';
try {
var stored = localStorage.getItem('sidebar-state');
if (stored) {
@@ -108,11 +109,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
document.documentElement.style.setProperty('--sidebar-width', width + 'px');
} else if (width > maxSidebarWidth) {
document.documentElement.style.setProperty('--sidebar-width', maxSidebarWidth + 'px');
} else {
document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth);
}
}
} else {
document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth);
}
} catch (e) {
// Fallback handled by CSS defaults
document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth);
}
// Panel width and active tab

View File

@@ -108,8 +108,6 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
lastUpdate: input.lastUpdate,
metadata: input.metadata,
variables: input.variables,
deploymentStatuses: input.deploymentStatuses,
needsRedeployment: input.needsRedeployment,
dragStartPosition: input.dragStartPosition ?? null,
}

View File

@@ -16,7 +16,7 @@ import {
} from '@/components/emcn'
import { client, useSession } from '@/lib/auth/auth-client'
import type { OAuthReturnContext } from '@/lib/credentials/client-state'
import { writeOAuthReturnContext } from '@/lib/credentials/client-state'
import { ADD_CONNECTOR_SEARCH_PARAM, writeOAuthReturnContext } from '@/lib/credentials/client-state'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
@@ -59,8 +59,8 @@ type OAuthModalConnectProps = OAuthModalBaseProps & {
workspaceId: string
credentialCount: number
} & (
| { workflowId: string; knowledgeBaseId?: never }
| { workflowId?: never; knowledgeBaseId: string }
| { workflowId: string; knowledgeBaseId?: never; connectorType?: never }
| { workflowId?: never; knowledgeBaseId: string; connectorType?: string }
)
interface OAuthModalReauthorizeProps extends OAuthModalBaseProps {
@@ -81,6 +81,7 @@ export function OAuthModal(props: OAuthModalProps) {
const workspaceId = isConnect ? props.workspaceId : ''
const workflowId = isConnect ? props.workflowId : undefined
const knowledgeBaseId = isConnect ? props.knowledgeBaseId : undefined
const connectorType = isConnect ? props.connectorType : undefined
const toolName = !isConnect ? props.toolName : ''
const requiredScopes = !isConnect ? (props.requiredScopes ?? EMPTY_SCOPES) : EMPTY_SCOPES
const newScopes = !isConnect ? (props.newScopes ?? EMPTY_SCOPES) : EMPTY_SCOPES
@@ -172,7 +173,7 @@ export function OAuthModal(props: OAuthModalProps) {
}
const returnContext: OAuthReturnContext = knowledgeBaseId
? { ...baseContext, origin: 'kb-connectors' as const, knowledgeBaseId }
? { ...baseContext, origin: 'kb-connectors' as const, knowledgeBaseId, connectorType }
: { ...baseContext, origin: 'workflow' as const, workflowId: workflowId! }
writeOAuthReturnContext(returnContext)
@@ -205,7 +206,11 @@ export function OAuthModal(props: OAuthModalProps) {
return
}
await client.oauth2.link({ providerId, callbackURL: window.location.href })
const callbackURL = new URL(window.location.href)
if (connectorType) {
callbackURL.searchParams.set(ADD_CONNECTOR_SEARCH_PARAM, connectorType)
}
await client.oauth2.link({ providerId, callbackURL: callbackURL.toString() })
handleClose()
} catch (err) {
logger.error('Failed to initiate OAuth connection', { error: err })

View File

@@ -1,9 +0,0 @@
import { Loader2 } from 'lucide-react'
export default function FileViewLoading() {
return (
<div className='fixed inset-0 z-50 flex items-center justify-center bg-[var(--bg)]'>
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
</div>
)
}

View File

@@ -1407,17 +1407,6 @@ export function useChat(
const output = tc.result?.output as Record<string, unknown> | undefined
const deployedWorkflowId = (output?.workflowId as string) ?? undefined
if (deployedWorkflowId && typeof output?.isDeployed === 'boolean') {
const isDeployed = output.isDeployed as boolean
const serverDeployedAt = output.deployedAt
? new Date(output.deployedAt as string)
: undefined
useWorkflowRegistry
.getState()
.setDeploymentStatus(
deployedWorkflowId,
isDeployed,
isDeployed ? (serverDeployedAt ?? new Date()) : undefined
)
queryClient.invalidateQueries({
queryKey: deploymentKeys.info(deployedWorkflowId),
})

View File

@@ -1,22 +0,0 @@
import { Skeleton } from '@/components/emcn'
const SKELETON_LINE_COUNT = 4
export default function HomeLoading() {
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='min-h-0 flex-1 overflow-hidden px-6 py-4'>
<div className='mx-auto max-w-[42rem] space-y-[10px] pt-3'>
{Array.from({ length: SKELETON_LINE_COUNT }).map((_, i) => (
<Skeleton key={i} className='h-[16px]' style={{ width: `${120 + (i % 4) * 48}px` }} />
))}
</div>
</div>
<div className='flex-shrink-0 px-[24px] pb-[16px]'>
<div className='mx-auto max-w-[42rem]'>
<Skeleton className='h-[48px] w-full rounded-[12px]' />
</div>
</div>
</div>
)
}

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { format } from 'date-fns'
import { AlertCircle, Loader2, Pencil, Plus, Tag, X } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { useParams, usePathname, useRouter, useSearchParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import {
Badge,
@@ -25,6 +25,7 @@ import {
import { Database, DatabaseX } from '@/components/emcn/icons'
import { SearchHighlight } from '@/components/ui/search-highlight'
import { cn } from '@/lib/core/utils/cn'
import { ADD_CONNECTOR_SEARCH_PARAM } from '@/lib/credentials/client-state'
import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowledge/constants'
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types'
@@ -192,6 +193,10 @@ export function KnowledgeBase({
}: KnowledgeBaseProps) {
const params = useParams()
const workspaceId = propWorkspaceId || (params.workspaceId as string)
const router = useRouter()
const searchParams = useSearchParams()
const pathname = usePathname()
const addConnectorParam = searchParams.get(ADD_CONNECTOR_SEARCH_PARAM)
const posthog = usePostHog()
useEffect(() => {
@@ -278,7 +283,29 @@ export function KnowledgeBase({
const [contextMenuDocument, setContextMenuDocument] = useState<DocumentData | null>(null)
const [showRenameModal, setShowRenameModal] = useState(false)
const [documentToRename, setDocumentToRename] = useState<DocumentData | null>(null)
const [showAddConnectorModal, setShowAddConnectorModal] = useState(false)
const showAddConnectorModal = addConnectorParam != null
const searchParamsRef = useRef(searchParams)
searchParamsRef.current = searchParams
const updateAddConnectorParam = useCallback(
(value: string | null) => {
const current = searchParamsRef.current
const currentValue = current.get(ADD_CONNECTOR_SEARCH_PARAM)
if (value === currentValue || (value === null && currentValue === null)) return
const next = new URLSearchParams(current.toString())
if (value === null) {
next.delete(ADD_CONNECTOR_SEARCH_PARAM)
} else {
next.set(ADD_CONNECTOR_SEARCH_PARAM, value)
}
const qs = next.toString()
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false })
},
[pathname, router]
)
const setShowAddConnectorModal = useCallback(
(open: boolean) => updateAddConnectorParam(open ? '' : null),
[updateAddConnectorParam]
)
const {
isOpen: isContextMenuOpen,
@@ -340,8 +367,6 @@ export function KnowledgeBase({
prevHadSyncingRef.current = hasSyncingConnectors
}, [hasSyncingConnectors, refreshKnowledgeBase, refreshDocuments])
const router = useRouter()
const knowledgeBaseName = knowledgeBase?.name || passedKnowledgeBaseName || 'Knowledge Base'
const error = knowledgeBaseError || documentsError
@@ -1254,7 +1279,13 @@ export function KnowledgeBase({
/>
{showAddConnectorModal && (
<AddConnectorModal open onOpenChange={setShowAddConnectorModal} knowledgeBaseId={id} />
<AddConnectorModal
open
onOpenChange={setShowAddConnectorModal}
onConnectorTypeChange={updateAddConnectorParam}
knowledgeBaseId={id}
initialConnectorType={addConnectorParam || undefined}
/>
)}
{documentToRename && (

View File

@@ -44,14 +44,22 @@ const CONNECTOR_ENTRIES = Object.entries(CONNECTOR_REGISTRY)
interface AddConnectorModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onConnectorTypeChange?: (connectorType: string | null) => void
knowledgeBaseId: string
initialConnectorType?: string | null
}
type Step = 'select-type' | 'configure'
export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddConnectorModalProps) {
const [step, setStep] = useState<Step>('select-type')
const [selectedType, setSelectedType] = useState<string | null>(null)
export function AddConnectorModal({
open,
onOpenChange,
onConnectorTypeChange,
knowledgeBaseId,
initialConnectorType,
}: AddConnectorModalProps) {
const [step, setStep] = useState<Step>(() => (initialConnectorType ? 'configure' : 'select-type'))
const [selectedType, setSelectedType] = useState<string | null>(initialConnectorType ?? null)
const [sourceConfig, setSourceConfig] = useState<Record<string, string>>({})
const [syncInterval, setSyncInterval] = useState(1440)
const [selectedCredentialId, setSelectedCredentialId] = useState<string | null>(null)
@@ -151,6 +159,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
setError(null)
setSearchTerm('')
setStep('configure')
onConnectorTypeChange?.(type)
}
const handleFieldChange = useCallback(
@@ -286,7 +295,10 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
<Button
variant='ghost'
className='mr-2 h-6 w-6 p-0'
onClick={() => setStep('select-type')}
onClick={() => {
setStep('select-type')
onConnectorTypeChange?.('')
}}
>
<ArrowLeft className='h-4 w-4' />
</Button>
@@ -565,6 +577,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
workspaceId={workspaceId}
knowledgeBaseId={knowledgeBaseId}
credentialCount={credentials.length}
connectorType={selectedType ?? undefined}
/>
)}
</>

View File

@@ -1,13 +0,0 @@
import { Loader2 } from 'lucide-react'
export default function TaskLoading() {
return (
<div className='flex h-full bg-[var(--bg)]'>
<div className='flex h-full min-w-0 flex-1 flex-col'>
<div className='flex min-h-0 flex-1 items-center justify-center'>
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
</div>
</div>
</div>
)
}

View File

@@ -111,8 +111,6 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
lastUpdate: input.lastUpdate,
metadata: input.metadata,
variables: input.variables,
deploymentStatuses: input.deploymentStatuses,
needsRedeployment: input.needsRedeployment,
dragStartPosition: input.dragStartPosition ?? null,
}

View File

@@ -40,7 +40,6 @@ 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'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -90,10 +89,7 @@ export function DeployModal({
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 isDeployed = isDeployedProp
const { data: workflowMap = {} } = useWorkflowMap(workspaceId)
const workflowMetadata = workflowId ? workflowMap[workflowId] : undefined
const workflowWorkspaceId = workflowMetadata?.workspaceId ?? null
@@ -381,8 +377,6 @@ export function DeployModal({
invalidateDeploymentQueries(queryClient, workflowId)
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
if (chatSuccessTimeoutRef.current) {
clearTimeout(chatSuccessTimeoutRef.current)
}

View File

@@ -9,7 +9,7 @@ import {
useDeployment,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
import { useDeployedWorkflowState } from '@/hooks/queries/deployments'
import { useDeployedWorkflowState, useDeploymentInfo } from '@/hooks/queries/deployments'
import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -25,10 +25,10 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP
const isRegistryLoading = hydrationPhase === 'idle' || hydrationPhase === 'state-loading'
const { hasBlocks } = useCurrentWorkflow()
const deploymentStatus = useWorkflowRegistry((state) =>
state.getWorkflowDeploymentStatus(activeWorkflowId)
)
const isDeployed = deploymentStatus?.isDeployed || false
const { data: deploymentInfo } = useDeploymentInfo(activeWorkflowId, {
enabled: !isRegistryLoading,
})
const isDeployed = deploymentInfo?.isDeployed ?? false
const isDeployedStateEnabled = Boolean(activeWorkflowId) && isDeployed && !isRegistryLoading
const {

View File

@@ -1,7 +1,7 @@
import { useMemo } from 'react'
import { hasWorkflowChanged } from '@/lib/workflows/comparison'
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { useVariablesStore } from '@/stores/variables/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'

View File

@@ -31,8 +31,8 @@ import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/
import { getBlock } from '@/blocks'
import type { BlockConfig } from '@/blocks/types'
import { normalizeName } from '@/executor/constants'
import type { Variable } from '@/stores/panel'
import { useVariablesStore } from '@/stores/panel'
import { useVariablesStore } from '@/stores/variables/store'
import type { Variable } from '@/stores/variables/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'

View File

@@ -19,8 +19,8 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import type { Variable } from '@/stores/panel'
import { useVariablesStore } from '@/stores/panel'
import { useVariablesStore } from '@/stores/variables/store'
import type { Variable } from '@/stores/variables/types'
interface VariableAssignment {
id: string

View File

@@ -5,8 +5,8 @@ import { useParams } from 'next/navigation'
import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context'
import type { SubBlockConfig } from '@/blocks/types'
import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants'
import { usePersonalEnvironment } from '@/hooks/queries/environment'
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
import { useEnvironmentStore } from '@/stores/settings/environment'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useDependsOnGate } from './use-depends-on-gate'
import { useSubBlockValue } from './use-sub-block-value'
@@ -32,7 +32,7 @@ export function useSelectorSetup(
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const workflowId = (params?.workflowId as string) || activeWorkflowId || ''
const envVariables = useEnvironmentStore((s) => s.variables)
const { data: envVariables = {} } = usePersonalEnvironment()
const { finalDisabled, dependencyValues, canonicalIndex } = useDependsOnGate(
blockId,

View File

@@ -63,7 +63,8 @@ import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useChatStore } from '@/stores/chat/store'
import { useNotificationStore } from '@/stores/notifications/store'
import type { ChatContext, PanelTab } from '@/stores/panel'
import { usePanelStore, useVariablesStore as usePanelVariablesStore } from '@/stores/panel'
import { usePanelStore } from '@/stores/panel'
import { useVariablesModalStore } from '@/stores/variables/modal'
import { useVariablesStore } from '@/stores/variables/store'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { captureBaselineSnapshot } from '@/stores/workflow-diff/utils'
@@ -205,7 +206,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
setIsChatOpen: state.setIsChatOpen,
}))
)
const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesStore(
const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesModalStore(
useShallow((state) => ({
isOpen: state.isOpen,
setIsOpen: state.setIsOpen,
@@ -410,6 +411,17 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
setHasHydrated(true)
}, [setHasHydrated])
useEffect(() => {
const handler = (e: Event) => {
const message = (e as CustomEvent<{ message: string }>).detail?.message
if (!message) return
setActiveTab('copilot')
copilotSendMessage(message)
}
window.addEventListener('mothership-send-message', handler)
return () => window.removeEventListener('mothership-send-message', handler)
}, [setActiveTab, copilotSendMessage])
/**
* Handles tab click events
*/
@@ -482,7 +494,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
throw new Error('No workflow state found')
}
const workflowVariables = usePanelVariablesStore
const workflowVariables = useVariablesStore
.getState()
.getVariablesByWorkflowId(activeWorkflowId)

View File

@@ -27,15 +27,15 @@ import {
usePreventZoom,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useVariablesStore as usePanelVariablesStore } from '@/stores/panel'
import {
getVariablesPosition,
MAX_VARIABLES_HEIGHT,
MAX_VARIABLES_WIDTH,
MIN_VARIABLES_HEIGHT,
MIN_VARIABLES_WIDTH,
useVariablesStore,
} from '@/stores/variables/store'
useVariablesModalStore,
} from '@/stores/variables/modal'
import { useVariablesStore } from '@/stores/variables/store'
import type { Variable } from '@/stores/variables/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -96,7 +96,7 @@ export function Variables() {
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const { isOpen, position, width, height, setIsOpen, setPosition, setDimensions } =
useVariablesStore(
useVariablesModalStore(
useShallow((s) => ({
isOpen: s.isOpen,
position: s.position,
@@ -108,7 +108,7 @@ export function Variables() {
}))
)
const variables = usePanelVariablesStore((s) => s.variables)
const variables = useVariablesStore((s) => s.variables)
const { collaborativeUpdateVariable, collaborativeAddVariable, collaborativeDeleteVariable } =
useCollaborativeWorkflow()

View File

@@ -48,7 +48,7 @@ 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 { useVariablesStore } from '@/stores/variables/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { wouldCreateCycle } from '@/stores/workflows/workflow/utils'

View File

@@ -2,7 +2,6 @@ import { useMemo } from 'react'
import type { Edge } from 'reactflow'
import { useShallow } from 'zustand/react/shallow'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import type { DeploymentStatus } from '@/stores/workflows/registry/types'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
@@ -16,8 +15,6 @@ export interface CurrentWorkflow {
loops: Record<string, Loop>
parallels: Record<string, Parallel>
lastSaved?: number
deploymentStatuses?: Record<string, DeploymentStatus>
needsRedeployment?: boolean
// Mode information
isDiffMode: boolean
@@ -48,8 +45,6 @@ export function useCurrentWorkflow(): CurrentWorkflow {
loops: state.loops,
parallels: state.parallels,
lastSaved: state.lastSaved,
deploymentStatuses: state.deploymentStatuses,
needsRedeployment: state.needsRedeployment,
}))
)
@@ -78,8 +73,6 @@ export function useCurrentWorkflow(): CurrentWorkflow {
loops: activeWorkflow.loops || {},
parallels: activeWorkflow.parallels || {},
lastSaved: activeWorkflow.lastSaved,
deploymentStatuses: activeWorkflow.deploymentStatuses,
needsRedeployment: activeWorkflow.needsRedeployment,
// Mode information - update to reflect ready state
isDiffMode: hasActiveDiff && isShowingDiff,

View File

@@ -36,8 +36,6 @@ import { useExecutionStream } from '@/hooks/use-execution-stream'
import { WorkflowValidationError } from '@/serializer'
import { useCurrentWorkflowExecution, useExecutionStore } from '@/stores/execution'
import { useNotificationStore } from '@/stores/notifications'
import { useVariablesStore } from '@/stores/panel'
import { useEnvironmentStore } from '@/stores/settings/environment'
import {
clearExecutionPointer,
consolePersistence,
@@ -45,6 +43,7 @@ import {
saveExecutionPointer,
useTerminalConsoleStore,
} from '@/stores/terminal'
import { useVariablesStore } from '@/stores/variables/store'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
@@ -120,7 +119,6 @@ export function useWorkflowExecution() {
}))
)
const hasHydrated = useTerminalConsoleStore((s) => s._hasHydrated)
const getAllVariables = useEnvironmentStore((s) => s.getAllVariables)
const { getVariablesByWorkflowId, variables } = useVariablesStore(
useShallow((s) => ({
getVariablesByWorkflowId: s.getVariablesByWorkflowId,
@@ -744,7 +742,6 @@ export function useWorkflowExecution() {
activeWorkflowId,
currentWorkflow,
toggleConsole,
getAllVariables,
getVariablesByWorkflowId,
setIsExecuting,
setIsDebugging,

View File

@@ -1,11 +0,0 @@
import { Loader2 } from 'lucide-react'
export default function WorkflowLoading() {
return (
<div className='flex h-full w-full flex-col overflow-hidden bg-[var(--bg)]'>
<div className='relative flex h-full w-full flex-1 items-center justify-center bg-[var(--bg)]'>
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
</div>
</div>
)
}

View File

@@ -124,8 +124,7 @@ export async function applyAutoLayoutAndUpdateStore(
try {
useWorkflowStore.getState().updateLastSaved()
const { deploymentStatuses, needsRedeployment, dragStartPosition, ...stateToSave } =
newWorkflowState
const { dragStartPosition, ...stateToSave } = newWorkflowState
const cleanedWorkflowState = {
...stateToSave,

View File

@@ -85,7 +85,7 @@ import { useSearchModalStore } from '@/stores/modals/search/store'
import { useNotificationStore } from '@/stores/notifications'
import { usePanelEditorStore } from '@/stores/panel'
import { useUndoRedoStore } from '@/stores/undo-redo'
import { useVariablesStore } from '@/stores/variables/store'
import { useVariablesModalStore } from '@/stores/variables/modal'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { getUniqueBlockName, prepareBlockState } from '@/stores/workflows/utils'
@@ -265,7 +265,7 @@ const WorkflowContent = React.memo(
const { fitViewToBounds, getViewportCenter } = useCanvasViewport(reactFlowInstance, {
embedded,
})
const { emitCursorUpdate } = useSocket()
const { emitCursorUpdate, joinWorkflow, leaveWorkflow } = useSocket()
useDynamicHandleRefresh()
const workspaceId = propWorkspaceId || (params.workspaceId as string)
@@ -273,6 +273,14 @@ const WorkflowContent = React.memo(
const addNotification = useNotificationStore((state) => state.addNotification)
useEffect(() => {
if (!embedded || !workflowIdParam) return
joinWorkflow(workflowIdParam)
return () => {
leaveWorkflow()
}
}, [embedded, workflowIdParam, joinWorkflow, leaveWorkflow])
useOAuthReturnForWorkflow(workflowIdParam)
const {
@@ -337,7 +345,7 @@ const WorkflowContent = React.memo(
autoConnectRef.current = isAutoConnectEnabled
// Panel open states for context menu
const isVariablesOpen = useVariablesStore((state) => state.isOpen)
const isVariablesOpen = useVariablesModalStore((state) => state.isOpen)
const isChatOpen = useChatStore((state) => state.isChatOpen)
const snapGrid: [number, number] = useMemo(
@@ -1374,7 +1382,7 @@ const WorkflowContent = React.memo(
}, [router, workspaceId, workflowIdParam])
const handleContextToggleVariables = useCallback(() => {
const { isOpen, setIsOpen } = useVariablesStore.getState()
const { isOpen, setIsOpen } = useVariablesModalStore.getState()
setIsOpen(!isOpen)
}, [])
@@ -2144,12 +2152,9 @@ const WorkflowContent = React.memo(
const handleCanvasPointerMove = useCallback(
(event: React.PointerEvent<Element>) => {
const target = event.currentTarget as HTMLElement
const bounds = target.getBoundingClientRect()
const position = screenToFlowPosition({
x: event.clientX - bounds.left,
y: event.clientY - bounds.top,
x: event.clientX,
y: event.clientY,
})
emitCursorUpdate(position)

View File

@@ -13,7 +13,7 @@ import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils
import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { getBlock } from '@/blocks'
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { useVariablesStore } from '@/stores/variables/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
/** Execution status for blocks in preview mode */

View File

@@ -343,7 +343,11 @@ export function SearchModal({
'-translate-x-1/2 fixed top-[15%] z-50 w-[500px] rounded-xl border-[4px] border-black/[0.06] bg-[var(--bg)] shadow-[0_24px_80px_-16px_rgba(0,0,0,0.15)] dark:border-white/[0.06] dark:shadow-[0_24px_80px_-16px_rgba(0,0,0,0.4)]',
open ? 'visible opacity-100' : 'invisible opacity-0'
)}
style={{ left: 'calc(var(--sidebar-width) / 2 + 50%)' }}
style={{
left: isOnWorkflowPage
? 'calc(50% + (var(--sidebar-width) - var(--panel-width)) / 2)'
: 'calc(var(--sidebar-width) / 2 + 50%)',
}}
>
<Command label='Search' shouldFilter={false}>
<div className='mx-2 mt-2 mb-1 flex items-center gap-1.5 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-2 dark:bg-[var(--surface-4)]'>

View File

@@ -1,11 +0,0 @@
import { Loader2 } from 'lucide-react'
export default function WorkflowsLoading() {
return (
<div className='flex h-full w-full flex-col overflow-hidden bg-[var(--bg)]'>
<div className='relative flex h-full w-full flex-1 items-center justify-center bg-[var(--bg)]'>
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
</div>
</div>
)
}

View File

@@ -90,6 +90,7 @@ interface SocketContextType {
onSelectionUpdate: (handler: (data: any) => void) => void
onWorkflowDeleted: (handler: (data: any) => void) => void
onWorkflowReverted: (handler: (data: any) => void) => void
onWorkflowUpdated: (handler: (data: any) => void) => void
onOperationConfirmed: (handler: (data: any) => void) => void
onOperationFailed: (handler: (data: any) => void) => void
}
@@ -118,6 +119,7 @@ const SocketContext = createContext<SocketContextType>({
onSelectionUpdate: () => {},
onWorkflowDeleted: () => {},
onWorkflowReverted: () => {},
onWorkflowUpdated: () => {},
onOperationConfirmed: () => {},
onOperationFailed: () => {},
})
@@ -155,6 +157,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
selectionUpdate?: (data: any) => void
workflowDeleted?: (data: any) => void
workflowReverted?: (data: any) => void
workflowUpdated?: (data: any) => void
operationConfirmed?: (data: any) => void
operationFailed?: (data: any) => void
}>({})
@@ -334,7 +337,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
socketInstance.on('join-workflow-success', ({ workflowId, presenceUsers }) => {
isRejoiningRef.current = false
// Ignore stale success responses from previous navigation
if (workflowId !== urlWorkflowIdRef.current) {
if (urlWorkflowIdRef.current && workflowId !== urlWorkflowIdRef.current) {
logger.debug(`Ignoring stale join-workflow-success for ${workflowId}`)
return
}
@@ -382,6 +385,11 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
eventHandlers.current.workflowReverted?.(data)
})
socketInstance.on('workflow-updated', (data) => {
logger.info(`Workflow ${data.workflowId} has been updated externally`)
eventHandlers.current.workflowUpdated?.(data)
})
const rehydrateWorkflowStores = async (workflowId: string, workflowState: any) => {
const [
{ useOperationQueueStore },
@@ -424,7 +432,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
loops: workflowState.loops || {},
parallels: workflowState.parallels || {},
lastSaved: workflowState.lastSaved || Date.now(),
deploymentStatuses: workflowState.deploymentStatuses || {},
})
useSubBlockStore.setState((state: any) => ({
@@ -804,6 +811,10 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
eventHandlers.current.workflowReverted = handler
}, [])
const onWorkflowUpdated = useCallback((handler: (data: any) => void) => {
eventHandlers.current.workflowUpdated = handler
}, [])
const onOperationConfirmed = useCallback((handler: (data: any) => void) => {
eventHandlers.current.operationConfirmed = handler
}, [])
@@ -837,6 +848,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
onSelectionUpdate,
onWorkflowDeleted,
onWorkflowReverted,
onWorkflowUpdated,
onOperationConfirmed,
onOperationFailed,
}),
@@ -864,6 +876,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
onSelectionUpdate,
onWorkflowDeleted,
onWorkflowReverted,
onWorkflowUpdated,
onOperationConfirmed,
onOperationFailed,
]

View File

@@ -40,6 +40,7 @@ import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { X } from 'lucide-react'
import { usePathname } from 'next/navigation'
import { cn } from '@/lib/core/utils/cn'
import { Button } from '../button/button'
@@ -50,13 +51,6 @@ import { Button } from '../button/button'
const ANIMATION_CLASSES =
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=open]:animate-in motion-reduce:animate-none'
/**
* Modal content animation classes.
* We keep only the slide animations (no zoom) to stabilize positioning while avoiding scale effects.
*/
const CONTENT_ANIMATION_CLASSES =
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[50%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[50%] motion-reduce:animate-none'
/**
* Root modal component. Manages open state.
*/
@@ -145,6 +139,8 @@ const ModalContent = React.forwardRef<
ModalContentProps
>(({ className, children, showClose = true, size = 'md', style, ...props }, ref) => {
const [isInteractionReady, setIsInteractionReady] = React.useState(false)
const pathname = usePathname()
const isWorkflowPage = pathname?.includes('/w/') ?? false
React.useEffect(() => {
const timer = setTimeout(() => setIsInteractionReady(true), 100)
@@ -157,14 +153,15 @@ const ModalContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
ANIMATION_CLASSES,
CONTENT_ANIMATION_CLASSES,
'fixed top-[50%] z-[var(--z-modal)] flex max-h-[84vh] translate-x-[-50%] translate-y-[-50%] flex-col overflow-hidden rounded-xl bg-[var(--bg)] text-small ring-1 ring-foreground/10 duration-200',
MODAL_SIZES[size],
className
)}
style={{
left: '50%',
left: isWorkflowPage
? // --panel-width is always the rendered panel width on /w/ routes (panel is never hidden/collapsed)
'calc(50% + (var(--sidebar-width) - var(--panel-width)) / 2)'
: 'calc(var(--sidebar-width) / 2 + 50%)',
...style,
}}
onEscapeKeyDown={(e) => {

View File

@@ -5,7 +5,6 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tansta
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'
const logger = createLogger('DeploymentQueries')
@@ -321,7 +320,6 @@ interface DeployWorkflowResult {
*/
export function useDeployWorkflow() {
const queryClient = useQueryClient()
const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus)
return useMutation({
mutationFn: async ({
@@ -351,18 +349,12 @@ export function useDeployWorkflow() {
warnings: data.warnings,
}
},
onSuccess: (data, variables) => {
logger.info('Workflow deployed successfully', { workflowId: variables.workflowId })
setDeploymentStatus(
variables.workflowId,
data.isDeployed,
data.deployedAt ? new Date(data.deployedAt) : undefined,
data.apiKey
)
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(variables.workflowId, false)
onSettled: (_data, error, variables) => {
if (error) {
logger.error('Failed to deploy workflow', { error })
} else {
logger.info('Workflow deployed successfully', { workflowId: variables.workflowId })
}
return Promise.all([
invalidateDeploymentQueries(queryClient, variables.workflowId),
queryClient.invalidateQueries({
@@ -370,9 +362,6 @@ export function useDeployWorkflow() {
}),
])
},
onError: (error) => {
logger.error('Failed to deploy workflow', { error })
},
})
}
@@ -389,7 +378,6 @@ interface UndeployWorkflowVariables {
*/
export function useUndeployWorkflow() {
const queryClient = useQueryClient()
const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus)
return useMutation({
mutationFn: async ({ workflowId }: UndeployWorkflowVariables): Promise<void> => {
@@ -402,11 +390,12 @@ export function useUndeployWorkflow() {
throw new Error(errorData.error || 'Failed to undeploy workflow')
}
},
onSuccess: (_, variables) => {
logger.info('Workflow undeployed successfully', { workflowId: variables.workflowId })
setDeploymentStatus(variables.workflowId, false)
onSettled: (_data, error, variables) => {
if (error) {
logger.error('Failed to undeploy workflow', { error })
} else {
logger.info('Workflow undeployed successfully', { workflowId: variables.workflowId })
}
return Promise.all([
invalidateDeploymentQueries(queryClient, variables.workflowId),
queryClient.invalidateQueries({
@@ -414,9 +403,6 @@ export function useUndeployWorkflow() {
}),
])
},
onError: (error) => {
logger.error('Failed to undeploy workflow', { error })
},
})
}
@@ -613,7 +599,6 @@ interface ActivateVersionResult {
*/
export function useActivateDeploymentVersion() {
const queryClient = useQueryClient()
const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus)
return useMutation({
mutationFn: async ({
@@ -663,20 +648,13 @@ export function useActivateDeploymentVersion() {
)
}
},
onSuccess: (data, variables) => {
logger.info('Deployment version activated', {
workflowId: variables.workflowId,
version: variables.version,
})
setDeploymentStatus(
variables.workflowId,
true,
data.deployedAt ? new Date(data.deployedAt) : undefined,
data.apiKey
)
},
onSettled: (_, __, variables) => {
onSettled: (_data, error, variables) => {
if (!error) {
logger.info('Deployment version activated', {
workflowId: variables.workflowId,
version: variables.version,
})
}
return invalidateDeploymentQueries(queryClient, variables.workflowId)
},
})

View File

@@ -1,4 +1,3 @@
import { useEffect } from 'react'
import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { WorkspaceEnvironmentData } from '@/lib/environment/api'
@@ -6,7 +5,6 @@ import { fetchPersonalEnvironment, fetchWorkspaceEnvironment } from '@/lib/envir
import { workspaceCredentialKeys } from '@/hooks/queries/credentials'
import { API_ENDPOINTS } from '@/stores/constants'
import type { EnvironmentVariable } from '@/stores/settings/environment'
import { useEnvironmentStore } from '@/stores/settings/environment'
export type { WorkspaceEnvironmentData } from '@/lib/environment/api'
export type { EnvironmentVariable } from '@/stores/settings/environment'
@@ -22,29 +20,16 @@ export const environmentKeys = {
workspace: (workspaceId: string) => [...environmentKeys.all, 'workspace', workspaceId] as const,
}
/**
* Environment Variable Types
*/
/**
* Hook to fetch personal environment variables
*/
export function usePersonalEnvironment() {
const setVariables = useEnvironmentStore((state) => state.setVariables)
const query = useQuery({
return useQuery({
queryKey: environmentKeys.personal(),
queryFn: ({ signal }) => fetchPersonalEnvironment(signal),
staleTime: 60 * 1000, // 1 minute
placeholderData: keepPreviousData,
})
useEffect(() => {
if (query.data) {
setVariables(query.data)
}
}, [query.data, setVariables])
return query
}
/**

View File

@@ -1,39 +0,0 @@
/**
* @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')
})
})

View File

@@ -1,23 +0,0 @@
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)
}

View File

@@ -1,9 +1,9 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants'
import { usePersonalEnvironment } from '@/hooks/queries/environment'
import { getSelectorDefinition, mergeOption } from '@/hooks/selectors/registry'
import type { SelectorKey, SelectorOption, SelectorQueryArgs } from '@/hooks/selectors/types'
import { useEnvironmentStore } from '@/stores/settings/environment'
interface SelectorHookArgs extends Omit<SelectorQueryArgs, 'key'> {
search?: string
@@ -31,7 +31,7 @@ export function useSelectorOptionDetail(
key: SelectorKey,
args: SelectorHookArgs & { detailId?: string }
) {
const envVariables = useEnvironmentStore((s) => s.variables)
const { data: envVariables = {} } = usePersonalEnvironment()
const definition = getSelectorDefinition(key)
const resolvedDetailId = useMemo(() => {

View File

@@ -19,8 +19,9 @@ import {
} from '@/socket/constants'
import { useNotificationStore } from '@/stores/notifications'
import { registerEmitFunctions, useOperationQueue } from '@/stores/operation-queue/store'
import { usePanelEditorStore, useVariablesStore } from '@/stores/panel'
import { usePanelEditorStore } from '@/stores/panel'
import { useCodeUndoRedoStore, useUndoRedoStore } from '@/stores/undo-redo'
import { useVariablesStore } from '@/stores/variables/store'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -122,6 +123,7 @@ export function useCollaborativeWorkflow() {
onVariableUpdate,
onWorkflowDeleted,
onWorkflowReverted,
onWorkflowUpdated,
onOperationConfirmed,
onOperationFailed,
} = useSocket()
@@ -536,82 +538,99 @@ export function useCollaborativeWorkflow() {
}
}
const reloadWorkflowFromApi = async (workflowId: string, reason: string): Promise<boolean> => {
const response = await fetch(`/api/workflows/${workflowId}`)
if (!response.ok) {
logger.error(`Failed to fetch workflow data after ${reason}: ${response.statusText}`)
return false
}
const responseData = await response.json()
const workflowData = responseData.data
if (!workflowData?.state) {
logger.error(`No state found in workflow data after ${reason}`, { workflowData })
return false
}
isApplyingRemoteChange.current = true
try {
useWorkflowStore.getState().replaceWorkflowState({
blocks: workflowData.state.blocks || {},
edges: workflowData.state.edges || [],
loops: workflowData.state.loops || {},
parallels: workflowData.state.parallels || {},
lastSaved: workflowData.state.lastSaved || Date.now(),
})
const subblockValues: Record<string, Record<string, any>> = {}
Object.entries(workflowData.state.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: any) => ({
workflowValues: {
...state.workflowValues,
[workflowId]: subblockValues,
},
}))
const graph = {
blocksById: workflowData.state.blocks || {},
edgesById: Object.fromEntries(
(workflowData.state.edges || []).map((e: any) => [e.id, e])
),
}
const undoRedoStore = useUndoRedoStore.getState()
const stackKeys = Object.keys(undoRedoStore.stacks)
stackKeys.forEach((key) => {
const [wfId, userId] = key.split(':')
if (wfId === workflowId) {
undoRedoStore.pruneInvalidEntries(wfId, userId, graph)
}
})
logger.info(`Successfully reloaded workflow state after ${reason}`, { workflowId })
return true
} finally {
isApplyingRemoteChange.current = false
}
}
const handleWorkflowReverted = async (data: any) => {
const { workflowId } = data
logger.info(`Workflow ${workflowId} has been reverted to deployed state`)
// If the reverted workflow is the currently active one, reload the workflow state
if (activeWorkflowId === workflowId) {
logger.info(`Currently active workflow ${workflowId} was reverted, reloading state`)
if (activeWorkflowId !== workflowId) return
try {
// Fetch the updated workflow state from the server (which loads from normalized tables)
const response = await fetch(`/api/workflows/${workflowId}`)
if (response.ok) {
const responseData = await response.json()
const workflowData = responseData.data
try {
await reloadWorkflowFromApi(workflowId, 'revert')
} catch (error) {
logger.error('Error reloading workflow state after revert:', error)
}
}
if (workflowData?.state) {
// Update the workflow store with the reverted state
isApplyingRemoteChange.current = true
try {
// Update the main workflow state using the API response
useWorkflowStore.getState().replaceWorkflowState({
blocks: workflowData.state.blocks || {},
edges: workflowData.state.edges || [],
loops: workflowData.state.loops || {},
parallels: workflowData.state.parallels || {},
lastSaved: workflowData.state.lastSaved || Date.now(),
deploymentStatuses: workflowData.state.deploymentStatuses || {},
})
const handleWorkflowUpdated = async (data: any) => {
const { workflowId } = data
logger.info(`Workflow ${workflowId} has been updated externally`)
// Update subblock store with reverted values
const subblockValues: Record<string, Record<string, any>> = {}
Object.entries(workflowData.state.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
})
})
if (activeWorkflowId !== workflowId) return
// Update subblock store for this workflow
useSubBlockStore.setState((state: any) => ({
workflowValues: {
...state.workflowValues,
[workflowId]: subblockValues,
},
}))
const { hasActiveDiff } = useWorkflowDiffStore.getState()
if (hasActiveDiff) {
logger.info('Skipping workflow-updated: active diff in progress', { workflowId })
return
}
logger.info(`Successfully loaded reverted workflow state for ${workflowId}`)
const graph = {
blocksById: workflowData.state.blocks || {},
edgesById: Object.fromEntries(
(workflowData.state.edges || []).map((e: any) => [e.id, e])
),
}
const undoRedoStore = useUndoRedoStore.getState()
const stackKeys = Object.keys(undoRedoStore.stacks)
stackKeys.forEach((key) => {
const [wfId, userId] = key.split(':')
if (wfId === workflowId) {
undoRedoStore.pruneInvalidEntries(wfId, userId, graph)
}
})
} finally {
isApplyingRemoteChange.current = false
}
} else {
logger.error('No state found in workflow data after revert', { workflowData })
}
} else {
logger.error(`Failed to fetch workflow data after revert: ${response.statusText}`)
}
} catch (error) {
logger.error('Error reloading workflow state after revert:', error)
}
try {
await reloadWorkflowFromApi(workflowId, 'external update')
} catch (error) {
logger.error('Error reloading workflow state after external update:', error)
}
}
@@ -633,6 +652,7 @@ export function useCollaborativeWorkflow() {
onVariableUpdate(handleVariableUpdate)
onWorkflowDeleted(handleWorkflowDeleted)
onWorkflowReverted(handleWorkflowReverted)
onWorkflowUpdated(handleWorkflowUpdated)
onOperationConfirmed(handleOperationConfirmed)
onOperationFailed(handleOperationFailed)
}, [
@@ -641,6 +661,7 @@ export function useCollaborativeWorkflow() {
onVariableUpdate,
onWorkflowDeleted,
onWorkflowReverted,
onWorkflowUpdated,
onOperationConfirmed,
onOperationFailed,
activeWorkflowId,

View File

@@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { toast } from '@/components/emcn'
import {
ADD_CONNECTOR_SEARCH_PARAM,
consumeOAuthReturnContext,
type OAuthReturnContext,
readOAuthReturnContext,
@@ -98,7 +99,11 @@ export function useOAuthReturnRouter() {
try {
sessionStorage.removeItem(SETTINGS_RETURN_URL_KEY)
} catch {}
router.replace(`/workspace/${workspaceId}/knowledge/${ctx.knowledgeBaseId}`)
const kbUrl = `/workspace/${workspaceId}/knowledge/${ctx.knowledgeBaseId}`
const connectorParam = ctx.connectorType
? `?${ADD_CONNECTOR_SEARCH_PARAM}=${encodeURIComponent(ctx.connectorType)}`
: ''
router.replace(`${kbUrl}${connectorParam}`)
return
}
}, [router, workspaceId])

View File

@@ -40,7 +40,11 @@ import {
XCircle,
Zap,
} from 'lucide-react'
import { getCustomTool } from '@/hooks/queries/utils/custom-tool-cache'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import type { CustomToolDefinition } from '@/hooks/queries/custom-tools'
import type { WorkflowDeploymentInfo } from '@/hooks/queries/deployments'
import { deploymentKeys } from '@/hooks/queries/deployments'
import { customToolsKeys } from '@/hooks/queries/utils/custom-tool-keys'
import { getWorkflowById } from '@/hooks/queries/utils/workflow-cache'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -445,7 +449,8 @@ const META_deploy_api: ToolMetadata = {
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
const isAlreadyDeployed = workflowId
? useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)?.isDeployed
? (getQueryClient().getQueryData<WorkflowDeploymentInfo>(deploymentKeys.info(workflowId))
?.isDeployed ?? false)
: false
let actionText = action
@@ -1053,7 +1058,11 @@ const META_manage_custom_tool: ToolMetadata = {
let toolName = params?.schema?.function?.name
if (!toolName && params?.toolId && workspaceId) {
try {
const tool = getCustomTool(params.toolId, workspaceId)
const tools =
getQueryClient().getQueryData<CustomToolDefinition[]>(
customToolsKeys.list(workspaceId)
) ?? []
const tool = tools.find((t) => t.id === params.toolId || t.title === params.toolId)
toolName = tool?.schema?.function?.name
} catch {
// Ignore errors accessing cache

View File

@@ -7,6 +7,7 @@ import {
type BaseServerTool,
type ServerToolContext,
} from '@/lib/copilot/tools/server/base-tool'
import { env } from '@/lib/core/config/env'
import { applyTargetedLayout, getTargetedLayoutImpact } from '@/lib/workflows/autolayout'
import {
DEFAULT_HORIZONTAL_SPACING,
@@ -287,6 +288,18 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown>
logger.info('Workflow state persisted to database', { workflowId })
const socketUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002'
fetch(`${socketUrl}/api/workflow-updated`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': env.INTERNAL_API_SECRET,
},
body: JSON.stringify({ workflowId }),
}).catch((error) => {
logger.warn('Failed to notify socket server of workflow update', { workflowId, error })
})
const sanitizationWarnings = validation.warnings.length > 0 ? validation.warnings : undefined
return {

View File

@@ -91,6 +91,8 @@ export function clearPendingCredentialCreateRequest() {
window.sessionStorage.removeItem(PENDING_CREDENTIAL_CREATE_REQUEST_KEY)
}
export const ADD_CONNECTOR_SEARCH_PARAM = 'addConnector' as const
const OAUTH_RETURN_CONTEXT_KEY = 'sim.oauth-return-context'
export type OAuthReturnOrigin = 'workflow' | 'integrations' | 'kb-connectors'
@@ -116,6 +118,7 @@ interface OAuthReturnIntegrations extends OAuthReturnBase {
interface OAuthReturnKBConnectors extends OAuthReturnBase {
origin: 'kb-connectors'
knowledgeBaseId: string
connectorType?: string
}
export type OAuthReturnContext =

View File

@@ -2,10 +2,9 @@ import type { Edge } from 'reactflow'
import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types'
import type { ParentIteration, SerializableExecutionState } from '@/executor/execution/types'
import type { BlockLog, NormalizedBlockOutput } from '@/executor/types'
import type { DeploymentStatus } from '@/stores/workflows/registry/types'
import type { Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
export type { WorkflowState, Loop, Parallel, DeploymentStatus }
export type { WorkflowState, Loop, Parallel }
export type WorkflowEdge = Edge
export type { NormalizedBlockOutput, BlockLog }

View File

@@ -123,8 +123,6 @@ export function buildDefaultWorkflowArtifacts(): DefaultWorkflowArtifacts {
loops: {},
parallels: {},
lastSaved: Date.now(),
deploymentStatuses: {},
needsRedeployment: false,
}
return {

View File

@@ -14,7 +14,7 @@ import {
deduplicateWorkflowName,
} from '@/lib/workflows/utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import type { Variable } from '@/stores/panel/variables/types'
import type { Variable } from '@/stores/variables/types'
import type { LoopConfig, ParallelConfig } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowDuplicateHelper')

View File

@@ -985,7 +985,6 @@ describe('Database Helpers', () => {
edges: loadedState!.edges,
loops: {},
parallels: {},
deploymentStatuses: {},
}
const mockTransaction = vi.fn().mockImplementation(async (callback) => {

View File

@@ -1,4 +1,4 @@
import type { VariableType } from '@/stores/panel/variables/types'
import type { VariableType } from '@/stores/variables/types'
/**
* Central manager for handling all variable-related operations.

View File

@@ -186,7 +186,6 @@ export async function getWorkflowState(workflowId: string) {
if (normalizedData) {
const finalState = {
deploymentStatuses: {},
hasActiveWebhook: false,
blocks: normalizedData.blocks,
edges: normalizedData.edges,

View File

@@ -1,10 +1,9 @@
'use client'
import { useEffect } from 'react'
import { createLogger } from '@sim/logger'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { environmentKeys } from '@/hooks/queries/environment'
import { useExecutionStore } from '@/stores/execution'
import { useVariablesStore } from '@/stores/panel'
import { useEnvironmentStore } from '@/stores/settings/environment'
import { consolePersistence, useTerminalConsoleStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -12,198 +11,13 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('Stores')
// Track initialization state
let isInitializing = false
let appFullyInitialized = false
let dataInitialized = false // Flag for actual data loading completion
/**
* Initialize the application state and sync system
* localStorage persistence has been removed - relies on DB and Zustand stores only
* Reset all Zustand stores and React Query caches to initial state.
*/
async function initializeApplication(): Promise<void> {
if (typeof window === 'undefined' || isInitializing) return
isInitializing = true
appFullyInitialized = false
// Track initialization start time
const initStartTime = Date.now()
try {
// Load environment variables directly from DB
await useEnvironmentStore.getState().loadEnvironmentVariables()
// Mark data as initialized only after sync managers have loaded data from DB
dataInitialized = true
// Log initialization timing information
const initDuration = Date.now() - initStartTime
logger.info(`Application initialization completed in ${initDuration}ms`)
// Mark application as fully initialized
appFullyInitialized = true
} catch (error) {
logger.error('Error during application initialization:', { error })
// Still mark as initialized to prevent being stuck in initializing state
appFullyInitialized = true
// But don't mark data as initialized on error
dataInitialized = false
} finally {
isInitializing = false
}
}
/**
* Checks if application is fully initialized
*/
export function isAppInitialized(): boolean {
return appFullyInitialized
}
/**
* Checks if data has been loaded from the database
* This should be checked before any sync operations
*/
export function isDataInitialized(): boolean {
return dataInitialized
}
/**
* Handle application cleanup before unload
*/
function handleBeforeUnload(event: BeforeUnloadEvent): void {
// Check if we're on an authentication page and skip confirmation if we are
if (typeof window !== 'undefined') {
const path = window.location.pathname
// Skip confirmation for auth-related pages
if (
path === '/login' ||
path === '/signup' ||
path === '/reset-password' ||
path === '/verify'
) {
return
}
}
// Standard beforeunload pattern
event.preventDefault()
event.returnValue = ''
}
/**
* Clean up sync system
*/
function cleanupApplication(): void {
window.removeEventListener('beforeunload', handleBeforeUnload)
// Note: No sync managers to dispose - Socket.IO handles cleanup
}
/**
* Clear all user data when signing out
* localStorage persistence has been removed
*/
export async function clearUserData(): Promise<void> {
if (typeof window === 'undefined') return
try {
// Note: No sync managers to dispose - Socket.IO handles cleanup
// Reset all stores to their initial state
resetAllStores()
// Clear localStorage except for essential app settings (minimal usage)
const keysToKeep = ['next-favicon', 'theme']
const keysToRemove = Object.keys(localStorage).filter((key) => !keysToKeep.includes(key))
keysToRemove.forEach((key) => localStorage.removeItem(key))
// Reset application initialization state
appFullyInitialized = false
dataInitialized = false
logger.info('User data cleared successfully')
} catch (error) {
logger.error('Error clearing user data:', { error })
}
}
/**
* Hook to manage application lifecycle
*/
export function useAppInitialization() {
useEffect(() => {
// Use Promise to handle async initialization
initializeApplication()
return () => {
cleanupApplication()
}
}, [])
}
/**
* Hook to reinitialize the application after successful login
* Use this in the login success handler or post-login page
*/
export function useLoginInitialization() {
useEffect(() => {
reinitializeAfterLogin()
}, [])
}
/**
* Reinitialize the application after login
* This ensures we load fresh data from the database for the new user
*/
export async function reinitializeAfterLogin(): Promise<void> {
if (typeof window === 'undefined') return
try {
// Reset application initialization state
appFullyInitialized = false
dataInitialized = false
// Note: No sync managers to dispose - Socket.IO handles cleanup
// Clean existing state to avoid stale data
resetAllStores()
// Reset initialization flags to force a fresh load
isInitializing = false
// Reinitialize the application
await initializeApplication()
logger.info('Application reinitialized after login')
} catch (error) {
logger.error('Error reinitializing application:', { error })
}
}
// Initialize immediately when imported on client
if (typeof window !== 'undefined') {
initializeApplication()
}
// Export all stores
export {
useWorkflowStore,
useWorkflowRegistry,
useEnvironmentStore,
useExecutionStore,
useTerminalConsoleStore,
useVariablesStore,
useSubBlockStore,
}
// Helper function to reset all stores
export const resetAllStores = () => {
// Reset all stores to initial state
useWorkflowRegistry.setState({
activeWorkflowId: null,
error: null,
deploymentStatuses: {},
hydration: {
phase: 'idle',
workspaceId: null,
@@ -214,7 +28,7 @@ export const resetAllStores = () => {
})
useWorkflowStore.getState().clear()
useSubBlockStore.getState().clear()
useEnvironmentStore.getState().reset()
getQueryClient().removeQueries({ queryKey: environmentKeys.all })
useExecutionStore.getState().reset()
useTerminalConsoleStore.setState({
workflowEntries: {},
@@ -223,21 +37,24 @@ export const resetAllStores = () => {
isOpen: false,
})
consolePersistence.persist()
// Custom tools are managed by React Query cache, not a Zustand store
// Variables store has no tracking to reset; registry hydrates
}
// Helper function to log all store states
export const logAllStores = () => {
const state = {
workflow: useWorkflowStore.getState(),
workflowRegistry: useWorkflowRegistry.getState(),
environment: useEnvironmentStore.getState(),
execution: useExecutionStore.getState(),
console: useTerminalConsoleStore.getState(),
subBlock: useSubBlockStore.getState(),
variables: useVariablesStore.getState(),
/**
* Clear all user data when signing out.
*/
export async function clearUserData(): Promise<void> {
if (typeof window === 'undefined') return
try {
resetAllStores()
// Clear localStorage except for essential app settings
const keysToKeep = ['next-favicon', 'theme']
const keysToRemove = Object.keys(localStorage).filter((key) => !keysToKeep.includes(key))
keysToRemove.forEach((key) => localStorage.removeItem(key))
logger.info('User data cleared successfully')
} catch (error) {
logger.error('Error clearing user data:', { error })
}
return state
}

View File

@@ -9,6 +9,3 @@ export { usePanelStore } from './store'
// Toolbar
export { useToolbarStore } from './toolbar'
export type { ChatContext, PanelState, PanelTab } from './types'
export type { Variable, VariablesStore, VariableType } from './variables'
// Variables
export { useVariablesStore } from './variables'

View File

@@ -1,2 +0,0 @@
export { useVariablesStore } from './store'
export type { Variable, VariablesStore, VariableType } from './types'

View File

@@ -1,290 +0,0 @@
import { createLogger } from '@sim/logger'
import JSON5 from 'json5'
import { create } from 'zustand'
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 { useSubBlockStore } from '@/stores/workflows/subblock/store'
const logger = createLogger('VariablesStore')
function validateVariable(variable: Variable): string | undefined {
try {
switch (variable.type) {
case 'number':
if (Number.isNaN(Number(variable.value))) {
return 'Not a valid number'
}
break
case 'boolean':
if (!/^(true|false)$/i.test(String(variable.value).trim())) {
return 'Expected "true" or "false"'
}
break
case 'object':
try {
const valueToEvaluate = String(variable.value).trim()
if (!valueToEvaluate.startsWith('{') || !valueToEvaluate.endsWith('}')) {
return 'Not a valid object format'
}
const parsed = JSON5.parse(valueToEvaluate)
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return 'Not a valid object'
}
return undefined
} catch (e) {
logger.error('Object parsing error:', e)
return 'Invalid object syntax'
}
case 'array':
try {
const parsed = JSON5.parse(String(variable.value))
if (!Array.isArray(parsed)) {
return 'Not a valid array'
}
} catch {
return 'Invalid array syntax'
}
break
}
return undefined
} catch (e) {
return e instanceof Error ? e.message : 'Invalid format'
}
}
function migrateStringToPlain(variable: Variable): Variable {
if (variable.type !== 'string') {
return variable
}
const updated = {
...variable,
type: 'plain' as const,
}
return updated
}
export const useVariablesStore = create<VariablesStore>()(
devtools((set, get) => ({
variables: {},
isLoading: false,
error: null,
isEditing: null,
async loadForWorkflow(workflowId) {
try {
set({ isLoading: true, error: null })
const res = await fetch(`/api/workflows/${workflowId}/variables`, { method: 'GET' })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `Failed to load variables: ${res.statusText}`)
}
const data = await res.json()
const variables = (data?.data as Record<string, Variable>) || {}
set((state) => {
const withoutWorkflow = Object.fromEntries(
Object.entries(state.variables).filter(
(entry): entry is [string, Variable] => entry[1].workflowId !== workflowId
)
)
return {
variables: { ...withoutWorkflow, ...variables },
isLoading: false,
error: null,
}
})
} catch (e) {
const message = e instanceof Error ? e.message : 'Unknown error'
set({ isLoading: false, error: message })
}
},
addVariable: (variable, providedId?: string) => {
const id = providedId || crypto.randomUUID()
const workflowVariables = get().getVariablesByWorkflowId(variable.workflowId)
if (!variable.name || /^variable\d+$/.test(variable.name)) {
const existingNumbers = workflowVariables
.map((v) => {
const match = v.name.match(/^variable(\d+)$/)
return match ? Number.parseInt(match[1]) : 0
})
.filter((n) => !Number.isNaN(n))
const nextNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1
variable.name = `variable${nextNumber}`
}
let uniqueName = variable.name
let nameIndex = 1
while (workflowVariables.some((v) => v.name === uniqueName)) {
uniqueName = `${variable.name} (${nameIndex})`
nameIndex++
}
if (variable.type === 'string') {
variable.type = 'plain'
}
const newVariable: Variable = {
id,
workflowId: variable.workflowId,
name: uniqueName,
type: variable.type,
value: variable.value || '',
validationError: undefined,
}
const validationError = validateVariable(newVariable)
if (validationError) {
newVariable.validationError = validationError
}
set((state) => ({
variables: {
...state.variables,
[id]: newVariable,
},
}))
return id
},
updateVariable: (id, update) => {
set((state) => {
if (!state.variables[id]) return state
if (update.name !== undefined) {
const oldVariable = state.variables[id]
const oldVariableName = oldVariable.name
const newName = update.name.trim()
if (!newName) {
update = { ...update }
update.name = undefined
} else if (newName !== oldVariableName) {
const subBlockStore = useSubBlockStore.getState()
const targetWorkflowId = oldVariable.workflowId
if (targetWorkflowId) {
const workflowValues = subBlockStore.workflowValues[targetWorkflowId] || {}
const updatedWorkflowValues = { ...workflowValues }
const changedSubBlocks: Array<{ blockId: string; subBlockId: string; value: any }> =
[]
const oldVarName = normalizeName(oldVariableName)
const newVarName = normalizeName(newName)
const regex = new RegExp(`<variable\\.${oldVarName}>`, 'gi')
const updateReferences = (value: any, pattern: RegExp, replacement: string): any => {
if (typeof value === 'string') {
return pattern.test(value) ? value.replace(pattern, replacement) : value
}
if (Array.isArray(value)) {
return value.map((item) => updateReferences(item, pattern, replacement))
}
if (value !== null && typeof value === 'object') {
const result = { ...value }
for (const key in result) {
result[key] = updateReferences(result[key], pattern, replacement)
}
return result
}
return value
}
Object.entries(workflowValues).forEach(([blockId, blockValues]) => {
Object.entries(blockValues as Record<string, any>).forEach(
([subBlockId, value]) => {
const updatedValue = updateReferences(value, regex, `<variable.${newVarName}>`)
if (JSON.stringify(updatedValue) !== JSON.stringify(value)) {
if (!updatedWorkflowValues[blockId]) {
updatedWorkflowValues[blockId] = { ...workflowValues[blockId] }
}
updatedWorkflowValues[blockId][subBlockId] = updatedValue
changedSubBlocks.push({ blockId, subBlockId, value: updatedValue })
}
}
)
})
// Update local state
useSubBlockStore.setState({
workflowValues: {
...subBlockStore.workflowValues,
[targetWorkflowId]: updatedWorkflowValues,
},
})
// Queue operations for persistence via socket
const operationQueue = useOperationQueueStore.getState()
for (const { blockId, subBlockId, value } of changedSubBlocks) {
operationQueue.addToQueue({
id: crypto.randomUUID(),
operation: {
operation: 'subblock-update',
target: 'subblock',
payload: { blockId, subblockId: subBlockId, value },
},
workflowId: targetWorkflowId,
userId: 'system',
})
}
}
}
}
if (update.type === 'string') {
update = { ...update, type: 'plain' }
}
const updatedVariable: Variable = {
...state.variables[id],
...update,
validationError: undefined,
}
if (update.type || update.value !== undefined) {
updatedVariable.validationError = validateVariable(updatedVariable)
}
const updated = {
...state.variables,
[id]: updatedVariable,
}
return { variables: updated }
})
},
deleteVariable: (id) => {
set((state) => {
if (!state.variables[id]) return state
const workflowId = state.variables[id].workflowId
const { [id]: _, ...rest } = state.variables
return { variables: rest }
})
},
getVariablesByWorkflowId: (workflowId) => {
return Object.values(get().variables).filter((variable) => variable.workflowId === workflowId)
},
}))
)

View File

@@ -1,50 +0,0 @@
/**
* Variable types supported in the application
* Note: 'string' is deprecated - use 'plain' for text values instead
*/
export type VariableType = 'plain' | 'number' | 'boolean' | 'object' | 'array' | 'string'
/**
* Represents a workflow variable with workflow-specific naming
* Variable names must be unique within each workflow
*/
export interface Variable {
id: string
workflowId: string
name: string // Must be unique per workflow
type: VariableType
value: unknown
validationError?: string // Tracks format validation errors
}
export interface VariablesStore {
variables: Record<string, Variable>
isLoading: boolean
error: string | null
isEditing: string | null
/**
* Loads variables for a specific workflow from the API and hydrates the store.
*/
loadForWorkflow: (workflowId: string) => Promise<void>
/**
* Adds a new variable with automatic name uniqueness validation
* If a variable with the same name exists, it will be suffixed with a number
* Optionally accepts a predetermined ID for collaborative operations
*/
addVariable: (variable: Omit<Variable, 'id'>, providedId?: string) => string
/**
* Updates a variable, ensuring name remains unique within the workflow
* If an updated name conflicts with existing ones, a numbered suffix is added
*/
updateVariable: (id: string, update: Partial<Omit<Variable, 'id' | 'workflowId'>>) => void
deleteVariable: (id: string) => void
/**
* Returns all variables for a specific workflow
*/
getVariablesByWorkflowId: (workflowId: string) => Variable[]
}

View File

@@ -1,7 +1 @@
export { useEnvironmentStore } from './store'
export type {
CachedWorkspaceEnvData,
EnvironmentState,
EnvironmentStore,
EnvironmentVariable,
} from './types'
export type { CachedWorkspaceEnvData, EnvironmentState, EnvironmentVariable } from './types'

View File

@@ -1,43 +0,0 @@
import { createLogger } from '@sim/logger'
import { create } from 'zustand'
import { fetchPersonalEnvironment } from '@/lib/environment/api'
import type { EnvironmentStore, EnvironmentVariable } from './types'
const logger = createLogger('EnvironmentStore')
export const useEnvironmentStore = create<EnvironmentStore>()((set, get) => ({
variables: {},
isLoading: false,
error: null,
loadEnvironmentVariables: async () => {
try {
set({ isLoading: true, error: null })
const data = await fetchPersonalEnvironment()
set({ variables: data, isLoading: false })
} catch (error) {
logger.error('Error loading environment variables:', { error })
set({
error: error instanceof Error ? error.message : 'Unknown error',
isLoading: false,
})
throw error
}
},
setVariables: (variables: Record<string, EnvironmentVariable>) => {
set({ variables })
},
getAllVariables: () => {
return get().variables
},
reset: () => {
set({
variables: {},
isLoading: false,
error: null,
})
},
}))

View File

@@ -15,10 +15,3 @@ export interface EnvironmentState {
isLoading: boolean
error: string | null
}
export interface EnvironmentStore extends EnvironmentState {
loadEnvironmentVariables: () => Promise<void>
setVariables: (variables: Record<string, EnvironmentVariable>) => void
getAllVariables: () => Record<string, EnvironmentVariable>
reset: () => void
}

View File

@@ -0,0 +1,11 @@
export {
getDefaultVariablesDimensions,
getVariablesPosition,
MAX_VARIABLES_HEIGHT,
MAX_VARIABLES_WIDTH,
MIN_VARIABLES_HEIGHT,
MIN_VARIABLES_WIDTH,
useVariablesModalStore,
} from './modal'
export { useVariablesStore } from './store'
export type { Variable, VariablesStore, VariableType } from './types'

View File

@@ -0,0 +1,145 @@
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import type {
VariablesDimensions,
VariablesModalStore,
VariablesPosition,
} from '@/stores/variables/types'
/**
* Floating variables modal default dimensions.
* Slightly larger than the chat modal for more comfortable editing.
*/
const DEFAULT_WIDTH = 320
const DEFAULT_HEIGHT = 320
/**
* Minimum and maximum modal dimensions.
*/
export const MIN_VARIABLES_WIDTH = DEFAULT_WIDTH
export const MIN_VARIABLES_HEIGHT = DEFAULT_HEIGHT
export const MAX_VARIABLES_WIDTH = 500
export const MAX_VARIABLES_HEIGHT = 600
/** Inset gap between the viewport edge and the content window */
const CONTENT_WINDOW_GAP = 8
/**
* Compute a center-biased default position, factoring in current layout chrome
* (sidebar, right panel, terminal) and content window inset.
*/
const calculateDefaultPosition = (): VariablesPosition => {
if (typeof window === 'undefined') {
return { x: 100, y: 100 }
}
const sidebarWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
)
const panelWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
)
const terminalHeight = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
)
const availableWidth = window.innerWidth - sidebarWidth - CONTENT_WINDOW_GAP - panelWidth
const availableHeight = window.innerHeight - CONTENT_WINDOW_GAP * 2 - terminalHeight
const x = sidebarWidth + (availableWidth - DEFAULT_WIDTH) / 2
const y = CONTENT_WINDOW_GAP + (availableHeight - DEFAULT_HEIGHT) / 2
return { x, y }
}
/**
* Constrain a position to the visible canvas, considering layout chrome.
*/
const constrainPosition = (
position: VariablesPosition,
width: number = DEFAULT_WIDTH,
height: number = DEFAULT_HEIGHT
): VariablesPosition => {
if (typeof window === 'undefined') return position
const sidebarWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
)
const panelWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
)
const terminalHeight = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
)
const minX = sidebarWidth
const maxX = window.innerWidth - CONTENT_WINDOW_GAP - panelWidth - width
const minY = CONTENT_WINDOW_GAP
const maxY = window.innerHeight - CONTENT_WINDOW_GAP - terminalHeight - height
return {
x: Math.max(minX, Math.min(maxX, position.x)),
y: Math.max(minY, Math.min(maxY, position.y)),
}
}
/**
* Return a valid, constrained position. If the stored one is off-bounds due to
* layout changes, prefer a fresh default center position.
*/
export const getVariablesPosition = (
stored: VariablesPosition | null,
width: number = DEFAULT_WIDTH,
height: number = DEFAULT_HEIGHT
): VariablesPosition => {
if (!stored) return calculateDefaultPosition()
const constrained = constrainPosition(stored, width, height)
const deltaX = Math.abs(constrained.x - stored.x)
const deltaY = Math.abs(constrained.y - stored.y)
if (deltaX > 100 || deltaY > 100) return calculateDefaultPosition()
return constrained
}
/**
* UI-only store for the floating variables modal.
* Variable data lives in the variables data store (`@/stores/variables/store`).
*/
export const useVariablesModalStore = create<VariablesModalStore>()(
devtools(
persist(
(set) => ({
isOpen: false,
position: null,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
setIsOpen: (open) => set({ isOpen: open }),
setPosition: (position) => set({ position }),
setDimensions: (dimensions) =>
set({
width: Math.max(MIN_VARIABLES_WIDTH, Math.min(MAX_VARIABLES_WIDTH, dimensions.width)),
height: Math.max(
MIN_VARIABLES_HEIGHT,
Math.min(MAX_VARIABLES_HEIGHT, dimensions.height)
),
}),
resetPosition: () => set({ position: null }),
}),
{
name: 'variables-modal-store',
partialize: (state) => ({
position: state.position,
width: state.width,
height: state.height,
}),
}
),
{ name: 'variables-modal-store' }
)
)
/**
* Get default floating variables modal dimensions.
*/
export const getDefaultVariablesDimensions = (): VariablesDimensions => ({
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
})

View File

@@ -1,145 +1,47 @@
import { createLogger } from '@sim/logger'
import JSON5 from 'json5'
import { v4 as uuidv4 } from 'uuid'
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { devtools } from 'zustand/middleware'
import { normalizeName } from '@/executor/constants'
import type {
Variable,
VariablesDimensions,
VariablesPosition,
VariablesStore,
VariableType,
} from '@/stores/variables/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useOperationQueueStore } from '@/stores/operation-queue/store'
import type { Variable, VariablesStore } from '@/stores/variables/types'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
const logger = createLogger('VariablesModalStore')
const logger = createLogger('VariablesStore')
/**
* Floating variables modal default dimensions.
* Slightly larger than the chat modal for more comfortable editing.
*/
const DEFAULT_WIDTH = 320
const DEFAULT_HEIGHT = 320
/**
* Minimum and maximum modal dimensions.
* Kept in sync with the chat modal experience.
*/
export const MIN_VARIABLES_WIDTH = DEFAULT_WIDTH
export const MIN_VARIABLES_HEIGHT = DEFAULT_HEIGHT
export const MAX_VARIABLES_WIDTH = 500
export const MAX_VARIABLES_HEIGHT = 600
/** Inset gap between the viewport edge and the content window */
const CONTENT_WINDOW_GAP = 8
/**
* Compute a center-biased default position, factoring in current layout chrome
* (sidebar, right panel, terminal) and content window inset.
*/
const calculateDefaultPosition = (): VariablesPosition => {
if (typeof window === 'undefined') {
return { x: 100, y: 100 }
}
const sidebarWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
)
const panelWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
)
const terminalHeight = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
)
const availableWidth = window.innerWidth - sidebarWidth - CONTENT_WINDOW_GAP - panelWidth
const availableHeight = window.innerHeight - CONTENT_WINDOW_GAP * 2 - terminalHeight
const x = sidebarWidth + (availableWidth - DEFAULT_WIDTH) / 2
const y = CONTENT_WINDOW_GAP + (availableHeight - DEFAULT_HEIGHT) / 2
return { x, y }
}
/**
* Constrain a position to the visible canvas, considering layout chrome.
*/
const constrainPosition = (
position: VariablesPosition,
width: number = DEFAULT_WIDTH,
height: number = DEFAULT_HEIGHT
): VariablesPosition => {
if (typeof window === 'undefined') return position
const sidebarWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
)
const panelWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
)
const terminalHeight = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
)
const minX = sidebarWidth
const maxX = window.innerWidth - CONTENT_WINDOW_GAP - panelWidth - width
const minY = CONTENT_WINDOW_GAP
const maxY = window.innerHeight - CONTENT_WINDOW_GAP - terminalHeight - height
return {
x: Math.max(minX, Math.min(maxX, position.x)),
y: Math.max(minY, Math.min(maxY, position.y)),
}
}
/**
* Return a valid, constrained position. If the stored one is off-bounds due to
* layout changes, prefer a fresh default center position.
*/
export const getVariablesPosition = (
stored: VariablesPosition | null,
width: number = DEFAULT_WIDTH,
height: number = DEFAULT_HEIGHT
): VariablesPosition => {
if (!stored) return calculateDefaultPosition()
const constrained = constrainPosition(stored, width, height)
const deltaX = Math.abs(constrained.x - stored.x)
const deltaY = Math.abs(constrained.y - stored.y)
if (deltaX > 100 || deltaY > 100) return calculateDefaultPosition()
return constrained
}
/**
* Validate a variable's value given its type. Returns an error message or undefined.
*/
function validateVariable(variable: Variable): string | undefined {
try {
switch (variable.type) {
case 'number': {
return Number.isNaN(Number(variable.value)) ? 'Not a valid number' : undefined
}
case 'boolean': {
return !/^(true|false)$/i.test(String(variable.value).trim())
? 'Expected "true" or "false"'
: undefined
}
case 'object': {
case 'number':
if (Number.isNaN(Number(variable.value))) {
return 'Not a valid number'
}
break
case 'boolean':
if (!/^(true|false)$/i.test(String(variable.value).trim())) {
return 'Expected "true" or "false"'
}
break
case 'object':
try {
const valueToEvaluate = String(variable.value).trim()
if (!valueToEvaluate.startsWith('{') || !valueToEvaluate.endsWith('}')) {
return 'Not a valid object format'
}
const parsed = JSON5.parse(valueToEvaluate)
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return 'Not a valid object'
}
return undefined
} catch (e) {
logger.error('Object parsing error:', e)
return 'Invalid object syntax'
}
}
case 'array': {
case 'array':
try {
const parsed = JSON5.parse(String(variable.value))
if (!Array.isArray(parsed)) {
@@ -148,257 +50,199 @@ function validateVariable(variable: Variable): string | undefined {
} catch {
return 'Invalid array syntax'
}
return undefined
}
default:
return undefined
break
}
return undefined
} catch (e) {
return e instanceof Error ? e.message : 'Invalid format'
}
}
/**
* Migrate deprecated type 'string' -> 'plain'.
*/
function migrateStringToPlain(variable: Variable): Variable {
if (variable.type !== 'string') return variable
return { ...variable, type: 'plain' as const }
}
/**
* Floating Variables modal + Variables data store.
*/
export const useVariablesStore = create<VariablesStore>()(
devtools(
persist(
(set, get) => ({
// UI
isOpen: false,
position: null,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
devtools((set, get) => ({
variables: {},
isLoading: false,
error: null,
isEditing: null,
setIsOpen: (open) => set({ isOpen: open }),
setPosition: (position) => set({ position }),
setDimensions: (dimensions) =>
set({
width: Math.max(MIN_VARIABLES_WIDTH, Math.min(MAX_VARIABLES_WIDTH, dimensions.width)),
height: Math.max(
MIN_VARIABLES_HEIGHT,
Math.min(MAX_VARIABLES_HEIGHT, dimensions.height)
),
}),
resetPosition: () => set({ position: null }),
addVariable: (variable, providedId?: string) => {
const id = providedId || crypto.randomUUID()
// Data
variables: {},
isLoading: false,
error: null,
const workflowVariables = get().getVariablesByWorkflowId(variable.workflowId)
async loadForWorkflow(workflowId) {
try {
set({ isLoading: true, error: null })
const res = await fetch(`/api/workflows/${workflowId}/variables`, { method: 'GET' })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `Failed to load variables: ${res.statusText}`)
}
const data = await res.json()
const variables = (data?.data as Record<string, Variable>) || {}
// Migrate any deprecated types and merge into store (remove other workflow entries)
const migrated: Record<string, Variable> = Object.fromEntries(
Object.entries(variables).map(([id, v]) => [id, migrateStringToPlain(v)])
)
set((state) => {
const withoutThisWorkflow = Object.fromEntries(
Object.entries(state.variables).filter(
(entry): entry is [string, Variable] => entry[1].workflowId !== workflowId
)
)
return {
variables: { ...withoutThisWorkflow, ...migrated },
isLoading: false,
error: null,
}
})
} catch (e) {
const message = e instanceof Error ? e.message : 'Unknown error'
set({ isLoading: false, error: message })
}
},
addVariable: (variable, providedId) => {
const id = providedId || uuidv4()
const state = get()
const workflowVariables = state
.getVariablesByWorkflowId(variable.workflowId)
.map((v) => ({ id: v.id, name: v.name }))
// Default naming: variableN
if (!variable.name || /^variable\d+$/.test(variable.name)) {
const existingNumbers = workflowVariables
.map((v) => {
const match = v.name.match(/^variable(\d+)$/)
return match ? Number.parseInt(match[1]) : 0
})
.filter((n) => !Number.isNaN(n))
const nextNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1
variable.name = `variable${nextNumber}`
}
// Ensure uniqueness
let uniqueName = variable.name
let nameIndex = 1
while (workflowVariables.some((v) => v.name === uniqueName)) {
uniqueName = `${variable.name} (${nameIndex})`
nameIndex++
}
if (variable.type === 'string') {
variable.type = 'plain'
}
const newVariable: Variable = {
id,
workflowId: variable.workflowId,
name: uniqueName,
type: variable.type,
value: variable.value ?? '',
validationError: undefined,
}
const validationError = validateVariable(newVariable)
if (validationError) {
newVariable.validationError = validationError
}
set((state) => ({
variables: {
...state.variables,
[id]: newVariable,
},
}))
return id
},
updateVariable: (id, update) => {
set((state) => {
const existing = state.variables[id]
if (!existing) return state
// Handle name changes: keep references in sync across workflow values
if (update.name !== undefined) {
const oldVariableName = existing.name
const newName = String(update.name).trim()
if (!newName) {
update = { ...update, name: undefined }
} else if (newName !== oldVariableName) {
const subBlockStore = useSubBlockStore.getState()
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId) {
const workflowValues = subBlockStore.workflowValues[activeWorkflowId] || {}
const updatedWorkflowValues = { ...workflowValues }
Object.entries(workflowValues).forEach(([blockId, blockValues]) => {
Object.entries(blockValues as Record<string, any>).forEach(
([subBlockId, value]) => {
const oldVarName = normalizeName(oldVariableName)
const newVarName = normalizeName(newName)
const regex = new RegExp(`<variable\\.${oldVarName}>`, 'gi')
updatedWorkflowValues[blockId][subBlockId] = updateReferences(
value,
regex,
`<variable.${newVarName}>`
)
function updateReferences(
val: any,
refRegex: RegExp,
replacement: string
): any {
if (typeof val === 'string') {
return refRegex.test(val) ? val.replace(refRegex, replacement) : val
}
if (Array.isArray(val)) {
return val.map((item) => updateReferences(item, refRegex, replacement))
}
if (val !== null && typeof val === 'object') {
const result: Record<string, any> = { ...val }
for (const key in result) {
result[key] = updateReferences(result[key], refRegex, replacement)
}
return result
}
return val
}
}
)
})
useSubBlockStore.setState({
workflowValues: {
...subBlockStore.workflowValues,
[activeWorkflowId]: updatedWorkflowValues,
},
})
}
}
}
// Handle deprecated -> new type migration
if (update.type === 'string') {
update = { ...update, type: 'plain' as VariableType }
}
const updated: Variable = {
...existing,
...update,
validationError: undefined,
}
// Validate only when type or value changed
if (update.type || update.value !== undefined) {
updated.validationError = validateVariable(updated)
}
return {
variables: {
...state.variables,
[id]: updated,
},
}
if (!variable.name || /^variable\d+$/.test(variable.name)) {
const existingNumbers = workflowVariables
.map((v) => {
const match = v.name.match(/^variable(\d+)$/)
return match ? Number.parseInt(match[1]) : 0
})
},
.filter((n) => !Number.isNaN(n))
deleteVariable: (id) => {
set((state) => {
if (!state.variables[id]) return state
const { [id]: _deleted, ...rest } = state.variables
return { variables: rest }
})
},
const nextNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1
getVariablesByWorkflowId: (workflowId) => {
return Object.values(get().variables).filter((v) => v.workflowId === workflowId)
},
}),
{
name: 'variables-modal-store',
variable.name = `variable${nextNumber}`
}
)
)
)
/**
* Get default floating variables modal dimensions.
*/
export const getDefaultVariablesDimensions = (): VariablesDimensions => ({
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
})
let uniqueName = variable.name
let nameIndex = 1
while (workflowVariables.some((v) => v.name === uniqueName)) {
uniqueName = `${variable.name} (${nameIndex})`
nameIndex++
}
if (variable.type === 'string') {
variable.type = 'plain'
}
const newVariable: Variable = {
id,
workflowId: variable.workflowId,
name: uniqueName,
type: variable.type,
value: variable.value || '',
validationError: undefined,
}
const validationError = validateVariable(newVariable)
if (validationError) {
newVariable.validationError = validationError
}
set((state) => ({
variables: {
...state.variables,
[id]: newVariable,
},
}))
return id
},
updateVariable: (id, update) => {
set((state) => {
if (!state.variables[id]) return state
if (update.name !== undefined) {
const oldVariable = state.variables[id]
const oldVariableName = oldVariable.name
const newName = update.name.trim()
if (!newName) {
update = { ...update }
update.name = undefined
} else if (newName !== oldVariableName) {
const subBlockStore = useSubBlockStore.getState()
const targetWorkflowId = oldVariable.workflowId
if (targetWorkflowId) {
const workflowValues = subBlockStore.workflowValues[targetWorkflowId] || {}
const updatedWorkflowValues = { ...workflowValues }
const changedSubBlocks: Array<{ blockId: string; subBlockId: string; value: any }> =
[]
const oldVarName = normalizeName(oldVariableName)
const newVarName = normalizeName(newName)
const regex = new RegExp(`<variable\\.${oldVarName}>`, 'gi')
const updateReferences = (value: any, pattern: RegExp, replacement: string): any => {
if (typeof value === 'string') {
return pattern.test(value) ? value.replace(pattern, replacement) : value
}
if (Array.isArray(value)) {
return value.map((item) => updateReferences(item, pattern, replacement))
}
if (value !== null && typeof value === 'object') {
const result = { ...value }
for (const key in result) {
result[key] = updateReferences(result[key], pattern, replacement)
}
return result
}
return value
}
Object.entries(workflowValues).forEach(([blockId, blockValues]) => {
Object.entries(blockValues as Record<string, any>).forEach(
([subBlockId, value]) => {
const updatedValue = updateReferences(value, regex, `<variable.${newVarName}>`)
if (JSON.stringify(updatedValue) !== JSON.stringify(value)) {
if (!updatedWorkflowValues[blockId]) {
updatedWorkflowValues[blockId] = { ...workflowValues[blockId] }
}
updatedWorkflowValues[blockId][subBlockId] = updatedValue
changedSubBlocks.push({ blockId, subBlockId, value: updatedValue })
}
}
)
})
// Update local state
useSubBlockStore.setState({
workflowValues: {
...subBlockStore.workflowValues,
[targetWorkflowId]: updatedWorkflowValues,
},
})
// Queue operations for persistence via socket
const operationQueue = useOperationQueueStore.getState()
for (const { blockId, subBlockId, value } of changedSubBlocks) {
operationQueue.addToQueue({
id: crypto.randomUUID(),
operation: {
operation: 'subblock-update',
target: 'subblock',
payload: { blockId, subblockId: subBlockId, value },
},
workflowId: targetWorkflowId,
userId: 'system',
})
}
}
}
}
if (update.type === 'string') {
update = { ...update, type: 'plain' }
}
const updatedVariable: Variable = {
...state.variables[id],
...update,
validationError: undefined,
}
if (update.type || update.value !== undefined) {
updatedVariable.validationError = validateVariable(updatedVariable)
}
const updated = {
...state.variables,
[id]: updatedVariable,
}
return { variables: updated }
})
},
deleteVariable: (id) => {
set((state) => {
if (!state.variables[id]) return state
const { [id]: _, ...rest } = state.variables
return { variables: rest }
})
},
getVariablesByWorkflowId: (workflowId) => {
return Object.values(get().variables).filter((variable) => variable.workflowId === workflowId)
},
}))
)

View File

@@ -1,19 +1,47 @@
/**
* Variable types supported by the variables modal/editor.
* Note: 'string' is deprecated. Use 'plain' for freeform text values instead.
* Variable types supported in the application
* Note: 'string' is deprecated - use 'plain' for text values instead
*/
export type VariableType = 'plain' | 'number' | 'boolean' | 'object' | 'array' | 'string'
/**
* Workflow-scoped variable model.
* Represents a workflow variable with workflow-specific naming
* Variable names must be unique within each workflow
*/
export interface Variable {
id: string
workflowId: string
name: string
name: string // Must be unique per workflow
type: VariableType
value: unknown
validationError?: string
validationError?: string // Tracks format validation errors
}
export interface VariablesStore {
variables: Record<string, Variable>
isLoading: boolean
error: string | null
isEditing: string | null
/**
* Adds a new variable with automatic name uniqueness validation
* If a variable with the same name exists, it will be suffixed with a number
* Optionally accepts a predetermined ID for collaborative operations
*/
addVariable: (variable: Omit<Variable, 'id'>, providedId?: string) => string
/**
* Updates a variable, ensuring name remains unique within the workflow
* If an updated name conflicts with existing ones, a numbered suffix is added
*/
updateVariable: (id: string, update: Partial<Omit<Variable, 'id' | 'workflowId'>>) => void
deleteVariable: (id: string) => void
/**
* Returns all variables for a specific workflow
*/
getVariablesByWorkflowId: (workflowId: string) => Variable[]
}
/**
@@ -33,11 +61,10 @@ export interface VariablesDimensions {
}
/**
* Public store interface for variables editor/modal.
* Combines UI state of the floating modal and the variables data/actions.
* UI-only store interface for the floating variables modal.
* Variable data lives in the variables data store (`@/stores/variables/store`).
*/
export interface VariablesStore {
// UI State
export interface VariablesModalStore {
isOpen: boolean
position: VariablesPosition | null
width: number
@@ -46,16 +73,4 @@ export interface VariablesStore {
setPosition: (position: VariablesPosition) => void
setDimensions: (dimensions: VariablesDimensions) => void
resetPosition: () => void
// Data
variables: Record<string, Variable>
isLoading: boolean
error: string | null
// Actions
loadForWorkflow: (workflowId: string) => Promise<void>
addVariable: (variable: Omit<Variable, 'id'>, providedId?: string) => string
updateVariable: (id: string, update: Partial<Omit<Variable, 'id' | 'workflowId'>>) => void
deleteVariable: (id: string) => void
getVariablesByWorkflowId: (workflowId: string) => Variable[]
}

View File

@@ -3,7 +3,7 @@ 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'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('Workflows')
@@ -30,9 +30,6 @@ export function getWorkflowWithValues(workflowId: string, workspaceId: string) {
return null
}
// Get deployment status from registry
const deploymentStatus = useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)
// Use the current state from the store (only available for active workflow)
const workflowState: WorkflowState = useWorkflowStore.getState().getWorkflowState()
@@ -52,110 +49,10 @@ export function getWorkflowWithValues(workflowId: string, workspaceId: string) {
loops: workflowState.loops,
parallels: workflowState.parallels,
lastSaved: workflowState.lastSaved,
// Get deployment fields from registry for API compatibility
isDeployed: deploymentStatus?.isDeployed || false,
deployedAt: deploymentStatus?.deployedAt,
},
}
}
/**
* Get a specific block with its subblock values merged in
* @param blockId ID of the block to retrieve
* @returns The block with merged subblock values or null if not found
*/
export function getBlockWithValues(blockId: string): BlockState | null {
const workflowState = useWorkflowStore.getState()
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId || !workflowState.blocks[blockId]) return null
const mergedBlocks = mergeSubblockState(workflowState.blocks, activeWorkflowId, blockId)
return mergedBlocks[blockId] || 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(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
const activeMetadata = activeWorkflowId
? workflows.find((w) => w.id === activeWorkflowId)
: undefined
if (activeWorkflowId && activeMetadata) {
const metadata = activeMetadata
// Get deployment status from registry
const deploymentStatus = useWorkflowRegistry
.getState()
.getWorkflowDeploymentStatus(activeWorkflowId)
// Ensure state has all required fields for Zod validation
const workflowState: WorkflowState = {
...useWorkflowStore.getState().getWorkflowState(),
// Ensure fallback values for safer handling
blocks: currentState.blocks || {},
edges: currentState.edges || [],
loops: currentState.loops || {},
parallels: currentState.parallels || {},
lastSaved: currentState.lastSaved || Date.now(),
}
// Merge the subblock values for this specific workflow
const mergedBlocks = mergeSubblockState(workflowState.blocks, activeWorkflowId)
// Include the API key in the state if it exists in the deployment status
const apiKey = deploymentStatus?.apiKey
result[activeWorkflowId] = {
id: activeWorkflowId,
name: metadata.name,
description: metadata.description,
color: metadata.color || '#3972F6',
folderId: metadata.folderId,
state: {
blocks: mergedBlocks,
edges: workflowState.edges,
loops: workflowState.loops,
parallels: workflowState.parallels,
lastSaved: workflowState.lastSaved,
// Get deployment fields from registry for API compatibility
isDeployed: deploymentStatus?.isDeployed || false,
deployedAt: deploymentStatus?.deployedAt,
},
// Include API key if available
apiKey,
}
// Only include workspaceId if it's not null/undefined
if (metadata.workspaceId) {
result[activeWorkflowId].workspaceId = metadata.workspaceId
}
}
return result
}
export { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export type { WorkflowMetadata } from '@/stores/workflows/registry/types'
export { useSubBlockStore } from '@/stores/workflows/subblock/store'

View File

@@ -3,14 +3,12 @@ import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import type { WorkflowDeploymentInfo } from '@/hooks/queries/deployments'
import { deploymentKeys } from '@/hooks/queries/deployments'
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,
WorkflowRegistry,
} from '@/stores/workflows/registry/types'
import { useVariablesStore } from '@/stores/variables/store'
import type { Variable } from '@/stores/variables/types'
import type { HydrationState, 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'
@@ -34,7 +32,6 @@ function resetWorkflowStores() {
edges: [],
loops: {},
parallels: {},
deploymentStatuses: {},
lastSaved: Date.now(),
})
@@ -48,7 +45,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
(set, get) => ({
activeWorkflowId: null,
error: null,
deploymentStatuses: {},
hydration: initialHydration,
clipboard: null,
pendingSelection: null,
@@ -61,7 +57,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
set({
activeWorkflowId: null,
deploymentStatuses: {},
error: null,
hydration: {
phase: 'idle',
@@ -73,74 +68,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
})
},
getWorkflowDeploymentStatus: (workflowId: string | null): DeploymentStatus | null => {
if (!workflowId) {
workflowId = get().activeWorkflowId
if (!workflowId) return null
}
const { deploymentStatuses = {} } = get()
if (deploymentStatuses[workflowId]) {
return deploymentStatuses[workflowId]
}
return null
},
setDeploymentStatus: (
workflowId: string | null,
isDeployed: boolean,
deployedAt?: Date,
apiKey?: string
) => {
if (!workflowId) {
workflowId = get().activeWorkflowId
if (!workflowId) return
}
set((state) => ({
deploymentStatuses: {
...state.deploymentStatuses,
[workflowId as string]: {
isDeployed,
deployedAt: deployedAt || (isDeployed ? new Date() : undefined),
apiKey,
needsRedeployment: isDeployed
? false
: (state.deploymentStatuses?.[workflowId as string]?.needsRedeployment ?? false),
},
},
}))
},
setWorkflowNeedsRedeployment: (workflowId: string | null, needsRedeployment: boolean) => {
if (!workflowId) {
workflowId = get().activeWorkflowId
if (!workflowId) return
}
set((state) => {
const deploymentStatuses = state.deploymentStatuses || {}
const currentStatus = deploymentStatuses[workflowId as string] || { isDeployed: false }
return {
deploymentStatuses: {
...deploymentStatuses,
[workflowId as string]: {
...currentStatus,
needsRedeployment,
},
},
}
})
const { activeWorkflowId } = get()
if (workflowId === activeWorkflowId) {
useWorkflowStore.getState().setNeedsRedeploymentFlag(needsRedeployment)
}
},
loadWorkflowState: async (workflowId: string) => {
const workspaceId = get().hydration.workspaceId
if (!workspaceId) {
@@ -170,20 +97,19 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
}
const workflowData = (await response.json()).data
const nextDeploymentStatuses =
workflowData?.isDeployed || workflowData?.deployedAt
? {
...get().deploymentStatuses,
[workflowId]: {
isDeployed: workflowData.isDeployed || false,
deployedAt: workflowData.deployedAt
? new Date(workflowData.deployedAt)
: undefined,
apiKey: workflowData.apiKey || undefined,
needsRedeployment: false,
},
}
: get().deploymentStatuses
if (workflowData?.isDeployed !== undefined) {
getQueryClient().setQueryData<WorkflowDeploymentInfo>(
deploymentKeys.info(workflowId),
(prev) => ({
isDeployed: workflowData.isDeployed ?? false,
deployedAt: workflowData.deployedAt ?? null,
apiKey: workflowData.apiKey ?? prev?.apiKey ?? null,
needsRedeployment: prev?.needsRedeployment ?? false,
isPublicApi: prev?.isPublicApi ?? false,
})
)
}
let workflowState: WorkflowState
@@ -195,7 +121,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
loops: workflowData.state.loops || {},
parallels: workflowData.state.parallels || {},
lastSaved: Date.now(),
deploymentStatuses: nextDeploymentStatuses,
}
} else {
workflowState = {
@@ -204,7 +129,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
edges: [],
loops: {},
parallels: {},
deploymentStatuses: nextDeploymentStatuses,
lastSaved: Date.now(),
}
@@ -250,7 +174,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
set((state) => ({
activeWorkflowId: workflowId,
error: null,
deploymentStatuses: nextDeploymentStatuses,
hydration: {
phase: 'ready',
workspaceId: state.hydration.workspaceId,
@@ -367,7 +290,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
set({
activeWorkflowId: null,
deploymentStatuses: {},
error: null,
hydration: initialHydration,
clipboard: null,

View File

@@ -1,13 +1,6 @@
import type { Edge } from 'reactflow'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
export interface DeploymentStatus {
isDeployed: boolean
deployedAt?: Date
apiKey?: string
needsRedeployment?: boolean
}
export interface ClipboardData {
blocks: Record<string, BlockState>
edges: Edge[]
@@ -45,7 +38,6 @@ export interface HydrationState {
export interface WorkflowRegistryState {
activeWorkflowId: string | null
error: string | null
deploymentStatuses: Record<string, DeploymentStatus>
hydration: HydrationState
clipboard: ClipboardData | null
pendingSelection: string[] | null
@@ -57,14 +49,6 @@ export interface WorkflowRegistryActions {
switchToWorkspace: (id: string) => void
markWorkflowCreating: (workflowId: string) => void
markWorkflowCreated: (workflowId: string | null) => void
getWorkflowDeploymentStatus: (workflowId: string | null) => DeploymentStatus | null
setDeploymentStatus: (
workflowId: string | null,
isDeployed: boolean,
deployedAt?: Date,
apiKey?: string
) => void
setWorkflowNeedsRedeployment: (workflowId: string | null, needsRedeployment: boolean) => void
copyBlocks: (blockIds: string[]) => void
preparePasteData: (positionOffset?: { x: number; y: number }) => {
blocks: Record<string, BlockState>

View File

@@ -107,8 +107,6 @@ const initialState = {
loops: {},
parallels: {},
lastSaved: undefined,
deploymentStatuses: {},
needsRedeployment: false,
}
export const useWorkflowStore = create<WorkflowStore>()(
@@ -116,10 +114,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
(set, get) => ({
...initialState,
setNeedsRedeploymentFlag: (needsRedeployment: boolean) => {
set({ needsRedeployment })
},
setCurrentWorkflowId: (currentWorkflowId) => {
set({ currentWorkflowId })
},
@@ -540,8 +534,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
loops: state.loops,
parallels: state.parallels,
lastSaved: state.lastSaved,
deploymentStatuses: state.deploymentStatuses,
needsRedeployment: state.needsRedeployment,
}
},
replaceWorkflowState: (
@@ -580,11 +572,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
edges: nextEdges,
loops: nextLoops,
parallels: nextParallels,
deploymentStatuses: nextState.deploymentStatuses || state.deploymentStatuses,
needsRedeployment:
nextState.needsRedeployment !== undefined
? nextState.needsRedeployment
: state.needsRedeployment,
lastSaved:
options?.updateLastSaved === true
? Date.now()
@@ -1141,67 +1128,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
}))
},
revertToDeployedState: async (deployedState: WorkflowState) => {
const activeWorkflowId = get().currentWorkflowId
if (!activeWorkflowId) {
logger.error('Cannot revert: no active workflow ID')
return
}
const deploymentStatus = get().deploymentStatuses?.[activeWorkflowId]
get().replaceWorkflowState({
...deployedState,
needsRedeployment: false,
deploymentStatuses: {
...get().deploymentStatuses,
...(deploymentStatus ? { [activeWorkflowId]: deploymentStatus } : {}),
},
})
const values: Record<string, Record<string, any>> = {}
Object.entries(deployedState.blocks).forEach(([blockId, block]) => {
values[blockId] = {}
Object.entries(block.subBlocks || {}).forEach(([subBlockId, subBlock]) => {
values[blockId][subBlockId] = subBlock.value
})
})
useSubBlockStore.setState({
workflowValues: {
...useSubBlockStore.getState().workflowValues,
[activeWorkflowId]: values,
},
})
get().updateLastSaved()
// Call API to persist the revert to normalized tables
try {
const response = await fetch(
`/api/workflows/${activeWorkflowId}/deployments/active/revert`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
}
)
if (!response.ok) {
const errorData = await response.json()
logger.error('Failed to persist revert to deployed state:', errorData.error)
// Don't throw error to avoid breaking the UI, but log it
} else {
logger.info('Successfully persisted revert to deployed state')
}
} catch (error) {
logger.error('Error calling revert to deployed API:', error)
// Don't throw error to avoid breaking the UI
}
},
toggleBlockAdvancedMode: (id: string) => {
const block = get().blocks[id]
if (!block) return

View File

@@ -1,6 +1,5 @@
import type { Edge } from 'reactflow'
import type { OutputFieldDefinition, SubBlockType } from '@/blocks/types'
import type { DeploymentStatus } from '@/stores/workflows/registry/types'
export const SUBFLOW_TYPES = {
LOOP: 'loop',
@@ -173,8 +172,6 @@ export interface WorkflowState {
exportedAt?: string
}
variables?: Record<string, Variable>
deploymentStatuses?: Record<string, DeploymentStatus>
needsRedeployment?: boolean
dragStartPosition?: DragStartPosition | null
}
@@ -228,8 +225,6 @@ export interface WorkflowActions {
updateParallelType: (parallelId: string, parallelType: 'count' | 'collection') => void
generateLoopBlocks: () => Record<string, Loop>
generateParallelBlocks: () => Record<string, Parallel>
setNeedsRedeploymentFlag: (needsRedeployment: boolean) => void
revertToDeployedState: (deployedState: WorkflowState) => void
toggleBlockAdvancedMode: (id: string) => void
setDragStartPosition: (position: DragStartPosition | null) => void
getDragStartPosition: () => DragStartPosition | null

View File

@@ -25,6 +25,7 @@ const {
mockGetCustomToolById,
mockListCustomTools,
mockGetCustomToolByIdOrTitle,
mockGenerateInternalToken,
} = vi.hoisted(() => ({
mockIsHosted: { value: false },
mockEnv: { NEXT_PUBLIC_APP_URL: 'http://localhost:3000' } as Record<string, string | undefined>,
@@ -38,6 +39,7 @@ const {
mockGetCustomToolById: vi.fn(),
mockListCustomTools: vi.fn(),
mockGetCustomToolByIdOrTitle: vi.fn(),
mockGenerateInternalToken: vi.fn(),
}))
// Mock feature flags
@@ -65,6 +67,10 @@ vi.mock('@/lib/api-key/byok', () => ({
getBYOKKey: (...args: unknown[]) => mockGetBYOKKey(...args),
}))
vi.mock('@/lib/auth/internal', () => ({
generateInternalToken: (...args: unknown[]) => mockGenerateInternalToken(...args),
}))
vi.mock('@/lib/billing/core/usage-log', () => ({}))
vi.mock('@/lib/core/rate-limiter/hosted-key', () => ({
@@ -193,8 +199,8 @@ vi.mock('@/tools/registry', () => {
return { tools: mockTools }
})
// Mock custom tools - define mock data inside factory function
vi.mock('@/hooks/queries/utils/custom-tool-cache', () => {
// Mock query client for custom tool cache reads
vi.mock('@/app/_shell/providers/get-query-client', () => {
const mockCustomTool = {
id: 'custom-tool-123',
title: 'Custom Weather Tool',
@@ -214,13 +220,12 @@ vi.mock('@/hooks/queries/utils/custom-tool-cache', () => {
},
}
return {
getCustomTool: (toolId: string) => {
if (toolId === 'custom-tool-123') {
return mockCustomTool
}
return undefined
},
getCustomTools: () => [mockCustomTool],
getQueryClient: () => ({
getQueryData: (key: string[]) => {
if (key[0] === 'customTools') return [mockCustomTool]
return undefined
},
}),
}
})
@@ -1155,6 +1160,34 @@ describe('MCP Tool Execution', () => {
expect(result.timing).toBeDefined()
})
it('should embed userId in JWT when executionContext is undefined (agent block path)', async () => {
mockGenerateInternalToken.mockResolvedValue('test-token')
global.fetch = Object.assign(
vi.fn().mockImplementation(async () => ({
ok: true,
status: 200,
json: () =>
Promise.resolve({
success: true,
data: { output: { content: [{ type: 'text', text: 'OK' }] } },
}),
})),
{ preconnect: vi.fn() }
) as typeof fetch
await executeTool('mcp-123-test_tool', {
query: 'test',
_context: {
workspaceId: 'workspace-456',
workflowId: 'workflow-789',
userId: 'user-abc',
},
})
expect(mockGenerateInternalToken).toHaveBeenCalledWith('user-abc')
})
describe('Tool request retries', () => {
function makeJsonResponse(
status: number,

View File

@@ -1552,11 +1552,13 @@ async function executeMcpTool(
const baseUrl = getInternalApiBaseUrl()
const mcpScope = resolveToolScope(params, executionContext)
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (typeof window === 'undefined') {
try {
const internalToken = await generateInternalToken(executionContext?.userId)
const internalToken = await generateInternalToken(mcpScope.userId)
headers.Authorization = `Bearer ${internalToken}`
} catch (error) {
logger.error(`[${actualRequestId}] Failed to generate internal token:`, error)
@@ -1587,8 +1589,6 @@ async function executeMcpTool(
)
}
const mcpScope = resolveToolScope(params, executionContext)
if (mcpScope.callChain && mcpScope.callChain.length > 0) {
headers[SIM_VIA_HEADER] = serializeCallChain(mcpScope.callChain)
}

View File

@@ -21,24 +21,23 @@ vi.mock('@/lib/core/security/input-validation.server', () => ({
secureFetchWithPinnedIP: vi.fn(),
}))
vi.mock('@/stores/settings/environment', () => {
const mockStore = {
getAllVariables: vi.fn().mockReturnValue({
API_KEY: { value: 'mock-api-key' },
BASE_URL: { value: 'https://example.com' },
}),
}
const { mockGetQueryData } = vi.hoisted(() => ({
mockGetQueryData: vi.fn(),
}))
return {
useEnvironmentStore: {
getState: vi.fn().mockImplementation(() => mockStore),
},
}
})
vi.mock('@/app/_shell/providers/get-query-client', () => ({
getQueryClient: () => ({
getQueryData: mockGetQueryData,
}),
}))
const originalWindow = global.window
beforeEach(() => {
global.window = {} as any
mockGetQueryData.mockReturnValue({
API_KEY: { key: 'API_KEY', value: 'mock-api-key' },
BASE_URL: { key: 'BASE_URL', value: 'https://example.com' },
})
})
afterEach(() => {
@@ -651,15 +650,8 @@ describe('createParamSchema', () => {
})
describe('getClientEnvVars', () => {
it.concurrent('should return environment variables from store in browser environment', () => {
const mockStoreGetter = () => ({
getAllVariables: () => ({
API_KEY: { value: 'mock-api-key' },
BASE_URL: { value: 'https://example.com' },
}),
})
const result = getClientEnvVars(mockStoreGetter)
it('should return environment variables from React Query cache in browser environment', () => {
const result = getClientEnvVars()
expect(result).toEqual({
API_KEY: 'mock-api-key',
@@ -667,7 +659,7 @@ describe('getClientEnvVars', () => {
})
})
it.concurrent('should return empty object in server environment', () => {
it('should return empty object in server environment', () => {
global.window = undefined as any
const result = getClientEnvVars()
@@ -677,7 +669,7 @@ describe('getClientEnvVars', () => {
})
describe('createCustomToolRequestBody', () => {
it.concurrent('should create request body function for client-side execution', () => {
it('should create request body function for client-side execution', () => {
const customTool = {
code: 'return a + b',
schema: {
@@ -687,14 +679,7 @@ describe('createCustomToolRequestBody', () => {
},
}
const mockStoreGetter = () => ({
getAllVariables: () => ({
API_KEY: { value: 'mock-api-key' },
BASE_URL: { value: 'https://example.com' },
}),
})
const bodyFn = createCustomToolRequestBody(customTool, true, undefined, mockStoreGetter)
const bodyFn = createCustomToolRequestBody(customTool, true)
const result = bodyFn({ a: 5, b: 3 })
expect(result).toEqual({

View File

@@ -1,7 +1,9 @@
import { createLogger } from '@sim/logger'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import type { CustomToolDefinition } from '@/hooks/queries/custom-tools'
import { useEnvironmentStore } from '@/stores/settings/environment'
import { environmentKeys } from '@/hooks/queries/environment'
import type { EnvironmentVariable } from '@/stores/settings/environment'
import { tools } from '@/tools/registry'
import type { ToolConfig } from '@/tools/types'
@@ -215,20 +217,20 @@ export function createParamSchema(customTool: any): Record<string, any> {
}
/**
* Get environment variables from store (client-side only)
* @param getStore Optional function to get the store (useful for testing)
* Get environment variables from React Query cache (client-side only)
*/
export function getClientEnvVars(getStore?: () => any): Record<string, string> {
export function getClientEnvVars(): Record<string, string> {
if (typeof window === 'undefined') return {}
try {
// Allow injecting the store for testing
const envStore = getStore ? getStore() : useEnvironmentStore.getState()
const allEnvVars = envStore.getAllVariables()
const allEnvVars =
getQueryClient().getQueryData<Record<string, EnvironmentVariable>>(
environmentKeys.personal()
) ?? {}
// Convert environment variables to a simple key-value object
return Object.entries(allEnvVars).reduce(
(acc, [key, variable]: [string, any]) => {
(acc, [key, variable]) => {
acc[key] = variable.value
return acc
},
@@ -245,20 +247,14 @@ export function getClientEnvVars(getStore?: () => any): Record<string, string> {
* @param customTool The custom tool configuration
* @param isClient Whether running on client side
* @param workflowId Optional workflow ID for server-side
* @param getStore Optional function to get the store (useful for testing)
*/
export function createCustomToolRequestBody(
customTool: any,
isClient = true,
workflowId?: string,
getStore?: () => any
) {
export function createCustomToolRequestBody(customTool: any, isClient = true, workflowId?: string) {
return (params: Record<string, any>) => {
// Get environment variables - try multiple sources in order of preference:
// 1. envVars parameter (passed from provider/agent context)
// 2. Client-side store (if running in browser)
// 3. Empty object (fallback)
const envVars = params.envVars || (isClient ? getClientEnvVars(getStore) : {})
const envVars = params.envVars || (isClient ? getClientEnvVars() : {})
// Get workflow variables from params (passed from execution context)
const workflowVariables = params.workflowVariables || {}