chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*
|
||||
* Focused tests for the registry store's `loadWorkflowState` after the
|
||||
* workflow-state cache collapse: it hydrates the shared
|
||||
* `workflowKeys.state(id)` entry via `fetchQuery` (always-fresh,
|
||||
* `staleTime: 0`) and projects the envelope into the workflow / sub-block /
|
||||
* variables / deployment stores, guarding against superseded responses.
|
||||
*/
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockRequestJson, sharedQueryClient } = vi.hoisted(() => ({
|
||||
mockRequestJson: vi.fn(),
|
||||
sharedQueryClient: { current: null as unknown },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api/client/request', () => ({
|
||||
requestJson: mockRequestJson,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/_shell/providers/get-query-client', () => ({
|
||||
getQueryClient: () => sharedQueryClient.current as QueryClient,
|
||||
}))
|
||||
|
||||
const { replaceWorkflowState, initializeFromWorkflow, setVariablesState, clearError } = vi.hoisted(
|
||||
() => ({
|
||||
replaceWorkflowState: vi.fn(),
|
||||
initializeFromWorkflow: vi.fn(),
|
||||
setVariablesState: vi.fn(),
|
||||
clearError: vi.fn(),
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/stores/workflows/workflow/store', () => ({
|
||||
useWorkflowStore: {
|
||||
getState: () => ({ replaceWorkflowState, blocks: {} }),
|
||||
setState: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workflows/subblock/store', () => ({
|
||||
useSubBlockStore: {
|
||||
getState: () => ({ initializeFromWorkflow }),
|
||||
setState: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/variables/store', () => ({
|
||||
useVariablesStore: {
|
||||
getState: () => ({ variables: {} }),
|
||||
setState: (updater: unknown) => setVariablesState(updater),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/operation-queue/store', () => ({
|
||||
useOperationQueueStore: {
|
||||
getState: () => ({ clearError }),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/queries/utils/invalidate-workflow-lists', () => ({
|
||||
invalidateWorkflowLists: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workflows/utils', () => ({
|
||||
getUniqueBlockName: vi.fn(),
|
||||
regenerateBlockIds: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/autolayout/constants', () => ({
|
||||
DEFAULT_DUPLICATE_OFFSET: { x: 0, y: 0 },
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/queries/deployments', () => ({
|
||||
deploymentKeys: {
|
||||
infos: () => ['deployments', 'info'],
|
||||
info: (workflowId: string | null) => ['deployments', 'info', workflowId ?? ''],
|
||||
},
|
||||
}))
|
||||
|
||||
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
function makeEnvelope(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'wf-1',
|
||||
isDeployed: true,
|
||||
deployedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
isPublicApi: false,
|
||||
state: {
|
||||
blocks: { b1: { id: 'b1' } },
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
},
|
||||
variables: { v1: { id: 'v1', workflowId: 'wf-1', name: 'x' } },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('registry store loadWorkflowState (collapsed cache)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// The store dispatches an `active-workflow-changed` CustomEvent on the
|
||||
// window; provide a minimal stub under the node environment.
|
||||
vi.stubGlobal('window', { dispatchEvent: vi.fn() })
|
||||
sharedQueryClient.current = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
// Reset store to a clean state with a workspace scope so loadWorkflowState
|
||||
// does not bail on the missing-workspace guard.
|
||||
useWorkflowRegistry.setState({
|
||||
activeWorkflowId: null,
|
||||
error: null,
|
||||
hydration: {
|
||||
phase: 'idle',
|
||||
workspaceId: 'ws-1',
|
||||
workflowId: null,
|
||||
requestId: null,
|
||||
error: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('projects envelope state, variables, and deployment info into the stores', async () => {
|
||||
mockRequestJson.mockResolvedValue({ data: makeEnvelope() })
|
||||
|
||||
await useWorkflowRegistry.getState().loadWorkflowState('wf-1')
|
||||
|
||||
expect(replaceWorkflowState).toHaveBeenCalledTimes(1)
|
||||
expect(replaceWorkflowState.mock.calls[0][0]).toMatchObject({
|
||||
currentWorkflowId: 'wf-1',
|
||||
blocks: { b1: { id: 'b1' } },
|
||||
edges: [],
|
||||
})
|
||||
expect(initializeFromWorkflow).toHaveBeenCalledWith('wf-1', { b1: { id: 'b1' } })
|
||||
expect(setVariablesState).toHaveBeenCalledTimes(1)
|
||||
|
||||
const deploymentInfo = (sharedQueryClient.current as QueryClient).getQueryData([
|
||||
'deployments',
|
||||
'info',
|
||||
'wf-1',
|
||||
])
|
||||
expect(deploymentInfo).toMatchObject({
|
||||
isDeployed: true,
|
||||
isPublicApi: false,
|
||||
deployedAt: '2026-01-01T00:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(useWorkflowRegistry.getState().activeWorkflowId).toBe('wf-1')
|
||||
expect(useWorkflowRegistry.getState().hydration.phase).toBe('ready')
|
||||
})
|
||||
|
||||
it('hydrates the SAME workflowKeys.state(id) cache entry the hooks read', async () => {
|
||||
const envelope = makeEnvelope()
|
||||
mockRequestJson.mockResolvedValue({ data: envelope })
|
||||
|
||||
await useWorkflowRegistry.getState().loadWorkflowState('wf-1')
|
||||
|
||||
const client = sharedQueryClient.current as QueryClient
|
||||
const cached = client.getQueryData(workflowKeys.state('wf-1'))
|
||||
expect(cached).toBeDefined()
|
||||
expect((cached as { id: string }).id).toBe('wf-1')
|
||||
|
||||
// Exactly one cache entry exists for this endpoint — the shared one.
|
||||
const stateEntries = client
|
||||
.getQueryCache()
|
||||
.findAll({ queryKey: workflowKeys.states() })
|
||||
.filter((q) => q.queryKey[2] === 'wf-1')
|
||||
expect(stateEntries).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('re-fetches on every call (staleTime: 0, never served stale)', async () => {
|
||||
mockRequestJson.mockResolvedValue({ data: makeEnvelope() })
|
||||
|
||||
await useWorkflowRegistry.getState().loadWorkflowState('wf-1')
|
||||
await useWorkflowRegistry.getState().loadWorkflowState('wf-1')
|
||||
|
||||
expect(mockRequestJson).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('discards a superseded response via the staleness guard', async () => {
|
||||
// First load (wf-1) is in-flight; a second load (wf-2) supersedes the
|
||||
// hydration workflowId, then wf-1 finally resolves. The guard compares the
|
||||
// current hydration workflowId/requestId against the resolving request and
|
||||
// must discard the now-stale wf-1 projection.
|
||||
let resolveFirst: (value: unknown) => void = () => {}
|
||||
const firstPending = new Promise((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
|
||||
mockRequestJson
|
||||
.mockImplementationOnce(() => firstPending)
|
||||
.mockImplementationOnce(() => Promise.resolve({ data: makeEnvelope({ id: 'wf-2' }) }))
|
||||
|
||||
const firstLoad = useWorkflowRegistry.getState().loadWorkflowState('wf-1')
|
||||
const secondLoad = useWorkflowRegistry.getState().loadWorkflowState('wf-2')
|
||||
await secondLoad
|
||||
|
||||
expect(useWorkflowRegistry.getState().activeWorkflowId).toBe('wf-2')
|
||||
const projectionsAfterSecond = replaceWorkflowState.mock.calls.length
|
||||
|
||||
resolveFirst({ data: makeEnvelope({ id: 'wf-1' }) })
|
||||
await firstLoad
|
||||
|
||||
// The stale wf-1 result must not project again — hydration is now wf-2.
|
||||
expect(replaceWorkflowState.mock.calls.length).toBe(projectionsAfterSecond)
|
||||
expect(useWorkflowRegistry.getState().activeWorkflowId).toBe('wf-2')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,419 @@
|
||||
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' }
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Edge } from 'reactflow'
|
||||
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
|
||||
|
||||
interface ClipboardData {
|
||||
blocks: Record<string, BlockState>
|
||||
edges: Edge[]
|
||||
subBlockValues: Record<string, Record<string, unknown>>
|
||||
loops: Record<string, Loop>
|
||||
parallels: Record<string, Parallel>
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface WorkflowMetadata {
|
||||
id: string
|
||||
name: string
|
||||
lastModified: Date
|
||||
createdAt: Date
|
||||
description?: string
|
||||
workspaceId?: string
|
||||
folderId?: string | null
|
||||
sortOrder: number
|
||||
archivedAt?: Date | null
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
export type HydrationPhase = 'idle' | 'creating' | 'state-loading' | 'ready' | 'error'
|
||||
|
||||
export interface HydrationState {
|
||||
phase: HydrationPhase
|
||||
workspaceId: string | null
|
||||
workflowId: string | null
|
||||
requestId: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface WorkflowRegistryState {
|
||||
activeWorkflowId: string | null
|
||||
error: string | null
|
||||
hydration: HydrationState
|
||||
clipboard: ClipboardData | null
|
||||
pendingSelection: string[] | null
|
||||
}
|
||||
|
||||
interface WorkflowRegistryActions {
|
||||
setActiveWorkflow: (id: string) => Promise<void>
|
||||
loadWorkflowState: (workflowId: string) => Promise<void>
|
||||
switchToWorkspace: (id: string) => void
|
||||
markWorkflowCreating: (workflowId: string) => void
|
||||
markWorkflowCreated: (workflowId: string | null) => void
|
||||
copyBlocks: (blockIds: string[]) => void
|
||||
preparePasteData: (positionOffset?: { x: number; y: number }) => {
|
||||
blocks: Record<string, BlockState>
|
||||
edges: Edge[]
|
||||
loops: Record<string, Loop>
|
||||
parallels: Record<string, Parallel>
|
||||
subBlockValues: Record<string, Record<string, unknown>>
|
||||
} | null
|
||||
hasClipboard: () => boolean
|
||||
clearClipboard: () => void
|
||||
setPendingSelection: (blockIds: string[]) => void
|
||||
clearPendingSelection: () => void
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
export type WorkflowRegistry = WorkflowRegistryState & WorkflowRegistryActions
|
||||
@@ -0,0 +1,420 @@
|
||||
import { randomItem } from '@sim/utils/random'
|
||||
|
||||
// Cosmos-themed adjectives and nouns for creative workflow names (max 9 chars each)
|
||||
const ADJECTIVES = [
|
||||
// Light & Luminosity
|
||||
'Radiant',
|
||||
'Luminous',
|
||||
'Blazing',
|
||||
'Glowing',
|
||||
'Bright',
|
||||
'Gleaming',
|
||||
'Shining',
|
||||
'Lustrous',
|
||||
'Flaring',
|
||||
'Vivid',
|
||||
'Dazzling',
|
||||
'Beaming',
|
||||
'Brilliant',
|
||||
'Lit',
|
||||
'Ablaze',
|
||||
// Celestial Descriptors
|
||||
'Stellar',
|
||||
'Cosmic',
|
||||
'Astral',
|
||||
'Galactic',
|
||||
'Nebular',
|
||||
'Orbital',
|
||||
'Lunar',
|
||||
'Solar',
|
||||
'Starlit',
|
||||
'Heavenly',
|
||||
'Celestial',
|
||||
'Sidereal',
|
||||
'Planetary',
|
||||
'Starry',
|
||||
'Spacial',
|
||||
// Scale & Magnitude
|
||||
'Infinite',
|
||||
'Vast',
|
||||
'Boundless',
|
||||
'Immense',
|
||||
'Colossal',
|
||||
'Titanic',
|
||||
'Massive',
|
||||
'Grand',
|
||||
'Supreme',
|
||||
'Ultimate',
|
||||
'Epic',
|
||||
'Enormous',
|
||||
'Gigantic',
|
||||
'Limitless',
|
||||
'Total',
|
||||
// Temporal
|
||||
'Eternal',
|
||||
'Ancient',
|
||||
'Timeless',
|
||||
'Enduring',
|
||||
'Ageless',
|
||||
'Immortal',
|
||||
'Primal',
|
||||
'Nascent',
|
||||
'First',
|
||||
'Elder',
|
||||
'Lasting',
|
||||
'Undying',
|
||||
'Perpetual',
|
||||
'Final',
|
||||
'Prime',
|
||||
// Movement & Energy
|
||||
'Sidbuck',
|
||||
'Swift',
|
||||
'Drifting',
|
||||
'Spinning',
|
||||
'Surging',
|
||||
'Pulsing',
|
||||
'Soaring',
|
||||
'Racing',
|
||||
'Falling',
|
||||
'Rising',
|
||||
'Circling',
|
||||
'Streaking',
|
||||
'Hurtling',
|
||||
'Floating',
|
||||
'Orbiting',
|
||||
'Spiraling',
|
||||
// Colors of Space
|
||||
'Crimson',
|
||||
'Azure',
|
||||
'Violet',
|
||||
'Indigo',
|
||||
'Amber',
|
||||
'Sapphire',
|
||||
'Obsidian',
|
||||
'Silver',
|
||||
'Golden',
|
||||
'Scarlet',
|
||||
'Cobalt',
|
||||
'Emerald',
|
||||
'Ruby',
|
||||
'Onyx',
|
||||
'Ivory',
|
||||
// Physical Properties
|
||||
'Magnetic',
|
||||
'Quantum',
|
||||
'Thermal',
|
||||
'Photonic',
|
||||
'Ionic',
|
||||
'Plasma',
|
||||
'Spectral',
|
||||
'Charged',
|
||||
'Polar',
|
||||
'Dense',
|
||||
'Atomic',
|
||||
'Nuclear',
|
||||
'Electric',
|
||||
'Kinetic',
|
||||
'Static',
|
||||
// Atmosphere & Mystery
|
||||
'Ethereal',
|
||||
'Mystic',
|
||||
'Phantom',
|
||||
'Shadow',
|
||||
'Silent',
|
||||
'Distant',
|
||||
'Hidden',
|
||||
'Veiled',
|
||||
'Fading',
|
||||
'Arcane',
|
||||
'Cryptic',
|
||||
'Obscure',
|
||||
'Dim',
|
||||
'Dusky',
|
||||
'Shrouded',
|
||||
// Temperature & State
|
||||
'Frozen',
|
||||
'Burning',
|
||||
'Molten',
|
||||
'Volatile',
|
||||
'Icy',
|
||||
'Fiery',
|
||||
'Cool',
|
||||
'Warm',
|
||||
'Cold',
|
||||
'Hot',
|
||||
'Searing',
|
||||
'Frigid',
|
||||
'Scalding',
|
||||
'Chilled',
|
||||
'Heated',
|
||||
// Power & Force
|
||||
'Mighty',
|
||||
'Fierce',
|
||||
'Raging',
|
||||
'Wild',
|
||||
'Serene',
|
||||
'Tranquil',
|
||||
'Harmonic',
|
||||
'Resonant',
|
||||
'Steady',
|
||||
'Bold',
|
||||
'Potent',
|
||||
'Violent',
|
||||
'Calm',
|
||||
'Furious',
|
||||
'Forceful',
|
||||
// Texture & Form
|
||||
'Smooth',
|
||||
'Jagged',
|
||||
'Fractured',
|
||||
'Solid',
|
||||
'Hollow',
|
||||
'Curved',
|
||||
'Sharp',
|
||||
'Fluid',
|
||||
'Rigid',
|
||||
'Warped',
|
||||
// Rare & Precious
|
||||
'Noble',
|
||||
'Pure',
|
||||
'Rare',
|
||||
'Pristine',
|
||||
'Flawless',
|
||||
'Unique',
|
||||
'Exotic',
|
||||
'Sacred',
|
||||
'Divine',
|
||||
'Hallowed',
|
||||
]
|
||||
|
||||
const NOUNS = [
|
||||
// Stars & Stellar Objects
|
||||
'Star',
|
||||
'Sun',
|
||||
'Pulsar',
|
||||
'Quasar',
|
||||
'Magnetar',
|
||||
'Nova',
|
||||
'Supernova',
|
||||
'Hypernova',
|
||||
'Neutron',
|
||||
'Dwarf',
|
||||
'Giant',
|
||||
'Protostar',
|
||||
'Blazar',
|
||||
'Cepheid',
|
||||
'Binary',
|
||||
// Galaxies & Clusters
|
||||
'Galaxy',
|
||||
'Nebula',
|
||||
'Cluster',
|
||||
'Void',
|
||||
'Filament',
|
||||
'Halo',
|
||||
'Bulge',
|
||||
'Spiral',
|
||||
'Ellipse',
|
||||
'Arm',
|
||||
'Disk',
|
||||
'Shell',
|
||||
'Remnant',
|
||||
'Cloud',
|
||||
'Dust',
|
||||
// Planets & Moons
|
||||
'Planet',
|
||||
'Moon',
|
||||
'World',
|
||||
'Exoplanet',
|
||||
'Jovian',
|
||||
'Titan',
|
||||
'Europa',
|
||||
'Io',
|
||||
'Callisto',
|
||||
'Ganymede',
|
||||
'Triton',
|
||||
'Phobos',
|
||||
'Deimos',
|
||||
'Enceladus',
|
||||
'Charon',
|
||||
// Small Bodies
|
||||
'Comet',
|
||||
'Meteor',
|
||||
'Asteroid',
|
||||
'Meteorite',
|
||||
'Bolide',
|
||||
'Fireball',
|
||||
'Iceball',
|
||||
'Plutino',
|
||||
'Centaur',
|
||||
'Trojan',
|
||||
'Shard',
|
||||
'Fragment',
|
||||
'Debris',
|
||||
'Rock',
|
||||
'Ice',
|
||||
// Constellations & Myths
|
||||
'Orion',
|
||||
'Andromeda',
|
||||
'Perseus',
|
||||
'Pegasus',
|
||||
'Phoenix',
|
||||
'Draco',
|
||||
'Cygnus',
|
||||
'Aquila',
|
||||
'Lyra',
|
||||
'Vega',
|
||||
'Centaurus',
|
||||
'Hydra',
|
||||
'Sirius',
|
||||
'Polaris',
|
||||
'Altair',
|
||||
// Celestial Phenomena
|
||||
'Eclipse',
|
||||
'Aurora',
|
||||
'Corona',
|
||||
'Flare',
|
||||
'Storm',
|
||||
'Vortex',
|
||||
'Jet',
|
||||
'Burst',
|
||||
'Pulse',
|
||||
'Wave',
|
||||
'Ripple',
|
||||
'Shimmer',
|
||||
'Glow',
|
||||
'Flash',
|
||||
'Spark',
|
||||
// Cosmic Structures
|
||||
'Horizon',
|
||||
'Zenith',
|
||||
'Nadir',
|
||||
'Apex',
|
||||
'Meridian',
|
||||
'Equinox',
|
||||
'Solstice',
|
||||
'Transit',
|
||||
'Aphelion',
|
||||
'Orbit',
|
||||
'Axis',
|
||||
'Pole',
|
||||
'Equator',
|
||||
'Limb',
|
||||
'Arc',
|
||||
// Space & Dimensions
|
||||
'Cosmos',
|
||||
'Universe',
|
||||
'Dimension',
|
||||
'Realm',
|
||||
'Expanse',
|
||||
'Infinity',
|
||||
'Continuum',
|
||||
'Manifold',
|
||||
'Abyss',
|
||||
'Ether',
|
||||
'Vacuum',
|
||||
'Space',
|
||||
'Fabric',
|
||||
'Plane',
|
||||
'Domain',
|
||||
// Energy & Particles
|
||||
'Photon',
|
||||
'Neutrino',
|
||||
'Proton',
|
||||
'Electron',
|
||||
'Positron',
|
||||
'Quark',
|
||||
'Boson',
|
||||
'Fermion',
|
||||
'Tachyon',
|
||||
'Graviton',
|
||||
'Meson',
|
||||
'Gluon',
|
||||
'Lepton',
|
||||
'Muon',
|
||||
'Pion',
|
||||
// Regions & Zones
|
||||
'Sector',
|
||||
'Quadrant',
|
||||
'Zone',
|
||||
'Belt',
|
||||
'Ring',
|
||||
'Field',
|
||||
'Stream',
|
||||
'Current',
|
||||
'Wake',
|
||||
'Region',
|
||||
'Frontier',
|
||||
'Border',
|
||||
'Edge',
|
||||
'Margin',
|
||||
'Rim',
|
||||
// Navigation & Discovery
|
||||
'Beacon',
|
||||
'Signal',
|
||||
'Probe',
|
||||
'Voyager',
|
||||
'Pioneer',
|
||||
'Seeker',
|
||||
'Wanderer',
|
||||
'Nomad',
|
||||
'Drifter',
|
||||
'Scout',
|
||||
'Explorer',
|
||||
'Ranger',
|
||||
'Surveyor',
|
||||
'Sentinel',
|
||||
'Watcher',
|
||||
// Portals & Passages
|
||||
'Gateway',
|
||||
'Portal',
|
||||
'Nexus',
|
||||
'Bridge',
|
||||
'Conduit',
|
||||
'Channel',
|
||||
'Passage',
|
||||
'Rift',
|
||||
'Warp',
|
||||
'Fold',
|
||||
'Tunnel',
|
||||
'Crossing',
|
||||
'Link',
|
||||
'Path',
|
||||
'Route',
|
||||
// Core & Systems
|
||||
'Core',
|
||||
'Matrix',
|
||||
'Lattice',
|
||||
'Network',
|
||||
'Circuit',
|
||||
'Array',
|
||||
'Reactor',
|
||||
'Engine',
|
||||
'Forge',
|
||||
'Crucible',
|
||||
'Hub',
|
||||
'Node',
|
||||
'Kernel',
|
||||
'Center',
|
||||
'Heart',
|
||||
// Cosmic Objects
|
||||
'Crater',
|
||||
'Rift',
|
||||
'Chasm',
|
||||
'Canyon',
|
||||
'Peak',
|
||||
'Ridge',
|
||||
'Basin',
|
||||
'Plateau',
|
||||
'Valley',
|
||||
'Trench',
|
||||
]
|
||||
|
||||
/**
|
||||
* Generates a creative workflow name using random adjectives and nouns
|
||||
* @returns A creative workflow name like "blazing-phoenix" or "crystal-dragon"
|
||||
*/
|
||||
export function generateCreativeWorkflowName(): string {
|
||||
const adjective = randomItem(ADJECTIVES)
|
||||
const noun = randomItem(NOUNS)
|
||||
return `${adjective.toLowerCase()}-${noun.toLowerCase()}`
|
||||
}
|
||||
Reference in New Issue
Block a user