Files
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

420 lines
13 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { generateRandomHex } from '@sim/utils/random'
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 { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-envelope'
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
import { useOperationQueueStore } from '@/stores/operation-queue/store'
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'
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowRegistry')
const initialHydration: HydrationState = {
phase: 'idle',
workspaceId: null,
workflowId: null,
requestId: null,
error: null,
}
const createRequestId = () => `${Date.now()}-${generateRandomHex(8)}`
function resetWorkflowStores() {
useWorkflowStore.setState({
currentWorkflowId: null,
blocks: {},
edges: [],
loops: {},
parallels: {},
lastSaved: Date.now(),
})
useSubBlockStore.setState({
workflowValues: {},
})
}
export const useWorkflowRegistry = create<WorkflowRegistry>()(
devtools(
(set, get) => ({
activeWorkflowId: null,
error: null,
hydration: initialHydration,
clipboard: null,
pendingSelection: null,
switchToWorkspace: (workspaceId: string) => {
logger.info(`Switching to workspace: ${workspaceId}`)
resetWorkflowStores()
// Workflow stores are fully reset and reloaded from the server in the new
// workspace, so a previously tripped offline mode must not carry over.
useOperationQueueStore.getState().clearError()
void invalidateWorkflowLists(getQueryClient(), workspaceId)
set({
activeWorkflowId: null,
error: null,
hydration: {
phase: 'idle',
workspaceId,
workflowId: null,
requestId: null,
error: null,
},
})
},
loadWorkflowState: async (workflowId: string) => {
const workspaceId = get().hydration.workspaceId
if (!workspaceId) {
const message = `Cannot load workflow ${workflowId} without a workspace scope`
logger.error(message)
set({ error: message })
throw new Error(message)
}
const requestId = createRequestId()
set((state) => ({
error: null,
hydration: {
phase: 'state-loading',
workspaceId: workspaceId ?? state.hydration.workspaceId,
workflowId,
requestId,
error: null,
},
}))
try {
const workflowData = await getQueryClient().fetchQuery({
queryKey: workflowKeys.state(workflowId),
queryFn: ({ signal }) => fetchWorkflowEnvelope(workflowId, signal),
staleTime: 0,
})
const deployedAt = workflowData.deployedAt ? workflowData.deployedAt.toISOString() : null
getQueryClient().setQueryData<WorkflowDeploymentInfo>(
deploymentKeys.info(workflowId),
(prev) => ({
isDeployed: workflowData.isDeployed,
deployedAt,
apiKey: prev?.apiKey ?? null,
needsRedeployment: prev?.needsRedeployment ?? false,
isPublicApi: workflowData.isPublicApi,
})
)
let workflowState: WorkflowState
if (workflowData?.state) {
const wireState = workflowData.state as Pick<
WorkflowState,
'blocks' | 'edges' | 'loops' | 'parallels'
>
workflowState = {
currentWorkflowId: workflowId,
blocks: wireState.blocks || {},
edges: wireState.edges || [],
loops: wireState.loops || {},
parallels: wireState.parallels || {},
lastSaved: Date.now(),
}
} else {
workflowState = {
currentWorkflowId: workflowId,
blocks: {},
edges: [],
loops: {},
parallels: {},
lastSaved: Date.now(),
}
logger.info(
`Workflow ${workflowId} has no state yet - will load from DB or show empty canvas`
)
}
const currentHydration = get().hydration
if (
currentHydration.requestId !== requestId ||
currentHydration.workflowId !== workflowId
) {
logger.info('Discarding stale workflow hydration result', {
workflowId,
requestId,
})
return
}
useWorkflowStore.getState().replaceWorkflowState(workflowState)
useSubBlockStore.getState().initializeFromWorkflow(workflowId, workflowState.blocks || {})
const wireVariables = workflowData.variables
if (wireVariables) {
useVariablesStore.setState((state) => {
const withoutWorkflow = Object.fromEntries(
Object.entries(state.variables).filter(
(entry): entry is [string, Variable] => entry[1].workflowId !== workflowId
)
)
return {
variables: {
...withoutWorkflow,
...(wireVariables as Record<string, Variable>),
},
}
})
}
window.dispatchEvent(
new CustomEvent('active-workflow-changed', {
detail: { workflowId },
})
)
set((state) => ({
activeWorkflowId: workflowId,
error: null,
hydration: {
phase: 'ready',
workspaceId: state.hydration.workspaceId,
workflowId,
requestId,
error: null,
},
}))
logger.info(`Switched to workflow ${workflowId}`)
} catch (error) {
const message =
error instanceof Error
? error.message
: `Failed to load workflow ${workflowId}: Unknown error`
logger.error(message)
const currentHydration = get().hydration
if (
currentHydration.requestId !== requestId ||
currentHydration.workflowId !== workflowId
) {
logger.info('Discarding stale workflow error', { workflowId, requestId })
return
}
set((state) => ({
error: message,
hydration: {
phase: 'error',
workspaceId: state.hydration.workspaceId,
workflowId,
requestId: null,
error: message,
},
}))
throw error
}
},
setActiveWorkflow: async (id: string) => {
const { activeWorkflowId, hydration } = get()
const workflowStoreState = useWorkflowStore.getState()
const hasWorkflowData = Object.keys(workflowStoreState.blocks).length > 0
const isFullyHydrated =
activeWorkflowId === id &&
hasWorkflowData &&
hydration.phase === 'ready' &&
hydration.workflowId === id
if (isFullyHydrated) {
logger.info(`Already active workflow ${id} with data loaded, skipping switch`)
return
}
await get().loadWorkflowState(id)
},
markWorkflowCreating: (workflowId: string) => {
set((state) => ({
error: null,
hydration: {
phase: 'creating' as const,
workspaceId: state.hydration.workspaceId,
workflowId,
requestId: null,
error: null,
},
}))
logger.info(`Marked workflow ${workflowId} as creating`)
},
markWorkflowCreated: (workflowId: string | null) => {
const { hydration } = get()
if (!workflowId) {
if (hydration.phase === 'creating') {
set((state) => ({
hydration: {
...state.hydration,
phase: 'idle' as const,
workflowId: null,
error: null,
},
}))
}
return
}
if (hydration.phase !== 'creating' || hydration.workflowId !== workflowId) {
logger.info(
`Ignoring markWorkflowCreated for ${workflowId} — hydration is ${hydration.phase}/${hydration.workflowId}`
)
return
}
logger.info(`Workflow ${workflowId} created, loading state`)
get()
.loadWorkflowState(workflowId)
.catch((error) => {
logger.error(`Failed to load newly created workflow ${workflowId}:`, error)
})
},
logout: () => {
logger.info('Logging out - clearing all workflow data')
resetWorkflowStores()
// Clear the React Query cache to remove all server state
getQueryClient().clear()
set({
activeWorkflowId: null,
error: null,
hydration: initialHydration,
clipboard: null,
})
logger.info('Logout complete - all workflow data cleared')
},
copyBlocks: (blockIds: string[]) => {
if (blockIds.length === 0) return
const workflowStore = useWorkflowStore.getState()
const activeWorkflowId = get().activeWorkflowId
const subBlockStore = useSubBlockStore.getState()
const copiedBlocks: Record<string, BlockState> = {}
const copiedSubBlockValues: Record<string, Record<string, unknown>> = {}
const blockIdSet = new Set(blockIds)
blockIds.forEach((blockId) => {
const loop = workflowStore.loops[blockId]
if (loop?.nodes) loop.nodes.forEach((n) => blockIdSet.add(n))
const parallel = workflowStore.parallels[blockId]
if (parallel?.nodes) parallel.nodes.forEach((n) => blockIdSet.add(n))
})
blockIdSet.forEach((blockId) => {
const block = workflowStore.blocks[blockId]
if (block) {
copiedBlocks[blockId] = structuredClone(block)
if (activeWorkflowId) {
const blockValues = subBlockStore.workflowValues[activeWorkflowId]?.[blockId]
if (blockValues) {
copiedSubBlockValues[blockId] = structuredClone(blockValues)
}
}
}
})
const copiedEdges = workflowStore.edges.filter(
(edge) => blockIdSet.has(edge.source) && blockIdSet.has(edge.target)
)
const copiedLoops: Record<string, Loop> = {}
Object.entries(workflowStore.loops).forEach(([loopId, loop]) => {
if (blockIdSet.has(loopId)) {
copiedLoops[loopId] = structuredClone(loop)
}
})
const copiedParallels: Record<string, Parallel> = {}
Object.entries(workflowStore.parallels).forEach(([parallelId, parallel]) => {
if (blockIdSet.has(parallelId)) {
copiedParallels[parallelId] = structuredClone(parallel)
}
})
set({
clipboard: {
blocks: copiedBlocks,
edges: copiedEdges,
subBlockValues: copiedSubBlockValues,
loops: copiedLoops,
parallels: copiedParallels,
timestamp: Date.now(),
},
})
logger.info('Copied blocks to clipboard', { count: Object.keys(copiedBlocks).length })
},
preparePasteData: (positionOffset = DEFAULT_DUPLICATE_OFFSET) => {
const { clipboard, activeWorkflowId } = get()
if (!clipboard || Object.keys(clipboard.blocks).length === 0) return null
if (!activeWorkflowId) return null
const workflowStore = useWorkflowStore.getState()
const { blocks, edges, loops, parallels, subBlockValues } = regenerateBlockIds(
clipboard.blocks,
clipboard.edges,
clipboard.loops,
clipboard.parallels,
clipboard.subBlockValues,
positionOffset,
workflowStore.blocks,
getUniqueBlockName
)
return { blocks, edges, loops, parallels, subBlockValues }
},
hasClipboard: () => {
const { clipboard } = get()
return clipboard !== null && Object.keys(clipboard.blocks).length > 0
},
clearClipboard: () => {
set({ clipboard: null })
},
setPendingSelection: (blockIds: string[]) => {
set((state) => ({
pendingSelection: [...(state.pendingSelection ?? []), ...blockIds],
}))
},
clearPendingSelection: () => {
set({ pendingSelection: null })
},
}),
{ name: 'workflow-registry' }
)
)